diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 7a9cba3..cf383ff 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -29,7 +29,7 @@ repos: files: *python_files - repo: https://github.com/pre-commit/mirrors-mypy - rev: v1.9.0 + rev: v1.19.1 hooks: - id: mypy args: [--ignore-missing-imports, --no-error-summary] diff --git a/CHANGELOG.md b/CHANGELOG.md index ed9c8c5..c068f72 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,19 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/). +## [1.4.3] - 2026-07-06 + +### Added + +- R1 light-current helpers for package-native gravitational-lensing probes: + `planar_r1_light_packet()`, `r1_vacuum_subtracted_potential()`, + `r1_light_acceleration()`, and `r1_light_step()`. +- Noether-current readouts: `noether_spatial_current()` and + `positive_noether_current()`. +- 19-point-stencil-consistent FFT Poisson/equilibrium helpers: + `poisson_solve_fft_19pt()`, `equilibrate_chi_19pt()`, and + `equilibrate_from_fields_19pt()`. + ## [1.4.1] - 2026-04-24 ### Fixed diff --git a/examples/LFM_coupled_energy_exchange.py b/examples/LFM_coupled_energy_exchange.py new file mode 100644 index 0000000..feee46d --- /dev/null +++ b/examples/LFM_coupled_energy_exchange.py @@ -0,0 +1,832 @@ +"""38 - Coupled GOV-01/GOV-02 Energy Exchange + +Tester-facing demonstration of energy exchange in the action-closed LFM core. + +This standalone script embeds the small canonical LFM core it needs: constants, +the 19-point stencil, Hamiltonian ledger, and velocity-Verlet update. It does +not introduce a new force law or continuum target law. The wave register is the +full R2 channel register represented as six real components: + + (Re Psi_1, Im Psi_1, Re Psi_2, Im Psi_2, Re Psi_3, Im Psi_3) + +Discrete substrate equations used by this demo +---------------------------------------------- + +Let + + D_t2 f_i^n = (f_i^{n+1} - 2 f_i^n + f_i^{n-1}) / dt^2 + Delta19 = canonical 19-point face-plus-edge lattice Laplacian + N_2 = sum_a |Psi_a|^2 + B = chi0 / kappa + +The lattice is a 3-D periodic cubic grid. Periodic boundaries are used because +they preserve the closed-system Hamiltonian ledger for this exchange demo. + +GOV-01: + + D_t2 Psi_a^n = c^2 Delta19 Psi_a^n - (chi^n)^2 Psi_a^n + +GOV-02, v38 action-closed causal core: + + D_t2 chi^n = + c^2 Delta19 chi^n + - (kappa / chi0) chi^n (N_2^n - E0_sq) + - (8 lambda_H / chi0^4) chi^n ((chi^n)^2 - chi0^2)^3 + +Velocity-Verlet/leapfrog form used by ``step_bare_lfm``: + + p_{n+1/2} = p_n + 0.5 dt F(q_n) + q_{n+1} = q_n + dt M^{-1} p_{n+1/2} + p_{n+1} = p_{n+1/2} + 0.5 dt F(q_{n+1}) + +For the chi register, p_chi = B chi_dot. This is the standard staggered +leapfrog form written with canonical momenta. + +Hamiltonian ledger measured by this demo +---------------------------------------- + + H_total = integral [ + 0.5 |Psi_dot|^2 + + 0.5 c^2 |grad19 Psi|^2 + + B/2 chi_dot^2 + + B c^2/2 |grad19 chi|^2 + + 0.5 chi^2 (N_2 - E0_sq) + + B lambda_H / chi0^4 (chi^2 - chi0^2)^4 + ] d^3x + +Optional weak-current, color-classifier, cross-color, and flux-tube extension +terms are not activated here. The canonical documents mark those terms as +outside this bare Hamiltonian ledger unless a separate interacting-action audit +is supplied. This script is therefore an energy-exchange diagnostic for the +full channel register of the action-closed coupled core, not a force-emergence +or continuum-closure claim. + +Accounting convention: ``wave_sector`` is the sum of the six positive GOV-01 +component ledgers and includes the onsite coupling term 0.5*chi^2*N_2. +``chi_sector`` is total minus ``wave_sector``, i.e. the chi kinetic, gradient, +and flat-octic self-potential ledger. The plotted exchange is an accounting +exchange inside one conserved Hamiltonian, not two independently conserved +subsystem energies. +""" + +from __future__ import annotations + +import argparse +import csv +import json +import math +from dataclasses import dataclass +from pathlib import Path + +import numpy as np + +D = 3 +D_ST = D + 1 +CHI0 = float(3**D - 2**D) +KAPPA = 1.0 / (4**D - 1) +LAMBDA_H = D_ST / (2 * D_ST**2 - 1) +C_DEFAULT = 1.0 +STENCIL_FACE_WEIGHT = 1.0 / 3.0 +STENCIL_EDGE_WEIGHT = 1.0 / 6.0 +STENCIL_CENTER_WEIGHT = -4.0 + +EQUATIONS_TEXT = """ +LFM action-closed coupled core used in this demo + +Register: + R2 wave register Psi_a in C, a=1,2,3 + Numeric representation: six real components + N_2 = sum_a |Psi_a|^2 + chi is a real substrate field + +Discrete operators: + D_t2 f_i^n = (f_i^{n+1} - 2 f_i^n + f_i^{n-1}) / dt^2 + Delta19 = canonical 19-point face-plus-edge lattice Laplacian + B = chi0 / kappa + Boundary = periodic cubic lattice + +GOV-01: + D_t2 Psi_a^n = c^2 Delta19 Psi_a^n - (chi_i^n)^2 Psi_a^n + +GOV-02, v38 action-closed causal core: + D_t2 chi_i^n = + c^2 Delta19 chi_i^n + - (kappa / chi0) chi_i^n (N_2,i^n - E0_sq) + - (8 lambda_H / chi0^4) chi_i^n ((chi_i^n)^2 - chi0^2)^3 + +Hamiltonian: + H_total = integral [ + 0.5 |Psi_dot|^2 + + 0.5 c^2 |grad19 Psi|^2 + + B/2 chi_dot^2 + + B c^2/2 |grad19 chi|^2 + + 0.5 chi^2 (N_2 - E0_sq) + + B lambda_H / chi0^4 (chi^2 - chi0^2)^4 + ] d^3x + +Numerical step: + p_{n+1/2} = p_n + 0.5 dt F(q_n) + q_{n+1} = q_n + dt M^{-1} p_{n+1/2} + p_{n+1} = p_{n+1/2} + 0.5 dt F(q_{n+1}) + +Optional extension terms are off in this Hamiltonian demo: + GOV-02 weak-current source epsilon_w * j + GOV-02 color-classifier source kappa_c * f_c * N_2 + GOV-02 flux-tube source kappa_tube * SCV + GOV-01 cross-color term epsilon_cc * chi^2 * (Psi_a - mean(Psi)) + +Energy accounting: + wave_sector = six GOV-01 component ledgers, including 0.5 chi^2 N_2 + chi_sector = total - wave_sector +""".strip() + + +Offset = tuple[int, int, int] + + +@dataclass(frozen=True) +class BareLFMParameters: + """Canonical parameters for the v38 bare GOV-01/GOV-02 core.""" + + chi0: float = CHI0 + kappa: float = KAPPA + lambda_h: float = LAMBDA_H + wave_speed: float = C_DEFAULT + background_norm_sq: float = 0.0 + spacing: float = 1.0 + + @property + def chi_inertia(self) -> float: + """Return B=chi0/kappa, fixed by the action-closed chi source.""" + return self.chi0 / self.kappa + + def __post_init__(self) -> None: + positive = ( + self.chi0, + self.kappa, + self.lambda_h, + self.wave_speed, + self.spacing, + ) + if not all(np.isfinite(value) and value > 0.0 for value in positive): + raise ValueError("bare LFM parameters must be positive and finite") + if not np.isfinite(self.background_norm_sq) or self.background_norm_sq < 0.0: + raise ValueError("background_norm_sq must be finite and nonnegative") + + +@dataclass(frozen=True) +class BareHamiltonRates: + """Hamiltonian vector field for the bare LFM registers.""" + + wave: np.ndarray + wave_momentum: np.ndarray + chi: np.ndarray + chi_momentum: np.ndarray + + +@dataclass(frozen=True) +class BareLFMState: + """Coordinate and momentum registers for the bare LFM system.""" + + wave: np.ndarray + wave_momentum: np.ndarray + chi: np.ndarray + chi_momentum: np.ndarray + + +def stencil_19_links() -> tuple[tuple[Offset, float], ...]: + """Return oriented face and edge links for the canonical 19-point stencil.""" + unique: tuple[tuple[Offset, float], ...] = ( + ((1, 0, 0), STENCIL_FACE_WEIGHT), + ((0, 1, 0), STENCIL_FACE_WEIGHT), + ((0, 0, 1), STENCIL_FACE_WEIGHT), + ((1, 1, 0), STENCIL_EDGE_WEIGHT), + ((1, -1, 0), STENCIL_EDGE_WEIGHT), + ((1, 0, 1), STENCIL_EDGE_WEIGHT), + ((1, 0, -1), STENCIL_EDGE_WEIGHT), + ((0, 1, 1), STENCIL_EDGE_WEIGHT), + ((0, 1, -1), STENCIL_EDGE_WEIGHT), + ) + links: list[tuple[Offset, float]] = [] + for offset, weight in unique: + links.append((offset, weight)) + links.append(((-offset[0], -offset[1], -offset[2]), weight)) + return tuple(links) + + +def shift_scalar(values: np.ndarray, offset: Offset) -> np.ndarray: + """Periodic shift for a scalar 3-D lattice field.""" + return np.roll(values, shift=offset, axis=(0, 1, 2)) + + +def shift_components(values: np.ndarray, offset: Offset) -> np.ndarray: + """Periodic shift for a component field shaped (components, N, N, N).""" + return np.roll(values, shift=offset, axis=(1, 2, 3)) + + +def laplacian_19pt(field: np.ndarray) -> np.ndarray: + """Canonical 19-point face-plus-edge Laplacian on a periodic cubic grid.""" + faces = ( + np.roll(field, 1, axis=0) + + np.roll(field, -1, axis=0) + + np.roll(field, 1, axis=1) + + np.roll(field, -1, axis=1) + + np.roll(field, 1, axis=2) + + np.roll(field, -1, axis=2) + ) + edges = ( + np.roll(np.roll(field, 1, axis=0), 1, axis=1) + + np.roll(np.roll(field, 1, axis=0), -1, axis=1) + + np.roll(np.roll(field, -1, axis=0), 1, axis=1) + + np.roll(np.roll(field, -1, axis=0), -1, axis=1) + + np.roll(np.roll(field, 1, axis=0), 1, axis=2) + + np.roll(np.roll(field, 1, axis=0), -1, axis=2) + + np.roll(np.roll(field, -1, axis=0), 1, axis=2) + + np.roll(np.roll(field, -1, axis=0), -1, axis=2) + + np.roll(np.roll(field, 1, axis=1), 1, axis=2) + + np.roll(np.roll(field, 1, axis=1), -1, axis=2) + + np.roll(np.roll(field, -1, axis=1), 1, axis=2) + + np.roll(np.roll(field, -1, axis=1), -1, axis=2) + ) + return ( + STENCIL_FACE_WEIGHT * faces + + STENCIL_EDGE_WEIGHT * edges + + STENCIL_CENTER_WEIGHT * field + ) + + +def laplacian_19pt_components(values: np.ndarray) -> np.ndarray: + """Apply the canonical 19-point Laplacian to every wave component.""" + return np.stack([laplacian_19pt(component) for component in values], axis=0) + + +def as_components(values: np.ndarray, name: str) -> np.ndarray: + """Normalize wave registers to shape (components, N, N, N).""" + array = np.asarray(values, dtype=np.float64) + if array.ndim == 3: + return array[np.newaxis, ...] + if array.ndim == 4: + return array + raise ValueError(f"{name} must have shape (N,N,N) or (components,N,N,N)") + + +def validated_registers( + wave: np.ndarray, + wave_momentum: np.ndarray, + chi: np.ndarray, + chi_momentum: np.ndarray, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Validate and normalize the LFM state arrays.""" + wave_values = as_components(wave, "wave") + wave_p = as_components(wave_momentum, "wave_momentum") + chi_values = np.asarray(chi, dtype=np.float64) + chi_p = np.asarray(chi_momentum, dtype=np.float64) + if wave_values.shape != wave_p.shape: + raise ValueError("wave and wave_momentum shapes must match") + if chi_values.ndim != 3 or chi_p.ndim != 3: + raise ValueError("chi and chi_momentum must have shape (N,N,N)") + if chi_values.shape != chi_p.shape: + raise ValueError("chi and chi_momentum shapes must match") + if wave_values.shape[1:] != chi_values.shape: + raise ValueError("wave and chi spatial shapes must match") + return wave_values, wave_p, chi_values, chi_p + + +def chi_potential_density(chi: np.ndarray, parameters: BareLFMParameters) -> np.ndarray: + """Flat-octic v38 chi self-potential density.""" + displacement = chi**2 - parameters.chi0**2 + return ( + parameters.chi_inertia + * parameters.lambda_h + * displacement**4 + / parameters.chi0**4 + ) + + +def chi_potential_momentum_force( + chi: np.ndarray, + parameters: BareLFMParameters, +) -> np.ndarray: + """Negative derivative of the flat-octic v38 chi self-potential.""" + displacement = chi**2 - parameters.chi0**2 + return ( + -8.0 + * parameters.chi_inertia + * parameters.lambda_h + * chi + * displacement**3 + / parameters.chi0**4 + ) + + +def gradient_site_density( + values: np.ndarray, + *, + coefficient: float, + components: bool, +) -> np.ndarray: + """Endpoint-split 19-point gradient energy density.""" + spatial_shape = values.shape[1:] if components else values.shape + density = np.zeros(spatial_shape, dtype=np.float64) + shift = shift_components if components else shift_scalar + for offset, weight in stencil_19_links(): + difference = shift(values, offset) - values + squared = np.sum(difference**2, axis=0) if components else difference**2 + density += 0.25 * coefficient * weight * squared + return density + + +def bare_site_energy( + wave: np.ndarray, + wave_momentum: np.ndarray, + chi: np.ndarray, + chi_momentum: np.ndarray, + parameters: BareLFMParameters, +) -> np.ndarray: + """Return endpoint-split site energy for the canonical bare Hamiltonian.""" + wave_values, wave_p, chi_values, chi_p = validated_registers( + wave, + wave_momentum, + chi, + chi_momentum, + ) + norm_sq = np.sum(wave_values**2, axis=0) + onsite = ( + 0.5 * np.sum(wave_p**2, axis=0) + + chi_p**2 / (2.0 * parameters.chi_inertia) + + 0.5 * chi_values**2 * (norm_sq - parameters.background_norm_sq) + + chi_potential_density(chi_values, parameters) + ) + wave_gradient = gradient_site_density( + wave_values, + coefficient=parameters.wave_speed**2 / parameters.spacing**2, + components=True, + ) + chi_gradient = gradient_site_density( + chi_values, + coefficient=parameters.chi_inertia * parameters.wave_speed**2 / parameters.spacing**2, + components=False, + ) + return onsite + wave_gradient + chi_gradient + + +def bare_total_energy(state: BareLFMState, parameters: BareLFMParameters) -> float: + """Return the total canonical bare Hamiltonian.""" + density = bare_site_energy( + state.wave, + state.wave_momentum, + state.chi, + state.chi_momentum, + parameters, + ) + return float(np.sum(density) * parameters.spacing**3) + + +def bare_hamilton_rates( + wave: np.ndarray, + wave_momentum: np.ndarray, + chi: np.ndarray, + chi_momentum: np.ndarray, + parameters: BareLFMParameters, +) -> BareHamiltonRates: + """Return Hamilton's equations for canonical GOV-01/GOV-02.""" + wave_values, wave_p, chi_values, chi_p = validated_registers( + wave, + wave_momentum, + chi, + chi_momentum, + ) + norm_sq = np.sum(wave_values**2, axis=0) + wave_rate = wave_p + wave_momentum_rate = ( + parameters.wave_speed**2 + * laplacian_19pt_components(wave_values) + / parameters.spacing**2 + - chi_values[np.newaxis, ...] ** 2 * wave_values + ) + chi_rate = chi_p / parameters.chi_inertia + chi_momentum_rate = ( + parameters.chi_inertia + * parameters.wave_speed**2 + * laplacian_19pt(chi_values) + / parameters.spacing**2 + - chi_values * (norm_sq - parameters.background_norm_sq) + + chi_potential_momentum_force(chi_values, parameters) + ) + return BareHamiltonRates( + wave=wave_rate, + wave_momentum=wave_momentum_rate, + chi=chi_rate, + chi_momentum=chi_momentum_rate, + ) + + +def step_bare_lfm( + state: BareLFMState, + dt: float, + parameters: BareLFMParameters, +) -> BareLFMState: + """Advance canonical GOV-01/GOV-02 with one velocity-Verlet step.""" + if not np.isfinite(dt) or dt <= 0.0: + raise ValueError("dt must be positive and finite") + wave, wave_p, chi, chi_p = validated_registers( + state.wave, + state.wave_momentum, + state.chi, + state.chi_momentum, + ) + rates_0 = bare_hamilton_rates(wave, wave_p, chi, chi_p, parameters) + half_wave_p = wave_p + 0.5 * dt * rates_0.wave_momentum + half_chi_p = chi_p + 0.5 * dt * rates_0.chi_momentum + next_wave = wave + dt * half_wave_p + next_chi = chi + dt * half_chi_p / parameters.chi_inertia + rates_1 = bare_hamilton_rates( + next_wave, + half_wave_p, + next_chi, + half_chi_p, + parameters, + ) + next_wave_p = half_wave_p + 0.5 * dt * rates_1.wave_momentum + next_chi_p = half_chi_p + 0.5 * dt * rates_1.chi_momentum + return BareLFMState( + wave=next_wave, + wave_momentum=next_wave_p, + chi=next_chi, + chi_momentum=next_chi_p, + ) + + +def wave_component_site_energy( + state: BareLFMState, + component: int, + parameters: BareLFMParameters, +) -> np.ndarray: + """Return the positive energy ledger assigned to one real wave component.""" + wave, wave_p, chi, _ = validated_registers( + state.wave, + state.wave_momentum, + state.chi, + state.chi_momentum, + ) + if component < 0 or component >= wave.shape[0]: + raise IndexError("component is outside the GOV-01 register") + values = wave[component] + momentum = wave_p[component] + onsite = 0.5 * momentum**2 + 0.5 * chi**2 * values**2 + gradient = gradient_site_density( + values, + coefficient=parameters.wave_speed**2 / parameters.spacing**2, + components=False, + ) + return onsite + gradient + + +def parse_args() -> argparse.Namespace: + parser = argparse.ArgumentParser( + description="Run a clean LFM GOV-01/GOV-02 energy-exchange demo.", + ) + parser.add_argument("--grid", type=int, default=20, help="Cubic grid size.") + parser.add_argument("--steps", type=int, default=800, help="Verlet steps to run.") + parser.add_argument("--dt", type=float, default=0.002, help="Timestep.") + parser.add_argument( + "--sample-every", + type=int, + default=5, + help="Write one energy row every N steps.", + ) + parser.add_argument( + "--amplitude", + type=float, + default=0.42, + help="Initial wave-packet amplitude.", + ) + parser.add_argument( + "--sigma", + type=float, + default=3.2, + help="Initial Gaussian packet width in cells.", + ) + parser.add_argument( + "--chi-kick", + type=float, + default=0.020, + help="Initial chi velocity amplitude. p_chi is B times this velocity.", + ) + parser.add_argument( + "--output-dir", + type=Path, + default=Path(__file__).resolve().parent / "outputs" / "38_coupled_energy_exchange", + help="Directory for CSV, JSON, equations, and optional plot.", + ) + parser.add_argument( + "--no-plot", + action="store_true", + help="Skip PNG plotting even if matplotlib is installed.", + ) + return parser.parse_args() + + +def periodic_delta(axis: np.ndarray, center: float, size: int) -> np.ndarray: + """Return minimum-image coordinate differences on a periodic grid.""" + half = 0.5 * float(size) + return (axis - float(center) + half) % float(size) - half + + +def gaussian_blob( + x: np.ndarray, + y: np.ndarray, + z: np.ndarray, + center: tuple[float, float, float], + sigma: float, +) -> np.ndarray: + """Return a periodic Gaussian envelope.""" + n = x.shape[0] + dx = periodic_delta(x, center[0], n) + dy = periodic_delta(y, center[1], n) + dz = periodic_delta(z, center[2], n) + r2 = dx * dx + dy * dy + dz * dz + return np.exp(-0.5 * r2 / (sigma * sigma)) + + +def make_initial_state( + grid: int, + amplitude: float, + sigma: float, + parameters: BareLFMParameters, + chi_kick: float, +) -> BareLFMState: + """Build a full six-real-component R2 wave state coupled to chi.""" + if grid < 8: + raise ValueError("grid must be at least 8") + if amplitude <= 0.0 or not np.isfinite(amplitude): + raise ValueError("amplitude must be positive and finite") + if sigma <= 0.0 or not np.isfinite(sigma): + raise ValueError("sigma must be positive and finite") + if not np.isfinite(chi_kick): + raise ValueError("chi_kick must be finite") + + axis = np.arange(grid, dtype=np.float64) + x, y, z = np.meshgrid(axis, axis, axis, indexing="ij") + two_pi = 2.0 * math.pi + k1 = two_pi / float(grid) + k2 = 2.0 * two_pi / float(grid) + omega1 = math.sqrt(parameters.chi0**2 + parameters.wave_speed**2 * k1**2) + omega2 = math.sqrt(parameters.chi0**2 + parameters.wave_speed**2 * k2**2) + + center_1 = (0.36 * grid, 0.50 * grid, 0.50 * grid) + center_2 = (0.64 * grid, 0.50 * grid, 0.50 * grid) + center_3 = (0.50 * grid, 0.62 * grid, 0.50 * grid) + blob_1 = gaussian_blob(x, y, z, center_1, sigma) + blob_2 = gaussian_blob(x, y, z, center_2, sigma) + blob_3 = gaussian_blob(x, y, z, center_3, 1.25 * sigma) + + phase_1 = k1 * periodic_delta(x, center_1[0], grid) + phase_2 = k1 * periodic_delta(y, center_2[1], grid) + phase_3 = k2 * periodic_delta(z, center_3[2], grid) + + wave = np.zeros((6, grid, grid, grid), dtype=np.float64) + wave_momentum = np.zeros_like(wave) + + amp_1 = amplitude + amp_2 = 0.85 * amplitude + amp_3 = 0.55 * amplitude + + wave[0] = amp_1 * blob_1 * np.cos(phase_1) + wave[1] = amp_1 * blob_1 * np.sin(phase_1) + wave_momentum[0] = -omega1 * amp_1 * blob_1 * np.sin(phase_1) + wave_momentum[1] = omega1 * amp_1 * blob_1 * np.cos(phase_1) + + wave[2] = amp_2 * blob_2 * np.cos(phase_2) + wave[3] = amp_2 * blob_2 * np.sin(phase_2) + wave_momentum[2] = -omega1 * amp_2 * blob_2 * np.sin(phase_2) + wave_momentum[3] = omega1 * amp_2 * blob_2 * np.cos(phase_2) + + wave[4] = amp_3 * blob_3 * np.cos(phase_3) + wave[5] = amp_3 * blob_3 * np.sin(phase_3) + wave_momentum[4] = -omega2 * amp_3 * blob_3 * np.sin(phase_3) + wave_momentum[5] = omega2 * amp_3 * blob_3 * np.cos(phase_3) + + norm_sq = np.sum(wave * wave, axis=0) + source = norm_sq - float(np.mean(norm_sq)) + source_scale = max(float(np.max(np.abs(source))), 1.0e-30) + + # Start chi slightly below chi0 where the wave energy is concentrated, + # then give it a small canonical velocity kick so exchange is visible + # within a short tester run. + chi = parameters.chi0 - 0.018 * source / source_scale + chi_velocity = chi_kick * (blob_1 - blob_2) + chi_momentum = parameters.chi_inertia * chi_velocity + + return BareLFMState( + wave=wave, + wave_momentum=wave_momentum, + chi=chi, + chi_momentum=chi_momentum, + ) + + +def sector_energies( + state: BareLFMState, + parameters: BareLFMParameters, +) -> dict[str, float]: + """Return total, wave-sector, and chi-sector Hamiltonian ledgers.""" + total = bare_total_energy(state, parameters) + component_energies = [ + float(np.sum(wave_component_site_energy(state, index, parameters)) * parameters.spacing**3) + for index in range(state.wave.shape[0]) + ] + wave_sector = float(sum(component_energies)) + chi_sector = float(total - wave_sector) + return { + "total": float(total), + "wave_sector": wave_sector, + "chi_sector": chi_sector, + "wave_component_0": component_energies[0], + "wave_component_1": component_energies[1], + "wave_component_2": component_energies[2], + "wave_component_3": component_energies[3], + "wave_component_4": component_energies[4], + "wave_component_5": component_energies[5], + "chi_min": float(np.min(state.chi)), + "chi_max": float(np.max(state.chi)), + "chi_mean": float(np.mean(state.chi)), + "wave_norm_sq": float(np.sum(state.wave * state.wave) * parameters.spacing**3), + } + + +def record_row( + rows: list[dict[str, float]], + step: int, + state: BareLFMState, + parameters: BareLFMParameters, + dt: float, +) -> None: + row = {"step": float(step), "time": float(step) * dt} + row.update(sector_energies(state, parameters)) + rows.append(row) + + +def write_csv(path: Path, rows: list[dict[str, float]]) -> None: + if not rows: + raise ValueError("no rows to write") + with path.open("w", newline="", encoding="utf-8") as handle: + writer = csv.DictWriter(handle, fieldnames=list(rows[0].keys())) + writer.writeheader() + writer.writerows(rows) + + +def maybe_plot(path: Path, rows: list[dict[str, float]], enabled: bool) -> Path | None: + if not enabled: + return None + try: + import matplotlib.pyplot as plt + except ImportError: + return None + + steps = np.asarray([row["step"] for row in rows], dtype=np.float64) + total = np.asarray([row["total"] for row in rows], dtype=np.float64) + wave = np.asarray([row["wave_sector"] for row in rows], dtype=np.float64) + chi = np.asarray([row["chi_sector"] for row in rows], dtype=np.float64) + + fig, (ax_energy, ax_drift) = plt.subplots(2, 1, figsize=(10, 7), sharex=True) + ax_energy.plot(steps, wave, label="Wave plus interaction ledger") + ax_energy.plot(steps, chi, label="Chi self ledger") + ax_energy.set_ylabel("Hamiltonian sector energy") + ax_energy.legend(loc="best") + ax_energy.grid(True, alpha=0.3) + + drift = (total - total[0]) / max(abs(total[0]), 1.0) + ax_drift.plot(steps, drift, color="black", label="relative total drift") + ax_drift.set_xlabel("Verlet step") + ax_drift.set_ylabel("relative total drift") + ax_drift.legend(loc="best") + ax_drift.grid(True, alpha=0.3) + + fig.tight_layout() + fig.savefig(path, dpi=160) + plt.close(fig) + return path + + +def summarize(rows: list[dict[str, float]], args: argparse.Namespace) -> dict[str, float | int]: + total = np.asarray([row["total"] for row in rows], dtype=np.float64) + wave = np.asarray([row["wave_sector"] for row in rows], dtype=np.float64) + chi = np.asarray([row["chi_sector"] for row in rows], dtype=np.float64) + total0 = float(total[0]) + total_drift = float(np.max(np.abs(total - total0)) / max(abs(total0), 1.0)) + wave_range = float(np.max(wave) - np.min(wave)) + chi_range = float(np.max(chi) - np.min(chi)) + exchange_floor = 1.0e-30 + if np.std(wave) > exchange_floor and np.std(chi) > exchange_floor: + wave_chi_corr = float(np.corrcoef(wave, chi)[0, 1]) + else: + wave_chi_corr = float("nan") + return { + "grid": int(args.grid), + "steps": int(args.steps), + "dt": float(args.dt), + "sample_every": int(args.sample_every), + "initial_total_energy": total0, + "final_total_energy": float(total[-1]), + "max_relative_total_drift": total_drift, + "wave_sector_range": wave_range, + "chi_sector_range": chi_range, + "wave_sector_range_pct_of_total": 100.0 * wave_range / max(abs(total0), 1.0), + "chi_sector_range_pct_of_total": 100.0 * chi_range / max(abs(total0), 1.0), + "wave_chi_correlation": wave_chi_corr, + "initial_chi_min": float(rows[0]["chi_min"]), + "final_chi_min": float(rows[-1]["chi_min"]), + } + + +def run( + args: argparse.Namespace, +) -> tuple[list[dict[str, float]], dict[str, float | int], Path | None]: + if args.steps < 0: + raise ValueError("steps must be nonnegative") + if args.sample_every <= 0: + raise ValueError("sample-every must be positive") + if args.dt <= 0.0 or not np.isfinite(args.dt): + raise ValueError("dt must be positive and finite") + + parameters = BareLFMParameters( + chi0=CHI0, + kappa=KAPPA, + lambda_h=LAMBDA_H, + wave_speed=1.0, + background_norm_sq=0.0, + spacing=1.0, + ) + + state = make_initial_state( + grid=args.grid, + amplitude=args.amplitude, + sigma=args.sigma, + parameters=parameters, + chi_kick=args.chi_kick, + ) + + rows: list[dict[str, float]] = [] + record_row(rows, 0, state, parameters, args.dt) + for step in range(1, args.steps + 1): + state = step_bare_lfm(state, args.dt, parameters) + if step % args.sample_every == 0 or step == args.steps: + record_row(rows, step, state, parameters, args.dt) + + args.output_dir.mkdir(parents=True, exist_ok=True) + equations_path = args.output_dir / "governing_equations.txt" + csv_path = args.output_dir / "energy_exchange.csv" + summary_path = args.output_dir / "summary.json" + plot_path = args.output_dir / "energy_exchange.png" + + equations_path.write_text(EQUATIONS_TEXT + "\n", encoding="utf-8") + write_csv(csv_path, rows) + summary = summarize(rows, args) + summary_path.write_text(json.dumps(summary, indent=2) + "\n", encoding="utf-8") + plotted = maybe_plot(plot_path, rows, enabled=not args.no_plot) + + print("LFM coupled energy exchange demo") + print("=" * 40) + print("Discrete layer: six real wave components plus chi on a periodic 3-D cubic lattice.") + print("Readout layer: Hamiltonian sector ledger in external grid units.") + print("Continuum layer: no continuum force or metric claim is made by this demo.") + print() + print("GOV-01/GOV-02 equations written to:") + print(f" {equations_path}") + print("Energy table written to:") + print(f" {csv_path}") + print("Summary written to:") + print(f" {summary_path}") + if plotted is not None: + print("Plot written to:") + print(f" {plotted}") + elif not args.no_plot: + print("Plot skipped: matplotlib is not installed.") + print() + print("Energy exchange summary:") + print(f" initial total energy = {summary['initial_total_energy']:.12e}") + print(f" final total energy = {summary['final_total_energy']:.12e}") + print(f" max relative total drift = {summary['max_relative_total_drift']:.6e}") + print(f" wave+interaction range = {summary['wave_sector_range']:.12e}") + print(f" chi self-ledger range = {summary['chi_sector_range']:.12e}") + print( + " wave sector range / total = " + f"{summary['wave_sector_range_pct_of_total']:.6e}%" + ) + print( + " chi sector range / total = " + f"{summary['chi_sector_range_pct_of_total']:.6e}%" + ) + print(f" wave/chi sector correlation = {summary['wave_chi_correlation']:.6f}") + + return rows, summary, plotted + + +def main() -> None: + args = parse_args() + run(args) + + +if __name__ == "__main__": + main() diff --git a/lfm/__init__.py b/lfm/__init__.py index 0451e02..3d8bfc0 100644 --- a/lfm/__init__.py +++ b/lfm/__init__.py @@ -15,10 +15,13 @@ print(sim.metrics()) """ -__version__ = "1.4.1" +__version__ = "1.4.5" from lfm.analysis import ( + aggregate_wave_density, angular_momentum_density, + block_window_magnitude_sq, + blocked_static_propagator, charge_density, chi_statistics, classify_potential, @@ -53,24 +56,39 @@ halo_mass_function, horizon_mass, interior_mask, + inverse_response_intercept, keplerian_velocity, + leapfrog_branch_projection, list_sparc_galaxies, + localized_state_observables, + localized_weighted_centroid, matter_power_spectrum, measure_chi_midpoint, measure_force, measure_separation, metric_perturbation, + metric_refractive_index, momentum_density, + noether_spatial_current, + op05_spherical_chi_deflection, + periodic_mode_coefficient, phase_coherence, + phase_current_energy_density, phase_field, + plaquette_winding_summary, + positive_noether_current, power_spectrum, precession_rate, + principal_internal_projection, project_field_onto_modes, + project_leapfrog_mode, radial_profile, relative_spread, + response_log_slope, rotation_curve, rotation_curve_fit, schwarzschild_chi, + schwarzschild_radius_si, smoothed_color_variance, sparc_load, spinor_center_of_energy, @@ -93,13 +111,15 @@ weak_parity_asymmetry, well_fraction, ) -from lfm.config import BoundaryType, ChiMode, FieldLevel, PhysicsScale, SimulationConfig +from lfm.config import BoundaryType, ChiMode, FieldLevel, PhysicsScale, Precision, SimulationConfig from lfm.config_presets import full_physics, gravity_em, gravity_only, spinor_field from lfm.constants import ( AGE_UNIVERSE_GYR, ALPHA_EM, ALPHA_S, + ARCSEC_PER_RADIAN, BETA_0, + C_SI, CHI0, D_ST, DT_DEFAULT, @@ -107,6 +127,7 @@ E_AMPLITUDE_BY_GRID, EPSILON_CC, EPSILON_W, + G_SI, KAPPA, KAPPA_C, KAPPA_STRING, @@ -125,6 +146,8 @@ SA_GAMMA, SA_L, SIN2_THETA_W, + SOLAR_MASS_KG, + SOLAR_RADIUS_M, TOTAL_RADIUS_LOWER_BOUND_PLANCK, Z2_COORD, D, @@ -132,6 +155,7 @@ from lfm.core.backends import get_backend, gpu_available from lfm.core.backends.remote_backend import configure_remote from lfm.core.evolver import Evolver +from lfm.core.stencils import gradient_19pt, noether_current_19pt_raw from lfm.experiment import ( DEFAULT_RINGDOWN_K_MODES, Barrier, @@ -146,24 +170,39 @@ Slit, collision, dispersion, + integrate_limit02_two_body, next5_falsification_projection_v2, qnm_mode_projection_check, + summarize_limit02_orbit, + sweep_limit02_orbits, ) from lfm.fields import ( + Limit02BodyProfile, apply_rotation_x, apply_rotation_z, boosted_soliton, + build_limit02_body_profile, disk_positions, disk_velocities, equilibrate_chi, + equilibrate_chi_19pt, equilibrate_from_fields, + equilibrate_from_fields_19pt, gaussian_soliton, gaussian_spinor, grid_positions, initialize_disk, + limit02_acceleration_from_profile, + periodic_trilinear_sample, place_solitons, + planar_r1_light_packet, poisson_solve_fft, + poisson_solve_fft_19pt, + r1_light_acceleration, + r1_light_step, + r1_vacuum_subtracted_potential, seed_noise, + smooth_spherical_density, sparse_positions, spherical_phase_source, tetrahedral_positions, @@ -276,6 +315,14 @@ solve_eigenmode, ylm_seed, ) +from lfm.particles.stationary import ( + StationaryBranchPoint, + SupportRemovalPoint, + continue_stationary_branch, + continue_support_removal, + solve_stationary_branch_point, + solve_support_removal_point, +) from lfm.planning import ( FeasibilityReport, UseCaseName, @@ -293,7 +340,7 @@ solar_system, ) from lfm.simulation import Simulation -from lfm.sweep import sweep, sweep_2d +from lfm.sweep import sweep, sweep_2d, sweep_cases from lfm.units import CosmicScale, PlanckScale from lfm.viz.celestial import animate_celestial_3d from lfm.viz.collision import animate_collision_3d @@ -305,6 +352,11 @@ "CHI0", "D", "D_ST", + "G_SI", + "C_SI", + "SOLAR_MASS_KG", + "SOLAR_RADIUS_M", + "ARCSEC_PER_RADIAN", "KAPPA", "KAPPA_C", "KAPPA_STRING", @@ -340,6 +392,7 @@ "SimulationConfig", "FieldLevel", "BoundaryType", + "Precision", "PhysicsScale", "ChiMode", # Config presets @@ -349,7 +402,15 @@ "spinor_field", # Backends & Simulation "Evolver", + "gradient_19pt", + "noether_current_19pt_raw", "Simulation", + "StationaryBranchPoint", + "SupportRemovalPoint", + "continue_stationary_branch", + "continue_support_removal", + "solve_stationary_branch_point", + "solve_support_removal_point", "get_backend", "gpu_available", "configure_remote", @@ -362,17 +423,29 @@ "place_solitons", "wave_kick", "poisson_solve_fft", + "poisson_solve_fft_19pt", "equilibrate_chi", + "equilibrate_chi_19pt", "equilibrate_from_fields", + "equilibrate_from_fields_19pt", "seed_noise", "uniform_chi", "tetrahedral_positions", "sparse_positions", "spherical_phase_source", + "planar_r1_light_packet", + "r1_vacuum_subtracted_potential", + "r1_light_acceleration", + "r1_light_step", "grid_positions", "disk_positions", "disk_velocities", "initialize_disk", + "Limit02BodyProfile", + "smooth_spherical_density", + "build_limit02_body_profile", + "periodic_trilinear_sample", + "limit02_acceleration_from_profile", # Experiment components "Barrier", "Slit", @@ -389,6 +462,9 @@ "DEFAULT_RINGDOWN_K_MODES", "ExperimentConfig", "ExperimentResult", + "integrate_limit02_two_body", + "summarize_limit02_orbit", + "sweep_limit02_orbits", # Planning "FeasibilityReport", "UseCaseName", @@ -420,6 +496,14 @@ "confinement_proxy", "fluid_fields", "continuity_residual", + "aggregate_wave_density", + "block_window_magnitude_sq", + "blocked_static_propagator", + "inverse_response_intercept", + "response_log_slope", + "principal_internal_projection", + "plaquette_winding_summary", + "localized_state_observables", # SPARC galaxy data "sparc_load", "list_sparc_galaxies", @@ -437,12 +521,16 @@ "static_interaction_potential", # Ringdown extraction "fit_ringdown_series", + "periodic_mode_coefficient", + "leapfrog_branch_projection", + "project_leapfrog_mode", "project_field_onto_modes", "relative_spread", "split_frequency_bands", "target_band_summary", # Spectrum & Tracker "power_spectrum", + "localized_weighted_centroid", "track_peaks", "flatten_trajectories", "detect_collision_events", @@ -454,11 +542,17 @@ "time_dilation_factor", "gravitational_potential", "schwarzschild_chi", + "schwarzschild_radius_si", + "metric_refractive_index", + "op05_spherical_chi_deflection", "find_apparent_horizon", "horizon_mass", # Phase (EM / charge) "phase_field", "charge_density", + "noether_spatial_current", + "positive_noether_current", + "phase_current_energy_density", "phase_coherence", "coulomb_interaction_energy", # Angular momentum @@ -486,6 +580,7 @@ # Sweep "sweep", "sweep_2d", + "sweep_cases", # Units "CosmicScale", "PlanckScale", diff --git a/lfm/analysis/__init__.py b/lfm/analysis/__init__.py index dd9e93c..c71d981 100644 --- a/lfm/analysis/__init__.py +++ b/lfm/analysis/__init__.py @@ -24,6 +24,40 @@ precession_rate, total_angular_momentum, ) +from lfm.analysis.coarse_graining import ( + block_window_magnitude_sq, + blocked_static_propagator, + inverse_response_intercept, + response_log_slope, +) +from lfm.analysis.collective_geometry import ( + SOURCE_CASES, + ContinuumFit, + WeightedMoments, + analytic_leapfrog_limit, + apply_momentum_sponge, + block_average, + collective_initial_state, + continuum_fit, + dispersion_shell_metrics, + energy_current_vector_and_tensor, + minimum_image_mesh, + periodic_weighted_moments, + traceless, +) +from lfm.analysis.collective_spectrum import ( + RotatingBackground, + berry_action_derivative_audit, + collective_mode_eigenpairs, + collective_qep_matrices, + discrete_stiffness_19, + gov02_background_residual, + mode_polarization, + principal_symbol_audit, + rotating_background, + vacuum_spectrum_audit, + zero_chi_branch_audit, +) from lfm.analysis.color import ( color_variance, ) @@ -45,6 +79,12 @@ matter_power_spectrum, void_statistics, ) +from lfm.analysis.emergence import ( + aggregate_wave_density, + localized_state_observables, + plaquette_winding_summary, + principal_internal_projection, +) from lfm.analysis.energy import ( continuity_residual, energy_components, @@ -52,6 +92,49 @@ fluid_fields, total_energy, ) +from lfm.analysis.energy_current import ( + BareHamiltonRates, + BareLFMParameters, + BareLFMState, + bare_energy_continuity_residual, + bare_hamilton_rates, + bare_site_energy, + bare_site_energy_rate, + bare_total_energy, + energy_current_divergence, + oriented_energy_currents, + stencil_links, + step_bare_lfm, + wave_component_site_energy, +) +from lfm.analysis.frame_candidates import ( + CandidateAssessment, + CandidateVerdict, + FrameCandidate, + assess_frame_candidate, + current_frame_candidate_ledger, +) +from lfm.analysis.frame_completion import ( + FRAME_COMPONENT_COUNT, + FRAME_COMPONENT_LABELS, + FRAME_SCALE_COUNT, + FRAME_SHAPE_COUNT, + analytic_rest_energy_response, + frame_projectors, + frame_scale_direction, + frame_static_operator, + frame_static_response, + minimized_source_cross_energy, + rest_energy_source, + source_projection_weights, + zero_momentum_frame_spectrum, +) +from lfm.analysis.frame_links import ( + linked_frame_difference, + loop_holonomy, + loop_mismatch_energy, + reconstructed_frame_link, +) from lfm.analysis.grav_waves import ( gravitational_wave_strain, gw_power, @@ -63,10 +146,18 @@ gravitational_potential, horizon_mass, metric_perturbation, + metric_refractive_index, + op05_spherical_chi_deflection, schwarzschild_chi, + schwarzschild_radius_si, time_dilation_factor, ) from lfm.analysis.metrics import compute_metrics +from lfm.analysis.modes import ( + leapfrog_branch_projection, + periodic_mode_coefficient, + project_leapfrog_mode, +) from lfm.analysis.observables import ( confinement_proxy, find_peaks, @@ -80,11 +171,24 @@ rotation_curve_fit, weak_parity_asymmetry, ) +from lfm.analysis.particle_kinematics import ( + component_noether_charges, + fit_offset_power_convergence, + flat_octic_hamiltonian_19pt, + time_centered_momentum_19pt, +) from lfm.analysis.phase import ( + bare_charge_continuity_residual, + canonical_charge_density, + charge_current_divergence, charge_density, coulomb_interaction_energy, + noether_spatial_current, + oriented_charge_currents, phase_coherence, + phase_current_energy_density, phase_field, + positive_noether_current, ) from lfm.analysis.ringdown import ( fit_ringdown_series, @@ -110,11 +214,20 @@ void_fraction, well_fraction, ) +from lfm.analysis.substrate_emergence import ( + axial_static_inverse_length, + composite_connection_19pt, + composite_curvature_19pt, + curvature_rms, + normalize_internal_field, + relational_wave_scaling_scan, +) from lfm.analysis.tracker import ( collider_event_display, compute_impact_parameter, detect_collision_events, flatten_trajectories, + localized_weighted_centroid, track_peaks, ) @@ -125,6 +238,80 @@ "energy_conservation_drift", "fluid_fields", "continuity_residual", + "BareLFMParameters", + "BareHamiltonRates", + "BareLFMState", + "stencil_links", + "bare_site_energy", + "bare_hamilton_rates", + "bare_site_energy_rate", + "oriented_energy_currents", + "energy_current_divergence", + "bare_energy_continuity_residual", + "bare_total_energy", + "step_bare_lfm", + "wave_component_site_energy", + "SOURCE_CASES", + "WeightedMoments", + "ContinuumFit", + "minimum_image_mesh", + "collective_initial_state", + "apply_momentum_sponge", + "periodic_weighted_moments", + "block_average", + "energy_current_vector_and_tensor", + "traceless", + "continuum_fit", + "dispersion_shell_metrics", + "analytic_leapfrog_limit", + "FrameCandidate", + "CandidateAssessment", + "CandidateVerdict", + "assess_frame_candidate", + "current_frame_candidate_ledger", + "FRAME_COMPONENT_LABELS", + "FRAME_COMPONENT_COUNT", + "FRAME_SCALE_COUNT", + "FRAME_SHAPE_COUNT", + "frame_scale_direction", + "frame_projectors", + "rest_energy_source", + "source_projection_weights", + "frame_static_operator", + "frame_static_response", + "analytic_rest_energy_response", + "minimized_source_cross_energy", + "zero_momentum_frame_spectrum", + "reconstructed_frame_link", + "linked_frame_difference", + "loop_holonomy", + "loop_mismatch_energy", + "block_window_magnitude_sq", + "blocked_static_propagator", + "inverse_response_intercept", + "response_log_slope", + "RotatingBackground", + "berry_action_derivative_audit", + "collective_mode_eigenpairs", + "collective_qep_matrices", + "discrete_stiffness_19", + "gov02_background_residual", + "mode_polarization", + "principal_symbol_audit", + "rotating_background", + "vacuum_spectrum_audit", + "zero_chi_branch_audit", + # substrate-only emergence diagnostics + "aggregate_wave_density", + "principal_internal_projection", + "plaquette_winding_summary", + "localized_state_observables", + "axial_static_inverse_length", + "relational_wave_scaling_scan", + "normalize_internal_field", + "composite_connection_19pt", + "composite_curvature_19pt", + "curvature_rms", # structure "chi_statistics", "well_fraction", @@ -159,6 +346,7 @@ # spectrum "power_spectrum", # tracker + "localized_weighted_centroid", "track_peaks", "flatten_trajectories", "detect_collision_events", @@ -170,13 +358,32 @@ "time_dilation_factor", "gravitational_potential", "schwarzschild_chi", + "schwarzschild_radius_si", + "metric_refractive_index", + "op05_spherical_chi_deflection", "find_apparent_horizon", "horizon_mass", # phase (EM / charge) "phase_field", "charge_density", + "canonical_charge_density", + "oriented_charge_currents", + "charge_current_divergence", + "bare_charge_continuity_residual", + "noether_spatial_current", + "positive_noether_current", + "phase_current_energy_density", "phase_coherence", "coulomb_interaction_energy", + # external collective particle kinematics + "component_noether_charges", + "flat_octic_hamiltonian_19pt", + "time_centered_momentum_19pt", + "fit_offset_power_convergence", + # periodic spatial/temporal mode projections + "periodic_mode_coefficient", + "leapfrog_branch_projection", + "project_leapfrog_mode", # ringdown extraction "fit_ringdown_series", "project_field_onto_modes", diff --git a/lfm/analysis/clock_link.py b/lfm/analysis/clock_link.py new file mode 100644 index 0000000..8d7d354 --- /dev/null +++ b/lfm/analysis/clock_link.py @@ -0,0 +1,201 @@ +"""Diagnostics for the unpromoted LFM temporal-link geometry candidate. + +The functions in this module analyze a proposed positive local clock factor +``q = exp(varphi)`` coupled to the complete bare LFM Hamiltonian. They do not +add the candidate register to :class:`lfm.Simulation` and do not implement a +gravitational force or trajectory law. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + +from lfm.constants import C_DEFAULT, CHI0, KAPPA +from lfm.core.stencils import ( + eigenvalue_19pt, + eigenvalue_27pt, + laplacian_19pt, + laplacian_27pt, +) + + +@dataclass(frozen=True) +class ClockLinkParameters: + """Positive parameters for the candidate clock-link quadratic sector.""" + + inertia: float = CHI0 / KAPPA + speed: float = C_DEFAULT + + def __post_init__(self) -> None: + if not np.isfinite(self.inertia) or self.inertia <= 0.0: + raise ValueError("clock-link inertia must be positive and finite") + if not np.isfinite(self.speed) or self.speed <= 0.0: + raise ValueError("clock-link speed must be positive and finite") + + +def clock_link_stiffness( + stencil: str, + kx: np.ndarray, + ky: np.ndarray, + kz: np.ndarray, +) -> np.ndarray: + """Return nonnegative clock-link stiffness for a labeled stencil.""" + if stencil == "19": + eigenvalue = eigenvalue_19pt(kx, ky, kz) + elif stencil == "27": + eigenvalue = eigenvalue_27pt(kx, ky, kz) + else: + raise ValueError("stencil must be '19' or '27'") + stiffness = -np.asarray(eigenvalue, dtype=np.float64) + return np.maximum(stiffness, 0.0) + + +def clock_link_frequency_sq( + stiffness: np.ndarray | float, + parameters: ClockLinkParameters = ClockLinkParameters(), +) -> np.ndarray: + """Return the candidate vacuum branch ``omega^2 = c_phi^2 K``.""" + values = np.asarray(stiffness, dtype=np.float64) + if np.any(values < 0.0): + raise ValueError("stiffness must be nonnegative") + return parameters.speed**2 * values + + +def clock_link_static_response( + stiffness: np.ndarray | float, + source: np.ndarray | float = 1.0, + parameters: ClockLinkParameters = ClockLinkParameters(), +) -> np.ndarray: + """Return the nonzero-mode static response per candidate field equation.""" + values = np.asarray(stiffness, dtype=np.float64) + source_values = np.asarray(source, dtype=np.float64) + if np.any(values <= 0.0): + raise ValueError("static response requires strictly positive stiffness") + return -source_values / (parameters.inertia * parameters.speed**2 * values) + + +def clock_link_green_residue( + parameters: ClockLinkParameters = ClockLinkParameters(), +) -> float: + """Return ``K*varphi/rho`` for the static candidate response.""" + return -1.0 / (parameters.inertia * parameters.speed**2) + + +def clock_factor(varphi: np.ndarray | float) -> np.ndarray: + """Return the positive local temporal-link factor ``exp(varphi)``.""" + return np.exp(np.asarray(varphi, dtype=np.float64)) + + +def matter_frequency_sq( + stiffness: np.ndarray | float, + chi: np.ndarray | float, + varphi: np.ndarray | float, + *, + matter_speed: float = C_DEFAULT, +) -> np.ndarray: + """Return uniform-clock candidate GOV-01 dispersion.""" + stiffness_values = np.asarray(stiffness, dtype=np.float64) + if np.any(stiffness_values < 0.0): + raise ValueError("stiffness must be nonnegative") + if not np.isfinite(matter_speed) or matter_speed <= 0.0: + raise ValueError("matter_speed must be positive and finite") + chi_values = np.asarray(chi, dtype=np.float64) + return np.exp(2.0 * np.asarray(varphi, dtype=np.float64)) * ( + matter_speed**2 * stiffness_values + chi_values**2 + ) + + +def matter_clock_sensitivity( + stiffness: np.ndarray | float, + chi: np.ndarray | float, + *, + matter_speed: float = C_DEFAULT, +) -> np.ndarray: + """Return ``d omega^2/d varphi`` at the ordinary clock vacuum.""" + stiffness_values = np.asarray(stiffness, dtype=np.float64) + if np.any(stiffness_values < 0.0): + raise ValueError("stiffness must be nonnegative") + chi_values = np.asarray(chi, dtype=np.float64) + return 2.0 * (matter_speed**2 * stiffness_values + chi_values**2) + + +def solve_static_clock_link( + source: np.ndarray, + *, + stencil: str = "19", + parameters: ClockLinkParameters = ClockLinkParameters(), + remove_mean: bool = True, +) -> np.ndarray: + """Solve the candidate static clock-link equation on a periodic 3-D grid. + + The solved equation is + + ``c_phi^2 * Laplacian(varphi) = source / B_phi``. + + A periodic solution requires a zero-mean source. By default the uniform + source mode is removed and the returned field has zero mean. + """ + source_values = np.asarray(source, dtype=np.float64) + if source_values.ndim != 3: + raise ValueError("source must be a three-dimensional array") + if not np.all(np.isfinite(source_values)): + raise ValueError("source must contain only finite values") + effective_source = ( + source_values - float(np.mean(source_values)) if remove_mean else source_values.copy() + ) + if not remove_mean and abs(float(np.mean(effective_source))) > 1.0e-14: + raise ValueError("periodic static source must have zero mean") + + shape = effective_source.shape + kx = np.fft.fftfreq(shape[0]) * 2.0 * np.pi + ky = np.fft.fftfreq(shape[1]) * 2.0 * np.pi + kz = np.fft.fftfreq(shape[2]) * 2.0 * np.pi + grid_kx, grid_ky, grid_kz = np.meshgrid( + kx, + ky, + kz, + indexing="ij", + sparse=True, + ) + stiffness = clock_link_stiffness( + stencil, + grid_kx, + grid_ky, + grid_kz, + ) + source_hat = np.fft.fftn(effective_source) + field_hat = np.zeros_like(source_hat, dtype=np.complex128) + nonzero = stiffness > 1.0e-14 + field_hat[nonzero] = -source_hat[nonzero] / ( + parameters.inertia * parameters.speed**2 * stiffness[nonzero] + ) + field = np.fft.ifftn(field_hat).real + field -= float(np.mean(field)) + return field + + +def static_clock_link_residual( + field: np.ndarray, + source: np.ndarray, + *, + stencil: str = "19", + parameters: ClockLinkParameters = ClockLinkParameters(), + remove_mean: bool = True, +) -> np.ndarray: + """Return the real-space residual of the candidate static equation.""" + field_values = np.asarray(field, dtype=np.float64) + source_values = np.asarray(source, dtype=np.float64) + if field_values.shape != source_values.shape or field_values.ndim != 3: + raise ValueError("field and source must have the same 3-D shape") + effective_source = ( + source_values - float(np.mean(source_values)) if remove_mean else source_values + ) + if stencil == "19": + laplacian = laplacian_19pt(field_values) + elif stencil == "27": + laplacian = laplacian_27pt(field_values) + else: + raise ValueError("stencil must be '19' or '27'") + return parameters.speed**2 * laplacian - effective_source / parameters.inertia diff --git a/lfm/analysis/clock_link_live.py b/lfm/analysis/clock_link_live.py new file mode 100644 index 0000000..1844c0b --- /dev/null +++ b/lfm/analysis/clock_link_live.py @@ -0,0 +1,508 @@ +"""Live Hamiltonian diagnostics for the unpromoted LFM clock-link candidate. + +This module evolves the scalar bare GOV-01/GOV-02 Hamiltonian together with a +positive temporal-link factor ``q = exp(varphi)``. It contains no trajectory +law, quasi-static solve, or imported gravitational potential. + +The implementation is intentionally separate from ``lfm.Simulation`` because +the clock link is a research candidate, not a canonical LFM register. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + +from lfm.constants import C_DEFAULT, CHI0, KAPPA, LAMBDA_H +from lfm.core.stencils import laplacian_19pt, laplacian_27pt + +Offset = tuple[int, int, int] + + +def _offset3(values: tuple[int, ...]) -> Offset: + return (values[0], values[1], values[2]) + + +def _unique_links(stencil: str) -> tuple[tuple[tuple[int, int, int], float], ...]: + if stencil == "19": + return ( + ((1, 0, 0), 1.0 / 3.0), + ((0, 1, 0), 1.0 / 3.0), + ((0, 0, 1), 1.0 / 3.0), + ((1, 1, 0), 1.0 / 6.0), + ((1, -1, 0), 1.0 / 6.0), + ((1, 0, 1), 1.0 / 6.0), + ((1, 0, -1), 1.0 / 6.0), + ((0, 1, 1), 1.0 / 6.0), + ((0, 1, -1), 1.0 / 6.0), + ) + if stencil == "27": + return ( + ((1, 0, 0), 4.0 / 9.0), + ((0, 1, 0), 4.0 / 9.0), + ((0, 0, 1), 4.0 / 9.0), + ((1, 1, 0), 1.0 / 9.0), + ((1, -1, 0), 1.0 / 9.0), + ((1, 0, 1), 1.0 / 9.0), + ((1, 0, -1), 1.0 / 9.0), + ((0, 1, 1), 1.0 / 9.0), + ((0, 1, -1), 1.0 / 9.0), + ((1, 1, 1), 1.0 / 36.0), + ((1, 1, -1), 1.0 / 36.0), + ((1, -1, 1), 1.0 / 36.0), + ((1, -1, -1), 1.0 / 36.0), + ) + raise ValueError("stencil must be '19' or '27'") + + +def _all_links(stencil: str) -> tuple[tuple[tuple[int, int, int], float], ...]: + result: list[tuple[tuple[int, int, int], float]] = [] + for offset, weight in _unique_links(stencil): + result.append((offset, weight)) + result.append((_offset3(tuple(-value for value in offset)), weight)) + return tuple(result) + + +def _shift(field: np.ndarray, offset: tuple[int, int, int]) -> np.ndarray: + return np.roll(field, shift=offset, axis=(0, 1, 2)) + + +def _laplacian(field: np.ndarray, stencil: str) -> np.ndarray: + if stencil == "19": + return laplacian_19pt(field) + if stencil == "27": + return laplacian_27pt(field) + raise ValueError("stencil must be '19' or '27'") + + +@dataclass(frozen=True) +class LiveClockParameters: + """Parameters and stencil assignments for the candidate live system.""" + + chi0: float = CHI0 + kappa: float = KAPPA + lambda_h: float = LAMBDA_H + matter_speed: float = C_DEFAULT + clock_inertia: float = CHI0 / KAPPA + clock_speed: float = C_DEFAULT + gov01_stencil: str = "19" + gov02_stencil: str = "19" + clock_stencil: str = "19" + + @property + def chi_inertia(self) -> float: + return self.chi0 / self.kappa + + def __post_init__(self) -> None: + positive = ( + self.chi0, + self.kappa, + self.lambda_h, + self.matter_speed, + self.clock_inertia, + self.clock_speed, + ) + if not all(np.isfinite(value) and value > 0.0 for value in positive): + raise ValueError("live clock parameters must be positive and finite") + for stencil in ( + self.gov01_stencil, + self.gov02_stencil, + self.clock_stencil, + ): + _unique_links(stencil) + + +@dataclass +class LiveClockState: + """Canonical coordinates and momenta for a scalar live candidate run.""" + + field: np.ndarray + field_momentum: np.ndarray + chi: np.ndarray + chi_momentum: np.ndarray + varphi: np.ndarray + clock_momentum: np.ndarray + + def __post_init__(self) -> None: + arrays = ( + self.field, + self.field_momentum, + self.chi, + self.chi_momentum, + self.varphi, + self.clock_momentum, + ) + shape = np.asarray(self.field).shape + if len(shape) != 3 or any(np.asarray(value).shape != shape for value in arrays): + raise ValueError("all live clock arrays must share one 3-D shape") + if any(not np.all(np.isfinite(value)) for value in arrays): + raise ValueError("live clock state must contain only finite values") + + def copy(self) -> LiveClockState: + return LiveClockState( + field=self.field.copy(), + field_momentum=self.field_momentum.copy(), + chi=self.chi.copy(), + chi_momentum=self.chi_momentum.copy(), + varphi=self.varphi.copy(), + clock_momentum=self.clock_momentum.copy(), + ) + + +def positive_gradient_density( + field: np.ndarray, + *, + coefficient: float, + stencil: str, +) -> np.ndarray: + """Return a positive site density with half of each link at each end.""" + values = np.asarray(field, dtype=np.float64) + density = np.zeros_like(values) + for offset, weight in _all_links(stencil): + difference = _shift(values, offset) - values + density += 0.25 * coefficient * weight * difference**2 + return density + + +def weighted_gradient_force( + field: np.ndarray, + clock_factor: np.ndarray, + *, + coefficient: float, + stencil: str, +) -> np.ndarray: + """Return minus the field derivative of the clock-weighted link energy.""" + force, _ = _weighted_gradient_force_density( + field, + clock_factor, + coefficient=coefficient, + stencil=stencil, + ) + return force + + +def _weighted_gradient_force_density( + field: np.ndarray, + clock_factor: np.ndarray, + *, + coefficient: float, + stencil: str, +) -> tuple[np.ndarray, np.ndarray]: + """Return the weighted force and unweighted positive link density.""" + values = np.asarray(field, dtype=np.float64) + q = np.asarray(clock_factor, dtype=np.float64) + if values.shape != q.shape: + raise ValueError("field and clock factor must have matching shapes") + force = np.zeros_like(values) + density = np.zeros_like(values) + for offset, weight in _all_links(stencil): + neighbor = _shift(values, offset) + neighbor_q = _shift(q, offset) + difference = neighbor - values + force += 0.5 * coefficient * weight * (q + neighbor_q) * difference + density += 0.25 * coefficient * weight * difference**2 + return force, density + + +def bare_kinetic_density( + state: LiveClockState, + parameters: LiveClockParameters = LiveClockParameters(), +) -> np.ndarray: + """Return the positive bare momentum density.""" + return 0.5 * state.field_momentum**2 + (state.chi_momentum**2 / (2.0 * parameters.chi_inertia)) + + +def bare_potential_density( + state: LiveClockState, + parameters: LiveClockParameters = LiveClockParameters(), +) -> np.ndarray: + """Return the positive bare coordinate and neighbor-link density.""" + matter_gradient = positive_gradient_density( + state.field, + coefficient=parameters.matter_speed**2, + stencil=parameters.gov01_stencil, + ) + chi_gradient = positive_gradient_density( + state.chi, + coefficient=parameters.chi_inertia * parameters.matter_speed**2, + stencil=parameters.gov02_stencil, + ) + interaction = 0.5 * state.chi**2 * state.field**2 + radial = parameters.chi_inertia * parameters.lambda_h * (state.chi**2 - parameters.chi0**2) ** 2 + return matter_gradient + chi_gradient + interaction + radial + + +def bare_energy_density( + state: LiveClockState, + parameters: LiveClockParameters = LiveClockParameters(), +) -> np.ndarray: + """Return the complete positive scalar bare GOV-01/GOV-02 energy.""" + return bare_kinetic_density(state, parameters) + bare_potential_density( + state, + parameters, + ) + + +def clock_factor(state: LiveClockState) -> np.ndarray: + """Return the positive temporal-link factor without clipping.""" + return np.exp(state.varphi) + + +def total_hamiltonian( + state: LiveClockState, + parameters: LiveClockParameters = LiveClockParameters(), +) -> float: + """Evaluate the autonomous live candidate Hamiltonian.""" + q = clock_factor(state) + bare = bare_energy_density(state, parameters) + clock_kinetic = state.clock_momentum**2 / (2.0 * parameters.clock_inertia) + clock_gradient = positive_gradient_density( + state.varphi, + coefficient=parameters.clock_inertia * parameters.clock_speed**2, + stencil=parameters.clock_stencil, + ) + return float(np.sum(q * bare + clock_kinetic + clock_gradient)) + + +def potential_momentum_rates( + state: LiveClockState, + parameters: LiveClockParameters = LiveClockParameters(), +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Return momentum rates from coordinate-dependent Hamiltonian terms.""" + q = clock_factor(state) + field_rate, matter_gradient = _weighted_gradient_force_density( + state.field, + q, + coefficient=parameters.matter_speed**2, + stencil=parameters.gov01_stencil, + ) + field_rate -= q * state.chi**2 * state.field + + chi_rate, chi_gradient = _weighted_gradient_force_density( + state.chi, + q, + coefficient=parameters.chi_inertia * parameters.matter_speed**2, + stencil=parameters.gov02_stencil, + ) + chi_rate -= q * ( + state.chi * state.field**2 + + 4.0 + * parameters.chi_inertia + * parameters.lambda_h + * state.chi + * (state.chi**2 - parameters.chi0**2) + ) + + interaction = 0.5 * state.chi**2 * state.field**2 + radial = parameters.chi_inertia * parameters.lambda_h * (state.chi**2 - parameters.chi0**2) ** 2 + potential = matter_gradient + chi_gradient + interaction + radial + clock_rate = ( + parameters.clock_inertia + * parameters.clock_speed**2 + * _laplacian(state.varphi, parameters.clock_stencil) + - q * potential + ) + return field_rate, chi_rate, clock_rate + + +def clock_momentum_rate( + state: LiveClockState, + parameters: LiveClockParameters = LiveClockParameters(), +) -> np.ndarray: + """Return the complete instantaneous clock momentum rate.""" + q = clock_factor(state) + return parameters.clock_inertia * parameters.clock_speed**2 * _laplacian( + state.varphi, parameters.clock_stencil + ) - q * bare_energy_density(state, parameters) + + +def _potential_kick( + state: LiveClockState, + duration: float, + parameters: LiveClockParameters, +) -> None: + field_rate, chi_rate, clock_rate = potential_momentum_rates( + state, + parameters, + ) + state.field_momentum += duration * field_rate + state.chi_momentum += duration * chi_rate + state.clock_momentum += duration * clock_rate + + +def _clock_kinetic_drift( + state: LiveClockState, + duration: float, + parameters: LiveClockParameters, +) -> None: + state.varphi += duration * state.clock_momentum / parameters.clock_inertia + + +def _bare_kinetic_drift( + state: LiveClockState, + duration: float, + parameters: LiveClockParameters, +) -> None: + q = clock_factor(state) + kinetic = bare_kinetic_density(state, parameters) + state.field += duration * q * state.field_momentum + state.chi += duration * q * state.chi_momentum / parameters.chi_inertia + state.clock_momentum -= duration * q * kinetic + + +def step_live_clock( + state: LiveClockState, + dt: float, + parameters: LiveClockParameters = LiveClockParameters(), +) -> None: + """Advance one symmetric second-order Hamiltonian-splitting step.""" + if not np.isfinite(dt) or dt <= 0.0: + raise ValueError("dt must be positive and finite") + half = 0.5 * dt + _potential_kick(state, half, parameters) + _clock_kinetic_drift(state, half, parameters) + _bare_kinetic_drift(state, dt, parameters) + _clock_kinetic_drift(state, half, parameters) + _potential_kick(state, half, parameters) + + +def step_fixed_clock( + state: LiveClockState, + dt: float, + parameters: LiveClockParameters = LiveClockParameters(), +) -> None: + """Advance bare GOV-01/GOV-02 with the candidate clock fixed to zero.""" + if not np.isfinite(dt) or dt <= 0.0: + raise ValueError("dt must be positive and finite") + if np.max(np.abs(state.varphi)) > 0.0: + raise ValueError("fixed-clock control requires varphi=0") + half = 0.5 * dt + q = np.ones_like(state.field) + + field_rate = ( + weighted_gradient_force( + state.field, + q, + coefficient=parameters.matter_speed**2, + stencil=parameters.gov01_stencil, + ) + - state.chi**2 * state.field + ) + chi_rate = weighted_gradient_force( + state.chi, + q, + coefficient=parameters.chi_inertia * parameters.matter_speed**2, + stencil=parameters.gov02_stencil, + ) - ( + state.chi * state.field**2 + + 4.0 + * parameters.chi_inertia + * parameters.lambda_h + * state.chi + * (state.chi**2 - parameters.chi0**2) + ) + state.field_momentum += half * field_rate + state.chi_momentum += half * chi_rate + + state.field += dt * state.field_momentum + state.chi += dt * state.chi_momentum / parameters.chi_inertia + + field_rate = ( + weighted_gradient_force( + state.field, + q, + coefficient=parameters.matter_speed**2, + stencil=parameters.gov01_stencil, + ) + - state.chi**2 * state.field + ) + chi_rate = weighted_gradient_force( + state.chi, + q, + coefficient=parameters.chi_inertia * parameters.matter_speed**2, + stencil=parameters.gov02_stencil, + ) - ( + state.chi * state.field**2 + + 4.0 + * parameters.chi_inertia + * parameters.lambda_h + * state.chi + * (state.chi**2 - parameters.chi0**2) + ) + state.field_momentum += half * field_rate + state.chi_momentum += half * chi_rate + + +def make_traveling_packet( + size: int, + *, + amplitude: float, + width: float, + carrier_index: int, + parameters: LiveClockParameters = LiveClockParameters(), +) -> LiveClockState: + """Construct an unsupported periodic scalar GOV-01 packet.""" + if size < 8: + raise ValueError("size must be at least 8") + if amplitude <= 0.0 or width <= 0.0 or carrier_index <= 0: + raise ValueError("packet settings must be positive") + coordinates = np.arange(size, dtype=np.float64) - size // 2 + x, y, z = np.meshgrid( + coordinates, + coordinates, + coordinates, + indexing="ij", + ) + radius_sq = x**2 + y**2 + z**2 + wave_number = 2.0 * np.pi * carrier_index / size + envelope = np.exp(-radius_sq / (2.0 * width**2)) + field = amplitude * envelope * np.cos(wave_number * x) + + frequencies = np.fft.fftfreq(size) * 2.0 * np.pi + kx, ky, kz = np.meshgrid( + frequencies, + frequencies, + frequencies, + indexing="ij", + sparse=True, + ) + if parameters.gov01_stencil == "19": + stiffness = -( + (2.0 * np.cos(kx) - 2.0) / 3.0 + + (2.0 * np.cos(ky) - 2.0) / 3.0 + + (2.0 * np.cos(kz) - 2.0) / 3.0 + + ( + np.cos(kx + ky) + + np.cos(kx - ky) + + np.cos(kx + kz) + + np.cos(kx - kz) + + np.cos(ky + kz) + + np.cos(ky - kz) + - 6.0 + ) + / 3.0 + ) + else: + stiffness = -( + (8.0 / 9.0) * (np.cos(kx) + np.cos(ky) + np.cos(kz)) + + (4.0 / 9.0) + * (np.cos(kx) * np.cos(ky) + np.cos(kx) * np.cos(kz) + np.cos(ky) * np.cos(kz)) + + (2.0 / 9.0) * np.cos(kx) * np.cos(ky) * np.cos(kz) + - (38.0 / 9.0) + ) + omega = np.sqrt(parameters.matter_speed**2 * np.maximum(stiffness, 0.0) + parameters.chi0**2) + field_hat = np.fft.fftn(field) + direction = np.sign(np.asarray(kx + np.zeros_like(ky) + np.zeros_like(kz))) + momentum_hat = -1j * direction * omega * field_hat + field_momentum = np.fft.ifftn(momentum_hat).real + + shape = (size, size, size) + return LiveClockState( + field=field, + field_momentum=field_momentum, + chi=np.full(shape, parameters.chi0, dtype=np.float64), + chi_momentum=np.zeros(shape, dtype=np.float64), + varphi=np.zeros(shape, dtype=np.float64), + clock_momentum=np.zeros(shape, dtype=np.float64), + ) diff --git a/lfm/analysis/coarse_graining.py b/lfm/analysis/coarse_graining.py new file mode 100644 index 0000000..936dfed --- /dev/null +++ b/lfm/analysis/coarse_graining.py @@ -0,0 +1,171 @@ +"""Local Fourier blocking diagnostics for LFM lattice propagators. + +The routines in this module evaluate exact alias sums produced by averaging +finite blocks of existing lattice registers and then sampling one value per +block. They do not add a field, modify a governing equation, or solve a +Poisson equation. + +A finite local block map cannot turn an analytic gapped propagator into a +massless pole. The functions expose that statement numerically for the LFM +19-point baseline and the labeled 27-point ablation. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np + +from lfm.core.stencils import eigenvalue_19pt, eigenvalue_27pt + +if TYPE_CHECKING: + from collections.abc import Callable + +Array = np.ndarray + + +def block_window_magnitude_sq( + wave_number: Array | float, + block_factor: int, +) -> Array: + """Return the squared Fourier response of a finite block average. + + The block contains ``block_factor`` consecutive fine sites. Directly + summing the phase factors avoids removable ``0/0`` singularities at + reciprocal-lattice wave numbers. + """ + if block_factor < 1: + raise ValueError("block_factor must be at least one") + values = np.asarray(wave_number, dtype=np.float64) + offsets = np.arange(block_factor, dtype=np.float64) + phases = np.exp(1j * values[..., np.newaxis] * offsets) + window = np.mean(phases, axis=-1) + return np.asarray(np.abs(window) ** 2, dtype=np.float64) + + +def _stencil_eigenvalue(stencil: str) -> Callable[[Array, Array, Array], Array]: + if stencil == "19": + return eigenvalue_19pt + if stencil == "27": + return eigenvalue_27pt + raise ValueError("stencil must be '19' or '27'") + + +def blocked_static_propagator( + coarse_kx: Array | float, + coarse_ky: Array | float, + coarse_kz: Array | float, + *, + block_factor: int, + mass_sq: float, + stencil: str = "19", +) -> Array: + """Return the exact propagator of a locally block-averaged register. + + ``coarse_k*`` are wave numbers on the decimated grid. Each coarse mode + aliases ``block_factor**3`` fine-grid modes + + ``k_fine = (k_coarse + 2*pi*n) / block_factor``. + + The returned response is the finite positive weighted sum of microscopic + propagators ``1 / (mass_sq + K_stencil)``. For ``mass_sq > 0`` it is + analytic at zero momentum. For ``mass_sq == 0`` the uniform mode is + singular and callers must provide nonzero coarse wave numbers. + """ + if block_factor < 1: + raise ValueError("block_factor must be at least one") + if not np.isfinite(mass_sq) or mass_sq < 0.0: + raise ValueError("mass_sq must be finite and nonnegative") + eigenvalue = _stencil_eigenvalue(stencil) + + kx, ky, kz = np.broadcast_arrays( + np.asarray(coarse_kx, dtype=np.float64), + np.asarray(coarse_ky, dtype=np.float64), + np.asarray(coarse_kz, dtype=np.float64), + ) + response = np.zeros_like(kx, dtype=np.float64) + weight_sum = np.zeros_like(kx, dtype=np.float64) + for alias_x in range(block_factor): + fine_kx = (kx + 2.0 * np.pi * alias_x) / block_factor + weight_x = block_window_magnitude_sq(fine_kx, block_factor) + for alias_y in range(block_factor): + fine_ky = (ky + 2.0 * np.pi * alias_y) / block_factor + weight_y = block_window_magnitude_sq(fine_ky, block_factor) + for alias_z in range(block_factor): + fine_kz = (kz + 2.0 * np.pi * alias_z) / block_factor + weight_z = block_window_magnitude_sq( + fine_kz, + block_factor, + ) + weight = weight_x * weight_y * weight_z + stiffness = -np.asarray( + eigenvalue(fine_kx, fine_ky, fine_kz), + dtype=np.float64, + ) + stiffness = np.maximum(stiffness, 0.0) + denominator = mass_sq + stiffness + if np.any(denominator <= 0.0): + raise ValueError("massless blocked propagator requires nonzero modes") + response += weight / denominator + weight_sum += weight + + if not np.allclose(weight_sum, 1.0, rtol=2.0e-13, atol=2.0e-13): + raise RuntimeError("block-alias weights do not form a partition") + return response + + +def response_log_slope( + wave_number: Array, + response: Array, + *, + count: int, +) -> float: + """Fit the small-wave-number power of a positive response.""" + k_values = np.asarray(wave_number, dtype=np.float64) + response_values = np.asarray(response, dtype=np.float64) + if k_values.shape != response_values.shape: + raise ValueError("wave_number and response shapes must match") + if count < 3 or count > k_values.size: + raise ValueError("count must select at least three available modes") + selected_k = k_values[:count] + selected_response = response_values[:count] + if np.any(selected_k <= 0.0) or np.any(selected_response <= 0.0): + raise ValueError("slope fit requires positive values") + return float( + np.polyfit( + np.log(selected_k), + np.log(selected_response), + 1, + )[0] + ) + + +def inverse_response_intercept( + wave_number: Array, + response: Array, + *, + count: int, +) -> float: + """Fit the zero-wave-number intercept of the inverse response.""" + k_values = np.asarray(wave_number, dtype=np.float64) + response_values = np.asarray(response, dtype=np.float64) + if k_values.shape != response_values.shape: + raise ValueError("wave_number and response shapes must match") + if count < 3 or count > k_values.size: + raise ValueError("count must select at least three available modes") + if np.any(response_values[:count] <= 0.0): + raise ValueError("intercept fit requires positive response") + coefficients = np.polyfit( + k_values[:count] ** 2, + 1.0 / response_values[:count], + 1, + ) + return float(coefficients[1]) + + +__all__ = [ + "block_window_magnitude_sq", + "blocked_static_propagator", + "inverse_response_intercept", + "response_log_slope", +] diff --git a/lfm/analysis/collective_geometry.py b/lfm/analysis/collective_geometry.py new file mode 100644 index 0000000..5288bc8 --- /dev/null +++ b/lfm/analysis/collective_geometry.py @@ -0,0 +1,393 @@ +"""Operational coarse-graining for the bare discrete LFM substrate. + +The routines in this module do not define a metric or add evolution state. +They construct registered GOV-01 initial data and measure how an independently +tagged GOV-01 component propagates through the live GOV-01/GOV-02 substrate. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, cast + +import numpy as np + +from lfm.analysis.energy_current import ( + BareLFMParameters, + BareLFMState, + LinkCurrentMap, +) +from lfm.core.stencils import eigenvalue_19pt, eigenvalue_27pt + +if TYPE_CHECKING: + from numpy.typing import NDArray + +SOURCE_CASES = ( + "vacuum_probe", + "static_sphere", + "moving_plus", + "moving_minus", + "quadrupole_plus", + "quadrupole_cross", +) + + +@dataclass(frozen=True) +class WeightedMoments: + """Centroid, covariance, and total weight on a periodic cube.""" + + centroid: np.ndarray + covariance: np.ndarray + total_weight: float + + +@dataclass(frozen=True) +class ContinuumFit: + """Three-resolution fit y(h)=intercept+slope*h^2.""" + + intercept: np.ndarray + slope: np.ndarray + intercept_standard_error: np.ndarray + relative_standard_error: np.ndarray + drop_one_relative_change: np.ndarray + sign_consistent: np.ndarray + + +def minimum_image_mesh( + size: int, + length: float, + center_shift: tuple[float, float, float] = (0.0, 0.0, 0.0), +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Return cell-centered minimum-image coordinates about a shifted origin.""" + if size <= 0 or length <= 0.0: + raise ValueError("size and length must be positive") + spacing = length / size + base = (np.arange(size, dtype=float) - size // 2) * spacing + axes = [] + for shift in center_shift: + coordinate = (base - shift + 0.5 * length) % length - 0.5 * length + axes.append(coordinate) + return cast( + "tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]", + np.meshgrid(*axes, indexing="ij"), + ) + + +def collective_initial_state( + size: int, + length: float, + case: str, + *, + chi0: float = 19.0, + source_amplitude: float = 20.0, + probe_amplitude: float = 0.05, + source_shift: tuple[float, float, float] = (0.0, 0.0, 0.0), + probe_shift: tuple[float, float, float] = (0.0, 0.0, 0.0), + motion_direction: tuple[float, float, float] = (1.0, 0.0, 0.0), + quadrupole_angle: float = 0.0, +) -> BareLFMState: + """Construct the frozen six-real-component source/probe initial state.""" + if case not in SOURCE_CASES: + raise ValueError(f"case must be one of {SOURCE_CASES}") + if source_amplitude < 0.0 or probe_amplitude <= 0.0: + raise ValueError("source amplitude must be nonnegative and probe positive") + x, y, z = minimum_image_mesh(size, length, source_shift) + radius_sq = x**2 + y**2 + z**2 + shape = (6, size, size, size) + wave = np.zeros(shape, dtype=float) + wave_momentum = np.zeros_like(wave) + chi = np.full((size, size, size), chi0, dtype=float) + chi_momentum = np.zeros_like(chi) + + if case != "vacuum_probe": + sigma = 0.16 if case.startswith("quadrupole") else 0.12 + envelope = np.exp(-0.5 * radius_sq / sigma**2) + omega = chi0 + if case == "moving_plus" or case == "moving_minus": + sign = 1.0 if case == "moving_plus" else -1.0 + direction = np.asarray(motion_direction, dtype=float) + norm = float(np.linalg.norm(direction)) + if not np.isfinite(norm) or norm <= 0.0: + raise ValueError("motion_direction must be finite and nonzero") + direction /= norm + wave_number = sign * 4.0 * np.pi / length + phase = wave_number * (direction[0] * x + direction[1] * y + direction[2] * z) + wave[0] = source_amplitude * envelope * np.cos(phase) + wave[1] = source_amplitude * envelope * np.sin(phase) + omega = float(np.sqrt(chi0**2 + wave_number**2)) + elif case == "static_sphere": + wave[0] = source_amplitude * envelope + else: + cosine = np.cos(quadrupole_angle) + sine = np.sin(quadrupole_angle) + rotated_x = cosine * x + sine * y + rotated_y = -sine * x + cosine * y + pattern = rotated_x**2 - rotated_y**2 + if case == "quadrupole_cross": + pattern = 2.0 * rotated_x * rotated_y + peak = float(np.max(np.abs(pattern * envelope))) + if peak <= 0.0: + raise ValueError("quadrupole pattern is unresolved") + wave[0] = source_amplitude * pattern * envelope / peak + wave_momentum[0] = omega * wave[1] + wave_momentum[1] = -omega * wave[0] + + probe_x, probe_y, probe_z = minimum_image_mesh(size, length, probe_shift) + probe_radius_sq = probe_x**2 + probe_y**2 + probe_z**2 + wave_momentum[4] = probe_amplitude * np.exp(-0.5 * probe_radius_sq / 0.08**2) + return BareLFMState( + wave=wave, + wave_momentum=wave_momentum, + chi=chi, + chi_momentum=chi_momentum, + ) + + +def apply_momentum_sponge( + state: BareLFMState, + length: float, + dt: float, + *, + start_fraction: float = 0.75, + strength: float = 40.0, +) -> BareLFMState: + """Apply an explicitly non-Hamiltonian absorbing boundary ablation. + + The sponge is a boundary-condition red-team tool, not part of GOV-01 or + GOV-02. It damps canonical momenta only in the outer cubical layer. + """ + if not 0.0 < start_fraction < 1.0: + raise ValueError("start_fraction must lie between zero and one") + if length <= 0.0 or dt <= 0.0 or strength < 0.0: + raise ValueError("length and dt must be positive and strength nonnegative") + size = state.chi.shape[0] + x, y, z = minimum_image_mesh(size, length) + radial_fraction = np.maximum.reduce((np.abs(x), np.abs(y), np.abs(z))) / (0.5 * length) + ramp = np.clip( + (radial_fraction - start_fraction) / (1.0 - start_fraction), + 0.0, + 1.0, + ) + damping = np.exp(-strength * ramp**2 * dt) + return BareLFMState( + wave=state.wave, + wave_momentum=state.wave_momentum * damping[np.newaxis, ...], + chi=state.chi, + chi_momentum=state.chi_momentum * damping, + ) + + +def periodic_weighted_moments( + weights: np.ndarray, + length: float, +) -> WeightedMoments: + """Measure centroid and covariance without a periodic-boundary seam.""" + values = np.asarray(weights, dtype=float) + if values.ndim != 3 or len(set(values.shape)) != 1: + raise ValueError("weights must have cubic shape (N,N,N)") + if not np.all(np.isfinite(values)) or np.min(values) < -1.0e-14: + raise ValueError("weights must be finite and nonnegative") + values = np.maximum(values, 0.0) + total = float(np.sum(values)) + if total <= 0.0: + raise ValueError("weights must have positive sum") + size = values.shape[0] + phase = 2.0 * np.pi * np.arange(size, dtype=float) / size + centroid_index = np.empty(3, dtype=float) + for axis in range(3): + marginal_axes = tuple(candidate for candidate in range(3) if candidate != axis) + marginal = np.sum(values, axis=marginal_axes) + phasor = np.sum(marginal * np.exp(1j * phase)) + angle = float(np.angle(phasor)) % (2.0 * np.pi) + centroid_index[axis] = angle * size / (2.0 * np.pi) + + index = np.arange(size, dtype=float) + delta_axes = [] + for center in centroid_index: + delta_index = (index - center + 0.5 * size) % size - 0.5 * size + delta_axes.append(delta_index * length / size) + dx, dy, dz = np.meshgrid(*delta_axes, indexing="ij") + deltas = (dx, dy, dz) + covariance = np.empty((3, 3), dtype=float) + for row in range(3): + for column in range(3): + covariance[row, column] = float(np.sum(values * deltas[row] * deltas[column]) / total) + centroid = ((centroid_index - size // 2 + 0.5 * size) % size - 0.5 * size) * length / size + return WeightedMoments( + centroid=centroid, + covariance=covariance, + total_weight=total, + ) + + +def block_average(values: np.ndarray, factor: int) -> np.ndarray: + """Average scalar/vector/tensor data over nonoverlapping cubic blocks.""" + array = np.asarray(values) + if array.ndim < 3 or len(set(array.shape[-3:])) != 1: + raise ValueError("the final three axes must form a cubic lattice") + size = array.shape[-1] + if factor <= 0 or size % factor != 0: + raise ValueError("factor must divide the lattice size") + blocks = size // factor + prefix = array.shape[:-3] + reshaped = array.reshape(prefix + (blocks, factor, blocks, factor, blocks, factor)) + offset = len(prefix) + return np.mean(reshaped, axis=(offset + 1, offset + 3, offset + 5)) + + +def energy_current_vector_and_tensor( + currents: LinkCurrentMap, + spacing: float, +) -> tuple[np.ndarray, np.ndarray]: + """Convert exact link currents into local vector and direction tensor.""" + if not currents or spacing <= 0.0: + raise ValueError("currents must be nonempty and spacing positive") + sample = next(iter(currents.values())) + vector = np.zeros((3,) + sample.shape, dtype=float) + tensor = np.zeros((3, 3) + sample.shape, dtype=float) + magnitude = np.zeros(sample.shape, dtype=float) + for offset, current in currents.items(): + direction = np.asarray(offset, dtype=float) + unit = direction / np.linalg.norm(direction) + vector += ( + 0.5 + * current[np.newaxis, ...] + * direction[:, np.newaxis, np.newaxis, np.newaxis] + * spacing + ) + absolute = 0.5 * np.abs(current) + magnitude += absolute + tensor += ( + np.outer(unit, unit)[:, :, np.newaxis, np.newaxis, np.newaxis] + * absolute[np.newaxis, np.newaxis, ...] + ) + tensor /= np.maximum(magnitude, np.finfo(float).tiny)[np.newaxis, np.newaxis, ...] + return vector, tensor + + +def traceless(tensor: np.ndarray) -> np.ndarray: + """Return the traceless part of arrays whose first axes are 3 by 3.""" + values = np.asarray(tensor, dtype=float) + if values.shape[:2] != (3, 3): + raise ValueError("tensor must begin with shape (3,3)") + trace = np.trace(values, axis1=0, axis2=1) / 3.0 + result = values.copy() + for axis in range(3): + result[axis, axis] -= trace + return result + + +def continuum_fit(spacings: np.ndarray, values: np.ndarray) -> ContinuumFit: + """Fit three or more matched measurements to y(h)=a+b*h^2.""" + h = np.asarray(spacings, dtype=float) + y = np.asarray(values, dtype=float) + if h.ndim != 1 or h.size < 3 or y.shape[0] != h.size: + raise ValueError("need at least three values with resolution on axis zero") + design = np.column_stack((np.ones_like(h), h**2)) + flat = y.reshape(h.size, -1) + coefficients, _, _, _ = np.linalg.lstsq(design, flat, rcond=None) + fitted = design @ coefficients + residual = flat - fitted + degrees = h.size - 2 + variance = np.sum(residual**2, axis=0) / degrees + covariance_factor = np.linalg.inv(design.T @ design)[0, 0] + standard_error = np.sqrt(np.maximum(variance * covariance_factor, 0.0)) + intercept = coefficients[0] + slope = coefficients[1] + + drop_changes = np.zeros_like(intercept) + for dropped in range(h.size): + keep = np.arange(h.size) != dropped + reduced_design = design[keep] + reduced_coefficients, _, _, _ = np.linalg.lstsq( + reduced_design, + flat[keep], + rcond=None, + ) + change = np.abs(reduced_coefficients[0] - intercept) / np.maximum( + np.abs(intercept), + np.finfo(float).tiny, + ) + drop_changes = np.maximum(drop_changes, change) + nonzero = np.abs(flat) > 100.0 * np.finfo(float).eps + positive = np.all((flat > 0.0) | ~nonzero, axis=0) + negative = np.all((flat < 0.0) | ~nonzero, axis=0) + output_shape = y.shape[1:] + return ContinuumFit( + intercept=intercept.reshape(output_shape), + slope=slope.reshape(output_shape), + intercept_standard_error=standard_error.reshape(output_shape), + relative_standard_error=( + standard_error / np.maximum(np.abs(intercept), np.finfo(float).tiny) + ).reshape(output_shape), + drop_one_relative_change=drop_changes.reshape(output_shape), + sign_consistent=(positive | negative).reshape(output_shape), + ) + + +def dispersion_shell_metrics( + stencil: str, + spacing: float, + physical_wave_number: float, + *, + mass: float = 19.0, + wave_speed: float = 1.0, +) -> dict[str, float]: + """Compare equal-|k| axis, face-diagonal, and body-diagonal modes.""" + if stencil == "19": + eigenvalue = eigenvalue_19pt + elif stencil == "27": + eigenvalue = eigenvalue_27pt + else: + raise ValueError("stencil must be '19' or '27'") + directions = np.asarray( + ( + (1.0, 0.0, 0.0), + (1.0 / np.sqrt(2.0), 1.0 / np.sqrt(2.0), 0.0), + (1.0 / np.sqrt(3.0),) * 3, + ) + ) + dimensionless = physical_wave_number * spacing * directions + lambdas = np.asarray( + [eigenvalue(*wave_vector) for wave_vector in dimensionless], + dtype=float, + ) + frequencies = np.sqrt(mass**2 - wave_speed**2 * lambdas / spacing**2) + continuum = float(np.sqrt(mass**2 + wave_speed**2 * physical_wave_number**2)) + relative = (frequencies - continuum) / continuum + return { + "axis_relative_error": float(relative[0]), + "face_relative_error": float(relative[1]), + "body_relative_error": float(relative[2]), + "directional_anisotropy": float( + (np.max(frequencies) - np.min(frequencies)) / np.mean(frequencies) + ), + } + + +def analytic_leapfrog_limit( + parameters: BareLFMParameters, + *, + symbol_samples: int = 65, +) -> dict[str, float]: + """Return the conservative vacuum Verlet limit from both lattice symbols.""" + wave_numbers = np.linspace(-np.pi, np.pi, symbol_samples) + kx, ky, kz = np.meshgrid(wave_numbers, wave_numbers, wave_numbers, indexing="ij") + eigenvalues = {"19": eigenvalue_19pt, "27": eigenvalue_27pt} + wave_mu = float(-np.min(eigenvalues[parameters.gov01_stencil](kx, ky, kz))) + chi_mu = float(-np.min(eigenvalues[parameters.gov02_stencil](kx, ky, kz))) + wave_omega_sq = parameters.chi0**2 + parameters.wave_speed**2 * wave_mu / parameters.spacing**2 + chi_mass_sq = ( + 8.0 * parameters.lambda_h * parameters.chi0**2 + if parameters.chi_potential == "quartic" + else 0.0 + ) + chi_omega_sq = chi_mass_sq + parameters.wave_speed**2 * chi_mu / parameters.spacing**2 + maximum_omega = float(np.sqrt(max(wave_omega_sq, chi_omega_sq))) + return { + "wave_symbol_max": wave_mu, + "chi_symbol_max": chi_mu, + "maximum_vacuum_omega": maximum_omega, + "maximum_dt": 2.0 / maximum_omega, + "maximum_courant": 2.0 / (maximum_omega * parameters.spacing), + } diff --git a/lfm/analysis/collective_spectrum.py b/lfm/analysis/collective_spectrum.py new file mode 100644 index 0000000..f8182ad --- /dev/null +++ b/lfm/analysis/collective_spectrum.py @@ -0,0 +1,382 @@ +"""Collective-mode diagnostics for the bare full-R2 LFM equations. + +This module does not add a metric, gauge field, constraint, or target force +law. It linearizes the unchanged three-complex-component GOV-01 field and +the real GOV-02 field around homogeneous rotating backgrounds. The spatial +operator is the exact canonical 19-point Fourier symbol. + +The resulting quadratic eigenvalue problem is useful as a fail-closed screen: +Einstein or Maxwell closure needs the required propagating polarizations to +exist before a nonlinear live experiment can sensibly test their interactions. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Literal + +import numpy as np + +from lfm.constants import CHI0, KAPPA, LAMBDA_H +from lfm.core.stencils import eigenvalue_19pt + +if TYPE_CHECKING: + from numpy.typing import NDArray + + +RestoringModel = Literal["canonical_quartic", "flat_octic"] + + +@dataclass(frozen=True) +class RotatingBackground: + """Equal-density full-R2 rotating background. + + ``q_vectors[a]`` is the carrier wave vector of component ``a``. A zero + matrix is the homogeneous condensate. Equal-magnitude Cartesian rows are + the preregistered isotropic Fourier triad. + """ + + model: RestoringModel + total_density: float + component_amplitude: float + chi: float + chi_mass_sq: float + q_vectors: NDArray[np.float64] + carrier_frequencies: NDArray[np.float64] + spacing: float + wave_speed: float + + +def _symbol_19(vector: NDArray[np.float64], spacing: float) -> float: + argument = np.asarray(vector, dtype=float) * spacing + value = eigenvalue_19pt(argument[0], argument[1], argument[2]) + return float(value) / spacing**2 + + +def discrete_stiffness_19(vector: NDArray[np.float64], spacing: float = 1.0) -> float: + """Return the nonnegative ``-L19`` eigenvalue at a physical wave vector.""" + + if spacing <= 0.0: + raise ValueError("spacing must be positive") + return -_symbol_19(np.asarray(vector, dtype=float), spacing) + + +def rotating_background( + total_density: float, + q_vectors: NDArray[np.float64] | None = None, + *, + model: RestoringModel = "canonical_quartic", + spacing: float = 1.0, + wave_speed: float = 1.0, +) -> RotatingBackground: + """Construct an exact uniform-density background of bare GOV-01/GOV-02. + + The density is shared equally by the three complex GOV-01 components. + The positive GOV-02 branch is used. ``E0`` is zero, matching the current + canonical vacuum experiments. + """ + + density = float(total_density) + if density < 0.0: + raise ValueError("total_density must be nonnegative") + if spacing <= 0.0: + raise ValueError("spacing must be positive") + if wave_speed <= 0.0: + raise ValueError("wave_speed must be positive") + + q = np.zeros((3, 3), dtype=float) if q_vectors is None else np.asarray(q_vectors, dtype=float) + if q.shape != (3, 3): + raise ValueError("q_vectors must have shape (3, 3)") + + source_coupling = KAPPA / CHI0 + if model == "canonical_quartic": + chi_sq = CHI0**2 - source_coupling * density / (4.0 * LAMBDA_H) + if chi_sq <= 0.0: + raise ValueError("density lies beyond the positive quartic background branch") + chi_mass_sq = 8.0 * LAMBDA_H * chi_sq + elif model == "flat_octic": + delta = -np.cbrt(source_coupling * density * CHI0**4 / (8.0 * LAMBDA_H)) + chi_sq = CHI0**2 + float(delta) + if chi_sq <= 0.0: + raise ValueError("density lies beyond the positive flat-octic background branch") + chi_mass_sq = 48.0 * LAMBDA_H * chi_sq * float(delta) ** 2 / CHI0**4 + else: + raise ValueError(f"unknown restoring model: {model}") + + chi = float(np.sqrt(chi_sq)) + frequencies = np.array( + [np.sqrt(chi_sq + wave_speed**2 * discrete_stiffness_19(row, spacing)) for row in q], + dtype=float, + ) + return RotatingBackground( + model=model, + total_density=density, + component_amplitude=float(np.sqrt(density / 3.0)), + chi=chi, + chi_mass_sq=float(chi_mass_sq), + q_vectors=q.copy(), + carrier_frequencies=frequencies, + spacing=float(spacing), + wave_speed=float(wave_speed), + ) + + +def gov02_background_residual(background: RotatingBackground) -> float: + """Evaluate the algebraic GOV-02 residual of a rotating background.""" + + chi = background.chi + density = background.total_density + source_coupling = KAPPA / CHI0 + delta = chi**2 - CHI0**2 + if background.model == "canonical_quartic": + restoring = 4.0 * LAMBDA_H * chi * delta + else: + restoring = 8.0 * LAMBDA_H * chi * delta**3 / CHI0**4 + return float(source_coupling * chi * density + restoring) + + +def principal_symbol_audit() -> dict[str, object]: + """Return the exact highest-derivative structure of the bare equations.""" + + return { + "real_field_count": 7, + "principal_operator": "(partial_t^2 - c^2 L19) times identity_7", + "background_dependent": False, + "component_spin_under_spatial_rotations": "seven scalar amplitudes", + "trivial_vacuum_gapless_spin_1_count": 0, + "trivial_vacuum_gapless_spin_2_count": 0, + "reason": ( + "GOV-01/GOV-02 couplings are algebraic. They change the mass matrix " + "but not the shared 19-point principal symbol." + ), + } + + +def vacuum_spectrum_audit(model: RestoringModel) -> dict[str, object]: + """Return the exact small-perturbation spectrum at the positive vacuum.""" + + if model == "canonical_quartic": + chi_mass_sq = 8.0 * LAMBDA_H * CHI0**2 + elif model == "flat_octic": + chi_mass_sq = 0.0 + else: + raise ValueError(f"unknown restoring model: {model}") + return { + "model": model, + "vacuum": "Psi_a=0, chi=chi0", + "gov01_real_mode_count": 6, + "gov01_mass_sq": CHI0**2, + "gov01_spatial_spin": 0, + "chi_real_mode_count": 1, + "chi_mass_sq": chi_mass_sq, + "chi_spatial_spin": 0, + "gapless_spin_0_count": 1 if chi_mass_sq == 0.0 else 0, + "gapless_spin_1_count": 0, + "gapless_spin_2_count": 0, + "localized_background_implication": ( + "Any finite-energy localized state returns to this spectrum at spatial infinity." + ), + } + + +def zero_chi_branch_audit( + total_density: float, + model: RestoringModel, +) -> dict[str, object]: + """Audit the exact ``chi=0`` constant full-R2 background. + + The canonical parameter remains ``CHI0=19``. Because GOV-01 contains + ``chi**2 Psi``, every component is massless on this field-value branch. + The same square makes the linear matter/chi coupling vanish there. + """ + + density = float(total_density) + if density < 0.0: + raise ValueError("total_density must be nonnegative") + source_coupling = KAPPA / CHI0 + if model == "canonical_quartic": + threshold = 4.0 * LAMBDA_H * CHI0**2 / source_coupling + chi_mass_sq = source_coupling * density - 4.0 * LAMBDA_H * CHI0**2 + threshold_leading_force = "-4 lambda_h chi^3" + elif model == "flat_octic": + threshold = 8.0 * LAMBDA_H * CHI0**2 / source_coupling + chi_mass_sq = source_coupling * density - 8.0 * LAMBDA_H * CHI0**2 + threshold_leading_force = "-24 lambda_h chi^3" + else: + raise ValueError(f"unknown restoring model: {model}") + + tolerance = 1.0e-12 * max(1.0, threshold) + if chi_mass_sq > tolerance: + stability = "LINEARLY_STABLE_CHI_GAPPED" + elif chi_mass_sq < -tolerance: + stability = "TACHYONIC" + else: + stability = "CRITICAL_CHI_GAPLESS_NONLINEARLY_RESTORED" + return { + "model": model, + "chi0_parameter": CHI0, + "chi_field_value": 0.0, + "total_density": density, + "stability_threshold_density": threshold, + "chi_linear_mass_sq": chi_mass_sq, + "stability": stability, + "threshold_leading_force": threshold_leading_force, + "gov01_massless_real_scalar_count": 6, + "gov01_spatial_spin": 0, + "bare_local_gauge_redundancy": False, + "bare_gauss_constraint": False, + "longitudinal_modes_removed": 0, + "linear_matter_to_chi_source_coefficient": 0.0, + "linear_chi_to_matter_response_coefficient": 0.0, + "coupling_reason": ("Both coefficients are proportional to d(chi^2)/dchi = 2 chi."), + "simultaneous_newtonian_and_maxwell_carrier": False, + } + + +def collective_qep_matrices( + background: RotatingBackground, + k_vector: NDArray[np.float64], +) -> tuple[NDArray[np.complex128], NDArray[np.complex128]]: + """Build ``C, K`` for ``(-Omega^2 I + Omega C + K)x = 0``. + + The perturbation order is ``(u0,u1,u2,v0,v1,v2,xi)``. ``u`` and ``v`` + are the carrier-frame amplitude and phase quadratures, and ``xi`` is the + GOV-02 perturbation. Even and odd carrier sidebands use the exact L19 + symbol, so the result includes lattice dispersion without a continuum + replacement. + """ + + k = np.asarray(k_vector, dtype=float) + if k.shape != (3,): + raise ValueError("k_vector must have shape (3,)") + + c = background.wave_speed + spacing = background.spacing + amplitude = background.component_amplitude + chi = background.chi + source_coupling = KAPPA / CHI0 + c_matrix = np.zeros((7, 7), dtype=np.complex128) + k_matrix = np.zeros((7, 7), dtype=np.complex128) + + for component in range(3): + q = background.q_vectors[component] + symbol_q = _symbol_19(q, spacing) + symbol_plus = _symbol_19(q + k, spacing) + symbol_minus = _symbol_19(q - k, spacing) + even_stiffness = -(c**2) * (0.5 * (symbol_plus + symbol_minus) - symbol_q) + odd_stiffness = -(c**2) * 0.5 * (symbol_plus - symbol_minus) + u_index = component + v_index = 3 + component + mu = background.carrier_frequencies[component] + + c_matrix[u_index, v_index] = -2.0j * mu + c_matrix[v_index, u_index] = 2.0j * mu + k_matrix[u_index, u_index] = even_stiffness + k_matrix[v_index, v_index] = even_stiffness + k_matrix[u_index, v_index] = 1.0j * odd_stiffness + k_matrix[v_index, u_index] = -1.0j * odd_stiffness + k_matrix[u_index, 6] = 2.0 * chi * amplitude + k_matrix[6, u_index] = 2.0 * source_coupling * chi * amplitude + + k_matrix[6, 6] = c**2 * discrete_stiffness_19(k, spacing) + background.chi_mass_sq + return c_matrix, k_matrix + + +def collective_mode_eigenpairs( + background: RotatingBackground, + k_vector: NDArray[np.float64], +) -> tuple[NDArray[np.complex128], NDArray[np.complex128]]: + """Solve the 14-dimensional companion eigenproblem for ``Omega``.""" + + c_matrix, k_matrix = collective_qep_matrices(background, k_vector) + zero = np.zeros_like(c_matrix) + identity = np.eye(c_matrix.shape[0], dtype=np.complex128) + companion = np.block([[zero, identity], [k_matrix, c_matrix]]) + values, vectors = np.linalg.eig(companion) + order = np.lexsort((values.imag, values.real)) + return values[order], vectors[:, order] + + +def mode_polarization( + position_eigenvector: NDArray[np.complex128], + k_vector: NDArray[np.float64], +) -> dict[str, float]: + """Measure the phase-vector longitudinal/transverse and TT content. + + For the Fourier triad the three phase quadratures can be identified with + spatial axes. The symmetric phase-displacement strain is a useful test of + the proposed lattice-as-frame reading. Its transverse-traceless part is + computed rather than assumed. + """ + + state = np.asarray(position_eigenvector, dtype=np.complex128) + if state.shape != (7,): + raise ValueError("position_eigenvector must have shape (7,)") + k = np.asarray(k_vector, dtype=float) + norm_k = float(np.linalg.norm(k)) + phase = state[3:6] + phase_norm_sq = float(np.vdot(phase, phase).real) + if norm_k == 0.0 or phase_norm_sq == 0.0: + return { + "phase_fraction": 0.0, + "phase_transverse_fraction": 0.0, + "phase_longitudinal_fraction": 0.0, + "phase_strain_tt_fraction": 0.0, + } + + direction = k / norm_k + longitudinal = complex(np.dot(direction, phase)) + longitudinal_fraction = float(abs(longitudinal) ** 2 / phase_norm_sq) + transverse_fraction = float(max(0.0, 1.0 - longitudinal_fraction)) + + strain = 1.0j * (np.outer(k, phase) + np.outer(phase, k)) + projector = np.eye(3) - np.outer(direction, direction) + projected = projector @ strain @ projector + tt = projected - 0.5 * projector * np.trace(projected) + strain_norm_sq = float(np.vdot(strain, strain).real) + tt_norm_sq = float(np.vdot(tt, tt).real) + full_norm_sq = float(np.vdot(state, state).real) + return { + "phase_fraction": float(phase_norm_sq / full_norm_sq) if full_norm_sq else 0.0, + "phase_transverse_fraction": transverse_fraction, + "phase_longitudinal_fraction": longitudinal_fraction, + "phase_strain_tt_fraction": tt_norm_sq / strain_norm_sq if strain_norm_sq else 0.0, + } + + +def berry_action_derivative_audit() -> dict[str, object]: + """Audit whether the bare action already contains Maxwell dynamics. + + For ``z = Psi/sqrt(Psi^dagger Psi)``, the Berry connection + ``A_mu = Im(z^dagger partial_mu z)`` is a composite first-derivative + readout. Its curvature is quadratic in first derivatives. A Maxwell + ``F_mu_nu F^mu_nu`` term is fourth order in derivatives of ``z`` and is + not present in the two-derivative bare GOV-01 action. + """ + + return { + "composite_connection": "A_mu = Im(z_dagger partial_mu z)", + "composite_curvature": ("F_mu_nu = 2 Im(partial_mu z_dagger partial_nu z)"), + "bare_action_highest_derivative_order": 2, + "composite_f_squared_derivative_order": 4, + "bare_action_contains_independent_f_squared": False, + "maxwell_requires_controlled_elimination_or_new_effective_term": True, + "homogeneous_identity_is_kinematic": True, + "inhomogeneous_maxwell_equation_is_bare_euler_lagrange_equation": False, + } + + +__all__ = [ + "RestoringModel", + "RotatingBackground", + "berry_action_derivative_audit", + "collective_mode_eigenpairs", + "collective_qep_matrices", + "discrete_stiffness_19", + "gov02_background_residual", + "mode_polarization", + "principal_symbol_audit", + "rotating_background", + "vacuum_spectrum_audit", + "zero_chi_branch_audit", +] diff --git a/lfm/analysis/em_closure.py b/lfm/analysis/em_closure.py new file mode 100644 index 0000000..0924f30 --- /dev/null +++ b/lfm/analysis/em_closure.py @@ -0,0 +1,431 @@ +"""Reusable electromagnetic closure diagnostics for LFM experiments. + +This module contains diagnostic readouts and small live probes. It does not +promote a canonical equation change and it does not insert a Coulomb, Maxwell, +Lorentz, Poisson, or nonlocal Green-function update. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import Any, cast + +import numpy as np + +from lfm import BoundaryType, FieldLevel, Precision, Simulation, SimulationConfig +from lfm.analysis.phase import canonical_charge_density +from lfm.config import ChiPotentialModel +from lfm.constants import C_DEFAULT, CHI0, DT_DEFAULT, KAPPA, LAMBDA_H +from lfm.core.stencils import gradient_19pt + + +def _snapshot_array(snapshot: dict[str, object], key: str) -> np.ndarray: + return cast("np.ndarray", snapshot[key]) + + +def rms(values: np.ndarray) -> float: + """Return root-mean-square magnitude.""" + + arr = np.asarray(values, dtype=np.float64) + return float(np.sqrt(np.mean(arr * arr))) + + +def l2_norm(values: np.ndarray) -> float: + """Return L2 norm.""" + + arr = np.asarray(values, dtype=np.float64) + return float(np.sqrt(np.sum(arr * arr))) + + +def fit_power_law(xs: list[float], ys: list[float]) -> dict[str, float | None]: + """Fit y = A*x**slope on positive samples.""" + + x = np.asarray(xs, dtype=np.float64) + y = np.asarray(ys, dtype=np.float64) + if len(x) < 3 or np.any(x <= 0.0) or np.any(y <= 0.0): + return {"slope": None, "intercept": None, "r_squared": 0.0} + log_x = np.log(x) + log_y = np.log(y) + slope, intercept = np.polyfit(log_x, log_y, 1) + pred = slope * log_x + intercept + residual = float(np.sum((log_y - pred) ** 2)) + total = float(np.sum((log_y - np.mean(log_y)) ** 2)) + r_squared = 1.0 - residual / total if total > 0.0 else 1.0 + return { + "slope": float(slope), + "intercept": float(intercept), + "r_squared": float(r_squared), + } + + +def divergence_19(vector: np.ndarray, *, dx: float = 1.0) -> np.ndarray: + """Return site-centered divergence of a three-component vector field.""" + + arr = np.asarray(vector, dtype=np.float64) + if arr.shape[0] != 3 or arr.ndim != 4: + raise ValueError("vector must have shape (3,nx,ny,nz)") + grad_x = gradient_19pt(arr[0], dx=dx) + grad_y = gradient_19pt(arr[1], dx=dx) + grad_z = gradient_19pt(arr[2], dx=dx) + return grad_x[0] + grad_y[1] + grad_z[2] + + +def curl_19(vector: np.ndarray, *, dx: float = 1.0) -> np.ndarray: + """Return site-centered curl of a three-component vector field.""" + + arr = np.asarray(vector, dtype=np.float64) + if arr.shape[0] != 3 or arr.ndim != 4: + raise ValueError("vector must have shape (3,nx,ny,nz)") + grad_x = gradient_19pt(arr[0], dx=dx) + grad_y = gradient_19pt(arr[1], dx=dx) + grad_z = gradient_19pt(arr[2], dx=dx) + out = np.empty_like(arr) + out[0] = grad_z[1] - grad_y[2] + out[1] = grad_x[2] - grad_z[0] + out[2] = grad_y[0] - grad_x[1] + return out + + +def clock_shear_acceleration(vector: np.ndarray, *, dx: float = 1.0) -> np.ndarray: + """Return -curl(curl(A)) for the clock-shear carrier.""" + + return -curl_19(curl_19(vector, dx=dx), dx=dx) + + +def clock_shear_energy_density(a_field: np.ndarray, e_field: np.ndarray) -> np.ndarray: + """Return local clock-shear field energy density.""" + + b_field = curl_19(a_field) + return 0.5 * (np.sum(np.asarray(e_field) ** 2, axis=0) + np.sum(b_field * b_field, axis=0)) + + +def total_clock_shear_energy(a_field: np.ndarray, e_field: np.ndarray) -> float: + """Return total clock-shear field energy.""" + + return float(np.sum(clock_shear_energy_density(a_field, e_field))) + + +def color_noether_charge_density( + psi_real: np.ndarray, + psi_imag: np.ndarray, + psi_real_prev: np.ndarray, + psi_imag_prev: np.ndarray, + *, + dt: float, +) -> np.ndarray: + """Return summed temporal Noether charge density for color fields.""" + + if dt <= 0.0: + raise ValueError("dt must be positive") + real = np.asarray(psi_real, dtype=np.float64) + imag = np.asarray(psi_imag, dtype=np.float64) + real_prev = np.asarray(psi_real_prev, dtype=np.float64) + imag_prev = np.asarray(psi_imag_prev, dtype=np.float64) + if real.shape != imag.shape or real.shape != real_prev.shape: + raise ValueError("color field arrays must have matching shapes") + if real.ndim == 3: + real = real[None, ...] + imag = imag[None, ...] + real_prev = real_prev[None, ...] + imag_prev = imag_prev[None, ...] + if real.ndim != 4: + raise ValueError("color field arrays must have shape (components,nx,ny,nz)") + momentum_real = (real - real_prev) / dt + momentum_imag = (imag - imag_prev) / dt + charge = np.zeros(real.shape[1:], dtype=np.float64) + for component in range(real.shape[0]): + charge += canonical_charge_density( + real[component], + imag[component], + momentum_real[component], + momentum_imag[component], + ) + return charge + + +def color_noether_spatial_current( + psi_real: np.ndarray, + psi_imag: np.ndarray, + *, + c: float = C_DEFAULT, + dx: float = 1.0, +) -> np.ndarray: + """Return summed spatial Noether current for color fields.""" + + real = np.asarray(psi_real, dtype=np.float64) + imag = np.asarray(psi_imag, dtype=np.float64) + if real.shape != imag.shape: + raise ValueError("psi_real and psi_imag must have matching shapes") + if real.ndim == 3: + real = real[None, ...] + imag = imag[None, ...] + if real.ndim != 4: + raise ValueError("color fields must have shape (components,nx,ny,nz)") + current = np.zeros((3, *real.shape[1:]), dtype=np.float64) + c2 = c * c + for component in range(real.shape[0]): + grad_real = gradient_19pt(real[component], dx=dx) + grad_imag = gradient_19pt(imag[component], dx=dx) + for axis in range(3): + current[axis] += -c2 * ( + real[component] * grad_imag[axis] - imag[component] * grad_real[axis] + ) + return current + + +@dataclass(frozen=True) +class PacketSpec: + """Prepared charge packet specification.""" + + center: tuple[float, float, float] + charge_sign: int + component: int = 0 + phase: float = 0.0 + + +def periodic_displacement_grid( + size: int, + center: tuple[float, float, float], +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Return periodic displacement components and radius from center.""" + + coords = np.indices((size, size, size), dtype=np.float64) + displacements = [] + for axis, value in enumerate(center): + raw = coords[axis] - float(value) + raw = (raw + 0.5 * size) % size - 0.5 * size + displacements.append(raw) + radius_sq = sum(item * item for item in displacements) + return displacements[0], displacements[1], displacements[2], np.sqrt(radius_sq) + + +def make_prepared_color_charge_state( + *, + size: int, + packets: tuple[PacketSpec, ...], + sigma: float, + amplitude: float, + omega: float, + dt: float, + n_colors: int = 3, +) -> dict[str, np.ndarray]: + """Create a prepared COLOR wave state with signed temporal charge. + + The state is a prepared wave packet control, not an electron candidate. + Charge sign is encoded only through the leapfrog phase rotation. + """ + + if sigma <= 0.0 or amplitude <= 0.0 or omega <= 0.0 or dt <= 0.0: + raise ValueError("sigma, amplitude, omega, and dt must be positive") + psi_real = np.zeros((n_colors, size, size, size), dtype=np.float64) + psi_imag = np.zeros_like(psi_real) + psi_real_prev = np.zeros_like(psi_real) + psi_imag_prev = np.zeros_like(psi_real) + for packet in packets: + if packet.charge_sign not in (-1, 1): + raise ValueError("charge_sign must be -1 or +1") + if not 0 <= packet.component < n_colors: + raise ValueError("packet component out of range") + _dx, _dy, _dz, radius = periodic_displacement_grid(size, packet.center) + envelope = amplitude * np.exp(-0.5 * (radius / sigma) ** 2) + phase_now = packet.phase + phase_prev = phase_now - packet.charge_sign * omega * dt + psi_real[packet.component] += envelope * np.cos(phase_now) + psi_imag[packet.component] += envelope * np.sin(phase_now) + psi_real_prev[packet.component] += envelope * np.cos(phase_prev) + psi_imag_prev[packet.component] += envelope * np.sin(phase_prev) + return { + "psi_real": psi_real, + "psi_imag": psi_imag, + "psi_real_prev": psi_real_prev, + "psi_imag_prev": psi_imag_prev, + } + + +def charge_centroid( + charge_density: np.ndarray, + center_hint: tuple[float, float, float], + *, + sign: int, + radius: float, +) -> dict[str, Any]: + """Return local signed-charge centroid near a center hint.""" + + rho = np.asarray(charge_density, dtype=np.float64) + dx_grid, dy_grid, dz_grid, dist = periodic_displacement_grid(rho.shape[0], center_hint) + weight = np.maximum(rho, 0.0) if sign > 0 else np.maximum(-rho, 0.0) + mask = dist <= radius + weighted = weight * mask + total = float(np.sum(weighted)) + if total <= 1.0e-300: + return { + "ok": False, + "charge": 0.0, + "center": list(center_hint), + "local_displacement": [0.0, 0.0, 0.0], + } + disp = [ + float(np.sum(weighted * dx_grid) / total), + float(np.sum(weighted * dy_grid) / total), + float(np.sum(weighted * dz_grid) / total), + ] + center = [float((center_hint[axis] + disp[axis]) % rho.shape[0]) for axis in range(3)] + signed_charge = total if sign > 0 else -total + return { + "ok": True, + "charge": float(signed_charge), + "center": center, + "local_displacement": disp, + } + + +def run_prepared_charge_pair_probe( + *, + size: int = 32, + steps: int = 80, + dt: float = DT_DEFAULT, + sigma: float = 2.6, + amplitude: float = 0.030, + separation: float = 10.0, + signs: tuple[int, int] = (1, 1), + lambda_self: float = LAMBDA_H, + kappa: float = KAPPA, + epsilon_w: float = 0.0, + use_gravity_recovery: bool = False, +) -> dict[str, Any]: + """Run two prepared Noether-charge packets under full COLOR LFM. + + This diagnostic tests whether the current local equations make the signed + charge channel dynamically active. It is not an electron gate. + """ + + if signs[0] not in (-1, 1) or signs[1] not in (-1, 1): + raise ValueError("signs must contain only -1 or +1") + center_a = (0.5 * size - 0.5 * separation, 0.5 * size, 0.5 * size) + center_b = (0.5 * size + 0.5 * separation, 0.5 * size, 0.5 * size) + config = SimulationConfig( + grid_size=size, + dt=dt, + field_level=FieldLevel.COLOR, + n_colors=3, + boundary_type=BoundaryType.PERIODIC, + precision=Precision.FLOAT64, + lambda_self=lambda_self, + kappa=kappa, + epsilon_w=epsilon_w, + enable_chi_floor=False, + report_interval=max(1, steps + 1), + ) + sim = Simulation(config, backend="cpu") + state = make_prepared_color_charge_state( + size=size, + packets=( + PacketSpec(center=center_a, charge_sign=signs[0], component=0), + PacketSpec(center=center_b, charge_sign=signs[1], component=0), + ), + sigma=sigma, + amplitude=amplitude, + omega=CHI0, + dt=dt, + ) + sim.set_psi_real(state["psi_real"]) + sim.set_psi_imag(state["psi_imag"]) + sim.set_psi_real_prev(state["psi_real_prev"]) + sim.set_psi_imag_prev(state["psi_imag_prev"]) + chi = np.full((size, size, size), CHI0, dtype=np.float64) + sim.set_chi(chi) + sim.set_chi_prev(chi.copy()) + + def snapshot(label: str) -> dict[str, Any]: + snap = sim.phase_space_snapshot() + psi_real = _snapshot_array(snap, "psi_real") + psi_imag = _snapshot_array(snap, "psi_imag") + psi_real_prev = _snapshot_array(snap, "psi_real_prev") + psi_imag_prev = _snapshot_array(snap, "psi_imag_prev") + chi_snapshot = _snapshot_array(snap, "chi") + rho = color_noether_charge_density( + psi_real, + psi_imag, + psi_real_prev, + psi_imag_prev, + dt=dt, + ) + current = color_noether_spatial_current( + psi_real, + psi_imag, + c=C_DEFAULT, + ) + centroid_radius = min(2.0 * sigma, 0.40 * separation) + ca = charge_centroid(rho, center_a, sign=signs[0], radius=centroid_radius) + cb = charge_centroid(rho, center_b, sign=signs[1], radius=centroid_radius) + sep_vec = [ + ((cb["center"][axis] - ca["center"][axis] + 0.5 * size) % size) - 0.5 * size + for axis in range(3) + ] + separation_now = float(np.sqrt(sum(value * value for value in sep_vec))) + return { + "label": label, + "step": int(sim.step), + "charge_total": float(np.sum(rho)), + "charge_abs": float(np.sum(np.abs(rho))), + "current_rms": rms(current), + "centroid_radius": float(centroid_radius), + "packet_a": ca, + "packet_b": cb, + "separation": separation_now, + "chi_min": float(np.min(chi_snapshot)), + "chi_max": float(np.max(chi_snapshot)), + "psi_norm": float(np.sqrt(np.sum(psi_real**2 + psi_imag**2))), + } + + start = snapshot("start") + if use_gravity_recovery: + sim.run_gravity_recovery( + steps, + ChiPotentialModel.FLAT_OCTIC, + freeze_psi=False, + ) + else: + sim.run(steps, record_metrics=False) + end = snapshot("end") + return { + "settings": { + "size": size, + "steps": steps, + "dt": dt, + "sigma": sigma, + "amplitude": amplitude, + "separation": separation, + "signs": list(signs), + "lambda_self": lambda_self, + "kappa": kappa, + "epsilon_w": epsilon_w, + "use_gravity_recovery": use_gravity_recovery, + "field_level": "COLOR", + "boundary_type": "PERIODIC", + }, + "start": start, + "end": end, + "delta_separation": float(end["separation"] - start["separation"]), + "charge_abs_retention": float(end["charge_abs"] / max(start["charge_abs"], 1.0e-300)), + } + + +def run_signed_pair_comparison(**kwargs: Any) -> dict[str, Any]: + """Compare same-charge and opposite-charge prepared pair histories.""" + + same = run_prepared_charge_pair_probe(signs=(1, 1), **kwargs) + opposite = run_prepared_charge_pair_probe(signs=(1, -1), **kwargs) + same_delta = same["delta_separation"] + opposite_delta = opposite["delta_separation"] + signed_split = same_delta - opposite_delta + scale = max(abs(same_delta), abs(opposite_delta), 1.0e-300) + return { + "same_charge": same, + "opposite_charge": opposite, + "signed_split": float(signed_split), + "signed_split_relative_to_motion": float(abs(signed_split) / scale), + "charge_abs_retention_min": float( + min(same["charge_abs_retention"], opposite["charge_abs_retention"]) + ), + } diff --git a/lfm/analysis/emergence.py b/lfm/analysis/emergence.py new file mode 100644 index 0000000..bacc490 --- /dev/null +++ b/lfm/analysis/emergence.py @@ -0,0 +1,313 @@ +"""Basis-independent diagnostics for localized-state emergence. + +These functions inspect the native LFM wave register without introducing a +spinor, gauge link, particle catalog entry, or preferred color component. +They are intended for discovery experiments in which the object being +measured is not known in advance. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np + +from lfm.analysis.phase import charge_density + +if TYPE_CHECKING: + from numpy.typing import NDArray + + +def aggregate_wave_density( + psi_r: NDArray, + psi_i: NDArray | None = None, +) -> NDArray: + """Return the internal-basis-invariant density sum_a |Psi_a|^2.""" + pr = np.asarray(psi_r, dtype=np.float64) + pi = np.zeros_like(pr) if psi_i is None else np.asarray(psi_i, dtype=np.float64) + density = pr * pr + pi * pi + if density.ndim == 4: + density = np.sum(density, axis=0) + if density.ndim != 3: + raise ValueError(f"wave field must be 3-D or component-first 4-D, got {pr.shape}") + return density + + +def principal_internal_projection( + psi_r: NDArray, + psi_i: NDArray, +) -> tuple[NDArray, float]: + """Project a multicomponent field onto its leading covariance direction. + + The leading eigendirection transforms covariantly under a global unitary + change of internal basis. The returned eigengap fraction reports whether + that direction is well separated; a value near zero means the projection + is not unique and phase-topology measurements should be treated cautiously. + """ + psi = np.asarray(psi_r, dtype=np.float64) + 1j * np.asarray(psi_i, dtype=np.float64) + if psi.ndim == 3: + return psi.astype(np.complex128, copy=False), 1.0 + if psi.ndim != 4: + raise ValueError(f"wave field must be 3-D or component-first 4-D, got {psi.shape}") + + flat = psi.reshape(psi.shape[0], -1) + covariance = flat @ flat.conj().T + values, vectors = np.linalg.eigh(covariance) + order = np.argsort(values.real) + lead = vectors[:, order[-1]] + projection = np.einsum("a,axyz->xyz", lead.conj(), psi) + largest = float(max(values[order[-1]].real, 0.0)) + second = float(max(values[order[-2]].real, 0.0)) if len(order) > 1 else 0.0 + gap = (largest - second) / largest if largest > 0.0 else 0.0 + return projection, float(gap) + + +def plaquette_winding_summary( + field: NDArray, + amplitude_fraction: float = 1.0e-3, +) -> dict[str, int]: + """Count resolved integer branch windings on elementary plaquettes. + + A plaquette is included only when all four corner amplitudes exceed the + supplied fraction of the peak amplitude. This excludes arbitrary phase at + numerical zeros. The result is a diagnostic of the projected complex + field, not a compact-U(1) gauge curvature. + """ + if not 0.0 <= amplitude_fraction < 1.0: + raise ValueError("amplitude_fraction must lie in [0, 1)") + psi = np.asarray(field, dtype=np.complex128) + if psi.ndim != 3: + raise ValueError(f"projected field must be 3-D, got {psi.shape}") + amplitude = np.abs(psi) + peak = float(np.max(amplitude)) + if peak == 0.0: + return {"positive": 0, "negative": 0, "nonzero": 0, "valid": 0} + + theta = np.angle(psi) + + def wrap(delta: NDArray) -> NDArray: + return (delta + np.pi) % (2.0 * np.pi) - np.pi + + positive = 0 + negative = 0 + valid_total = 0 + threshold = amplitude_fraction * peak + for axis_a, axis_b in ((0, 1), (0, 2), (1, 2)): + theta_a = np.roll(theta, -1, axis=axis_a) + theta_b = np.roll(theta, -1, axis=axis_b) + theta_ab = np.roll(theta_a, -1, axis=axis_b) + amp_a = np.roll(amplitude, -1, axis=axis_a) + amp_b = np.roll(amplitude, -1, axis=axis_b) + amp_ab = np.roll(amp_a, -1, axis=axis_b) + valid = ( + (amplitude > threshold) + & (amp_a > threshold) + & (amp_b > threshold) + & (amp_ab > threshold) + ) + circulation = ( + wrap(theta_a - theta) + + wrap(theta_ab - theta_a) + + wrap(theta_b - theta_ab) + + wrap(theta - theta_b) + ) + winding = np.rint(circulation / (2.0 * np.pi)).astype(np.int8) + positive += int(np.count_nonzero((winding > 0) & valid)) + negative += int(np.count_nonzero((winding < 0) & valid)) + valid_total += int(np.count_nonzero(valid)) + return { + "positive": positive, + "negative": negative, + "nonzero": positive + negative, + "valid": valid_total, + } + + +def localized_state_observables( + psi_r: NDArray, + psi_i: NDArray | None, + psi_r_prev: NDArray, + psi_i_prev: NDArray | None, + chi: NDArray, + dt: float, + chi0: float, +) -> dict[str, float | int]: + """Measure localization, Noether charge, topology, and medium response.""" + raw_arrays = [np.asarray(psi_r), np.asarray(psi_r_prev), np.asarray(chi)] + if psi_i is not None: + raw_arrays.append(np.asarray(psi_i)) + if psi_i_prev is not None: + raw_arrays.append(np.asarray(psi_i_prev)) + if not all(np.all(np.isfinite(arr)) for arr in raw_arrays): + nan = float("nan") + return { + "wave_norm": nan, + "effective_sites": nan, + "effective_volume_fraction": nan, + "rms_radius": nan, + "radius3_fraction": nan, + "radius5_fraction": nan, + "c7_density_fraction": nan, + "c19_density_fraction": nan, + "top7_c7_overlap": 0, + "top19_c19_overlap": 0, + "peak_density": nan, + "peak_x": 0, + "peak_y": 0, + "peak_z": 0, + "boundary_fraction": nan, + "noether_charge": nan, + "charge_per_norm": nan, + "chi_min": nan, + "chi_at_peak": nan, + "chi_drop": nan, + "chi_density_peak_distance": nan, + "internal_principal_gap": nan, + "winding_positive": 0, + "winding_negative": 0, + "winding_nonzero": 0, + "winding_valid_plaquettes": 0, + "support_sites_10pct": 0, + "support_sites_50pct": 0, + } + density = aggregate_wave_density(psi_r, psi_i) + norm = float(np.sum(density)) + shape = density.shape + total_sites = int(density.size) + if norm <= 0.0: + return { + "wave_norm": 0.0, + "effective_sites": 0.0, + "effective_volume_fraction": 0.0, + "rms_radius": 0.0, + "radius3_fraction": 0.0, + "radius5_fraction": 0.0, + "c7_density_fraction": 0.0, + "c19_density_fraction": 0.0, + "top7_c7_overlap": 0, + "top19_c19_overlap": 0, + "peak_density": 0.0, + "peak_x": 0, + "peak_y": 0, + "peak_z": 0, + "boundary_fraction": 0.0, + "noether_charge": 0.0, + "charge_per_norm": 0.0, + "chi_min": float(np.min(chi)), + "chi_at_peak": float(chi.flat[0]), + "chi_drop": float(chi0 - np.min(chi)), + "chi_density_peak_distance": 0.0, + "internal_principal_gap": 0.0, + "winding_positive": 0, + "winding_negative": 0, + "winding_nonzero": 0, + "winding_valid_plaquettes": 0, + "support_sites_10pct": 0, + "support_sites_50pct": 0, + } + + probability = density / norm + effective_sites = 1.0 / float(np.sum(probability * probability)) + coords = np.indices(shape, dtype=np.float64) + center = np.array([float(np.sum(coords[a] * probability)) for a in range(3)]) + radius_sq = sum((coords[a] - center[a]) ** 2 for a in range(3)) + rms_radius = float(np.sqrt(np.sum(radius_sq * probability))) + peak = tuple(int(v) for v in np.unravel_index(int(np.argmax(density)), shape)) + peak_radius_sq = sum((coords[a] - peak[a]) ** 2 for a in range(3)) + + c7_offsets = ( + (0, 0, 0), + (1, 0, 0), + (-1, 0, 0), + (0, 1, 0), + (0, -1, 0), + (0, 0, 1), + (0, 0, -1), + ) + c19_offsets = c7_offsets + tuple( + (dx, dy, dz) + for dx in (-1, 0, 1) + for dy in (-1, 0, 1) + for dz in (-1, 0, 1) + if abs(dx) + abs(dy) + abs(dz) == 2 + ) + + def support_indices(offsets: tuple[tuple[int, int, int], ...]) -> set[tuple[int, int, int]]: + return { + ((peak[0] + dx) % shape[0], (peak[1] + dy) % shape[1], (peak[2] + dz) % shape[2]) + for dx, dy, dz in offsets + } + + c7_indices = support_indices(c7_offsets) + c19_indices = support_indices(c19_offsets) + flat_order = np.argsort(density.ravel())[::-1] + top7 = {tuple(int(v) for v in np.unravel_index(int(i), shape)) for i in flat_order[:7]} + top19 = {tuple(int(v) for v in np.unravel_index(int(i), shape)) for i in flat_order[:19]} + c7_fraction = float(sum(density[p] for p in c7_indices) / norm) + c19_fraction = float(sum(density[p] for p in c19_indices) / norm) + + edge_distance = np.minimum.reduce( + [ + coords[0], + coords[1], + coords[2], + shape[0] - 1 - coords[0], + shape[1] - 1 - coords[1], + shape[2] - 1 - coords[2], + ] + ) + boundary_fraction = float(np.sum(probability[edge_distance < 2.0])) + + pr = np.asarray(psi_r) + pr_prev = np.asarray(psi_r_prev) + if psi_i is None or psi_i_prev is None: + noether = 0.0 + projected = pr[0] if pr.ndim == 4 else pr + principal_gap = 0.0 + winding = {"positive": 0, "negative": 0, "nonzero": 0, "valid": 0} + else: + pi = np.asarray(psi_i) + pi_prev = np.asarray(psi_i_prev) + rho = charge_density(pr, pi, dt=dt, psi_r_prev=pr_prev, psi_i_prev=pi_prev) + noether = float(np.sum(rho)) + projected, principal_gap = principal_internal_projection(pr, pi) + winding = plaquette_winding_summary(projected) + + chi_arr = np.asarray(chi, dtype=np.float64) + chi_min_index = tuple(int(v) for v in np.unravel_index(int(np.argmin(chi_arr)), shape)) + alignment = float( + np.linalg.norm( + np.asarray(peak, dtype=np.float64) - np.asarray(chi_min_index, dtype=np.float64) + ) + ) + peak_density = float(density[peak]) + return { + "wave_norm": norm, + "effective_sites": effective_sites, + "effective_volume_fraction": effective_sites / total_sites, + "rms_radius": rms_radius, + "radius3_fraction": float(np.sum(probability[peak_radius_sq <= 9.0])), + "radius5_fraction": float(np.sum(probability[peak_radius_sq <= 25.0])), + "c7_density_fraction": c7_fraction, + "c19_density_fraction": c19_fraction, + "top7_c7_overlap": len(top7 & c7_indices), + "top19_c19_overlap": len(top19 & c19_indices), + "peak_density": peak_density, + "peak_x": peak[0], + "peak_y": peak[1], + "peak_z": peak[2], + "boundary_fraction": boundary_fraction, + "noether_charge": noether, + "charge_per_norm": noether / norm, + "chi_min": float(np.min(chi_arr)), + "chi_at_peak": float(chi_arr[peak]), + "chi_drop": float(chi0 - np.min(chi_arr)), + "chi_density_peak_distance": alignment, + "internal_principal_gap": principal_gap, + "winding_positive": winding["positive"], + "winding_negative": winding["negative"], + "winding_nonzero": winding["nonzero"], + "winding_valid_plaquettes": winding["valid"], + "support_sites_10pct": int(np.count_nonzero(density >= 0.1 * peak_density)), + "support_sites_50pct": int(np.count_nonzero(density >= 0.5 * peak_density)), + } diff --git a/lfm/analysis/energy_current.py b/lfm/analysis/energy_current.py new file mode 100644 index 0000000..9374372 --- /dev/null +++ b/lfm/analysis/energy_current.py @@ -0,0 +1,565 @@ +"""Exact local energy continuity observables for bare GOV-01/GOV-02. + +The conservative bare LFM Hamiltonian has a site energy obtained by assigning +half of every undirected stencil-link energy to each endpoint. Differentiating +that density with Hamilton's equations gives an exact oriented energy current. + +This module adds no evolution register or force. It exposes observables already +fixed by the bare Hamiltonian for real, complex, and color-component fields. +The 19-point stencil is canonical; the 27-point option is an ablation label. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + +from lfm.constants import C_DEFAULT, CHI0, KAPPA, LAMBDA_H +from lfm.core.stencils import laplacian_19pt, laplacian_27pt + +Offset = tuple[int, int, int] +LinkCurrentMap = dict[Offset, np.ndarray] + + +def _offset3(values: tuple[int, ...]) -> Offset: + return (values[0], values[1], values[2]) + + +def stencil_links( + stencil: str, + *, + oriented: bool = True, +) -> tuple[tuple[Offset, float], ...]: + """Return weighted cube links for a labeled cubic stencil. + + The unique list contains one representative of every undirected link. + The oriented list appends its reverse with the same weight. + """ + if stencil == "19": + unique: tuple[tuple[Offset, float], ...] = ( + ((1, 0, 0), 1.0 / 3.0), + ((0, 1, 0), 1.0 / 3.0), + ((0, 0, 1), 1.0 / 3.0), + ((1, 1, 0), 1.0 / 6.0), + ((1, -1, 0), 1.0 / 6.0), + ((1, 0, 1), 1.0 / 6.0), + ((1, 0, -1), 1.0 / 6.0), + ((0, 1, 1), 1.0 / 6.0), + ((0, 1, -1), 1.0 / 6.0), + ) + elif stencil == "27": + unique = ( + ((1, 0, 0), 4.0 / 9.0), + ((0, 1, 0), 4.0 / 9.0), + ((0, 0, 1), 4.0 / 9.0), + ((1, 1, 0), 1.0 / 9.0), + ((1, -1, 0), 1.0 / 9.0), + ((1, 0, 1), 1.0 / 9.0), + ((1, 0, -1), 1.0 / 9.0), + ((0, 1, 1), 1.0 / 9.0), + ((0, 1, -1), 1.0 / 9.0), + ((1, 1, 1), 1.0 / 36.0), + ((1, 1, -1), 1.0 / 36.0), + ((1, -1, 1), 1.0 / 36.0), + ((1, -1, -1), 1.0 / 36.0), + ) + else: + raise ValueError("stencil must be '19' or '27'") + if not oriented: + return unique + links: list[tuple[Offset, float]] = [] + for offset, weight in unique: + links.append((offset, weight)) + links.append((_offset3(tuple(-value for value in offset)), weight)) + return tuple(links) + + +def _shift_scalar(values: np.ndarray, offset: Offset) -> np.ndarray: + return np.roll(values, shift=offset, axis=(0, 1, 2)) + + +def _shift_components(values: np.ndarray, offset: Offset) -> np.ndarray: + return np.roll(values, shift=offset, axis=(1, 2, 3)) + + +def _as_components(values: np.ndarray, name: str) -> np.ndarray: + source = np.asarray(values) + dtype = np.longdouble if source.dtype == np.dtype(np.longdouble) else np.float64 + array = np.asarray(values, dtype=dtype) + if array.ndim == 3: + return array[np.newaxis, ...] + if array.ndim == 4: + return array + raise ValueError(f"{name} must have shape (N,N,N) or (C,N,N,N)") + + +def _laplacian_scalar(values: np.ndarray, stencil: str) -> np.ndarray: + if stencil == "19": + return laplacian_19pt(values) + if stencil == "27": + return laplacian_27pt(values) + raise ValueError("stencil must be '19' or '27'") + + +def _laplacian_components(values: np.ndarray, stencil: str) -> np.ndarray: + return np.stack( + [_laplacian_scalar(component, stencil) for component in values], + axis=0, + ) + + +@dataclass(frozen=True) +class BareLFMParameters: + """Parameters for the conservative bare GOV-01/GOV-02 Hamiltonian.""" + + chi0: float = CHI0 + kappa: float = KAPPA + lambda_h: float = LAMBDA_H + wave_speed: float = C_DEFAULT + background_norm_sq: float = 0.0 + gov01_stencil: str = "19" + gov02_stencil: str = "19" + spacing: float = 1.0 + chi_potential: str = "quartic" + + @property + def chi_inertia(self) -> float: + """Return B=chi0/kappa fixed by the canonical chi source.""" + return self.chi0 / self.kappa + + def __post_init__(self) -> None: + positive = ( + self.chi0, + self.kappa, + self.lambda_h, + self.wave_speed, + self.spacing, + ) + if not all(np.isfinite(value) and value > 0.0 for value in positive): + raise ValueError("bare LFM parameters must be positive and finite") + if not np.isfinite(self.background_norm_sq) or self.background_norm_sq < 0.0: + raise ValueError("background_norm_sq must be finite and nonnegative") + stencil_links(self.gov01_stencil) + stencil_links(self.gov02_stencil) + if self.chi_potential not in ("quartic", "flat_octic"): + raise ValueError("chi_potential must be 'quartic' or 'flat_octic'") + + +@dataclass(frozen=True) +class BareHamiltonRates: + """Hamiltonian vector field for the bare LFM coordinate registers.""" + + wave: np.ndarray + wave_momentum: np.ndarray + chi: np.ndarray + chi_momentum: np.ndarray + + +@dataclass(frozen=True) +class BareLFMState: + """Coordinate and momentum registers of the bare six-plus-one system.""" + + wave: np.ndarray + wave_momentum: np.ndarray + chi: np.ndarray + chi_momentum: np.ndarray + + +def _chi_potential_density( + chi: np.ndarray, + parameters: BareLFMParameters, +) -> np.ndarray: + displacement = chi**2 - parameters.chi0**2 + if parameters.chi_potential == "quartic": + return parameters.chi_inertia * parameters.lambda_h * displacement**2 + return parameters.chi_inertia * parameters.lambda_h * displacement**4 / parameters.chi0**4 + + +def _chi_potential_momentum_force( + chi: np.ndarray, + parameters: BareLFMParameters, +) -> np.ndarray: + displacement = chi**2 - parameters.chi0**2 + if parameters.chi_potential == "quartic": + return -4.0 * parameters.chi_inertia * parameters.lambda_h * chi * displacement + return ( + -8.0 + * parameters.chi_inertia + * parameters.lambda_h + * chi + * displacement**3 + / parameters.chi0**4 + ) + + +def _chi_potential_rate( + chi: np.ndarray, + chi_rate: np.ndarray, + parameters: BareLFMParameters, +) -> np.ndarray: + return -_chi_potential_momentum_force(chi, parameters) * chi_rate + + +def _validated_registers( + wave: np.ndarray, + wave_momentum: np.ndarray, + chi: np.ndarray, + chi_momentum: np.ndarray, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + wave_components = _as_components(wave, "wave") + momentum_components = _as_components(wave_momentum, "wave_momentum") + chi_source = np.asarray(chi) + chi_p_source = np.asarray(chi_momentum) + chi_dtype = np.longdouble if chi_source.dtype == np.dtype(np.longdouble) else np.float64 + chi_p_dtype = np.longdouble if chi_p_source.dtype == np.dtype(np.longdouble) else np.float64 + chi_array = np.asarray(chi, dtype=chi_dtype) + chi_momentum_array = np.asarray(chi_momentum, dtype=chi_p_dtype) + if wave_components.shape != momentum_components.shape: + raise ValueError("wave and wave_momentum shapes must match") + if chi_array.ndim != 3 or chi_momentum_array.ndim != 3: + raise ValueError("chi and chi_momentum must have shape (N,N,N)") + if chi_array.shape != chi_momentum_array.shape: + raise ValueError("chi and chi_momentum shapes must match") + if wave_components.shape[1:] != chi_array.shape: + raise ValueError("wave and chi spatial shapes must match") + return ( + wave_components, + momentum_components, + chi_array, + chi_momentum_array, + ) + + +def _gradient_site_density( + values: np.ndarray, + *, + coefficient: float, + stencil: str, + components: bool, +) -> np.ndarray: + spatial_shape = values.shape[1:] if components else values.shape + density = np.zeros( + spatial_shape, + dtype=np.result_type(values.dtype, np.float64), + ) + shift = _shift_components if components else _shift_scalar + for offset, weight in stencil_links(stencil): + difference = shift(values, offset) - values + squared = np.sum(difference**2, axis=0) if components else difference**2 + density += 0.25 * coefficient * weight * squared + return density + + +def bare_site_energy( + wave: np.ndarray, + wave_momentum: np.ndarray, + chi: np.ndarray, + chi_momentum: np.ndarray, + parameters: BareLFMParameters = BareLFMParameters(), +) -> np.ndarray: + """Return exact endpoint-split site energy for the bare Hamiltonian.""" + wave_values, wave_p, chi_values, chi_p = _validated_registers( + wave, + wave_momentum, + chi, + chi_momentum, + ) + norm_sq = np.sum(wave_values**2, axis=0) + onsite = ( + 0.5 * np.sum(wave_p**2, axis=0) + + chi_p**2 / (2.0 * parameters.chi_inertia) + + 0.5 * chi_values**2 * (norm_sq - parameters.background_norm_sq) + + _chi_potential_density(chi_values, parameters) + ) + wave_gradient = _gradient_site_density( + wave_values, + coefficient=parameters.wave_speed**2 / parameters.spacing**2, + stencil=parameters.gov01_stencil, + components=True, + ) + chi_gradient = _gradient_site_density( + chi_values, + coefficient=(parameters.chi_inertia * parameters.wave_speed**2 / parameters.spacing**2), + stencil=parameters.gov02_stencil, + components=False, + ) + return onsite + wave_gradient + chi_gradient + + +def bare_hamilton_rates( + wave: np.ndarray, + wave_momentum: np.ndarray, + chi: np.ndarray, + chi_momentum: np.ndarray, + parameters: BareLFMParameters = BareLFMParameters(), +) -> BareHamiltonRates: + """Return Hamilton's equations for the conservative bare system.""" + wave_values, wave_p, chi_values, chi_p = _validated_registers( + wave, + wave_momentum, + chi, + chi_momentum, + ) + norm_sq = np.sum(wave_values**2, axis=0) + wave_rate = wave_p + wave_momentum_rate = ( + parameters.wave_speed**2 + * _laplacian_components(wave_values, parameters.gov01_stencil) + / parameters.spacing**2 + - chi_values[np.newaxis, ...] ** 2 * wave_values + ) + chi_rate = chi_p / parameters.chi_inertia + chi_momentum_rate = ( + parameters.chi_inertia + * parameters.wave_speed**2 + * _laplacian_scalar(chi_values, parameters.gov02_stencil) + / parameters.spacing**2 + - chi_values * (norm_sq - parameters.background_norm_sq) + + _chi_potential_momentum_force(chi_values, parameters) + ) + return BareHamiltonRates( + wave=wave_rate, + wave_momentum=wave_momentum_rate, + chi=chi_rate, + chi_momentum=chi_momentum_rate, + ) + + +def _gradient_site_rate( + values: np.ndarray, + rates: np.ndarray, + *, + coefficient: float, + stencil: str, + components: bool, +) -> np.ndarray: + spatial_shape = values.shape[1:] if components else values.shape + result = np.zeros( + spatial_shape, + dtype=np.result_type(values.dtype, rates.dtype, np.float64), + ) + shift = _shift_components if components else _shift_scalar + for offset, weight in stencil_links(stencil): + difference = shift(values, offset) - values + rate_difference = shift(rates, offset) - rates + product = ( + np.sum(difference * rate_difference, axis=0) + if components + else difference * rate_difference + ) + result += 0.5 * coefficient * weight * product + return result + + +def bare_site_energy_rate( + wave: np.ndarray, + wave_momentum: np.ndarray, + chi: np.ndarray, + chi_momentum: np.ndarray, + parameters: BareLFMParameters = BareLFMParameters(), +) -> np.ndarray: + """Differentiate endpoint-split site energy along Hamilton's equations.""" + wave_values, wave_p, chi_values, chi_p = _validated_registers( + wave, + wave_momentum, + chi, + chi_momentum, + ) + rates = bare_hamilton_rates( + wave_values, + wave_p, + chi_values, + chi_p, + parameters, + ) + norm_sq = np.sum(wave_values**2, axis=0) + onsite_rate = ( + np.sum(wave_p * rates.wave_momentum, axis=0) + + (chi_p / parameters.chi_inertia) * rates.chi_momentum + + chi_values * rates.chi * (norm_sq - parameters.background_norm_sq) + + chi_values**2 * np.sum(wave_values * rates.wave, axis=0) + + _chi_potential_rate(chi_values, rates.chi, parameters) + ) + wave_gradient_rate = _gradient_site_rate( + wave_values, + rates.wave, + coefficient=parameters.wave_speed**2 / parameters.spacing**2, + stencil=parameters.gov01_stencil, + components=True, + ) + chi_gradient_rate = _gradient_site_rate( + chi_values, + rates.chi, + coefficient=(parameters.chi_inertia * parameters.wave_speed**2 / parameters.spacing**2), + stencil=parameters.gov02_stencil, + components=False, + ) + return onsite_rate + wave_gradient_rate + chi_gradient_rate + + +def oriented_energy_currents( + wave: np.ndarray, + wave_momentum: np.ndarray, + chi: np.ndarray, + chi_momentum: np.ndarray, + parameters: BareLFMParameters = BareLFMParameters(), +) -> LinkCurrentMap: + """Return total outgoing energy current on every declared oriented link.""" + wave_values, wave_p, chi_values, chi_p = _validated_registers( + wave, + wave_momentum, + chi, + chi_momentum, + ) + currents: LinkCurrentMap = {} + wave_coefficient = -0.5 * parameters.wave_speed**2 / parameters.spacing**2 + for offset, weight in stencil_links(parameters.gov01_stencil): + difference = _shift_components(wave_values, offset) - wave_values + endpoint_rate_sum = _shift_components(wave_p, offset) + wave_p + currents[offset] = ( + wave_coefficient * weight * np.sum(difference * endpoint_rate_sum, axis=0) + ) + + chi_rate = chi_p / parameters.chi_inertia + chi_coefficient = ( + -0.5 * parameters.chi_inertia * parameters.wave_speed**2 / parameters.spacing**2 + ) + for offset, weight in stencil_links(parameters.gov02_stencil): + difference = _shift_scalar(chi_values, offset) - chi_values + endpoint_rate_sum = _shift_scalar(chi_rate, offset) + chi_rate + contribution = chi_coefficient * weight * difference * endpoint_rate_sum + if offset in currents: + currents[offset] = currents[offset] + contribution + else: + currents[offset] = contribution + return currents + + +def energy_current_divergence( + wave: np.ndarray, + wave_momentum: np.ndarray, + chi: np.ndarray, + chi_momentum: np.ndarray, + parameters: BareLFMParameters = BareLFMParameters(), +) -> np.ndarray: + """Return the sum of all outgoing oriented currents at each cube.""" + currents = oriented_energy_currents( + wave, + wave_momentum, + chi, + chi_momentum, + parameters, + ) + dtype = np.result_type( + *(current.dtype for current in currents.values()), + np.float64, + ) + result = np.zeros(np.asarray(chi).shape, dtype=dtype) + for current in currents.values(): + result += current + return result + + +def bare_energy_continuity_residual( + wave: np.ndarray, + wave_momentum: np.ndarray, + chi: np.ndarray, + chi_momentum: np.ndarray, + parameters: BareLFMParameters = BareLFMParameters(), +) -> np.ndarray: + """Return the exact lattice residual dh/dt + sum_j J(i->j).""" + return bare_site_energy_rate( + wave, + wave_momentum, + chi, + chi_momentum, + parameters, + ) + energy_current_divergence( + wave, + wave_momentum, + chi, + chi_momentum, + parameters, + ) + + +def bare_total_energy( + state: BareLFMState, + parameters: BareLFMParameters = BareLFMParameters(), +) -> float: + """Return the physical-volume integral of the exact site energy.""" + density = bare_site_energy( + state.wave, + state.wave_momentum, + state.chi, + state.chi_momentum, + parameters, + ) + return float(np.sum(density) * parameters.spacing**3) + + +def step_bare_lfm( + state: BareLFMState, + dt: float, + parameters: BareLFMParameters = BareLFMParameters(), +) -> BareLFMState: + """Advance the bare Hamiltonian with one velocity-Verlet step. + + This function introduces no force or register. It applies the package's + exact bare GOV-01/GOV-02 momentum rates in kick-drift-kick order. + """ + if not np.isfinite(dt) or dt <= 0.0: + raise ValueError("dt must be positive and finite") + wave, wave_p, chi, chi_p = _validated_registers( + state.wave, + state.wave_momentum, + state.chi, + state.chi_momentum, + ) + rates_0 = bare_hamilton_rates(wave, wave_p, chi, chi_p, parameters) + half_wave_p = wave_p + 0.5 * dt * rates_0.wave_momentum + half_chi_p = chi_p + 0.5 * dt * rates_0.chi_momentum + next_wave = wave + dt * half_wave_p + next_chi = chi + dt * half_chi_p / parameters.chi_inertia + rates_1 = bare_hamilton_rates( + next_wave, + half_wave_p, + next_chi, + half_chi_p, + parameters, + ) + next_wave_p = half_wave_p + 0.5 * dt * rates_1.wave_momentum + next_chi_p = half_chi_p + 0.5 * dt * rates_1.chi_momentum + return BareLFMState( + wave=next_wave, + wave_momentum=next_wave_p, + chi=next_chi, + chi_momentum=next_chi_p, + ) + + +def wave_component_site_energy( + state: BareLFMState, + component: int, + parameters: BareLFMParameters = BareLFMParameters(), +) -> np.ndarray: + """Return the positive energy assigned to one real GOV-01 component.""" + wave, wave_p, chi, _ = _validated_registers( + state.wave, + state.wave_momentum, + state.chi, + state.chi_momentum, + ) + if component < 0 or component >= wave.shape[0]: + raise IndexError("component is outside the GOV-01 register") + values = wave[component] + momentum = wave_p[component] + onsite = 0.5 * momentum**2 + 0.5 * chi**2 * values**2 + gradient = _gradient_site_density( + values, + coefficient=parameters.wave_speed**2 / parameters.spacing**2, + stencil=parameters.gov01_stencil, + components=False, + ) + return onsite + gradient diff --git a/lfm/analysis/frame_candidates.py b/lfm/analysis/frame_candidates.py new file mode 100644 index 0000000..e2756f4 --- /dev/null +++ b/lfm/analysis/frame_candidates.py @@ -0,0 +1,168 @@ +"""Algebraic screening records for candidate LFM cube-frame carriers. + +The screen does not add a degree of freedom to GOV-01/GOV-02. It makes the +requirements for a candidate explicit and rejects familiar false-positive +routes before nonlinear evolution or visualization. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from enum import Enum + + +class CandidateVerdict(str, Enum): + """Outcome of the algebraic candidate screen.""" + + SURVIVES = "SURVIVES" + REJECTED = "REJECTED" + BLOCKED = "BLOCKED" + + +@dataclass(frozen=True) +class FrameCandidate: + """Declared properties of one proposed long-range carrier.""" + + candidate_id: str + degrees_of_freedom: str + source_observable: str + gapless: bool | None + positive_energy: bool | None + source_derived_from_lfm: bool | None + attractive_for_positive_energy: bool | None + local_action_written: bool | None + net_source_compatible: bool | None + nonlinear_closure_written: bool | None + static_response_power: float | None + notes: str = "" + + +@dataclass(frozen=True) +class CandidateAssessment: + """Assessment of a frame candidate against frozen prerequisites.""" + + candidate: FrameCandidate + verdict: CandidateVerdict + reasons: tuple[str, ...] + + +def assess_frame_candidate(candidate: FrameCandidate) -> CandidateAssessment: + """Screen a candidate without assuming target gravitational equations. + + ``static_response_power`` is the measured or derived small-k exponent of + the sourced response. A long-range static Green response in three spatial + dimensions requires a ``k^-2`` pole, represented here by ``-2``. + """ + requirements = { + "gapless carrier": candidate.gapless, + "positive Hamiltonian": candidate.positive_energy, + "source derived from LFM": candidate.source_derived_from_lfm, + "attraction for positive energy": candidate.attractive_for_positive_energy, + "explicit local action": candidate.local_action_written, + "net-source/zero-mode consistency": candidate.net_source_compatible, + "nonlinear closure": candidate.nonlinear_closure_written, + } + unknown = tuple(name for name, value in requirements.items() if value is None) + failed = tuple(name for name, value in requirements.items() if value is False) + if candidate.static_response_power is None: + unknown += ("measured static small-k response",) + elif abs(candidate.static_response_power + 2.0) > 0.15: + failed += ( + "static response lacks the required small-k k^-2 pole " + f"(measured power {candidate.static_response_power:.3f})", + ) + + if failed: + return CandidateAssessment( + candidate=candidate, + verdict=CandidateVerdict.REJECTED, + reasons=failed + tuple(f"unknown: {name}" for name in unknown), + ) + if unknown: + return CandidateAssessment( + candidate=candidate, + verdict=CandidateVerdict.BLOCKED, + reasons=tuple(f"unknown: {name}" for name in unknown), + ) + return CandidateAssessment( + candidate=candidate, + verdict=CandidateVerdict.SURVIVES, + reasons=("all algebraic prerequisites satisfied",), + ) + + +def current_frame_candidate_ledger() -> tuple[CandidateAssessment, ...]: + """Return the frozen ledger for routes examined as of 2026-07-24.""" + candidates = ( + FrameCandidate( + candidate_id="canonical-radial-chi", + degrees_of_freedom="one real site scalar chi", + source_observable="bare local wave norm/energy coupling", + gapless=False, + positive_energy=True, + source_derived_from_lfm=True, + attractive_for_positive_energy=None, + local_action_written=True, + net_source_compatible=True, + nonlinear_closure_written=True, + static_response_power=0.0, + notes="Mexican-hat curvature gives the radial chi mode a mass gap.", + ), + FrameCandidate( + candidate_id="positive-sync-one-form", + degrees_of_freedom="oriented synchronization link", + source_observable="exact bare LFM energy current", + gapless=True, + positive_energy=True, + source_derived_from_lfm=True, + attractive_for_positive_energy=False, + local_action_written=True, + net_source_compatible=False, + nonlinear_closure_written=False, + static_response_power=-2.0, + notes="Positive normalization gives repulsion for positive sources.", + ), + FrameCandidate( + candidate_id="negative-sync-one-form", + degrees_of_freedom="oriented synchronization link", + source_observable="exact bare LFM energy current", + gapless=True, + positive_energy=False, + source_derived_from_lfm=True, + attractive_for_positive_energy=True, + local_action_written=True, + net_source_compatible=False, + nonlinear_closure_written=False, + static_response_power=-2.0, + notes="Attraction requires a negative-energy normalization.", + ), + FrameCandidate( + candidate_id="ordinary-displacement-strain", + degrees_of_freedom="cube displacement vector and symmetric strain", + source_observable="derivative strain coupling", + gapless=True, + positive_energy=True, + source_derived_from_lfm=True, + attractive_for_positive_energy=None, + local_action_written=True, + net_source_compatible=True, + nonlinear_closure_written=False, + static_response_power=0.0, + notes="Derivative source/backreaction cancels the elastic 1/k^2 pole.", + ), + FrameCandidate( + candidate_id="independent-affine-cube-frame", + degrees_of_freedom="undetermined local cube-frame variables", + source_observable="exact bare LFM energy-current candidate", + gapless=None, + positive_energy=None, + source_derived_from_lfm=True, + attractive_for_positive_energy=None, + local_action_written=None, + net_source_compatible=None, + nonlinear_closure_written=None, + static_response_power=None, + notes="This is the open construction problem, not an implemented mode.", + ), + ) + return tuple(assess_frame_candidate(candidate) for candidate in candidates) diff --git a/lfm/analysis/frame_completion.py b/lfm/analysis/frame_completion.py new file mode 100644 index 0000000..bb74e6e --- /dev/null +++ b/lfm/analysis/frame_completion.py @@ -0,0 +1,176 @@ +"""Algebra for an unpromoted spacetime cube-frame completion of LFM. + +The current canonical LFM register does not contain these frame variables. +This module is deliberately limited to a candidate structural audit. It +decomposes a symmetric four-direction frame strain into one overall-scale +component and nine first-order volume-preserving shape components. + +No Newtonian, relativistic-gravity, trajectory, or inverse-Laplacian solver is +implemented here. Static response is evaluated directly from a supplied +lattice stiffness eigenvalue. +""" + +from __future__ import annotations + +import numpy as np + +FRAME_COMPONENT_LABELS = ( + "00", + "11", + "22", + "33", + "01", + "02", + "03", + "12", + "13", + "23", +) +FRAME_COMPONENT_COUNT = len(FRAME_COMPONENT_LABELS) +FRAME_SCALE_COUNT = 1 +FRAME_SHAPE_COUNT = FRAME_COMPONENT_COUNT - FRAME_SCALE_COUNT + + +def frame_scale_direction() -> np.ndarray: + """Return the unit overall-scale direction in symmetric-frame space.""" + + direction = np.zeros(FRAME_COMPONENT_COUNT, dtype=np.float64) + direction[:4] = 0.5 + return direction + + +def frame_projectors() -> tuple[np.ndarray, np.ndarray]: + """Return orthogonal projectors onto scale and shape sectors.""" + + scale_direction = frame_scale_direction() + scale = np.outer(scale_direction, scale_direction) + shape = np.eye(FRAME_COMPONENT_COUNT, dtype=np.float64) - scale + return scale, shape + + +def rest_energy_source() -> np.ndarray: + """Return a unit source on the temporal frame component.""" + + source = np.zeros(FRAME_COMPONENT_COUNT, dtype=np.float64) + source[0] = 1.0 + return source + + +def source_projection_weights( + source: np.ndarray | None = None, +) -> dict[str, float]: + """Return squared source weights in scale and shape sectors.""" + + vector = ( + rest_energy_source() + if source is None + else np.asarray( + source, + dtype=np.float64, + ) + ) + if vector.shape != (FRAME_COMPONENT_COUNT,): + raise ValueError("source must have shape (10,)") + scale, shape = frame_projectors() + norm_sq = float(vector @ vector) + if norm_sq <= 0.0: + raise ValueError("source must be nonzero") + return { + "scale": float(vector @ scale @ vector / norm_sq), + "shape": float(vector @ shape @ vector / norm_sq), + } + + +def frame_static_operator( + lattice_stiffness: float, + *, + radial_mass_sq: float, + normalization: float, +) -> np.ndarray: + """Return the positive candidate static quadratic operator.""" + + stiffness = float(lattice_stiffness) + mass_sq = float(radial_mass_sq) + inertia = float(normalization) + if stiffness < 0.0 or mass_sq <= 0.0 or inertia <= 0.0: + raise ValueError("stiffness must be nonnegative; mass and normalization positive") + scale, shape = frame_projectors() + return inertia * ((stiffness + mass_sq) * scale + stiffness * shape) + + +def frame_static_response( + lattice_stiffness: float, + *, + radial_mass_sq: float, + normalization: float, + source: np.ndarray | None = None, +) -> float: + """Return source-projected response for a positive nonzero stiffness.""" + + stiffness = float(lattice_stiffness) + if stiffness <= 0.0: + raise ValueError("static response requires nonzero positive stiffness") + vector = ( + rest_energy_source() + if source is None + else np.asarray( + source, + dtype=np.float64, + ) + ) + operator = frame_static_operator( + stiffness, + radial_mass_sq=radial_mass_sq, + normalization=normalization, + ) + response = np.linalg.solve(operator, vector) + return float(vector @ response) + + +def analytic_rest_energy_response( + lattice_stiffness: float, + *, + radial_mass_sq: float, + normalization: float, +) -> float: + """Return the closed-form temporal-source response.""" + + stiffness = float(lattice_stiffness) + mass_sq = float(radial_mass_sq) + inertia = float(normalization) + if stiffness <= 0.0 or mass_sq <= 0.0 or inertia <= 0.0: + raise ValueError("all arguments must be positive") + return 0.75 / (inertia * stiffness) + 0.25 / (inertia * (stiffness + mass_sq)) + + +def minimized_source_cross_energy( + lattice_stiffness: float, + source_product: float, + *, + radial_mass_sq: float, + normalization: float, + coupling: float = 1.0, +) -> float: + """Return the cross term after minimizing the quadratic field energy.""" + + response = analytic_rest_energy_response( + lattice_stiffness, + radial_mass_sq=radial_mass_sq, + normalization=normalization, + ) + return -(float(coupling) ** 2) * float(source_product) * response + + +def zero_momentum_frame_spectrum( + *, + radial_mass_sq: float, + normalization: float = 1.0, +) -> np.ndarray: + """Return the ten static Hessian eigenvalues at zero momentum.""" + + operator = frame_static_operator( + 0.0, + radial_mass_sq=radial_mass_sq, + normalization=normalization, + ) + return np.linalg.eigvalsh(operator) diff --git a/lfm/analysis/frame_links.py b/lfm/analysis/frame_links.py new file mode 100644 index 0000000..7cf93a1 --- /dev/null +++ b/lfm/analysis/frame_links.py @@ -0,0 +1,70 @@ +"""Local frame-comparison algebra for LFM candidate provenance audits. + +These helpers do not add frame links to the canonical simulation register. +They distinguish a comparator reconstructed from site frames, which is flat +by construction, from an independent link capable of nontrivial loop +mismatch. +""" + +from __future__ import annotations + +import numpy as np + + +def reconstructed_frame_link( + frame_i: np.ndarray, + frame_j: np.ndarray, +) -> np.ndarray: + """Return the comparator carrying local-j components to local i.""" + + source = np.asarray(frame_i, dtype=np.float64) + target = np.asarray(frame_j, dtype=np.float64) + if source.shape != (4, 4) or target.shape != (4, 4): + raise ValueError("frames must have shape (4,4)") + return np.linalg.solve(source, target) + + +def linked_frame_difference( + value_i: np.ndarray, + value_j: np.ndarray, + link_ij: np.ndarray, +) -> np.ndarray: + """Return the locally covariant neighbor difference.""" + + left = np.asarray(value_i, dtype=np.float64) + right = np.asarray(value_j, dtype=np.float64) + link = np.asarray(link_ij, dtype=np.float64) + if left.shape != (4,) or right.shape != (4,) or link.shape != (4, 4): + raise ValueError("values must be four-vectors and link must be 4x4") + return link @ right - left + + +def loop_holonomy(*oriented_links: np.ndarray) -> np.ndarray: + """Return an ordered closed-loop product of local comparators.""" + + if not oriented_links: + raise ValueError("at least one oriented link is required") + product = np.eye(4, dtype=np.float64) + for link in oriented_links: + array = np.asarray(link, dtype=np.float64) + if array.shape != (4, 4): + raise ValueError("links must have shape (4,4)") + product = product @ array + return product + + +def loop_mismatch_energy( + holonomy: np.ndarray, + *, + coefficient: float = 1.0, +) -> float: + """Return a nonnegative candidate loop-mismatch diagnostic.""" + + matrix = np.asarray(holonomy, dtype=np.float64) + strength = float(coefficient) + if matrix.shape != (4, 4): + raise ValueError("holonomy must have shape (4,4)") + if strength <= 0.0: + raise ValueError("coefficient must be positive") + mismatch = matrix - np.eye(4, dtype=np.float64) + return 0.5 * strength * float(np.sum(mismatch**2)) diff --git a/lfm/analysis/gradient_spectroscopy.py b/lfm/analysis/gradient_spectroscopy.py new file mode 100644 index 0000000..8e0863d --- /dev/null +++ b/lfm/analysis/gradient_spectroscopy.py @@ -0,0 +1,384 @@ +"""Native one-link gradient-operator spectroscopy for full-R2 LFM. + +The operator basis is complete for Hermitian one-link bilinears of the three +complex GOV-01 components. It is an internal readout of the existing site +fields, not an independent gauge register or an added equation of motion. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass + +import numpy as np + +from lfm.analysis.energy_current import stencil_links + + +def u3_hermitian_generators() -> tuple[tuple[str, ...], np.ndarray]: + """Return T0 and the eight Gell-Mann matrices with Tr(TA TB)=2 deltaAB.""" + + zero = 0.0j + one = 1.0 + 0.0j + generators = [math.sqrt(2.0 / 3.0) * np.eye(3, dtype=np.complex128)] + generators.extend( + [ + np.array([[zero, one, zero], [one, zero, zero], [zero, zero, zero]]), + np.array([[zero, -1j, zero], [1j, zero, zero], [zero, zero, zero]]), + np.array([[one, zero, zero], [zero, -one, zero], [zero, zero, zero]]), + np.array([[zero, zero, one], [zero, zero, zero], [one, zero, zero]]), + np.array([[zero, zero, -1j], [zero, zero, zero], [1j, zero, zero]]), + np.array([[zero, zero, zero], [zero, zero, one], [zero, one, zero]]), + np.array([[zero, zero, zero], [zero, zero, -1j], [zero, 1j, zero]]), + np.diag([1.0, 1.0, -2.0]).astype(np.complex128) / math.sqrt(3.0), + ] + ) + names = ("T0", "T1", "T2", "T3", "T4", "T5", "T6", "T7", "T8") + return names, np.stack(generators, axis=0) + + +def operator_completeness_audit(seed: int = 20260727) -> dict[str, float | bool]: + """Numerically audit orthonormality and Hermitian-matrix reconstruction.""" + + _, generators = u3_hermitian_generators() + gram = np.einsum("aij,bji->ab", generators, generators).real + orthonormal_error = float(np.max(np.abs(gram - 2.0 * np.eye(9)))) + rng = np.random.default_rng(seed) + trial = rng.normal(size=(3, 3)) + 1j * rng.normal(size=(3, 3)) + hermitian = 0.5 * (trial + trial.conj().T) + coefficients = 0.5 * np.einsum("aij,ji->a", generators, hermitian) + reconstructed = np.einsum("a,aij->ij", coefficients, generators) + reconstruction_error = float(np.max(np.abs(reconstructed - hermitian))) + return { + "generator_count": 9.0, + "trace_orthonormality_max_error": orthonormal_error, + "hermitian_reconstruction_max_error": reconstruction_error, + "pass": orthonormal_error < 1.0e-12 and reconstruction_error < 1.0e-12, + } + + +def _link_bilinears( + psi: np.ndarray, + shift: tuple[int, int, int, int], +) -> np.ndarray: + _, generators = u3_hermitian_generators() + neighbor = np.roll(psi, shift=shift, axis=(0, 1, 2, 3)) + return np.imag( + np.einsum( + "...a,gab,...b->...g", + np.conj(psi), + generators, + neighbor, + optimize=True, + ) + ) + + +def _link_berry( + psi: np.ndarray, + chi: np.ndarray, + shift: tuple[int, int, int, int], +) -> np.ndarray: + neighbor_psi = np.roll(psi, shift=shift, axis=(0, 1, 2, 3)) + neighbor_chi = np.roll(chi, shift=shift, axis=(0, 1, 2, 3)) + overlap = chi * neighbor_chi + np.sum(np.conj(psi) * neighbor_psi, axis=-1) + return np.angle(overlap) + + +def native_gradient_operators(psi: np.ndarray, chi: np.ndarray) -> np.ndarray: + """Return the registered 19-channel site-centered four-vector basis. + + Channels 0:9 are the raw U(3) currents, channels 9:18 are their regular + full-state-normalized counterparts, and channel 18 is the regular Berry + link. The final axis is (time, x, y, z). + """ + + values = np.asarray(psi, dtype=np.complex128) + chi_values = np.asarray(chi, dtype=float) + if values.ndim != 5 or values.shape[-1] != 3: + raise ValueError("psi must have shape (Lt,Lx,Ly,Lz,3)") + if chi_values.shape != values.shape[:-1]: + raise ValueError("chi must match the four-dimensional psi lattice") + + shape = values.shape[:-1] + (9, 4) + raw = np.zeros(shape, dtype=np.float64) + regular = np.zeros(shape, dtype=np.float64) + berry = np.zeros(values.shape[:-1] + (1, 4), dtype=np.float64) + norms = np.sqrt(chi_values**2 + np.sum(np.abs(values) ** 2, axis=-1)) + + spacetime_links: list[tuple[tuple[int, int, int, int], float]] = [ + ((-1, 0, 0, 0), 1.0), + ((1, 0, 0, 0), 1.0), + ] + for offset, weight in stencil_links("19"): + spacetime_links.append(((0, offset[0], offset[1], offset[2]), weight)) + + for shift, weight in spacetime_links: + displacement = np.asarray(shift, dtype=float) + link = _link_bilinears(values, shift) + neighbor_norm = np.roll(norms, shift=shift, axis=(0, 1, 2, 3)) + denominator = np.maximum(norms * neighbor_norm, 1.0e-300) + regular_link = link / denominator[..., None] + berry_link = _link_berry(values, chi_values, shift) + for component in range(4): + coefficient = weight * displacement[component] + if coefficient == 0.0: + continue + raw[..., component] += coefficient * link + regular[..., component] += coefficient * regular_link + berry[..., 0, component] += coefficient * berry_link + return np.concatenate((raw, regular, berry), axis=-2) + + +def operator_family_slices() -> dict[str, slice]: + """Return fixed channel slices for preregistered operator families.""" + + return { + "raw_u3": slice(0, 9), + "regular_u3": slice(9, 18), + "regular_berry": slice(18, 19), + "combined": slice(0, 19), + } + + +def extract_low_momentum_modes(operators: np.ndarray) -> dict[str, np.ndarray]: + """Extract registered transverse, longitudinal, temporal, and cone modes.""" + + vector = np.asarray(operators, dtype=float) + if vector.ndim != 6 or vector.shape[-1] != 4: + raise ValueError("operators must have shape (Lt,Lx,Ly,Lz,C,4)") + if len(set(vector.shape[:4])) != 1: + raise ValueError("registered spectroscopy uses equal four-dimensional extents") + size = vector.shape[0] + transformed = np.fft.fftn(vector, axes=(0, 1, 2, 3), norm="ortho") + + def spatial_modes(harmonic: int, temporal: int) -> tuple[np.ndarray, ...]: + transverse = [] + longitudinal = [] + pol0 = [] + pol1 = [] + temporal_values = [] + for direction in range(3): + index = [temporal, 0, 0, 0] + index[direction + 1] = harmonic % size + sample = transformed[tuple(index)] + spatial_components = [axis for axis in range(3) if axis != direction] + pol0.append(sample[:, spatial_components[0] + 1]) + pol1.append(sample[:, spatial_components[1] + 1]) + transverse.extend( + [ + sample[:, spatial_components[0] + 1], + sample[:, spatial_components[1] + 1], + ] + ) + longitudinal.append(sample[:, direction + 1]) + temporal_values.append(sample[:, 0]) + return ( + np.stack(transverse), + np.stack(longitudinal), + np.stack(pol0), + np.stack(pol1), + np.stack(temporal_values), + ) + + t1, l1, p01, p11, a01 = spatial_modes(1, 0) + t2, l2, _p02, _p12, a02 = spatial_modes(2, 0) + cone, _cone_l, _cone_p0, _cone_p1, _cone_a0 = spatial_modes(1, 1) + return { + "transverse_k1": t1, + "transverse_k2": t2, + "longitudinal_k1": l1, + "polarization_0_k1": p01, + "polarization_1_k1": p11, + "cone_p1_k1": cone, + "temporal_k1": a01, + "temporal_k2": a02, + } + + +def _covariance(samples: np.ndarray) -> np.ndarray: + values = np.asarray(samples, dtype=np.complex128) + flattened = values.reshape(-1, values.shape[-1]) + flattened = flattened - np.mean(flattened, axis=0, keepdims=True) + covariance = np.real(flattened.conj().T @ flattened) / max(len(flattened), 1) + return 0.5 * (covariance + covariance.T) + + +def _leading_generalized_mode( + low: np.ndarray, + high: np.ndarray, +) -> tuple[np.ndarray, np.ndarray]: + c_low = _covariance(low) + c_high = _covariance(high) + scale = float(np.trace(c_high) / max(c_high.shape[0], 1)) + ridge = max(scale * 1.0e-8, 1.0e-300) + eigenvalues, eigenvectors = np.linalg.eigh(c_high + ridge * np.eye(c_high.shape[0])) + keep = eigenvalues > max(eigenvalues[-1] * 1.0e-10, ridge * 0.1) + whitening = eigenvectors[:, keep] / np.sqrt(eigenvalues[keep])[None, :] + reduced = whitening.T @ c_low @ whitening + ratios, modes = np.linalg.eigh(0.5 * (reduced + reduced.T)) + order = np.argsort(ratios)[::-1] + vector = whitening @ modes[:, order[0]] + vector /= max(float(np.linalg.norm(vector)), 1.0e-300) + dominant = int(np.argmax(np.abs(vector))) + if vector[dominant] < 0.0: + vector = -vector + return vector, ratios[order] + + +def _power(samples: np.ndarray, vector: np.ndarray) -> float: + values = np.asarray(samples, dtype=np.complex128) + projection = values @ vector + return float(np.mean(np.abs(projection) ** 2)) + + +def expected_massless_ir_ratio(size: int) -> float: + """Return the canonical lattice 1/k^2 ratio between harmonics one and two.""" + + k = 2.0 * math.pi / size + stiffness_1 = 4.0 * math.sin(0.5 * k) ** 2 + stiffness_2 = 4.0 * math.sin(k) ** 2 + return stiffness_2 / stiffness_1 + + +@dataclass(frozen=True) +class CrossValidatedMode: + """Held-out statistics for one registered operator family and volume.""" + + family: str + size: int + expected_ir_ratio: float + heldout_ir_ratios: tuple[float, float] + heldout_k1_powers: tuple[float, float] + heldout_cone_ratios: tuple[float, float] + heldout_longitudinal_ratios: tuple[float, float] + heldout_polarization_splits: tuple[float, float] + train_eigenvalue_counts: tuple[int, int] + vectors: tuple[np.ndarray, np.ndarray] + + @property + def mean_ir_ratio(self) -> float: + return float(np.mean(self.heldout_ir_ratios)) + + @property + def mean_k1_power(self) -> float: + return float(np.mean(self.heldout_k1_powers)) + + +def cross_validated_mode( + sample_modes: dict[str, np.ndarray], + family: str, + size: int, +) -> CrossValidatedMode: + """Select on one sample half and evaluate on the held-out half both ways.""" + + family_slice = operator_family_slices()[family] + selected = {key: np.asarray(value)[..., family_slice] for key, value in sample_modes.items()} + sample_count = selected["transverse_k1"].shape[0] + halves = (np.arange(sample_count) % 2 == 0, np.arange(sample_count) % 2 == 1) + expected = expected_massless_ir_ratio(size) + ir_ratios = [] + k1_powers = [] + cone_ratios = [] + longitudinal_ratios = [] + polarization_splits = [] + eigenvalue_counts = [] + vectors = [] + for train_mask, test_mask in (halves, halves[::-1]): + vector, eigenvalues = _leading_generalized_mode( + selected["transverse_k1"][train_mask], + selected["transverse_k2"][train_mask], + ) + k1_power = _power(selected["transverse_k1"][test_mask], vector) + k2_power = _power(selected["transverse_k2"][test_mask], vector) + cone_power = _power(selected["cone_p1_k1"][test_mask], vector) + longitudinal = _power(selected["longitudinal_k1"][test_mask], vector) + pol0 = _power(selected["polarization_0_k1"][test_mask], vector) + pol1 = _power(selected["polarization_1_k1"][test_mask], vector) + ir_ratios.append(k1_power / max(k2_power, 1.0e-300)) + k1_powers.append(k1_power) + cone_ratios.append(cone_power / max(k1_power, 1.0e-300)) + longitudinal_ratios.append(longitudinal / max(k1_power, 1.0e-300)) + polarization_splits.append(abs(pol0 - pol1) / max(0.5 * (pol0 + pol1), 1.0e-300)) + eigenvalue_counts.append(int(np.sum(np.abs(eigenvalues / expected - 1.0) <= 0.25))) + vectors.append(vector) + return CrossValidatedMode( + family=family, + size=size, + expected_ir_ratio=expected, + heldout_ir_ratios=(float(ir_ratios[0]), float(ir_ratios[1])), + heldout_k1_powers=(float(k1_powers[0]), float(k1_powers[1])), + heldout_cone_ratios=(float(cone_ratios[0]), float(cone_ratios[1])), + heldout_longitudinal_ratios=( + float(longitudinal_ratios[0]), + float(longitudinal_ratios[1]), + ), + heldout_polarization_splits=( + float(polarization_splits[0]), + float(polarization_splits[1]), + ), + train_eigenvalue_counts=(int(eigenvalue_counts[0]), int(eigenvalue_counts[1])), + vectors=(vectors[0], vectors[1]), + ) + + +def gauss_charge_regression( + sample_modes: dict[str, np.ndarray], + family: str, + size: int, + vector: np.ndarray, + sample_mask: np.ndarray | None = None, +) -> dict[str, float]: + """Test one coupling across k1/k2 against the fixed raw U(1) charge mode.""" + + family_slice = operator_family_slices()[family] + if sample_mask is None: + sample_mask = np.ones(len(sample_modes["temporal_k1"]), dtype=bool) + mask = np.asarray(sample_mask, dtype=bool) + temporal_1 = np.asarray(sample_modes["temporal_k1"])[mask][..., family_slice] @ vector + temporal_2 = np.asarray(sample_modes["temporal_k2"])[mask][..., family_slice] @ vector + raw_charge_1 = np.asarray(sample_modes["temporal_k1"])[mask][..., 0] + raw_charge_2 = np.asarray(sample_modes["temporal_k2"])[mask][..., 0] + k = 2.0 * math.pi / size + stiffness = ( + 4.0 * math.sin(0.5 * k) ** 2, + 4.0 * math.sin(k) ** 2, + ) + gauss_1 = -stiffness[0] * temporal_1 + gauss_2 = -stiffness[1] * temporal_2 + gauss = np.concatenate((gauss_1.ravel(), gauss_2.ravel())) + charge = np.concatenate((raw_charge_1.ravel(), raw_charge_2.ravel())) + gauss -= np.mean(gauss) + charge -= np.mean(charge) + covariance = np.mean(np.conj(charge) * gauss) + variance_gauss = float(np.mean(np.abs(gauss) ** 2)) + variance_charge = float(np.mean(np.abs(charge) ** 2)) + r_squared = float(abs(covariance) ** 2 / max(variance_gauss * variance_charge, 1.0e-300)) + coupling_1 = abs(np.mean(np.conj(raw_charge_1) * gauss_1)) / max( + float(np.mean(np.abs(raw_charge_1) ** 2)), + 1.0e-300, + ) + coupling_2 = abs(np.mean(np.conj(raw_charge_2) * gauss_2)) / max( + float(np.mean(np.abs(raw_charge_2) ** 2)), + 1.0e-300, + ) + coupling_cv = abs(coupling_1 - coupling_2) / max(0.5 * (coupling_1 + coupling_2), 1.0e-300) + return { + "r_squared": r_squared, + "coupling_k1": float(coupling_1), + "coupling_k2": float(coupling_2), + "coupling_cv": float(coupling_cv), + } + + +__all__ = [ + "CrossValidatedMode", + "cross_validated_mode", + "expected_massless_ir_ratio", + "extract_low_momentum_modes", + "gauss_charge_regression", + "native_gradient_operators", + "operator_completeness_audit", + "operator_family_slices", + "u3_hermitian_generators", +] diff --git a/lfm/analysis/metric.py b/lfm/analysis/metric.py index 411f0c3..ef7081a 100644 --- a/lfm/analysis/metric.py +++ b/lfm/analysis/metric.py @@ -13,7 +13,7 @@ import numpy as np -from lfm.constants import CHI0 +from lfm.constants import ARCSEC_PER_RADIAN, C_SI, CHI0, G_SI if TYPE_CHECKING: from numpy.typing import NDArray @@ -152,6 +152,109 @@ def schwarzschild_chi( return chi.astype(np.float32) +def schwarzschild_radius_si( + mass_kg: float, + gravitational_constant: float = G_SI, + c_si: float = C_SI, +) -> float: + """Return the Schwarzschild radius in meters for an SI mass input.""" + if mass_kg <= 0.0: + raise ValueError("mass_kg must be positive") + if gravitational_constant <= 0.0: + raise ValueError("gravitational_constant must be positive") + if c_si <= 0.0: + raise ValueError("c_si must be positive") + return float(2.0 * gravitational_constant * mass_kg / (c_si * c_si)) + + +def metric_refractive_index( + chi: NDArray, + chi0: float = CHI0, + ppn_gamma: float = 1.0, +) -> NDArray: + """Return the LFM geometric-optics refractive index from the chi metric. + + The GOV-01 metric map gives g00 = -(chi/chi0)^2. In the weak-field + optical limit, the PPN spatial metric contribution gives + n = (chi0 / chi) ** (1 + gamma). The canonical LFM weak-GR closure has + gamma = 1, so n = (chi0 / chi) ** 2. + """ + if chi0 <= 0.0: + raise ValueError("chi0 must be positive") + if ppn_gamma < 0.0: + raise ValueError("ppn_gamma must be non-negative") + chi_f = np.asarray(chi, dtype=np.float64) + if np.any(chi_f <= 0.0): + raise ValueError("chi must be positive for metric refractive index") + return np.power(chi0 / chi_f, 1.0 + ppn_gamma) + + +def op05_spherical_chi_deflection( + mass_kg: float, + impact_parameter_m: float, + x_extent_multiplier: float = 500.0, + sample_count: int = 20001, + ppn_gamma: float = 1.0, +) -> dict[str, object]: + """Integrate OP-05 for a spherical GR-16 chi profile. + + This uses the canonical LFM chain: + + - GR-16: chi/chi0 = sqrt(1 - r_s / r) + - GR-24: gamma = 1 for the weak-field spatial metric + - OP-05: dtheta/dx = (1/n) * partial_y n + + The returned comparator is not used by the integration; it is the + closed-form weak-field value 2*r_s/b for checking the numerical result. + """ + if impact_parameter_m <= 0.0: + raise ValueError("impact_parameter_m must be positive") + if x_extent_multiplier <= 1.0: + raise ValueError("x_extent_multiplier must be greater than 1") + if sample_count < 101: + raise ValueError("sample_count must be at least 101") + if sample_count % 2 == 0: + sample_count += 1 + + rs_m = schwarzschild_radius_si(mass_kg) + x_extent_m = float(x_extent_multiplier) * impact_parameter_m + x_m = np.linspace(-x_extent_m, x_extent_m, sample_count, dtype=np.float64) + y0_m = np.full_like(x_m, impact_parameter_m) + radius_m = np.sqrt(x_m * x_m + y0_m * y0_m) + exponent = 0.5 * (1.0 + ppn_gamma) + safe = np.maximum(1.0 - rs_m / radius_m, 1.0e-15) + n_eff = np.power(safe, -exponent) + + dn_dr = -exponent * rs_m / (radius_m * radius_m) * np.power(safe, -exponent - 1.0) + dn_dy = dn_dr * y0_m / radius_m + dtheta_dx = dn_dy / n_eff + angle_rad = abs(float(np.trapezoid(dtheta_dx, x_m))) + comparator_rad = 2.0 * rs_m / impact_parameter_m + + increments = 0.5 * (dtheta_dx[1:] + dtheta_dx[:-1]) * np.diff(x_m) + theta_rad = np.concatenate([[0.0], np.cumsum(increments)]) + y_increments = 0.5 * (theta_rad[1:] + theta_rad[:-1]) * np.diff(x_m) + y_m = impact_parameter_m + np.concatenate([[0.0], np.cumsum(y_increments)]) + + return { + "mass_kg": float(mass_kg), + "impact_parameter_m": float(impact_parameter_m), + "schwarzschild_radius_m": float(rs_m), + "x_extent_multiplier": float(x_extent_multiplier), + "sample_count": int(sample_count), + "ppn_gamma": float(ppn_gamma), + "x_over_b": (x_m / impact_parameter_m).tolist(), + "y_over_b": (y_m / impact_parameter_m).tolist(), + "theta_arcsec": (np.abs(theta_rad) * ARCSEC_PER_RADIAN).tolist(), + "n_eff": n_eff.tolist(), + "dtheta_dx": dtheta_dx.tolist(), + "recovered_angle_radians": float(angle_rad), + "recovered_angle_arcsec": float(angle_rad * ARCSEC_PER_RADIAN), + "canonical_comparator_arcsec": float(comparator_rad * ARCSEC_PER_RADIAN), + "comparator_relative_error": float((angle_rad - comparator_rad) / comparator_rad), + } + + # --------------------------------------------------------------------------- # Apparent horizon detection (v16 black-hole analysis) # --------------------------------------------------------------------------- diff --git a/lfm/analysis/modes.py b/lfm/analysis/modes.py new file mode 100644 index 0000000..9de9647 --- /dev/null +++ b/lfm/analysis/modes.py @@ -0,0 +1,97 @@ +"""Mode projections for periodic LFM fields and leapfrog phase space.""" + +from __future__ import annotations + +import numpy as np + + +def periodic_mode_coefficient( + field: np.ndarray, + mode: int, + *, + axis: int = -1, + background: float | complex = 0.0, +) -> np.ndarray | complex: + """Project periodic lines onto one spatial Fourier mode. + + Leading dimensions are preserved, so a bank of lines can be measured in + one call. The returned coefficient uses the convention + + ``mean((field-background) * exp(-2*pi*i*mode*x/N))``. + + This function is a terminal observable. It does not alter the field. + """ + arr = np.asarray(field) + if arr.ndim == 0: + raise ValueError("field must have at least one dimension") + normalized_axis = int(axis) + if not -arr.ndim <= normalized_axis < arr.ndim: + raise ValueError(f"axis {normalized_axis} is out of bounds for dimension {arr.ndim}") + normalized_axis %= arr.ndim + n = int(arr.shape[normalized_axis]) + if n <= 0: + raise ValueError("projection axis must be non-empty") + coordinate = np.arange(n, dtype=np.float64) + carrier = np.exp(-2j * np.pi * int(mode) * coordinate / float(n)) + shape = [1] * arr.ndim + shape[normalized_axis] = n + coefficient = np.mean( + (arr - background) * carrier.reshape(shape), + axis=normalized_axis, + ) + if np.ndim(coefficient) == 0: + return complex(coefficient) + return np.asarray(coefficient, dtype=np.complex128) + + +def leapfrog_branch_projection( + current: np.ndarray | complex, + previous: np.ndarray | complex, + theta: float, +) -> tuple[np.ndarray | complex, np.ndarray | complex]: + """Resolve a two-buffer leapfrog state into temporal branches. + + The convention is + + ``current = forward + backward`` + + ``previous = forward*exp(+i*theta) + backward*exp(-i*theta)``. + + This is a terminal observable for a known temporal frequency. It does not + evolve, relocate, or otherwise modify the input fields. + """ + if not np.isfinite(theta): + raise ValueError("theta must be finite") + if abs(float(np.sin(theta))) <= 1.0e-15: + raise ValueError("theta does not separate the two temporal branches") + + current_arr = np.asarray(current, dtype=np.complex128) + previous_arr = np.asarray(previous, dtype=np.complex128) + try: + current_arr, previous_arr = np.broadcast_arrays(current_arr, previous_arr) + except ValueError as exc: + raise ValueError("current and previous must be broadcast-compatible") from exc + + positive = np.exp(1j * float(theta)) + negative = np.exp(-1j * float(theta)) + denominator = positive - negative + forward = (previous_arr - current_arr * negative) / denominator + backward = (current_arr * positive - previous_arr) / denominator + if forward.ndim == 0: + return complex(forward), complex(backward) + return forward, backward + + +def project_leapfrog_mode( + current_field: np.ndarray, + previous_field: np.ndarray, + mode: int, + theta: float, + *, + axis: int = -1, + background: float | complex = 0.0, +) -> tuple[np.ndarray | complex, np.ndarray | complex]: + """Project one spatial mode and split its two temporal branches.""" + current = periodic_mode_coefficient(current_field, mode, axis=axis, background=background) + previous = periodic_mode_coefficient(previous_field, mode, axis=axis, background=background) + return leapfrog_branch_projection(current, previous, theta) diff --git a/lfm/analysis/particle_kinematics.py b/lfm/analysis/particle_kinematics.py new file mode 100644 index 0000000..60ff655 --- /dev/null +++ b/lfm/analysis/particle_kinematics.py @@ -0,0 +1,413 @@ +"""Collective kinematics readouts for source-free LFM field states. + +The functions in this module are external-grid diagnostics. They do not alter +live evolution and they do not insert a particle trajectory or force law. +""" + +from __future__ import annotations + +from typing import Any + +import numpy as np +from scipy.optimize import least_squares + +from lfm.constants import CHI0, KAPPA, LAMBDA_H +from lfm.core.stencils import laplacian_19pt + +try: + from numba import njit, prange +except ModuleNotFoundError: # pragma: no cover - exercised only without optional numba + + def njit(*jit_args: Any, **jit_kwargs: Any) -> Any: + """Return a no-op decorator when numba is unavailable.""" + + del jit_kwargs + if len(jit_args) == 1 and callable(jit_args[0]): + return jit_args[0] + + def decorate(function: Any) -> Any: + return function + + return decorate + + prange = range + + +@njit(inline="always") +def _gradient19_at( + field: np.ndarray, + i: int, + j: int, + k: int, + axis: int, + inv_two_dx: float, +) -> float: + size = field.shape[0] + ip = i + 1 if i + 1 < size else 0 + im = i - 1 if i > 0 else size - 1 + jp = j + 1 if j + 1 < size else 0 + jm = j - 1 if j > 0 else size - 1 + kp = k + 1 if k + 1 < size else 0 + km = k - 1 if k > 0 else size - 1 + if axis == 0: + face = field[ip, j, k] - field[im, j, k] + edges = ( + field[ip, jp, k] + + field[ip, jm, k] + + field[ip, j, kp] + + field[ip, j, km] + - field[im, jp, k] + - field[im, jm, k] + - field[im, j, kp] + - field[im, j, km] + ) + elif axis == 1: + face = field[i, jp, k] - field[i, jm, k] + edges = ( + field[ip, jp, k] + + field[im, jp, k] + + field[i, jp, kp] + + field[i, jp, km] + - field[ip, jm, k] + - field[im, jm, k] + - field[i, jm, kp] + - field[i, jm, km] + ) + else: + face = field[i, j, kp] - field[i, j, km] + edges = ( + field[ip, j, kp] + + field[im, j, kp] + + field[i, jp, kp] + + field[i, jm, kp] + - field[ip, j, km] + - field[im, j, km] + - field[i, jp, km] + - field[i, jm, km] + ) + return ((1.0 / 3.0) * face + (1.0 / 6.0) * edges) * inv_two_dx + + +@njit(inline="always") +def _average_gradient19_at( + current: np.ndarray, + previous: np.ndarray, + i: int, + j: int, + k: int, + axis: int, + inv_two_dx: float, +) -> float: + return 0.5 * ( + _gradient19_at(current, i, j, k, axis, inv_two_dx) + + _gradient19_at(previous, i, j, k, axis, inv_two_dx) + ) + + +@njit(parallel=True, cache=True) +def _time_centered_momentum19( + psi_real: np.ndarray, + psi_real_prev: np.ndarray, + psi_imag: np.ndarray, + psi_imag_prev: np.ndarray, + chi: np.ndarray, + chi_prev: np.ndarray, + dt: float, + dx: float, + b_chi: float, +) -> np.ndarray: + size = chi.shape[0] + total = size * size * size + inv_dt = 1.0 / dt + inv_two_dx = 1.0 / (2.0 * dx) + px = 0.0 + py = 0.0 + pz = 0.0 + for index in prange(total): + i = index // (size * size) + j = (index // size) % size + k = index % size + local_x = 0.0 + local_y = 0.0 + local_z = 0.0 + for component in range(psi_real.shape[0]): + rate_r = (psi_real[component, i, j, k] - psi_real_prev[component, i, j, k]) * inv_dt + rate_i = (psi_imag[component, i, j, k] - psi_imag_prev[component, i, j, k]) * inv_dt + local_x += rate_r * _average_gradient19_at( + psi_real[component], + psi_real_prev[component], + i, + j, + k, + 0, + inv_two_dx, + ) + local_x += rate_i * _average_gradient19_at( + psi_imag[component], + psi_imag_prev[component], + i, + j, + k, + 0, + inv_two_dx, + ) + local_y += rate_r * _average_gradient19_at( + psi_real[component], + psi_real_prev[component], + i, + j, + k, + 1, + inv_two_dx, + ) + local_y += rate_i * _average_gradient19_at( + psi_imag[component], + psi_imag_prev[component], + i, + j, + k, + 1, + inv_two_dx, + ) + local_z += rate_r * _average_gradient19_at( + psi_real[component], + psi_real_prev[component], + i, + j, + k, + 2, + inv_two_dx, + ) + local_z += rate_i * _average_gradient19_at( + psi_imag[component], + psi_imag_prev[component], + i, + j, + k, + 2, + inv_two_dx, + ) + rate_chi = (chi[i, j, k] - chi_prev[i, j, k]) * inv_dt + local_x += b_chi * rate_chi * _average_gradient19_at(chi, chi_prev, i, j, k, 0, inv_two_dx) + local_y += b_chi * rate_chi * _average_gradient19_at(chi, chi_prev, i, j, k, 1, inv_two_dx) + local_z += b_chi * rate_chi * _average_gradient19_at(chi, chi_prev, i, j, k, 2, inv_two_dx) + px += -local_x + py += -local_y + pz += -local_z + volume = dx**3 + return np.asarray((px * volume, py * volume, pz * volume)) + + +def time_centered_momentum_19pt( + psi_real: np.ndarray, + psi_real_prev: np.ndarray, + psi_imag: np.ndarray, + psi_imag_prev: np.ndarray, + chi: np.ndarray, + chi_prev: np.ndarray, + *, + dt: float, + dx: float, + b_chi: float = CHI0 / KAPPA, +) -> np.ndarray: + """Return the leapfrog-time-centered 19-point collective momentum.""" + arrays = tuple( + np.ascontiguousarray(value) + for value in ( + psi_real, + psi_real_prev, + psi_imag, + psi_imag_prev, + chi, + chi_prev, + ) + ) + pr, pp, pi, pip, chi_value, chi_previous = arrays + if pr.ndim != 4 or pp.shape != pr.shape or pi.shape != pr.shape or pip.shape != pr.shape: + raise ValueError("complex component arrays must share shape (components,N,N,N)") + if chi_value.ndim != 3 or chi_previous.shape != chi_value.shape: + raise ValueError("chi arrays must share shape (N,N,N)") + if pr.shape[1:] != chi_value.shape: + raise ValueError("matter and chi spatial shapes differ") + if dt <= 0.0 or dx <= 0.0 or b_chi <= 0.0: + raise ValueError("dt, dx, and b_chi must be positive") + return _time_centered_momentum19( + pr, + pp, + pi, + pip, + chi_value, + chi_previous, + float(dt), + float(dx), + float(b_chi), + ) + + +def component_noether_charges( + psi_real: np.ndarray, + psi_real_prev: np.ndarray, + psi_imag: np.ndarray, + psi_imag_prev: np.ndarray, + *, + dt: float, + dx: float, +) -> np.ndarray: + """Return one leapfrog Noether charge for each complex component.""" + if dt <= 0.0 or dx <= 0.0: + raise ValueError("dt and dx must be positive") + arrays = tuple( + np.asarray(value) for value in (psi_real, psi_real_prev, psi_imag, psi_imag_prev) + ) + if arrays[0].ndim != 4 or any(value.shape != arrays[0].shape for value in arrays[1:]): + raise ValueError("all matter arrays must share shape (components,N,N,N)") + factor = dx**3 / dt + charges = [] + for component in range(arrays[0].shape[0]): + bilinear = ( + arrays[1][component] * arrays[2][component] + - arrays[3][component] * arrays[0][component] + ) + charges.append(float(np.sum(bilinear, dtype=np.float64)) * factor) + return np.asarray(charges, dtype=np.float64) + + +def flat_octic_hamiltonian_19pt( + psi_real: np.ndarray, + psi_real_prev: np.ndarray, + psi_imag: np.ndarray, + psi_imag_prev: np.ndarray, + chi: np.ndarray, + chi_prev: np.ndarray, + *, + dt: float, + dx: float, + chi0: float = CHI0, + kappa: float = KAPPA, + lambda_h: float = LAMBDA_H, +) -> dict[str, float]: + """Return the phase-space Hamiltonian of the flat-octic C3 system.""" + if dt <= 0.0 or dx <= 0.0 or chi0 <= 0.0 or kappa <= 0.0 or lambda_h <= 0.0: + raise ValueError("scales and couplings must be positive") + pr = np.asarray(psi_real) + pp = np.asarray(psi_real_prev) + pi = np.asarray(psi_imag) + pip = np.asarray(psi_imag_prev) + ch = np.asarray(chi) + ch_prev = np.asarray(chi_prev) + if pr.ndim != 4 or pp.shape != pr.shape or pi.shape != pr.shape or pip.shape != pr.shape: + raise ValueError("complex component arrays must share shape (components,N,N,N)") + if ch.shape != pr.shape[1:] or ch_prev.shape != ch.shape: + raise ValueError("chi and matter spatial shapes differ") + + volume = dx**3 + inv_dx2 = 1.0 / dx**2 + b_chi = chi0 / kappa + matter_temporal = 0.0 + matter_gradient = 0.0 + matter_mass = 0.0 + chi_sq = ch * ch + for component in range(pr.shape[0]): + rate_r = (pr[component] - pp[component]) / dt + rate_i = (pi[component] - pip[component]) / dt + matter_temporal += ( + 0.5 * volume * float(np.sum(rate_r * rate_r + rate_i * rate_i, dtype=np.float64)) + ) + lap_r = laplacian_19pt(pr[component]) + lap_i = laplacian_19pt(pi[component]) + matter_gradient += ( + -0.5 + * volume + * inv_dx2 + * float(np.sum(pr[component] * lap_r + pi[component] * lap_i, dtype=np.float64)) + ) + matter_mass += ( + 0.5 + * volume + * float( + np.sum( + chi_sq * (pr[component] * pr[component] + pi[component] * pi[component]), + dtype=np.float64, + ) + ) + ) + chi_rate = (ch - ch_prev) / dt + chi_temporal = 0.5 * b_chi * volume * float(np.sum(chi_rate * chi_rate, dtype=np.float64)) + lap_chi = laplacian_19pt(ch) + chi_gradient = -0.5 * b_chi * volume * inv_dx2 * float(np.sum(ch * lap_chi, dtype=np.float64)) + delta = chi_sq - chi0**2 + chi_potential = b_chi * lambda_h / chi0**4 * volume * float(np.sum(delta**4, dtype=np.float64)) + total = ( + matter_temporal + + matter_gradient + + matter_mass + + chi_temporal + + chi_gradient + + chi_potential + ) + return { + "total": total, + "matter_temporal": matter_temporal, + "matter_gradient": matter_gradient, + "matter_mass": matter_mass, + "chi_temporal": chi_temporal, + "chi_gradient": chi_gradient, + "chi_potential": chi_potential, + } + + +def fit_offset_power_convergence( + spacings: np.ndarray, + errors: np.ndarray, +) -> dict[str, Any]: + """Fit nonnegative errors to error(h) = offset + coefficient*h**order.""" + h = np.asarray(spacings, dtype=np.float64) + y = np.asarray(errors, dtype=np.float64) + if h.ndim != 1 or y.shape != h.shape or h.size < 4: + raise ValueError("at least four matched one-dimensional samples are required") + if np.any(~np.isfinite(h)) or np.any(~np.isfinite(y)): + raise ValueError("samples must be finite") + if np.any(h <= 0.0) or np.any(y < 0.0): + raise ValueError("spacings must be positive and errors nonnegative") + scale = max(float(np.max(y)), 1.0e-15) + initial_offset = max(0.0, min(float(np.min(y)) * 0.25, scale)) + initial_order = 2.0 + initial_coefficient = max( + (float(np.max(y)) - initial_offset) / float(np.max(h) ** initial_order), + 1.0e-15, + ) + + def residual(parameters: np.ndarray) -> np.ndarray: + offset, coefficient, order = parameters + return (offset + coefficient * h**order - y) / scale + + solution = least_squares( + residual, + x0=np.asarray((initial_offset, initial_coefficient, initial_order)), + bounds=( + np.asarray((0.0, 0.0, 0.1)), + np.asarray((2.0 * scale, np.inf, 6.0)), + ), + xtol=1.0e-13, + ftol=1.0e-13, + gtol=1.0e-13, + max_nfev=20000, + ) + offset, coefficient, order = (float(value) for value in solution.x) + predicted = offset + coefficient * h**order + residual_sum = float(np.sum((y - predicted) ** 2)) + total_sum = float(np.sum((y - np.mean(y)) ** 2)) + r_squared = ( + 1.0 + if total_sum <= 1.0e-30 and residual_sum <= 1.0e-30 + else 1.0 - residual_sum / max(total_sum, 1.0e-30) + ) + return { + "offset": offset, + "coefficient": coefficient, + "order": order, + "r_squared": r_squared, + "predicted": predicted.tolist(), + "converged": bool(solution.success), + "message": str(solution.message), + } diff --git a/lfm/analysis/phase.py b/lfm/analysis/phase.py index 3345337..7146402 100644 --- a/lfm/analysis/phase.py +++ b/lfm/analysis/phase.py @@ -11,14 +11,25 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast import numpy as np +from lfm.analysis.energy_current import stencil_links +from lfm.core.stencils import laplacian_19pt, laplacian_27pt + if TYPE_CHECKING: from numpy.typing import NDArray +def _runtime_float_dtype(*arrays: object) -> type[np.float32] | type[np.float64]: + for arr in arrays: + dtype = getattr(arr, "dtype", None) + if dtype is not None and np.dtype(dtype) == np.dtype(np.float64): + return np.float64 + return np.float32 + + def phase_field( psi_r: NDArray, psi_i: NDArray, @@ -76,6 +87,185 @@ def charge_density( return psi_r * dpsi_i_dt - psi_i * dpsi_r_dt +def canonical_charge_density( + psi_r: NDArray, + psi_i: NDArray, + momentum_r: NDArray, + momentum_i: NDArray, +) -> NDArray: + """Return the canonical U(1) charge density from phase-space fields. + + The bare complex GOV-01 field has + ``rho = psi_r * momentum_i - psi_i * momentum_r``. Unlike a finite + difference estimate, this observable uses the canonical momentum directly. + """ + return psi_r * momentum_i - psi_i * momentum_r + + +def oriented_charge_currents( + psi_r: NDArray, + psi_i: NDArray, + *, + wave_speed: float = 1.0, + stencil: str = "19", +) -> dict[tuple[int, int, int], NDArray]: + """Return exact outgoing U(1) current on every oriented stencil link. + + The current is paired with the selected discrete Laplacian. It therefore + obeys an exact semidiscrete continuity identity for the bare complex + GOV-01 equation on a periodic lattice. + """ + if wave_speed <= 0.0 or not np.isfinite(wave_speed): + raise ValueError("wave_speed must be positive and finite") + real = np.asarray(psi_r) + imag = np.asarray(psi_i) + if real.shape != imag.shape or real.ndim != 3: + raise ValueError("psi_r and psi_i must have matching shape (N,N,N)") + c2 = wave_speed**2 + currents: dict[tuple[int, int, int], NDArray] = {} + for offset, weight in stencil_links(stencil): + shifted_real = np.roll(real, shift=offset, axis=(0, 1, 2)) + shifted_imag = np.roll(imag, shift=offset, axis=(0, 1, 2)) + currents[offset] = -c2 * weight * (real * shifted_imag - imag * shifted_real) + return currents + + +def charge_current_divergence( + psi_r: NDArray, + psi_i: NDArray, + *, + wave_speed: float = 1.0, + stencil: str = "19", +) -> NDArray: + """Return the sum of exact outgoing U(1) link currents.""" + currents = oriented_charge_currents( + psi_r, + psi_i, + wave_speed=wave_speed, + stencil=stencil, + ) + result = np.zeros_like(np.asarray(psi_r), dtype=np.float64) + for current in currents.values(): + result += current + return result + + +def bare_charge_continuity_residual( + psi_r: NDArray, + psi_i: NDArray, + momentum_r: NDArray, + momentum_i: NDArray, + chi: NDArray, + *, + wave_speed: float = 1.0, + stencil: str = "19", +) -> NDArray: + """Return the exact semidiscrete residual ``d_t rho + div J``. + + The local ``chi**2 * Psi`` term cancels from the U(1) charge rate. This + function tests the identity rather than advancing a new equation. + """ + arrays = tuple(np.asarray(value) for value in (psi_r, psi_i, momentum_r, momentum_i, chi)) + if any(array.shape != arrays[0].shape for array in arrays[1:]): + raise ValueError("all fields must have matching shapes") + if arrays[0].ndim != 3: + raise ValueError("all fields must have shape (N,N,N)") + if stencil == "19": + laplacian = laplacian_19pt + elif stencil == "27": + laplacian = laplacian_27pt + else: + raise ValueError("stencil must be '19' or '27'") + real, imag, _momentum_real, _momentum_imag, chi_values = arrays + momentum_rate_real = wave_speed**2 * laplacian(real) - chi_values**2 * real + momentum_rate_imag = wave_speed**2 * laplacian(imag) - chi_values**2 * imag + charge_rate = real * momentum_rate_imag - imag * momentum_rate_real + return charge_rate + charge_current_divergence( + real, + imag, + wave_speed=wave_speed, + stencil=stencil, + ) + + +def noether_spatial_current( + psi_r: NDArray, + psi_i: NDArray, + axis: int = 0, +) -> NDArray: + """Compute spatial Noether current along one lattice axis. + + j_axis = Im(conj(Psi) * d_axis Psi). A centered finite difference is + used on the periodic lattice. + """ + if axis not in (0, 1, 2): + raise ValueError("axis must be 0, 1, or 2") + dpsi_r = 0.5 * (np.roll(psi_r, -1, axis=axis) - np.roll(psi_r, 1, axis=axis)) + dpsi_i = 0.5 * (np.roll(psi_i, -1, axis=axis) - np.roll(psi_i, 1, axis=axis)) + out_dtype = _runtime_float_dtype(psi_r, psi_i) + return (psi_r * dpsi_i - psi_i * dpsi_r).astype(out_dtype) + + +def positive_noether_current( + psi_r: NDArray, + psi_i: NDArray, + axis: int = 0, +) -> NDArray: + """Return only the positive outgoing part of spatial Noether current.""" + out_dtype = _runtime_float_dtype(psi_r, psi_i) + return np.maximum(noether_spatial_current(psi_r, psi_i, axis=axis), out_dtype(0.0)).astype( + out_dtype + ) + + +def phase_current_energy_density( + psi_r: NDArray, + psi_i: NDArray, + psi_r_prev: NDArray, + psi_i_prev: NDArray, + dt: float, + c_speed: float = 1.0, + amplitude_floor: float = 1.0e-30, +) -> NDArray: + """Return the phase-current stress-energy component of a complex wave. + + A pure phase photon can carry energy while ``|Psi|^2`` remains nearly + constant. This observable extracts that missing source from the U(1) + Noether current: + + rho_phase = 0.5 * (j_0^2 + c^2 |j_space|^2) / |Psi|^2 + + where ``j_0 = Im(conj(Psi) d_t Psi)`` and + ``j_i = Im(conj(Psi) d_i Psi)``. For ``Psi = A exp(i theta)``, this is + ``0.5 * A^2 * (theta_t^2 + c^2 |grad theta|^2)``. It is invariant under + global phase rotations and vanishes for a static uniform phase. + """ + if dt <= 0.0: + raise ValueError("dt must be positive") + if c_speed < 0.0: + raise ValueError("c_speed must be non-negative") + out_dtype = _runtime_float_dtype(psi_r, psi_i, psi_r_prev, psi_i_prev) + + psi_r_f = psi_r.astype(out_dtype, copy=False) + psi_i_f = psi_i.astype(out_dtype, copy=False) + psi_r_prev_f = psi_r_prev.astype(out_dtype, copy=False) + psi_i_prev_f = psi_i_prev.astype(out_dtype, copy=False) + + dpsi_r_dt = (psi_r_f - psi_r_prev_f) / out_dtype(dt) # type: ignore[operator] + dpsi_i_dt = (psi_i_f - psi_i_prev_f) / out_dtype(dt) # type: ignore[operator] + j0 = psi_r_f * dpsi_i_dt - psi_i_f * dpsi_r_dt + + jx = noether_spatial_current(psi_r_f, psi_i_f, axis=0) + jy = noether_spatial_current(psi_r_f, psi_i_f, axis=1) + jz = noether_spatial_current(psi_r_f, psi_i_f, axis=2) + + amp_sq = psi_r_f * psi_r_f + psi_i_f * psi_i_f # type: ignore[operator] + amp_safe = np.maximum(amp_sq, out_dtype(amplitude_floor)) + c2 = out_dtype(c_speed * c_speed) + energy = 0.5 * (j0 * j0 + c2 * (jx * jx + jy * jy + jz * jz)) / amp_safe + return cast("NDArray[np.floating]", energy.astype(out_dtype)) + + def phase_coherence( psi_r: NDArray, psi_i: NDArray, diff --git a/lfm/analysis/poincare.py b/lfm/analysis/poincare.py new file mode 100644 index 0000000..b4665da --- /dev/null +++ b/lfm/analysis/poincare.py @@ -0,0 +1,793 @@ +"""Poincare-emergence diagnostics for the LFM cubic substrate. + +The finite lattice has exact integer translations and cubic rotations, not the +continuous Poincare group. This module quantifies how the exact lattice +dispersion approaches the continuum mass shell and how rotation and boost +defects vanish in the long-wavelength limit. + +The implementation is spectral. It uses the exact symbols of the 19-point +and 27-point cubic stencils and the exact leapfrog time symbol, so it does not +duplicate the production GOV-01 or GOV-02 update loops. +""" + +from __future__ import annotations + +import itertools +import math +from dataclasses import dataclass +from typing import TYPE_CHECKING + +import numpy as np + +if TYPE_CHECKING: + from collections.abc import Iterable + + from numpy.typing import ArrayLike, NDArray + + +@dataclass(frozen=True) +class CubicStencil: + """Weights for a center/face/edge/corner cubic Laplacian.""" + + name: str + face_weight: float + edge_weight: float + corner_weight: float + + +STENCIL_19 = CubicStencil( + name="19", + face_weight=1.0 / 3.0, + edge_weight=1.0 / 6.0, + corner_weight=0.0, +) + +STENCIL_27 = CubicStencil( + name="27", + face_weight=4.0 / 9.0, + edge_weight=1.0 / 9.0, + corner_weight=1.0 / 36.0, +) + +STENCILS = {"19": STENCIL_19, "27": STENCIL_27} + + +def get_stencil(stencil: str | CubicStencil) -> CubicStencil: + """Return a validated stencil specification.""" + + if isinstance(stencil, CubicStencil): + return stencil + try: + return STENCILS[str(stencil)] + except KeyError as exc: + raise ValueError(f"unknown stencil {stencil!r}; expected '19' or '27'") from exc + + +def _wavevectors(k: ArrayLike) -> NDArray[np.float64]: + arr = np.asarray(k, dtype=np.float64) + if arr.shape == (3,): + return arr + if arr.ndim < 1 or arr.shape[-1] != 3: + raise ValueError("wavevectors must have shape (3,) or (..., 3)") + return arr + + +def stencil_symbol( + k: ArrayLike, + *, + spacing: float = 1.0, + stencil: str | CubicStencil = "19", +) -> NDArray[np.float64]: + """Return the exact Laplacian symbol lambda(k), including 1/spacing^2.""" + + if spacing <= 0.0: + raise ValueError("spacing must be positive") + spec = get_stencil(stencil) + wave = _wavevectors(k) + q = wave * spacing + cos_q = np.cos(q) + face_sum = np.sum(cos_q, axis=-1) + edge_sum = ( + cos_q[..., 0] * cos_q[..., 1] + + cos_q[..., 0] * cos_q[..., 2] + + cos_q[..., 1] * cos_q[..., 2] + ) + corner_product = np.prod(cos_q, axis=-1) + symbol = ( + 2.0 * spec.face_weight * (face_sum - 3.0) + + 4.0 * spec.edge_weight * (edge_sum - 3.0) + + 8.0 * spec.corner_weight * (corner_product - 1.0) + ) + return np.asarray(symbol / (spacing * spacing), dtype=np.float64) + + +def lattice_k_squared( + k: ArrayLike, + *, + spacing: float = 1.0, + stencil: str | CubicStencil = "19", +) -> NDArray[np.float64]: + """Return the nonnegative lattice momentum squared, -lambda(k).""" + + value = -stencil_symbol(k, spacing=spacing, stencil=stencil) + return np.maximum(value, 0.0) + + +def discrete_omega( + k: ArrayLike, + *, + mass: float = 0.0, + c: float = 1.0, + dt: float = 0.02, + spacing: float = 1.0, + stencil: str | CubicStencil = "19", +) -> NDArray[np.float64]: + """Return the exact positive-frequency leapfrog branch.""" + + if dt <= 0.0 or c <= 0.0 or mass < 0.0: + raise ValueError("dt and c must be positive and mass must be nonnegative") + k2 = lattice_k_squared(k, spacing=spacing, stencil=stencil) + frequency_sq = c * c * k2 + mass * mass + argument = 0.5 * dt * np.sqrt(frequency_sq) + if np.any(argument > 1.0 + 1.0e-13): + raise ValueError("requested mode is outside the stable leapfrog branch") + return 2.0 * np.arcsin(np.clip(argument, 0.0, 1.0)) / dt + + +def _lattice_k_squared_gradient( + k: ArrayLike, + *, + spacing: float, + stencil: str | CubicStencil, +) -> NDArray[np.float64]: + spec = get_stencil(stencil) + wave = _wavevectors(k) + q = wave * spacing + sin_q = np.sin(q) + cos_q = np.cos(q) + gradients = np.empty_like(wave, dtype=np.float64) + for axis in range(3): + other = [candidate for candidate in range(3) if candidate != axis] + gradients[..., axis] = ( + sin_q[..., axis] + * ( + 2.0 * spec.face_weight + + 4.0 * spec.edge_weight * (cos_q[..., other[0]] + cos_q[..., other[1]]) + + 8.0 * spec.corner_weight * cos_q[..., other[0]] * cos_q[..., other[1]] + ) + / spacing + ) + return gradients + + +def group_velocity( + k: ArrayLike, + *, + mass: float = 0.0, + c: float = 1.0, + dt: float = 0.02, + spacing: float = 1.0, + stencil: str | CubicStencil = "19", +) -> NDArray[np.float64]: + """Return the exact group-velocity vector for the leapfrog branch.""" + + wave = _wavevectors(k) + k2 = lattice_k_squared(wave, spacing=spacing, stencil=stencil) + frequency_sq = c * c * k2 + mass * mass + root = np.sqrt(frequency_sq) + phase_factor_sq = 1.0 - 0.25 * dt * dt * frequency_sq + if np.any(phase_factor_sq <= 0.0): + raise ValueError("group velocity is undefined at or above the leapfrog branch edge") + denominator = 2.0 * root * np.sqrt(phase_factor_sq) + gradient = _lattice_k_squared_gradient(wave, spacing=spacing, stencil=stencil) + velocity = np.zeros_like(gradient) + np.divide( + c * c * gradient, + np.expand_dims(denominator, axis=-1), + out=velocity, + where=np.expand_dims(denominator > 0.0, axis=-1), + ) + return velocity + + +def fibonacci_sphere(count: int) -> NDArray[np.float64]: + """Return deterministic near-uniform unit vectors on the two-sphere.""" + + if count < 6: + raise ValueError("count must be at least 6") + index = np.arange(count, dtype=np.float64) + z = 1.0 - 2.0 * (index + 0.5) / count + phi = math.pi * (3.0 - math.sqrt(5.0)) * index + radius = np.sqrt(np.maximum(1.0 - z * z, 0.0)) + return np.column_stack((radius * np.cos(phi), radius * np.sin(phi), z)) + + +def shell_metrics( + k_magnitude: float, + *, + directions: ArrayLike | None = None, + mass: float = 0.0, + c: float = 1.0, + dt: float = 0.02, + spacing: float = 1.0, + stencil: str | CubicStencil = "19", +) -> dict[str, float]: + """Measure dispersion and directional errors on one physical k-shell.""" + + if k_magnitude <= 0.0: + raise ValueError("k_magnitude must be positive") + unit = fibonacci_sphere(512) if directions is None else _wavevectors(directions) + norms = np.linalg.norm(unit, axis=-1) + if np.any(norms <= 0.0): + raise ValueError("directions must be nonzero") + unit = unit / norms[..., None] + wave = k_magnitude * unit + omega = discrete_omega( + wave, + mass=mass, + c=c, + dt=dt, + spacing=spacing, + stencil=stencil, + ) + continuum_omega = math.sqrt(c * c * k_magnitude * k_magnitude + mass * mass) + dispersion_error = np.abs(omega / continuum_omega - 1.0) + + velocity = group_velocity( + wave, + mass=mass, + c=c, + dt=dt, + spacing=spacing, + stencil=stencil, + ) + radial_velocity = np.sum(velocity * unit, axis=-1) + continuum_velocity = c * c * k_magnitude / continuum_omega + group_error = np.abs(radial_velocity / continuum_velocity - 1.0) + anisotropy = (np.max(radial_velocity) - np.min(radial_velocity)) / np.mean( + np.abs(radial_velocity) + ) + transverse = np.linalg.norm(velocity - radial_velocity[..., None] * unit, axis=-1) + + return { + "dispersion_error_mean": float(np.mean(dispersion_error)), + "dispersion_error_max": float(np.max(dispersion_error)), + "group_velocity_error_mean": float(np.mean(group_error)), + "group_velocity_error_max": float(np.max(group_error)), + "directional_anisotropy": float(anisotropy), + "transverse_group_velocity_max_over_c": float(np.max(transverse) / c), + "radial_velocity_mean_over_c": float(np.mean(radial_velocity) / c), + } + + +def mass_shell_residual( + omega: ArrayLike, + k: ArrayLike, + *, + mass: float = 0.0, + c: float = 1.0, + dt: float = 0.02, + spacing: float = 1.0, + stencil: str | CubicStencil = "19", +) -> NDArray[np.float64]: + """Evaluate the exact discrete mass-shell function.""" + + omega_arr = np.asarray(omega, dtype=np.float64) + temporal = 4.0 * np.sin(0.5 * omega_arr * dt) ** 2 / (dt * dt) + spatial = c * c * lattice_k_squared(k, spacing=spacing, stencil=stencil) + return temporal - spatial - mass * mass + + +def lorentz_boost_wavevector( + omega: ArrayLike, + k: ArrayLike, + *, + beta: float, + axis: int = 0, + c: float = 1.0, +) -> tuple[NDArray[np.float64], NDArray[np.float64]]: + """Apply a continuum passive Lorentz boost to a frequency-wavevector pair.""" + + if not 0 <= axis < 3: + raise ValueError("axis must be 0, 1, or 2") + if abs(beta) >= 1.0: + raise ValueError("abs(beta) must be less than one") + wave = np.array(_wavevectors(k), copy=True) + omega_arr = np.asarray(omega, dtype=np.float64) + gamma = 1.0 / math.sqrt(1.0 - beta * beta) + boosted_omega = gamma * (omega_arr - beta * c * wave[..., axis]) + boosted_axis = gamma * (wave[..., axis] - beta * omega_arr / c) + wave[..., axis] = boosted_axis + return boosted_omega, wave + + +def boost_covariance_metrics( + k: ArrayLike, + *, + beta: float, + axis: int = 0, + mass: float = 0.0, + c: float = 1.0, + dt: float = 0.02, + spacing: float = 1.0, + stencil: str | CubicStencil = "19", +) -> dict[str, float]: + """Measure mass-shell and velocity-addition defects under a continuum boost.""" + + wave = _wavevectors(k) + omega = discrete_omega( + wave, + mass=mass, + c=c, + dt=dt, + spacing=spacing, + stencil=stencil, + ) + boosted_omega, boosted_wave = lorentz_boost_wavevector( + omega, + wave, + beta=beta, + axis=axis, + c=c, + ) + residual = mass_shell_residual( + boosted_omega, + boosted_wave, + mass=mass, + c=c, + dt=dt, + spacing=spacing, + stencil=stencil, + ) + normalization = np.maximum(boosted_omega * boosted_omega + mass * mass, 1.0e-30) + + velocity = group_velocity( + wave, + mass=mass, + c=c, + dt=dt, + spacing=spacing, + stencil=stencil, + ) + actual = group_velocity( + boosted_wave, + mass=mass, + c=c, + dt=dt, + spacing=spacing, + stencil=stencil, + ) + gamma = 1.0 / math.sqrt(1.0 - beta * beta) + denominator = 1.0 - beta * velocity[..., axis] / c + expected = np.array(velocity, copy=True) + expected[..., axis] = (velocity[..., axis] - beta * c) / denominator + for component in range(3): + if component != axis: + expected[..., component] = velocity[..., component] / (gamma * denominator) + addition_error = np.linalg.norm(actual - expected, axis=-1) / c + + return { + "boosted_mass_shell_residual_mean": float(np.mean(np.abs(residual) / normalization)), + "boosted_mass_shell_residual_max": float(np.max(np.abs(residual) / normalization)), + "velocity_addition_error_mean_over_c": float(np.mean(addition_error)), + "velocity_addition_error_max_over_c": float(np.max(addition_error)), + } + + +def cubic_rotation_residual( + k: ArrayLike, + *, + spacing: float = 1.0, + stencil: str | CubicStencil = "19", +) -> float: + """Return the maximum symbol change over all 48 signed axis permutations.""" + + wave = np.asarray(_wavevectors(k), dtype=np.float64) + reference = stencil_symbol(wave, spacing=spacing, stencil=stencil) + maximum = 0.0 + for permutation in itertools.permutations(range(3)): + permuted = wave[..., permutation] + for signs in itertools.product((-1.0, 1.0), repeat=3): + transformed = permuted * np.asarray(signs) + value = stencil_symbol(transformed, spacing=spacing, stencil=stencil) + maximum = max(maximum, float(np.max(np.abs(value - reference)))) + return maximum + + +def max_spatial_eigenvalue( + *, + spacing: float = 1.0, + stencil: str | CubicStencil = "19", +) -> float: + """Return max(-lambda) over the Brillouin zone. + + The symbol is multilinear in cos(k_i spacing), so its extrema occur at + the eight Brillouin-zone corners. + """ + + corners = np.asarray(list(itertools.product((0.0, math.pi / spacing), repeat=3))) + return float(np.max(lattice_k_squared(corners, spacing=spacing, stencil=stencil))) + + +def leapfrog_stability_limit( + *, + mass: float = 0.0, + c: float = 1.0, + spacing: float = 1.0, + stencil: str | CubicStencil = "19", +) -> float: + """Return the exact linear leapfrog timestep bound.""" + + maximum = max_spatial_eigenvalue(spacing=spacing, stencil=stencil) + return 2.0 / math.sqrt(c * c * maximum + mass * mass) + + +def symanzik_coefficients(stencil: str | CubicStencil) -> dict[str, float]: + """Return the small-spacing symbol coefficients through sixth order. + + The symbol is + lambda = -k^2 + h^2 k^4/12 + - h^4[a sum(k_i^6) + + b sum_{i != j}(k_i^4 k_j^2) + + d k_x^2 k_y^2 k_z^2] + O(h^6). + """ + + spec = get_stencil(stencil) + pure = spec.face_weight / 360.0 + spec.edge_weight / 90.0 + pure += spec.corner_weight / 90.0 + mixed_ordered = spec.edge_weight / 12.0 + spec.corner_weight / 6.0 + triple = spec.corner_weight + return { + "quadratic": 1.0, + "quartic_pure": 1.0 / 12.0, + "quartic_mixed": 1.0 / 6.0, + "sixth_pure": pure, + "sixth_mixed_ordered": mixed_ordered, + "sixth_triple": triple, + "isotropic_sixth_pure": 1.0 / 360.0, + "isotropic_sixth_mixed_ordered": 1.0 / 120.0, + "isotropic_sixth_triple": 1.0 / 60.0, + } + + +def poincare_algebra_matrix_residual() -> dict[str, float]: + """Verify a 5x5 affine representation of the Poincare Lie algebra.""" + + rotations = [] + boosts = [] + translations = [] + for mu in range(4): + generator = np.zeros((5, 5), dtype=np.float64) + generator[mu, 4] = 1.0 + translations.append(generator) + + for axis in range(3): + rotation = np.zeros((5, 5), dtype=np.float64) + first = 1 + (axis + 1) % 3 + second = 1 + (axis + 2) % 3 + rotation[first, second] = -1.0 + rotation[second, first] = 1.0 + rotations.append(rotation) + + boost = np.zeros((5, 5), dtype=np.float64) + boost[0, axis + 1] = 1.0 + boost[axis + 1, 0] = 1.0 + boosts.append(boost) + + def commutator(left: NDArray[np.float64], right: NDArray[np.float64]): + return left @ right - right @ left + + epsilon = np.zeros((3, 3, 3), dtype=np.float64) + epsilon[0, 1, 2] = epsilon[1, 2, 0] = epsilon[2, 0, 1] = 1.0 + epsilon[1, 0, 2] = epsilon[2, 1, 0] = epsilon[0, 2, 1] = -1.0 + + residuals: dict[str, float] = {} + checks: dict[str, list[NDArray[np.float64]]] = { + "P_P": [], + "J_J": [], + "J_K": [], + "K_K": [], + "J_P": [], + "J_H": [], + "K_H": [], + "K_P": [], + } + for mu in range(4): + for nu in range(4): + checks["P_P"].append(commutator(translations[mu], translations[nu])) + for i in range(3): + checks["J_H"].append(commutator(rotations[i], translations[0])) + checks["K_H"].append(commutator(boosts[i], translations[0]) - translations[i + 1]) + for j in range(3): + expected_jj = sum(epsilon[i, j, k] * rotations[k] for k in range(3)) + expected_jk = sum(epsilon[i, j, k] * boosts[k] for k in range(3)) + expected_kk = -sum(epsilon[i, j, k] * rotations[k] for k in range(3)) + expected_jp = sum(epsilon[i, j, k] * translations[k + 1] for k in range(3)) + checks["J_J"].append(commutator(rotations[i], rotations[j]) - expected_jj) + checks["J_K"].append(commutator(rotations[i], boosts[j]) - expected_jk) + checks["K_K"].append(commutator(boosts[i], boosts[j]) - expected_kk) + checks["J_P"].append(commutator(rotations[i], translations[j + 1]) - expected_jp) + checks["K_P"].append( + commutator(boosts[i], translations[j + 1]) - (translations[0] if i == j else 0.0) + ) + + for name, matrices in checks.items(): + residuals[name] = max(float(np.max(np.abs(matrix))) for matrix in matrices) + residuals["maximum"] = max(residuals.values()) + return residuals + + +def gaussian_packet( + grid_size: int, + *, + length: float, + center: Iterable[float], + direction: Iterable[float], + k_magnitude: float, + sigma: float, +) -> NDArray[np.complex128]: + """Construct a complex positive-frequency Gaussian packet on a 3D torus.""" + + if grid_size < 8 or length <= 0.0 or sigma <= 0.0 or k_magnitude <= 0.0: + raise ValueError("invalid packet geometry") + center_arr = np.asarray(tuple(center), dtype=np.float64) + direction_arr = np.asarray(tuple(direction), dtype=np.float64) + if center_arr.shape != (3,) or direction_arr.shape != (3,): + raise ValueError("center and direction must have three components") + direction_arr = direction_arr / np.linalg.norm(direction_arr) + coordinate = np.arange(grid_size, dtype=np.float64) * (length / grid_size) + offsets = [] + for axis in range(3): + delta = coordinate - center_arr[axis] + delta = (delta + 0.5 * length) % length - 0.5 * length + shape = [1, 1, 1] + shape[axis] = grid_size + offsets.append(delta.reshape(shape)) + radius_sq = offsets[0] ** 2 + offsets[1] ** 2 + offsets[2] ** 2 + phase = k_magnitude * sum(direction_arr[axis] * offsets[axis] for axis in range(3)) + return np.exp(-0.5 * radius_sq / (sigma * sigma) + 1j * phase) + + +def evolve_positive_frequency( + field: NDArray[np.complexfloating], + *, + time: float, + length: float, + mass: float = 0.0, + c: float = 1.0, + dt: float = 0.02, + stencil: str | CubicStencil = "19", +) -> NDArray[np.complex128]: + """Evolve a periodic 3D field with the exact positive-frequency branch.""" + + array = np.asarray(field, dtype=np.complex128) + if array.ndim != 3 or len(set(array.shape)) != 1: + raise ValueError("field must be a cubic 3D array") + grid_size = array.shape[0] + spacing = length / grid_size + frequencies = 2.0 * math.pi * np.fft.fftfreq(grid_size, d=spacing) + kx, ky, kz = np.meshgrid(frequencies, frequencies, frequencies, indexing="ij") + wave = np.stack((kx, ky, kz), axis=-1) + omega = discrete_omega( + wave, + mass=mass, + c=c, + dt=dt, + spacing=spacing, + stencil=stencil, + ) + spectrum = np.fft.fftn(array) + evolved = np.fft.ifftn(spectrum * np.exp(-1j * omega * time)) + return np.asarray(evolved, dtype=np.complex128) + + +def evolve_continuum_positive_frequency( + field: NDArray[np.complexfloating], + *, + time: float, + length: float, + mass: float = 0.0, + c: float = 1.0, +) -> NDArray[np.complex128]: + """Evolve the same periodic data with the continuum Klein-Gordon symbol.""" + + array = np.asarray(field, dtype=np.complex128) + if array.ndim != 3 or len(set(array.shape)) != 1: + raise ValueError("field must be a cubic 3D array") + grid_size = array.shape[0] + spacing = length / grid_size + frequencies = 2.0 * math.pi * np.fft.fftfreq(grid_size, d=spacing) + kx, ky, kz = np.meshgrid(frequencies, frequencies, frequencies, indexing="ij") + omega = np.sqrt(c * c * (kx * kx + ky * ky + kz * kz) + mass * mass) + spectrum = np.fft.fftn(array) + evolved = np.fft.ifftn(spectrum * np.exp(-1j * omega * time)) + return np.asarray(evolved, dtype=np.complex128) + + +def periodic_centroid( + field: NDArray[np.complexfloating], + *, + length: float, +) -> NDArray[np.float64]: + """Return the circular center of |field|^2 along each periodic axis.""" + + density = np.abs(np.asarray(field)) ** 2 + total = float(np.sum(density)) + if total <= 0.0: + raise ValueError("field has zero norm") + coordinate = np.arange(density.shape[0], dtype=np.float64) * length / density.shape[0] + phase = np.exp(2j * math.pi * coordinate / length) + centroid = np.empty(3, dtype=np.float64) + for axis in range(3): + reduce_axes = tuple(candidate for candidate in range(3) if candidate != axis) + marginal = np.sum(density, axis=reduce_axes) + moment = np.sum(marginal * phase) / total + centroid[axis] = (np.angle(moment) % (2.0 * math.pi)) * length / (2.0 * math.pi) + return centroid + + +def periodic_displacement( + final: ArrayLike, + initial: ArrayLike, + *, + length: float, +) -> NDArray[np.float64]: + """Return the minimum-image displacement on a periodic cube.""" + + delta = np.asarray(final, dtype=np.float64) - np.asarray(initial, dtype=np.float64) + return (delta + 0.5 * length) % length - 0.5 * length + + +def packet_propagation_metrics( + grid_size: int, + *, + length: float, + center: Iterable[float], + direction: Iterable[float], + k_magnitude: float, + sigma: float, + propagation_time: float, + mass: float = 0.0, + c: float = 1.0, + courant: float = 0.2, + stencil: str | CubicStencil = "19", +) -> dict[str, float | list[float]]: + """Propagate one Gaussian packet and measure its centroid velocity.""" + + spacing = length / grid_size + dt = courant * spacing / c + initial = gaussian_packet( + grid_size, + length=length, + center=center, + direction=direction, + k_magnitude=k_magnitude, + sigma=sigma, + ) + evolved = evolve_positive_frequency( + initial, + time=propagation_time, + length=length, + mass=mass, + c=c, + dt=dt, + stencil=stencil, + ) + continuum_evolved = evolve_continuum_positive_frequency( + initial, + time=propagation_time, + length=length, + mass=mass, + c=c, + ) + center_initial = periodic_centroid(initial, length=length) + center_final = periodic_centroid(evolved, length=length) + center_continuum = periodic_centroid(continuum_evolved, length=length) + displacement = periodic_displacement(center_final, center_initial, length=length) + continuum_displacement = periodic_displacement( + center_continuum, + center_initial, + length=length, + ) + velocity = displacement / propagation_time + continuum_velocity = continuum_displacement / propagation_time + unit = np.asarray(tuple(direction), dtype=np.float64) + unit = unit / np.linalg.norm(unit) + radial = float(np.dot(velocity, unit)) + continuum_radial = float(np.dot(continuum_velocity, unit)) + transverse = float(np.linalg.norm(velocity - radial * unit)) + continuum_transverse = float(np.linalg.norm(continuum_velocity - continuum_radial * unit)) + return { + "grid_size": grid_size, + "spacing": spacing, + "dt": dt, + "velocity": velocity.tolist(), + "radial_velocity_over_c": radial / c, + "transverse_velocity_over_c": transverse / c, + "continuum_velocity": continuum_velocity.tolist(), + "continuum_radial_velocity_over_c": continuum_radial / c, + "continuum_transverse_velocity_over_c": continuum_transverse / c, + "radial_error": abs(radial / continuum_radial - 1.0), + "norm_initial": float(np.sum(np.abs(initial) ** 2)), + "norm_final": float(np.sum(np.abs(evolved) ** 2)), + "continuum_norm_final": float(np.sum(np.abs(continuum_evolved) ** 2)), + } + + +def translation_equivariance_residual( + field: NDArray[np.complexfloating], + *, + shift: tuple[int, int, int], + time: float, + length: float, + mass: float = 0.0, + c: float = 1.0, + dt: float = 0.02, + stencil: str | CubicStencil = "19", +) -> float: + """Measure periodic integer-translation equivariance of spectral evolution.""" + + evolved = evolve_positive_frequency( + field, + time=time, + length=length, + mass=mass, + c=c, + dt=dt, + stencil=stencil, + ) + shifted = np.roll(field, shift=shift, axis=(0, 1, 2)) + evolved_shifted = evolve_positive_frequency( + shifted, + time=time, + length=length, + mass=mass, + c=c, + dt=dt, + stencil=stencil, + ) + expected = np.roll(evolved, shift=shift, axis=(0, 1, 2)) + return float(np.linalg.norm(evolved_shifted - expected) / np.linalg.norm(expected)) + + +def time_composition_residual( + field: NDArray[np.complexfloating], + *, + time_1: float, + time_2: float, + length: float, + mass: float = 0.0, + c: float = 1.0, + dt: float = 0.02, + stencil: str | CubicStencil = "19", +) -> float: + """Measure composition of the positive-frequency lattice evolution.""" + + direct = evolve_positive_frequency( + field, + time=time_1 + time_2, + length=length, + mass=mass, + c=c, + dt=dt, + stencil=stencil, + ) + first = evolve_positive_frequency( + field, + time=time_1, + length=length, + mass=mass, + c=c, + dt=dt, + stencil=stencil, + ) + composed = evolve_positive_frequency( + first, + time=time_2, + length=length, + mass=mass, + c=c, + dt=dt, + stencil=stencil, + ) + return float(np.linalg.norm(direct - composed) / np.linalg.norm(direct)) diff --git a/lfm/analysis/substrate_emergence.py b/lfm/analysis/substrate_emergence.py new file mode 100644 index 0000000..0d9ee88 --- /dev/null +++ b/lfm/analysis/substrate_emergence.py @@ -0,0 +1,286 @@ +"""Discrete-to-continuum observables for LFM emergence tests. + +The helpers in this module do not add fields or forces. They derive local +observables from the exact 19-point spatial symbol, the leapfrog time branch, +and normalized multicomponent wave fields. They are intended to keep a clear +lineage between finite-site LFM data and proposed continuum descriptions. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import numpy as np + +from lfm.analysis.poincare import discrete_omega, group_velocity +from lfm.core.stencils import gradient_19pt + +if TYPE_CHECKING: + from collections.abc import Iterable + + from numpy.typing import NDArray + + +def _log_log_slope(x: NDArray[np.float64], y: NDArray[np.float64]) -> float: + if np.any(x <= 0.0) or np.any(y <= 0.0): + raise ValueError("log-log slope inputs must be positive") + return float(np.polyfit(np.log(x), np.log(y), 1)[0]) + + +def axial_static_inverse_length( + mass: float, + *, + spacing: float, + c: float = 1.0, +) -> float: + """Return the exact axial inverse correlation length of the 19-point grid. + + Along a lattice axis the 19-point symbol reduces to the centered + second-difference symbol. Analytically continuing the static pole gives + + ``mu = 2*asinh(mass*spacing/(2*c))/spacing`` + + The continuum limit is ``mu -> mass/c``. + """ + if mass <= 0.0 or spacing <= 0.0 or c <= 0.0: + raise ValueError("mass, spacing, and c must be positive") + return float(2.0 * np.arcsinh(0.5 * mass * spacing / c) / spacing) + + +def relational_wave_scaling_scan( + chi_values: Iterable[float], + q_values: Iterable[float], + spacings: Iterable[float], + *, + mass_factors: Iterable[float] = (1.0,), + c: float = 1.0, + courant: float = 0.2, +) -> dict[str, object]: + """Measure clock, ruler, dispersion, and velocity scaling from GOV-01. + + For a homogeneous local value of ``chi``, each linear branch with mass + ``m = factor*chi`` has the exact discrete dispersion measured by + :func:`lfm.analysis.poincare.discrete_omega`. The dimensionless local + variables are ``Omega=omega/m`` and ``q=c*k/m``. A local static wave scale + supplies the ruler through the inverse correlation length ``mu``. + + The scan keeps physical ``chi`` and ``q`` fixed while reducing lattice + spacing and timestep at fixed Courant number. It therefore tests a real + continuum limit instead of merely increasing the number of sites in the + same lattice-unit configuration. + """ + chis = np.asarray(tuple(chi_values), dtype=np.float64) + q_modes = np.asarray(tuple(q_values), dtype=np.float64) + h_values = np.asarray(tuple(spacings), dtype=np.float64) + factors = np.asarray(tuple(mass_factors), dtype=np.float64) + if chis.size == 0 or q_modes.size == 0 or h_values.size < 3 or factors.size == 0: + raise ValueError("nonempty scans and at least three spacings are required") + if ( + np.any(chis <= 0.0) + or np.any(q_modes <= 0.0) + or np.any(h_values <= 0.0) + or np.any(factors <= 0.0) + or c <= 0.0 + or courant <= 0.0 + ): + raise ValueError("scan values, c, and courant must be positive") + + rows: list[dict[str, float]] = [] + summaries: list[dict[str, float]] = [] + for spacing in h_values: + dt = courant * spacing / c + dispersion_errors: list[float] = [] + velocity_errors: list[float] = [] + clock_ruler_errors: list[float] = [] + acceleration_universality: list[float] = [] + grouped: dict[tuple[float, float], list[float]] = {} + for chi in chis: + for factor in factors: + mass = factor * chi + rest_omega = float( + discrete_omega( + (0.0, 0.0, 0.0), + mass=mass, + c=c, + dt=dt, + spacing=spacing, + stencil="19", + ) + ) + inverse_length = axial_static_inverse_length( + mass, + spacing=spacing, + c=c, + ) + clock_ruler = rest_omega / (c * inverse_length) + clock_ruler_error = abs(clock_ruler - 1.0) + clock_ruler_errors.append(clock_ruler_error) + + # If m_alpha(x)=factor*chi(x), the factor cancels from + # -c^2*grad(log(m_alpha)). This records the branch coefficient + # used by the universality audit without inventing a force. + acceleration_coefficient = 1.0 + acceleration_universality.append(acceleration_coefficient) + for q_value in q_modes: + k_value = q_value * mass / c + omega = float( + discrete_omega( + (k_value, 0.0, 0.0), + mass=mass, + c=c, + dt=dt, + spacing=spacing, + stencil="19", + ) + ) + velocity = float( + group_velocity( + (k_value, 0.0, 0.0), + mass=mass, + c=c, + dt=dt, + spacing=spacing, + stencil="19", + )[0] + ) + omega_target = float(np.sqrt(1.0 + q_value * q_value)) + velocity_target = float(c * q_value / np.sqrt(1.0 + q_value * q_value)) + dimensionless_omega = omega / mass + dispersion_error = abs(dimensionless_omega - omega_target) + velocity_error = abs(velocity - velocity_target) / c + dispersion_errors.append(dispersion_error) + velocity_errors.append(velocity_error) + grouped.setdefault((float(factor), float(q_value)), []).append( + dimensionless_omega + ) + rows.append( + { + "spacing": float(spacing), + "dt": float(dt), + "chi": float(chi), + "mass_factor": float(factor), + "mass": float(mass), + "q": float(q_value), + "dimensionless_omega": dimensionless_omega, + "dimensionless_omega_target": omega_target, + "dispersion_error": dispersion_error, + "group_velocity_over_c": velocity / c, + "group_velocity_target_over_c": velocity_target / c, + "group_velocity_error_over_c": velocity_error, + "clock_ruler_product": clock_ruler, + "clock_ruler_error": clock_ruler_error, + "rest_acceleration_log_chi_coefficient": (acceleration_coefficient), + } + ) + local_spread = max(max(values) - min(values) for values in grouped.values()) + summaries.append( + { + "spacing": float(spacing), + "max_dispersion_error": float(max(dispersion_errors)), + "max_group_velocity_error_over_c": float(max(velocity_errors)), + "max_clock_ruler_error": float(max(clock_ruler_errors)), + "max_local_chi_dispersion_spread": float(local_spread), + "rest_acceleration_branch_spread": float( + max(acceleration_universality) - min(acceleration_universality) + ), + } + ) + + summary_arrays = { + key: np.asarray([row[key] for row in summaries], dtype=np.float64) + for key in ( + "max_dispersion_error", + "max_group_velocity_error_over_c", + "max_clock_ruler_error", + "max_local_chi_dispersion_spread", + ) + } + convergence = { + f"{key}_slope": _log_log_slope(h_values, values) for key, values in summary_arrays.items() + } + finest = summaries[int(np.argmin(h_values))] + gates = { + "second_order_dispersion": convergence["max_dispersion_error_slope"] > 1.8, + "second_order_group_velocity": convergence["max_group_velocity_error_over_c_slope"] > 1.8, + "second_order_clock_ruler": convergence["max_clock_ruler_error_slope"] > 1.8, + "local_relational_collapse": float(finest["max_local_chi_dispersion_spread"]) < 1.0e-3, + "linear_branch_rest_acceleration_universal": float( + finest["rest_acceleration_branch_spread"] + ) + < 1.0e-14, + } + return { + "definition": { + "local_clock": "exact k=0 leapfrog frequency", + "local_ruler": "inverse axial static correlation length", + "dimensionless_variables": "Omega=omega/m, q=c*k/m", + "wkb_rest_acceleration": "a=-c^2*grad(log(chi))", + }, + "rows": rows, + "spacing_summaries": summaries, + "convergence": convergence, + "gates": gates, + "pass": bool(all(gates.values())), + } + + +def normalize_internal_field(field: NDArray) -> NDArray[np.complex128]: + """Normalize a component-first complex wave field at every lattice site.""" + values = np.asarray(field, dtype=np.complex128) + if values.ndim != 4: + raise ValueError("field must have shape (components, nx, ny, nz)") + norm = np.sqrt(np.sum(np.abs(values) ** 2, axis=0)) + if np.any(norm <= 0.0): + raise ValueError("internal field must be nonzero at every site") + return np.asarray(values / norm[None, ...], dtype=np.complex128) + + +def composite_connection_19pt( + normalized_field: NDArray, + *, + dx: float = 1.0, +) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]: + """Return A_i=Im(z_dagger*partial_i z) from a normalized wave field.""" + z = np.asarray(normalized_field, dtype=np.complex128) + if z.ndim != 4: + raise ValueError("normalized_field must be component-first and 4-D") + norm_error = float(np.max(np.abs(np.sum(np.abs(z) ** 2, axis=0) - 1.0))) + if norm_error > 1.0e-10: + raise ValueError("normalized_field must have unit site norm") + connection = [np.zeros(z.shape[1:], dtype=np.float64) for _ in range(3)] + for component in z: + gradient_real = gradient_19pt(component.real, dx=dx) + gradient_imag = gradient_19pt(component.imag, dx=dx) + for axis in range(3): + connection[axis] += ( + component.real * gradient_imag[axis] - component.imag * gradient_real[axis] + ) + return connection[0], connection[1], connection[2] + + +def composite_curvature_19pt( + normalized_field: NDArray, + *, + dx: float = 1.0, +) -> tuple[NDArray[np.float64], NDArray[np.float64], NDArray[np.float64]]: + """Return the spatial curl of the composite internal connection. + + This is a derived D2 observable. It is not an independently evolved gauge + field and this function makes no Maxwell-dynamics claim. + """ + ax, ay, az = composite_connection_19pt(normalized_field, dx=dx) + grad_ax = gradient_19pt(ax, dx=dx) + grad_ay = gradient_19pt(ay, dx=dx) + grad_az = gradient_19pt(az, dx=dx) + f_xy = grad_ay[0] - grad_ax[1] + f_yz = grad_az[1] - grad_ay[2] + f_zx = grad_ax[2] - grad_az[0] + return f_xy, f_yz, f_zx + + +def curvature_rms(curvature: tuple[NDArray, NDArray, NDArray]) -> float: + """Return RMS magnitude of a three-component spatial curvature.""" + values = tuple(np.asarray(component, dtype=np.float64) for component in curvature) + if len({value.shape for value in values}) != 1: + raise ValueError("curvature components must have matching shapes") + return float(np.sqrt(np.mean(sum(value * value for value in values)))) diff --git a/lfm/analysis/tracker.py b/lfm/analysis/tracker.py index 63cc47a..2fa0e9f 100644 --- a/lfm/analysis/tracker.py +++ b/lfm/analysis/tracker.py @@ -12,6 +12,99 @@ from lfm.simulation import Simulation +def localized_weighted_centroid( + field: NDArray, + center: tuple[float, float, float] | NDArray, + radius: float, +) -> dict[str, float | bool]: + """Measure a localized weighted centroid around a predicted body centre. + + This tracker is intended for extended configurations whose broad internal + structure can produce several local maxima. Only samples inside a + spherical window are included, so a nearby larger body does not + automatically capture the smaller body's identity. + + Parameters + ---------- + field: + Non-negative 3-D density-like field. + center: + Predicted centre in grid coordinates. + radius: + Radius of the spherical tracking window in grid cells. + + Returns + ------- + dict + ``valid``, ``x``, ``y``, ``z``, ``weight``, ``peak``, and + ``rms_radius``. A zero-weight window is returned as invalid without + moving the supplied centre. + """ + density = np.asarray(field, dtype=np.float64) + if density.ndim != 3: + raise ValueError("field must be a 3-D array") + if radius <= 0.0: + raise ValueError("radius must be positive") + + predicted = np.asarray(center, dtype=np.float64) + if predicted.shape != (3,): + raise ValueError("center must contain exactly three coordinates") + + starts = np.maximum(0, np.floor(predicted - radius).astype(int)) + stops = np.minimum( + np.asarray(density.shape, dtype=int), + np.ceil(predicted + radius).astype(int) + 1, + ) + if np.any(starts >= stops): + return { + "valid": False, + "x": float(predicted[0]), + "y": float(predicted[1]), + "z": float(predicted[2]), + "weight": 0.0, + "peak": 0.0, + "rms_radius": float("nan"), + } + + slices = tuple(slice(int(starts[a]), int(stops[a])) for a in range(3)) + local = density[slices] + axes = [np.arange(starts[a], stops[a], dtype=np.float64) for a in range(3)] + gx, gy, gz = np.meshgrid(*axes, indexing="ij") + distance_sq = (gx - predicted[0]) ** 2 + (gy - predicted[1]) ** 2 + (gz - predicted[2]) ** 2 + weights = np.where(distance_sq <= radius * radius, local, 0.0) + total = float(np.sum(weights)) + if not np.isfinite(total) or total <= 0.0: + return { + "valid": False, + "x": float(predicted[0]), + "y": float(predicted[1]), + "z": float(predicted[2]), + "weight": 0.0, + "peak": float(np.max(local)) if local.size else 0.0, + "rms_radius": float("nan"), + } + + centroid = np.asarray( + [ + np.sum(weights * gx) / total, + np.sum(weights * gy) / total, + np.sum(weights * gz) / total, + ], + dtype=np.float64, + ) + radius_sq = (gx - centroid[0]) ** 2 + (gy - centroid[1]) ** 2 + (gz - centroid[2]) ** 2 + rms_radius = float(np.sqrt(np.sum(weights * radius_sq) / total)) + return { + "valid": True, + "x": float(centroid[0]), + "y": float(centroid[1]), + "z": float(centroid[2]), + "weight": total, + "peak": float(np.max(weights)), + "rms_radius": rms_radius, + } + + def track_peaks( sim: Simulation, steps: int, diff --git a/lfm/config.py b/lfm/config.py index 0d5d53a..29a9a07 100644 --- a/lfm/config.py +++ b/lfm/config.py @@ -142,6 +142,40 @@ class BoundaryType(enum.Enum): """Sponge layer. Reduces reflections for scattering experiments.""" +class Precision(str, enum.Enum): + """Floating-point precision used for persistent simulation state.""" + + FLOAT32 = "float32" + """Single precision. This is the canonical production default.""" + + FLOAT64 = "float64" + """Double precision for numerical-accuracy studies.""" + + +class ChiPotentialModel(enum.IntEnum): + """Experiment-only local chi stabilization laws. + + The canonical production path remains ``CANONICAL_QUARTIC``. The other + values are explicit GOV-02 gravity-recovery ablations and are used only by + :meth:`lfm.Simulation.run_gravity_recovery`. + """ + + CANONICAL_QUARTIC = 0 + FLAT_OCTIC = 1 + FLAT_DODECIC = 2 + FLAT_POWER_8 = 3 + FLAT_POWER_10 = 4 + FLAT_POWER_12 = 5 + SMOOTH_EXPONENTIAL = 6 + RATIONAL_CROSSOVER = 7 + HYPERBOLIC_CROSSOVER = 8 + NONLINEAR_GRADIENT = 9 + AMPLITUDE_STRENGTHENED = 10 + SOURCE_DEPENDENT = 11 + VARIABLE_INERTIA = 12 + RADICAL_CROSSOVER = 13 + + @dataclass class SimulationConfig: """Complete configuration for an LFM simulation. @@ -167,6 +201,11 @@ class SimulationConfig: epsilon_w: float = EPSILON_W """Weak/helicity coupling = 0.1. Only matters when j(x,t) is computed.""" + use_stencil19_noether_current: bool = False + """Use the 19-point face-and-edge link current for the optional A1 + current-feedback extension. False preserves the historical face-only + production observable.""" + kappa_c: float = 0.0 """Color variance coupling (v14). 0.0 = colorblind (v13 default). Set to KAPPA_C (1/189) for color-aware χ deepening of non-singlet states. @@ -204,6 +243,20 @@ class SimulationConfig: c: float = C_DEFAULT """Wave speed. 1.0 in natural lattice units.""" + dx: float = 1.0 + """Numerical lattice spacing in the dimensionless LFM coordinates. + + The canonical production default is one. Values below one are permitted + for continuum-refinement studies and scale only the spatial stencil. + """ + + enable_chi_floor: bool = True + """Apply the historical ``chi >= -chi0`` black-hole excision floor. + + This remains enabled by default for backward compatibility. Conservative + source-free decisions must disable it explicitly. + """ + # Field type field_level: FieldLevel = FieldLevel.REAL """Which field representation to use.""" @@ -279,14 +332,18 @@ class SimulationConfig: Set automatically when ``physical_scale`` is provided. """ - # Derived (computed in __post_init__) - dx: float = field(init=False, default=1.0) - """Grid spacing. Always 1.0 in natural units.""" + precision: Precision = Precision.FLOAT32 + """Persistent field precision. Defaults to the canonical float32 path.""" + # Derived (computed in __post_init__) sigma: float = field(init=False, default=0.0) """Gaussian soliton width = grid_size / blob_sigma_factor.""" def __post_init__(self) -> None: + try: + self.precision = Precision(self.precision) + except (TypeError, ValueError) as exc: + raise ValueError("precision must be Precision.FLOAT32 or Precision.FLOAT64") from exc # Apply regime defaults BEFORE validation so derived fields see them. if self.physical_scale is not None: fl, cm, ls, kc = _SCALE_DEFAULTS[self.physical_scale] @@ -295,7 +352,6 @@ def __post_init__(self) -> None: self.lambda_self = ls self.kappa_c = kc self._validate() - self.dx = 1.0 self.sigma = self.grid_size / self.blob_sigma_factor if self.e_amplitude == 0.0: self.e_amplitude = E_AMPLITUDE_BY_GRID.get( @@ -311,11 +367,13 @@ def _validate(self) -> None: raise ValueError(f"grid_size must be >= 8, got {self.grid_size}") if self.dt <= 0: raise ValueError(f"dt must be positive, got {self.dt}") - # CFL limit depends on chi0: dt < 1/sqrt(16/3 + chi0^2) - # For chi0=0 (massless/EM) the limit is the wave-only CFL: 1/sqrt(16/3) + if self.dx <= 0: + raise ValueError(f"dx must be positive, got {self.dx}") + # CFL limit depends on both numerical spacing and chi0: + # dt < 1/sqrt(c^2*(16/3)/dx^2 + chi0^2). import math - cfl_limit = 1.0 / math.sqrt(16.0 / 3.0 + self.chi0**2) + cfl_limit = 1.0 / math.sqrt(self.c**2 * (16.0 / 3.0) / self.dx**2 + self.chi0**2) if self.dt > cfl_limit: raise ValueError( f"dt={self.dt} exceeds CFL limit {cfl_limit:.4f} " diff --git a/lfm/constants.py b/lfm/constants.py index e5e5b27..797d2bb 100644 --- a/lfm/constants.py +++ b/lfm/constants.py @@ -33,6 +33,21 @@ CHI0: float = float(3**D - 2**D) """Background χ value = 19.0. From 3D discrete Laplacian: 1 center + 6 face + 12 edge modes.""" +G_SI: float = 6.67430e-11 +"""CODATA Newtonian constant in m^3 kg^-1 s^-2. Observed SI bridge input.""" + +C_SI: float = 299792458.0 +"""Speed of light in m/s. Exact SI bridge input.""" + +SOLAR_MASS_KG: float = 1.98847e30 +"""Nominal solar mass in kg. Observed input for solar-limb validation.""" + +SOLAR_RADIUS_M: float = 6.957e8 +"""Nominal solar radius in m. Observed impact parameter for solar-limb validation.""" + +ARCSEC_PER_RADIAN: float = 206264.80624709636 +"""Arcseconds per radian.""" + N_COLORS: int = 3 """Number of color components (Ψₐ, a = 1,2,3).""" diff --git a/lfm/core/__init__.py b/lfm/core/__init__.py index 590c677..02a1521 100644 --- a/lfm/core/__init__.py +++ b/lfm/core/__init__.py @@ -19,5 +19,21 @@ """ from lfm.core.evolver import Evolver +from lfm.core.stencils import ( + eigenvalue_19pt, + eigenvalue_27pt, + gradient_19pt, + laplacian_19pt, + laplacian_27pt, + noether_current_19pt_raw, +) -__all__ = ["Evolver"] +__all__ = [ + "Evolver", + "eigenvalue_19pt", + "eigenvalue_27pt", + "gradient_19pt", + "laplacian_19pt", + "laplacian_27pt", + "noether_current_19pt_raw", +] diff --git a/lfm/core/backends/__init__.py b/lfm/core/backends/__init__.py index add44d3..d26eb48 100644 --- a/lfm/core/backends/__init__.py +++ b/lfm/core/backends/__init__.py @@ -15,6 +15,7 @@ from __future__ import annotations +from lfm.config import Precision from lfm.core.backends.numpy_backend import NumpyBackend # Check GPU availability at import time (but don't fail) @@ -25,7 +26,10 @@ CUPY_AVAILABLE = False -def get_backend(preference: str = "auto") -> NumpyBackend: +def get_backend( + preference: str = "auto", + precision: Precision | str = Precision.FLOAT32, +) -> NumpyBackend: """Get a compute backend instance. Parameters @@ -35,6 +39,8 @@ def get_backend(preference: str = "auto") -> NumpyBackend: - 'auto': Use GPU if CuPy is available, else CPU. - 'cpu': Always use NumPy (CPU). - 'gpu': Use CuPy (GPU). Raises ImportError if unavailable. + precision : Precision or str + Persistent state precision. Defaults to the canonical float32 path. Returns ------- @@ -49,21 +55,26 @@ def get_backend(preference: str = "auto") -> NumpyBackend: If preference is not recognized. """ preference = preference.lower() + precision = Precision(precision) if preference == "cpu": - return NumpyBackend() + return NumpyBackend(precision=precision) if preference == "gpu": if not CUPY_AVAILABLE or CupyBackend is None: raise ImportError("CuPy not available. Install with: pip install lfm-physics[gpu]") - return CupyBackend() # type: ignore[return-value] + return CupyBackend(precision=precision) # type: ignore[return-value] if preference == "auto": if CUPY_AVAILABLE and CupyBackend is not None: - return CupyBackend() # type: ignore[return-value] - return NumpyBackend() + return CupyBackend(precision=precision) # type: ignore[return-value] + return NumpyBackend(precision=precision) if preference == "remote": + if precision != Precision.FLOAT32: + raise NotImplementedError( + "remote backend supports only float32 jobs; use cpu or gpu for float64" + ) from lfm.core.backends.remote_backend import RemoteBackend return RemoteBackend() # type: ignore[return-value] diff --git a/lfm/core/backends/cupy_backend.py b/lfm/core/backends/cupy_backend.py index e5365c5..8c4ee70 100644 --- a/lfm/core/backends/cupy_backend.py +++ b/lfm/core/backends/cupy_backend.py @@ -6,7 +6,7 @@ These are the production CUDA kernels from the canonical universe simulator. Uses double-buffering: kernel reads from A-set and writes to B-set (or vice versa). -Boundary mask is a float32 array (1.0 = frozen, 0.0 = interior). +Boundary mask uses the configured state dtype (1.0 = frozen, 0.0 = interior). Block size = 256 threads, 1D grid — optimal for RTX 4060. """ @@ -24,12 +24,15 @@ from typing import TYPE_CHECKING +from lfm.config import Precision from lfm.core.backends.kernel_source import ( EVOLUTION_COMPLEX_KERNEL_SRC, EVOLUTION_KERNEL_SRC, EVOLUTION_REAL_KERNEL_SRC, + GRAVITY_RECOVERY_REAL_KERNEL_SRC, PHASE1_KERNEL_SRC, SA_DIFFUSION_KERNEL_SRC, + kernel_source_for_precision, ) if TYPE_CHECKING: @@ -51,30 +54,43 @@ class CupyBackend: Raises ImportError if cupy is not installed. """ - def __init__(self) -> None: + def __init__(self, precision: Precision | str = Precision.FLOAT32) -> None: if not CUPY_AVAILABLE: raise ImportError( "CuPy is required for GPU backend. Install with: pip install lfm-physics[gpu]" ) - # Compile kernels (cached by CuPy after first call) + self.precision = Precision(precision) + self.dtype = np.dtype(self.precision.value) + self._array_dtype = cp.float32 if self.precision == Precision.FLOAT32 else cp.float64 + self._scalar_type = np.float32 if self.precision == Precision.FLOAT32 else np.float64 + + def _source(source: str) -> str: + return kernel_source_for_precision(source, self.precision.value) + + # Compile kernels (cached by CuPy after first call). The float32 + # source is returned unchanged to preserve the canonical baseline. self._kernel_real = cp.RawKernel( - EVOLUTION_REAL_KERNEL_SRC, + _source(EVOLUTION_REAL_KERNEL_SRC), "evolve_real", ) + self._kernel_real_gravity_recovery = cp.RawKernel( + _source(GRAVITY_RECOVERY_REAL_KERNEL_SRC), + "evolve_real_gravity_recovery", + ) self._kernel_complex = cp.RawKernel( - EVOLUTION_COMPLEX_KERNEL_SRC, + _source(EVOLUTION_COMPLEX_KERNEL_SRC), "evolve_complex", ) self._kernel_color = cp.RawKernel( - EVOLUTION_KERNEL_SRC, + _source(EVOLUTION_KERNEL_SRC), "evolve_gov01_gov02", ) self._kernel_phase1 = cp.RawKernel( - PHASE1_KERNEL_SRC, + _source(PHASE1_KERNEL_SRC), "phase1_parametric", ) self._kernel_sa_diffusion = cp.RawKernel( - SA_DIFFUSION_KERNEL_SRC, + _source(SA_DIFFUSION_KERNEL_SRC), "evolve_sa_diffusion", ) @@ -90,8 +106,8 @@ def allocate( ) -> dict[str, cp.ndarray]: total = N**3 psi_size = n_psi_arrays * total - zero_psi = cp.zeros(psi_size, dtype=cp.float32) - chi_init = cp.full(total, chi0, dtype=cp.float32) + zero_psi = cp.zeros(psi_size, dtype=self._array_dtype) + chi_init = cp.full(total, chi0, dtype=self._array_dtype) return { "psi_A": zero_psi.copy(), "psi_prev_A": zero_psi.copy(), @@ -116,12 +132,12 @@ def create_boundary_mask( center = N / 2.0 r_max = N / 2.0 r_freeze = (1.0 - boundary_fraction) * r_max - coords = np.arange(N, dtype=np.float32) - center + 0.5 + coords = np.arange(N, dtype=self.dtype) - center + 0.5 X, Y, Z = np.meshgrid(coords, coords, coords, indexing="ij") R = np.sqrt(X**2 + Y**2 + Z**2) # Smooth cosine taper: 0 at r_freeze, 1 at r_max t = np.clip((R - r_freeze) / (r_max - r_freeze), 0.0, 1.0) - mask = (np.sin(0.5 * np.pi * t) ** 2).astype(np.float32).ravel() + mask = (np.sin(0.5 * np.pi * t) ** 2).astype(self.dtype).ravel() return cp.asarray(mask) def step_real( @@ -141,6 +157,9 @@ def step_real( lambda_self: float, chi0: float, e0_sq: float, + *, + inv_dx2: float = 1.0, + enable_chi_floor: bool = True, ) -> None: total = N**3 grid, block = _grid_block(total) @@ -158,16 +177,249 @@ def step_real( chi_out, chi_prev_out, np.int32(N), - np.float32(dt2), - np.float32(kappa), - np.float32(lambda_self), - np.float32(chi0), - np.float32(e0_sq), + self._scalar_type(dt2), + self._scalar_type(kappa), + self._scalar_type(lambda_self), + self._scalar_type(chi0), + self._scalar_type(e0_sq), + self._scalar_type(inv_dx2), + np.int32(enable_chi_floor), ), ) # No synchronize() here — GPU ops are serialized on the default # stream and cp.asnumpy() / to_numpy() syncs when data is needed. + def step_real_gravity_recovery( + self, + psi_in, + psi_prev_in, + chi_in, + chi_prev_in, + boundary_mask, + psi_out, + psi_prev_out, + chi_out, + chi_prev_out, + N: int, + dt: float, + kappa: float, + lambda_self: float, + chi0: float, + e0_sq: float, + potential_model: int, + freeze_psi: bool, + relaxation_damping: float, + *, + inv_dx2: float = 1.0, + enable_chi_floor: bool = False, + ) -> None: + """Advance one experiment-only local GOV-02 candidate step.""" + + total = N**3 + grid, block = _grid_block(total) + self._kernel_real_gravity_recovery( + grid, + block, + ( + psi_in, + psi_prev_in, + chi_in, + chi_prev_in, + boundary_mask, + psi_out, + psi_prev_out, + chi_out, + chi_prev_out, + np.int32(N), + self._scalar_type(dt), + self._scalar_type(kappa), + self._scalar_type(lambda_self), + self._scalar_type(chi0), + self._scalar_type(e0_sq), + np.int32(potential_model), + np.int32(freeze_psi), + self._scalar_type(relaxation_damping), + self._scalar_type(inv_dx2), + np.int32(enable_chi_floor), + ), + ) + + def step_complex_gravity_recovery( + self, + psi_r_in, + psi_r_prev_in, + psi_i_in, + psi_i_prev_in, + chi_in, + chi_prev_in, + boundary_mask, + psi_r_out, + psi_r_prev_out, + psi_i_out, + psi_i_prev_out, + chi_out, + chi_prev_out, + N: int, + dt: float, + kappa: float, + lambda_self: float, + chi0: float, + e0_sq: float, + potential_model: int, + freeze_psi: bool, + relaxation_damping: float, + *, + inv_dx2: float = 1.0, + enable_chi_floor: bool = False, + ) -> None: + """Advance the local complex flat-octic candidate system.""" + + from lfm.config import ChiPotentialModel + + model = ChiPotentialModel(potential_model) + if model != ChiPotentialModel.FLAT_OCTIC: + raise ValueError("complex gravity recovery currently supports FLAT_OCTIC") + self.step_complex( + psi_r_in, + psi_r_prev_in, + psi_i_in, + psi_i_prev_in, + chi_in, + chi_prev_in, + boundary_mask, + psi_r_out, + psi_r_prev_out, + psi_i_out, + psi_i_prev_out, + chi_out, + chi_prev_out, + N, + dt**2, + kappa, + 0.0, + chi0, + e0_sq, + 0.0, + use_stencil19_noether_current=False, + inv_dx2=inv_dx2, + enable_chi_floor=False, + ) + absorb = 1.0 - boundary_mask + safe_absorb = cp.where(absorb > 0.0, absorb, 1.0) + undamped_chi = (chi_out - boundary_mask * chi0) / safe_absorb + y = (chi_in * chi_in - chi0 * chi0) / (chi0 * chi0) + flat_force = -8.0 * lambda_self * chi0**2 * chi_in * y**3 + damping_half_step = 0.5 * relaxation_damping * dt + corrected = (undamped_chi + damping_half_step * chi_prev_in + dt**2 * flat_force) / ( + 1.0 + damping_half_step + ) + if enable_chi_floor: + cp.maximum(corrected, -chi0, out=corrected) + chi_out[...] = boundary_mask * chi0 + absorb * corrected + if freeze_psi: + psi_r_out[...] = absorb * psi_r_in + psi_r_prev_out[...] = psi_r_in + psi_i_out[...] = absorb * psi_i_in + psi_i_prev_out[...] = psi_i_in + + def step_color_gravity_recovery( + self, + psi_r_in, + psi_r_prev_in, + psi_i_in, + psi_i_prev_in, + chi_in, + chi_prev_in, + boundary_mask, + psi_r_out, + psi_r_prev_out, + psi_i_out, + psi_i_prev_out, + chi_out, + chi_prev_out, + N: int, + dt: float, + kappa: float, + lambda_self: float, + chi0: float, + e0_sq: float, + potential_model: int, + freeze_psi: bool, + relaxation_damping: float, + *, + epsilon_w: float = 0.0, + kappa_c: float = 0.0, + epsilon_cc: float = 0.0, + kappa_string: float = 0.0, + kappa_tube: float = 0.0, + sa_fields_in=None, + sa_fields_out=None, + sa_gamma: float = 0.1, + sa_d: float = 4.9, + use_stencil19_noether_current: bool = False, + inv_dx2: float = 1.0, + enable_chi_floor: bool = False, + ) -> None: + """Advance the full three-channel flat-octic candidate system.""" + + from lfm.config import ChiPotentialModel + + model = ChiPotentialModel(potential_model) + if model != ChiPotentialModel.FLAT_OCTIC: + raise ValueError("color gravity recovery currently supports FLAT_OCTIC") + self.step_color( + psi_r_in, + psi_r_prev_in, + psi_i_in, + psi_i_prev_in, + chi_in, + chi_prev_in, + boundary_mask, + psi_r_out, + psi_r_prev_out, + psi_i_out, + psi_i_prev_out, + chi_out, + chi_prev_out, + N, + dt**2, + kappa, + 0.0, + chi0, + e0_sq, + epsilon_w, + kappa_c, + epsilon_cc, + kappa_string, + kappa_tube, + sa_fields_in, + sa_fields_out, + sa_gamma, + sa_d, + dt=dt, + use_stencil19_noether_current=use_stencil19_noether_current, + inv_dx2=inv_dx2, + enable_chi_floor=False, + ) + absorb = 1.0 - boundary_mask + safe_absorb = cp.where(absorb > 0.0, absorb, 1.0) + undamped_chi = (chi_out - boundary_mask * chi0) / safe_absorb + y = (chi_in * chi_in - chi0 * chi0) / (chi0 * chi0) + flat_force = -8.0 * lambda_self * chi0**2 * chi_in * y**3 + damping_half_step = 0.5 * relaxation_damping * dt + corrected = (undamped_chi + damping_half_step * chi_prev_in + dt**2 * flat_force) / ( + 1.0 + damping_half_step + ) + if enable_chi_floor: + cp.maximum(corrected, -chi0, out=corrected) + chi_out[...] = boundary_mask * chi0 + absorb * corrected + if freeze_psi: + absorb3 = cp.tile(absorb, 3) + psi_r_out[...] = absorb3 * psi_r_in + psi_r_prev_out[...] = psi_r_in + psi_i_out[...] = absorb3 * psi_i_in + psi_i_prev_out[...] = psi_i_in + def step_complex( self, psi_r_in, @@ -190,6 +442,10 @@ def step_complex( chi0: float, e0_sq: float, epsilon_w: float, + *, + use_stencil19_noether_current: bool = False, + inv_dx2: float = 1.0, + enable_chi_floor: bool = True, ) -> None: total = N**3 grid, block = _grid_block(total) @@ -211,12 +467,15 @@ def step_complex( chi_out, chi_prev_out, np.int32(N), - np.float32(dt2), - np.float32(kappa), - np.float32(lambda_self), - np.float32(chi0), - np.float32(e0_sq), - np.float32(epsilon_w), + self._scalar_type(dt2), + self._scalar_type(kappa), + self._scalar_type(lambda_self), + self._scalar_type(chi0), + self._scalar_type(e0_sq), + self._scalar_type(epsilon_w), + np.int32(use_stencil19_noether_current), + self._scalar_type(inv_dx2), + np.int32(enable_chi_floor), ), ) # No synchronize() here — syncs lazily on first CPU read. @@ -253,6 +512,10 @@ def step_color( sa_gamma: float = 0.1, sa_d: float = 4.9, dt: float = 0.02, + *, + use_stencil19_noether_current: bool = False, + inv_dx2: float = 1.0, + enable_chi_floor: bool = True, ) -> None: total = N**3 grid, block = _grid_block(total) @@ -265,17 +528,19 @@ def step_color( k_sq = kx[:, None, None] ** 2 + ky[None, :, None] ** 2 + kz[None, None, :] ** 2 h_filter = cp.float64(sa_gamma) / (cp.float64(sa_gamma) + cp.float64(sa_d) * k_sq) - _sa_in = cp.zeros(3 * total, dtype=cp.float32) + _sa_in = cp.zeros(3 * total, dtype=self._array_dtype) for a in range(3): s = slice(a * total, (a + 1) * total) psi_sq_a = psi_r_in[s] * psi_r_in[s] + psi_i_in[s] * psi_i_in[s] psi_sq_hat = cp.fft.rfftn(psi_sq_a.reshape(N, N, N)) sa_3d = cp.fft.irfftn(h_filter * psi_sq_hat, s=(N, N, N)) cp.clip(sa_3d, 0.0, None, out=sa_3d) - _sa_in[s] = sa_3d.ravel().astype(cp.float32) + _sa_in[s] = sa_3d.ravel().astype(self._array_dtype) else: _sa_in = ( - sa_fields_in if sa_fields_in is not None else cp.zeros(3 * total, dtype=cp.float32) + sa_fields_in + if sa_fields_in is not None + else cp.zeros(3 * total, dtype=self._array_dtype) ) self._kernel_color( @@ -296,17 +561,20 @@ def step_color( chi_out, chi_prev_out, np.int32(N), - np.float32(dt2), - np.float32(kappa), - np.float32(lambda_self), - np.float32(chi0), - np.float32(e0_sq), - np.float32(epsilon_w), - np.float32(kappa_c), - np.float32(epsilon_cc), + self._scalar_type(dt2), + self._scalar_type(kappa), + self._scalar_type(lambda_self), + self._scalar_type(chi0), + self._scalar_type(e0_sq), + self._scalar_type(epsilon_w), + self._scalar_type(kappa_c), + self._scalar_type(epsilon_cc), _sa_in, - np.float32(kappa_string), - np.float32(kappa_tube), + self._scalar_type(kappa_string), + self._scalar_type(kappa_tube), + np.int32(use_stencil19_noether_current), + self._scalar_type(inv_dx2), + np.int32(enable_chi_floor), ), ) @@ -346,8 +614,8 @@ def step_phase1( psi_i_out, psi_i_prev_out, np.int32(N), - np.float32(dt2), - np.float32(chi_sq), + self._scalar_type(dt2), + self._scalar_type(chi_sq), ), ) # No synchronize() here — syncs lazily on first CPU read. @@ -356,8 +624,22 @@ def synchronize(self) -> None: """Explicitly synchronise the GPU stream (use before timing or profiling).""" cp.cuda.Stream.null.synchronize() - def to_numpy(self, arr) -> NDArray[np.float32]: + def to_numpy(self, arr) -> NDArray[np.floating]: return cp.asnumpy(arr) def from_numpy(self, arr: NDArray): - return cp.asarray(arr, dtype=cp.float32) + return cp.asarray(arr, dtype=self._array_dtype) + + def apply_complex_phase_map( + self, + psi_r, + psi_i, + phase_cos, + phase_sin, + ) -> None: + """Rotate a complex field in place by a local phase map.""" + + real = psi_r.copy() + imag = psi_i.copy() + psi_r[:] = real * phase_cos - imag * phase_sin + psi_i[:] = real * phase_sin + imag * phase_cos diff --git a/lfm/core/backends/kernel_source.py b/lfm/core/backends/kernel_source.py index 58cf3e1..fc37f46 100644 --- a/lfm/core/backends/kernel_source.py +++ b/lfm/core/backends/kernel_source.py @@ -1,5 +1,4 @@ -""" -CUDA Kernel Source Strings +"""CUDA Kernel Source Strings ========================== Production CUDA kernels for LFM leapfrog evolution. @@ -12,6 +11,28 @@ - EVOLUTION_REAL_KERNEL_SRC: Simplified real-E gravity-only kernel """ +import re + +_FLOAT_TYPE_TOKEN = re.compile(r"\bfloat\b") +_FLOAT_LITERAL_SUFFIX = re.compile(r"(?<=[0-9.])f\b") + + +def kernel_source_for_precision(source: str, precision: str) -> str: + """Return a CUDA source variant for the requested state precision. + + The float32 path returns the original source unchanged so the canonical + production kernels retain identical arithmetic and compilation input. + The float64 path promotes both storage/local types and every explicitly + float-suffixed numeric literal. + """ + if precision == "float32": + return source + if precision != "float64": + raise ValueError(f"unsupported CUDA precision: {precision!r}") + promoted = _FLOAT_TYPE_TOKEN.sub("double", source) + return _FLOAT_LITERAL_SUFFIX.sub("", promoted) + + # --------------------------------------------------------------------------- # Full 3-color complex evolution kernel (Level 2 — all four forces) # --------------------------------------------------------------------------- @@ -45,7 +66,10 @@ const float eps_cc, const float* __restrict__ Sa_in, const float kappa_string, - const float kappa_tube) + const float kappa_tube, + const int use_stencil19_noether_current, + const float inv_dx2, + const int enable_chi_floor) { int idx = blockDim.x * blockIdx.x + threadIdx.x; int total = N * N * N; @@ -134,6 +158,8 @@ + Psi_i[off+ipkp] + Psi_i[off+ipkm] + Psi_i[off+imkp] + Psi_i[off+imkm] + Psi_i[off+jpkp] + Psi_i[off+jpkm] + Psi_i[off+jmkp] + Psi_i[off+jmkm]) - 4.0f * Pi_val; + lap_Pr *= inv_dx2; + lap_Pi *= inv_dx2; // GOV-01 leapfrog float Pr_new = 2.0f * Pr - Psi_r_prev[aidx] + dt2 * (lap_Pr - chi_sq * Pr); @@ -159,9 +185,40 @@ psi_sq_total += e_a; // Momentum density: Sum_a Im(Psi_a* . nabla(Psi_a)) - float j_x = Pr * (Psi_i[off+ip] - Psi_i[off+im]) - Pi_val * (Psi_r[off+ip] - Psi_r[off+im]); - float j_y = Pr * (Psi_i[off+jp] - Psi_i[off+jm]) - Pi_val * (Psi_r[off+jp] - Psi_r[off+jm]); - float j_z = Pr * (Psi_i[off+kp] - Psi_i[off+km]) - Pi_val * (Psi_r[off+kp] - Psi_r[off+km]); + float j_x; + float j_y; + float j_z; + if (use_stencil19_noether_current) { + float dPr_x = (1.0f/3.0f) * (Psi_r[off+ip] - Psi_r[off+im]) + + (1.0f/6.0f) * (Psi_r[off+ipjp] + Psi_r[off+ipjm] + Psi_r[off+ipkp] + Psi_r[off+ipkm] + - Psi_r[off+imjp] - Psi_r[off+imjm] - Psi_r[off+imkp] - Psi_r[off+imkm]); + float dPi_x = (1.0f/3.0f) * (Psi_i[off+ip] - Psi_i[off+im]) + + (1.0f/6.0f) * (Psi_i[off+ipjp] + Psi_i[off+ipjm] + Psi_i[off+ipkp] + Psi_i[off+ipkm] + - Psi_i[off+imjp] - Psi_i[off+imjm] - Psi_i[off+imkp] - Psi_i[off+imkm]); + float dPr_y = (1.0f/3.0f) * (Psi_r[off+jp] - Psi_r[off+jm]) + + (1.0f/6.0f) * (Psi_r[off+ipjp] + Psi_r[off+imjp] + Psi_r[off+jpkp] + Psi_r[off+jpkm] + - Psi_r[off+ipjm] - Psi_r[off+imjm] - Psi_r[off+jmkp] - Psi_r[off+jmkm]); + float dPi_y = (1.0f/3.0f) * (Psi_i[off+jp] - Psi_i[off+jm]) + + (1.0f/6.0f) * (Psi_i[off+ipjp] + Psi_i[off+imjp] + Psi_i[off+jpkp] + Psi_i[off+jpkm] + - Psi_i[off+ipjm] - Psi_i[off+imjm] - Psi_i[off+jmkp] - Psi_i[off+jmkm]); + float dPr_z = (1.0f/3.0f) * (Psi_r[off+kp] - Psi_r[off+km]) + + (1.0f/6.0f) * (Psi_r[off+ipkp] + Psi_r[off+imkp] + Psi_r[off+jpkp] + Psi_r[off+jmkp] + - Psi_r[off+ipkm] - Psi_r[off+imkm] - Psi_r[off+jpkm] - Psi_r[off+jmkm]); + float dPi_z = (1.0f/3.0f) * (Psi_i[off+kp] - Psi_i[off+km]) + + (1.0f/6.0f) * (Psi_i[off+ipkp] + Psi_i[off+imkp] + Psi_i[off+jpkp] + Psi_i[off+jmkp] + - Psi_i[off+ipkm] - Psi_i[off+imkm] - Psi_i[off+jpkm] - Psi_i[off+jmkm]); + j_x = Pr * dPi_x - Pi_val * dPr_x; + j_y = Pr * dPi_y - Pi_val * dPr_y; + j_z = Pr * dPi_z - Pi_val * dPr_z; + } else { + j_x = Pr * (Psi_i[off+ip] - Psi_i[off+im]) - Pi_val * (Psi_r[off+ip] - Psi_r[off+im]); + j_y = Pr * (Psi_i[off+jp] - Psi_i[off+jm]) - Pi_val * (Psi_r[off+jp] - Psi_r[off+jm]); + j_z = Pr * (Psi_i[off+kp] - Psi_i[off+km]) - Pi_val * (Psi_r[off+kp] - Psi_r[off+km]); + } + float inv_dx = sqrt(inv_dx2); + j_x *= inv_dx; + j_y *= inv_dx; + j_z *= inv_dx; j_total += 0.5f * (j_x + j_y + j_z); // Store per-color currents for CCV j_color_x[a] = j_x; @@ -213,6 +270,7 @@ + chi[ipkp] + chi[ipkm] + chi[imkp] + chi[imkm] + chi[jpkp] + chi[jpkm] + chi[jmkp] + chi[jmkm]) - 4.0f * chi_c; + lap_chi *= inv_dx2; // Mexican hat: -4*lam*chi*(chi^2 - chi0^2) float chi_self = -4.0f * lam * chi_c * (chi_sq - chi0 * chi0); @@ -224,7 +282,7 @@ - kappa_string * ccv - kappa_tube * scv); // BH excision: clamp to Z2 second vacuum - if (chi_new < -chi0) chi_new = -chi0; + if (enable_chi_floor && chi_new < -chi0) chi_new = -chi0; // Frozen boundary chi_new = mask * chi0 + absorb * chi_new; @@ -337,7 +395,9 @@ const float kappa, const float lam, const float chi0, - const float E0_sq) + const float E0_sq, + const float inv_dx2, + const int enable_chi_floor) { int idx = blockDim.x * blockIdx.x + threadIdx.x; int total = N * N * N; @@ -391,6 +451,8 @@ + chi[ipkp] + chi[ipkm] + chi[imkp] + chi[imkm] + chi[jpkp] + chi[jpkm] + chi[jmkp] + chi[jmkm]) - 4.0f * chi_c; + lap_E *= inv_dx2; + lap_chi *= inv_dx2; // GOV-01 float E_new = 2.0f * E_c - E_prev[idx] + dt2 * (lap_E - chi_sq * E_c); @@ -403,7 +465,7 @@ lap_chi - (kappa / chi0) * chi_c * (E_c * E_c - E0_sq) + chi_self); // BH excision - if (chi_new < -chi0) chi_new = -chi0; + if (enable_chi_floor && chi_new < -chi0) chi_new = -chi0; // Absorbing boundary — damp both new and prev to prevent leapfrog reflection. float mask = boundary_mask[idx]; @@ -421,6 +483,210 @@ # --------------------------------------------------------------------------- # Complex single-component kernel (Level 1 — gravity + EM) # --------------------------------------------------------------------------- +GRAVITY_RECOVERY_REAL_KERNEL_SRC = r""" +extern "C" __global__ __launch_bounds__(256) +void evolve_real_gravity_recovery( + const float* __restrict__ E, + const float* __restrict__ E_prev, + const float* __restrict__ chi, + const float* __restrict__ chi_prev, + const float* __restrict__ boundary_mask, + float* __restrict__ E_next, + float* __restrict__ E_prev_next, + float* __restrict__ chi_next, + float* __restrict__ chi_prev_next, + const int N, + const float dt, + const float kappa, + const float lam, + const float chi0, + const float E0_sq, + const int potential_model, + const int freeze_psi, + const float relaxation_damping, + const float inv_dx2, + const int enable_chi_floor) +{ + int idx = blockDim.x * blockIdx.x + threadIdx.x; + int total = N * N * N; + if (idx >= total) return; + + int i = idx / (N * N); + int j = (idx / N) % N; + int k = idx % N; + + int row_p = ((i + 1) % N) * N * N; + int row_m = ((i - 1 + N) % N) * N * N; + int row_c = i * N * N; + int col_p = ((j + 1) % N) * N; + int col_m = ((j - 1 + N) % N) * N; + int col_c = j * N; + int dep_p = (k + 1) % N; + int dep_m = (k - 1 + N) % N; + int ip = row_p + col_c + k; + int im = row_m + col_c + k; + int jp = row_c + col_p + k; + int jm = row_c + col_m + k; + int kp = row_c + col_c + dep_p; + int km = row_c + col_c + dep_m; + int ipjp = row_p + col_p + k; + int ipjm = row_p + col_m + k; + int imjp = row_m + col_p + k; + int imjm = row_m + col_m + k; + int ipkp = row_p + col_c + dep_p; + int ipkm = row_p + col_c + dep_m; + int imkp = row_m + col_c + dep_p; + int imkm = row_m + col_c + dep_m; + int jpkp = row_c + col_p + dep_p; + int jpkm = row_c + col_p + dep_m; + int jmkp = row_c + col_m + dep_p; + int jmkm = row_c + col_m + dep_m; + + float E_c = E[idx]; + float chi_c = chi[idx]; + float chi_sq = chi_c * chi_c; + float chi0_sq = chi0 * chi0; + float dt2 = dt * dt; + + float lap_E = (1.0f/3.0f) * (E[ip] + E[im] + E[jp] + E[jm] + E[kp] + E[km]) + + (1.0f/6.0f) * (E[ipjp] + E[ipjm] + E[imjp] + E[imjm] + + E[ipkp] + E[ipkm] + E[imkp] + E[imkm] + + E[jpkp] + E[jpkm] + E[jmkp] + E[jmkm]) + - 4.0f * E_c; + float lap_chi = (1.0f/3.0f) * (chi[ip] + chi[im] + chi[jp] + chi[jm] + chi[kp] + chi[km]) + + (1.0f/6.0f) * (chi[ipjp] + chi[ipjm] + chi[imjp] + chi[imjm] + + chi[ipkp] + chi[ipkm] + chi[imkp] + chi[imkm] + + chi[jpkp] + chi[jpkm] + chi[jmkp] + chi[jmkm]) + - 4.0f * chi_c; + lap_E *= inv_dx2; + lap_chi *= inv_dx2; + + float E_new; + if (freeze_psi) { + E_new = E_c; + } else { + E_new = 2.0f * E_c - E_prev[idx] + + dt2 * (lap_E - chi_sq * E_c); + } + + float source_density = E_c * E_c - E0_sq; + float y = (chi_sq - chi0_sq) / chi0_sq; + float y2 = y * y; + float y3 = y2 * y; + float y5 = y3 * y2; + float y7 = y5 * y2; + float y9 = y7 * y2; + float y11 = y9 * y2; + float f_prime = 0.0f; + + if (potential_model == 0) { + f_prime = 2.0f * y; + } else if ( + potential_model == 1 + || potential_model == 9 + || potential_model == 12 + ) { + f_prime = 4.0f * y3; + } else if (potential_model == 2) { + f_prime = 6.0f * y5; + } else if (potential_model == 3) { + f_prime = 8.0f * y7; + } else if (potential_model == 4) { + f_prime = 10.0f * y9; + } else if (potential_model == 5) { + f_prime = 12.0f * y11; + } else if (potential_model == 6) { + float exp_term = exp(-y2); + f_prime = 2.0f * y * (1.0f - exp_term + y2 * exp_term); + } else if (potential_model == 7) { + float denominator = 1.0f + y2; + f_prime = 2.0f * y3 * (2.0f + y2) + / (denominator * denominator); + } else if (potential_model == 8) { + float tanh_y = tanh(y); + float sech_sq = 1.0f - tanh_y * tanh_y; + f_prime = 2.0f * y * tanh_y + * (tanh_y + y * sech_sq); + } else if (potential_model == 10) { + f_prime = 4.0f * y3 + 6.0f * y5; + } else if (potential_model == 11) { + float source_ratio = source_density / chi0_sq; + f_prime = 4.0f * y3 + 2.0f * source_ratio * y; + } else if (potential_model == 13) { + f_prime = 4.0f * y7 / sqrt(1.0f + y2 * y2 * y2 * y2); + } + + float self_force = -2.0f * lam * chi0_sq * chi_c * f_prime; + float chi_accel = lap_chi + - (kappa / chi0) * chi_c * source_density + + self_force; + + if (potential_model == 9) { + float d_ip = chi[ip] - chi_c; + float d_im = chi[im] - chi_c; + float d_jp = chi[jp] - chi_c; + float d_jm = chi[jm] - chi_c; + float d_kp = chi[kp] - chi_c; + float d_km = chi[km] - chi_c; + float nonlinear = (1.0f/3.0f) * ( + d_ip*d_ip*d_ip + d_im*d_im*d_im + + d_jp*d_jp*d_jp + d_jm*d_jm*d_jm + + d_kp*d_kp*d_kp + d_km*d_km*d_km + ); + float d_ipjp = chi[ipjp] - chi_c; + float d_ipjm = chi[ipjm] - chi_c; + float d_imjp = chi[imjp] - chi_c; + float d_imjm = chi[imjm] - chi_c; + float d_ipkp = chi[ipkp] - chi_c; + float d_ipkm = chi[ipkm] - chi_c; + float d_imkp = chi[imkp] - chi_c; + float d_imkm = chi[imkm] - chi_c; + float d_jpkp = chi[jpkp] - chi_c; + float d_jpkm = chi[jpkm] - chi_c; + float d_jmkp = chi[jmkp] - chi_c; + float d_jmkm = chi[jmkm] - chi_c; + nonlinear += (1.0f/6.0f) * ( + d_ipjp*d_ipjp*d_ipjp + d_ipjm*d_ipjm*d_ipjm + + d_imjp*d_imjp*d_imjp + d_imjm*d_imjm*d_imjm + + d_ipkp*d_ipkp*d_ipkp + d_ipkm*d_ipkm*d_ipkm + + d_imkp*d_imkp*d_imkp + d_imkm*d_imkm*d_imkm + + d_jpkp*d_jpkp*d_jpkp + d_jpkm*d_jpkm*d_jpkm + + d_jmkp*d_jmkp*d_jmkp + d_jmkm*d_jmkm*d_jmkm + ); + chi_accel += inv_dx2 * nonlinear / chi0_sq; + } + + float velocity = (chi_c - chi_prev[idx]) / dt; + if (potential_model == 12) { + float inertia = 1.0f + y2; + float inertia_derivative = 4.0f * chi_c * y / chi0_sq; + chi_accel = ( + chi_accel + - 0.5f * inertia_derivative * velocity * velocity + ) / inertia; + } + + float damping_half_step = 0.5f * relaxation_damping * dt; + float chi_new = ( + 2.0f * chi_c + - (1.0f - damping_half_step) * chi_prev[idx] + + dt2 * chi_accel + ) / (1.0f + damping_half_step); + + if (enable_chi_floor && chi_new < -chi0) chi_new = -chi0; + + float mask = boundary_mask[idx]; + float absorb = 1.0f - mask; + E_new = absorb * E_new; + chi_new = mask * chi0 + absorb * chi_new; + + E_next[idx] = E_new; + E_prev_next[idx] = freeze_psi ? E_c : absorb * E_c; + chi_next[idx] = chi_new; + chi_prev_next[idx] = chi_c; +} +""" + EVOLUTION_COMPLEX_KERNEL_SRC = r""" extern "C" __global__ __launch_bounds__(256) void evolve_complex( @@ -443,7 +709,10 @@ const float lam, const float chi0, const float E0_sq, - const float eps_w) + const float eps_w, + const int use_stencil19_noether_current, + const float inv_dx2, + const int enable_chi_floor) { int idx = blockDim.x * blockIdx.x + threadIdx.x; int total = N * N * N; @@ -501,6 +770,9 @@ + chi[ipkp] + chi[ipkm] + chi[imkp] + chi[imkm] + chi[jpkp] + chi[jpkm] + chi[jmkp] + chi[jmkm]) - 4.0f * chi_c; + lap_Pr *= inv_dx2; + lap_Pi *= inv_dx2; + lap_chi *= inv_dx2; // GOV-01 float Pr_new = 2.0f * Pr - Psi_r_prev[idx] + dt2 * (lap_Pr - chi_sq * Pr); @@ -508,9 +780,40 @@ // |Psi|^2 and momentum density j float psi_sq = Pr * Pr + Pi_val * Pi_val; - float j_x = Pr * (Psi_i[ip] - Psi_i[im]) - Pi_val * (Psi_r[ip] - Psi_r[im]); - float j_y = Pr * (Psi_i[jp] - Psi_i[jm]) - Pi_val * (Psi_r[jp] - Psi_r[jm]); - float j_z = Pr * (Psi_i[kp] - Psi_i[km]) - Pi_val * (Psi_r[kp] - Psi_r[km]); + float j_x; + float j_y; + float j_z; + if (use_stencil19_noether_current) { + float dPr_x = (1.0f/3.0f) * (Psi_r[ip] - Psi_r[im]) + + (1.0f/6.0f) * (Psi_r[ipjp] + Psi_r[ipjm] + Psi_r[ipkp] + Psi_r[ipkm] + - Psi_r[imjp] - Psi_r[imjm] - Psi_r[imkp] - Psi_r[imkm]); + float dPi_x = (1.0f/3.0f) * (Psi_i[ip] - Psi_i[im]) + + (1.0f/6.0f) * (Psi_i[ipjp] + Psi_i[ipjm] + Psi_i[ipkp] + Psi_i[ipkm] + - Psi_i[imjp] - Psi_i[imjm] - Psi_i[imkp] - Psi_i[imkm]); + float dPr_y = (1.0f/3.0f) * (Psi_r[jp] - Psi_r[jm]) + + (1.0f/6.0f) * (Psi_r[ipjp] + Psi_r[imjp] + Psi_r[jpkp] + Psi_r[jpkm] + - Psi_r[ipjm] - Psi_r[imjm] - Psi_r[jmkp] - Psi_r[jmkm]); + float dPi_y = (1.0f/3.0f) * (Psi_i[jp] - Psi_i[jm]) + + (1.0f/6.0f) * (Psi_i[ipjp] + Psi_i[imjp] + Psi_i[jpkp] + Psi_i[jpkm] + - Psi_i[ipjm] - Psi_i[imjm] - Psi_i[jmkp] - Psi_i[jmkm]); + float dPr_z = (1.0f/3.0f) * (Psi_r[kp] - Psi_r[km]) + + (1.0f/6.0f) * (Psi_r[ipkp] + Psi_r[imkp] + Psi_r[jpkp] + Psi_r[jmkp] + - Psi_r[ipkm] - Psi_r[imkm] - Psi_r[jpkm] - Psi_r[jmkm]); + float dPi_z = (1.0f/3.0f) * (Psi_i[kp] - Psi_i[km]) + + (1.0f/6.0f) * (Psi_i[ipkp] + Psi_i[imkp] + Psi_i[jpkp] + Psi_i[jmkp] + - Psi_i[ipkm] - Psi_i[imkm] - Psi_i[jpkm] - Psi_i[jmkm]); + j_x = Pr * dPi_x - Pi_val * dPr_x; + j_y = Pr * dPi_y - Pi_val * dPr_y; + j_z = Pr * dPi_z - Pi_val * dPr_z; + } else { + j_x = Pr * (Psi_i[ip] - Psi_i[im]) - Pi_val * (Psi_r[ip] - Psi_r[im]); + j_y = Pr * (Psi_i[jp] - Psi_i[jm]) - Pi_val * (Psi_r[jp] - Psi_r[jm]); + j_z = Pr * (Psi_i[kp] - Psi_i[km]) - Pi_val * (Psi_r[kp] - Psi_r[km]); + } + float inv_dx = sqrt(inv_dx2); + j_x *= inv_dx; + j_y *= inv_dx; + j_z *= inv_dx; float j_scalar = 0.5f * (j_x + j_y + j_z); // Mexican hat @@ -521,7 +824,7 @@ lap_chi - (kappa / chi0) * chi_c * (psi_sq + eps_w * j_scalar - E0_sq) + chi_self); // BH excision - if (chi_new < -chi0) chi_new = -chi0; + if (enable_chi_floor && chi_new < -chi0) chi_new = -chi0; // Absorbing boundary — damp both new and prev to prevent leapfrog reflection. float mask = boundary_mask[idx]; diff --git a/lfm/core/backends/numpy_backend.py b/lfm/core/backends/numpy_backend.py index 4654920..3f59763 100644 --- a/lfm/core/backends/numpy_backend.py +++ b/lfm/core/backends/numpy_backend.py @@ -12,15 +12,34 @@ import numpy as np -from lfm.core.stencils import laplacian_19pt +from lfm.config import ChiPotentialModel, Precision +from lfm.core.chi_potentials import potential_force, variable_inertia +from lfm.core.stencils import laplacian_19pt, noether_current_19pt_raw if TYPE_CHECKING: from numpy.typing import NDArray +_GRAVITY_RECOVERY_LINKS = ( + ((1, 0, 0), 1.0 / 3.0), + ((-1, 0, 0), 1.0 / 3.0), + ((0, 1, 0), 1.0 / 3.0), + ((0, -1, 0), 1.0 / 3.0), + ((0, 0, 1), 1.0 / 3.0), + ((0, 0, -1), 1.0 / 3.0), + *tuple(((sx, sy, 0), 1.0 / 6.0) for sx in (-1, 1) for sy in (-1, 1)), + *tuple(((sx, 0, sz), 1.0 / 6.0) for sx in (-1, 1) for sz in (-1, 1)), + *tuple(((0, sy, sz), 1.0 / 6.0) for sy in (-1, 1) for sz in (-1, 1)), +) + + class NumpyBackend: """CPU compute backend using NumPy.""" + def __init__(self, precision: Precision | str = Precision.FLOAT32) -> None: + self.precision = Precision(precision) + self.dtype = np.dtype(self.precision.value) + @property def name(self) -> str: return "numpy" @@ -30,11 +49,11 @@ def allocate( N: int, n_psi_arrays: int, chi0: float, - ) -> dict[str, NDArray[np.float32]]: + ) -> dict[str, NDArray[np.floating]]: total = N**3 psi_size = n_psi_arrays * total - zero_psi = np.zeros(psi_size, dtype=np.float32) - chi_init = np.full(total, chi0, dtype=np.float32) + zero_psi = np.zeros(psi_size, dtype=self.dtype) + chi_init = np.full(total, chi0, dtype=self.dtype) return { "psi_A": zero_psi.copy(), "psi_prev_A": zero_psi.copy(), @@ -50,7 +69,7 @@ def create_boundary_mask( self, N: int, boundary_fraction: float, - ) -> NDArray[np.float32]: + ) -> NDArray[np.floating]: """Return a smooth cos² absorption mask in [0, 1]. 0 = fully transparent (interior), 1 = fully absorbed (boundary). @@ -60,15 +79,15 @@ def create_boundary_mask( center = N / 2.0 r_max = N / 2.0 r_freeze = (1.0 - boundary_fraction) * r_max - coords = np.arange(N, dtype=np.float32) - center + 0.5 + coords = np.arange(N, dtype=self.dtype) - center + 0.5 X, Y, Z = np.meshgrid(coords, coords, coords, indexing="ij") R = np.sqrt(X**2 + Y**2 + Z**2) # Smooth cosine taper: 0 at r_freeze, 1 at r_max t = np.clip((R - r_freeze) / (r_max - r_freeze), 0.0, 1.0) - mask = (np.sin(0.5 * np.pi * t) ** 2).astype(np.float32) + mask = (np.sin(0.5 * np.pi * t) ** 2).astype(self.dtype) return mask.ravel() - def _laplacian_3d(self, flat: NDArray[np.float32], N: int) -> NDArray[np.float32]: + def _laplacian_3d(self, flat: NDArray[np.floating], N: int) -> NDArray[np.floating]: """19-point Laplacian on a flat (N³,) or (K*N³,) array. Reshapes to 3D, computes, and flattens back. @@ -94,14 +113,17 @@ def step_real( lambda_self: float, chi0: float, e0_sq: float, + *, + inv_dx2: float = 1.0, + enable_chi_floor: bool = True, ) -> None: E = psi_in E_prev = psi_prev_in chi = chi_in chi_prev = chi_prev_in - lap_E = self._laplacian_3d(E, N) - lap_chi = self._laplacian_3d(chi, N) + lap_E = inv_dx2 * self._laplacian_3d(E, N) + lap_chi = inv_dx2 * self._laplacian_3d(chi, N) chi_sq = chi * chi # GOV-01 @@ -114,8 +136,8 @@ def step_real( chi_accel -= 4.0 * lambda_self * chi * (chi_sq - chi0 * chi0) chi_new = 2.0 * chi - chi_prev + dt2 * chi_accel - # BH excision - np.clip(chi_new, -chi0, None, out=chi_new) + if enable_chi_floor: + np.clip(chi_new, -chi0, None, out=chi_new) # Absorbing boundary — damp both new field AND prev field so the # leapfrog sees no energy at the boundary on the next step. @@ -129,6 +151,279 @@ def step_real( np.copyto(chi_out, chi_new) np.copyto(chi_prev_out, chi) + def step_real_gravity_recovery( + self, + psi_in: NDArray, + psi_prev_in: NDArray, + chi_in: NDArray, + chi_prev_in: NDArray, + boundary_mask: NDArray, + psi_out: NDArray, + psi_prev_out: NDArray, + chi_out: NDArray, + chi_prev_out: NDArray, + N: int, + dt: float, + kappa: float, + lambda_self: float, + chi0: float, + e0_sq: float, + potential_model: int, + freeze_psi: bool, + relaxation_damping: float, + *, + inv_dx2: float = 1.0, + enable_chi_floor: bool = False, + ) -> None: + """Advance the experiment-only local GOV-02 candidate system.""" + + model = ChiPotentialModel(potential_model) + E = psi_in + E_prev = psi_prev_in + chi = chi_in + chi_prev = chi_prev_in + lap_E = inv_dx2 * self._laplacian_3d(E, N) + lap_chi = inv_dx2 * self._laplacian_3d(chi, N) + chi_sq = chi * chi + E_new = E.copy() if freeze_psi else 2.0 * E - E_prev + dt**2 * (lap_E - chi_sq * E) + source_density = E * E - e0_sq + chi_accel = ( + lap_chi + - (kappa / chi0) * chi * source_density + + potential_force( + chi, + model, + chi0=chi0, + lambda_h=lambda_self, + source_density=source_density, + ) + ) + if model == ChiPotentialModel.NONLINEAR_GRADIENT: + field = chi.reshape(N, N, N) + nonlinear = np.zeros_like(field) + for offset, weight in _GRAVITY_RECOVERY_LINKS: + neighbor = np.roll( + field, + shift=tuple(-value for value in offset), + axis=(0, 1, 2), + ) + nonlinear += weight * (neighbor - field) ** 3 / chi0**2 + chi_accel += inv_dx2 * nonlinear.ravel() + velocity = (chi - chi_prev) / dt + if model == ChiPotentialModel.VARIABLE_INERTIA: + inertia = variable_inertia(chi, chi0=chi0) + y = (chi_sq - chi0**2) / chi0**2 + inertia_derivative = 4.0 * chi * y / chi0**2 + chi_accel = (chi_accel - 0.5 * inertia_derivative * velocity**2) / inertia + damping_half_step = 0.5 * relaxation_damping * dt + chi_new = (2.0 * chi - (1.0 - damping_half_step) * chi_prev + dt**2 * chi_accel) / ( + 1.0 + damping_half_step + ) + if enable_chi_floor: + np.clip(chi_new, -chi0, None, out=chi_new) + absorb = 1.0 - boundary_mask + E_new *= absorb + chi_new = boundary_mask * chi0 + absorb * chi_new + np.copyto(psi_out, E_new) + np.copyto( + psi_prev_out, + E if freeze_psi else E * absorb, + ) + np.copyto(chi_out, chi_new) + np.copyto(chi_prev_out, chi) + + def step_complex_gravity_recovery( + self, + psi_r_in: NDArray, + psi_r_prev_in: NDArray, + psi_i_in: NDArray, + psi_i_prev_in: NDArray, + chi_in: NDArray, + chi_prev_in: NDArray, + boundary_mask: NDArray, + psi_r_out: NDArray, + psi_r_prev_out: NDArray, + psi_i_out: NDArray, + psi_i_prev_out: NDArray, + chi_out: NDArray, + chi_prev_out: NDArray, + N: int, + dt: float, + kappa: float, + lambda_self: float, + chi0: float, + e0_sq: float, + potential_model: int, + freeze_psi: bool, + relaxation_damping: float, + *, + inv_dx2: float = 1.0, + enable_chi_floor: bool = False, + ) -> None: + """Advance the local complex flat-octic candidate system.""" + + model = ChiPotentialModel(potential_model) + if model != ChiPotentialModel.FLAT_OCTIC: + raise ValueError("complex gravity recovery currently supports FLAT_OCTIC") + psi_r = psi_r_in + psi_i = psi_i_in + chi = chi_in + chi_prev = chi_prev_in + lap_r = inv_dx2 * self._laplacian_3d(psi_r, N) + lap_i = inv_dx2 * self._laplacian_3d(psi_i, N) + lap_chi = inv_dx2 * self._laplacian_3d(chi, N) + chi_sq = chi * chi + if freeze_psi: + psi_r_new = psi_r.copy() + psi_i_new = psi_i.copy() + else: + psi_r_new = 2.0 * psi_r - psi_r_prev_in + dt**2 * (lap_r - chi_sq * psi_r) + psi_i_new = 2.0 * psi_i - psi_i_prev_in + dt**2 * (lap_i - chi_sq * psi_i) + source_density = psi_r**2 + psi_i**2 - e0_sq + chi_accel = ( + lap_chi + - (kappa / chi0) * chi * source_density + + potential_force( + chi, + model, + chi0=chi0, + lambda_h=lambda_self, + source_density=source_density, + ) + ) + damping_half_step = 0.5 * relaxation_damping * dt + chi_new = (2.0 * chi - (1.0 - damping_half_step) * chi_prev + dt**2 * chi_accel) / ( + 1.0 + damping_half_step + ) + if enable_chi_floor: + np.clip(chi_new, -chi0, None, out=chi_new) + absorb = 1.0 - boundary_mask + psi_r_new *= absorb + psi_i_new *= absorb + chi_new = boundary_mask * chi0 + absorb * chi_new + np.copyto(psi_r_out, psi_r_new) + np.copyto( + psi_r_prev_out, + psi_r if freeze_psi else psi_r * absorb, + ) + np.copyto(psi_i_out, psi_i_new) + np.copyto( + psi_i_prev_out, + psi_i if freeze_psi else psi_i * absorb, + ) + np.copyto(chi_out, chi_new) + np.copyto(chi_prev_out, chi) + + def step_color_gravity_recovery( + self, + psi_r_in: NDArray, + psi_r_prev_in: NDArray, + psi_i_in: NDArray, + psi_i_prev_in: NDArray, + chi_in: NDArray, + chi_prev_in: NDArray, + boundary_mask: NDArray, + psi_r_out: NDArray, + psi_r_prev_out: NDArray, + psi_i_out: NDArray, + psi_i_prev_out: NDArray, + chi_out: NDArray, + chi_prev_out: NDArray, + N: int, + dt: float, + kappa: float, + lambda_self: float, + chi0: float, + e0_sq: float, + potential_model: int, + freeze_psi: bool, + relaxation_damping: float, + *, + epsilon_w: float = 0.0, + kappa_c: float = 0.0, + epsilon_cc: float = 0.0, + kappa_string: float = 0.0, + kappa_tube: float = 0.0, + sa_fields_in: NDArray | None = None, + sa_fields_out: NDArray | None = None, + sa_gamma: float = 0.1, + sa_d: float = 4.9, + use_stencil19_noether_current: bool = False, + inv_dx2: float = 1.0, + enable_chi_floor: bool = False, + ) -> None: + """Advance the full three-channel flat-octic candidate system.""" + + model = ChiPotentialModel(potential_model) + if model != ChiPotentialModel.FLAT_OCTIC: + raise ValueError("color gravity recovery currently supports FLAT_OCTIC") + self.step_color( + psi_r_in, + psi_r_prev_in, + psi_i_in, + psi_i_prev_in, + chi_in, + chi_prev_in, + boundary_mask, + psi_r_out, + psi_r_prev_out, + psi_i_out, + psi_i_prev_out, + chi_out, + chi_prev_out, + N, + dt**2, + kappa, + 0.0, + chi0, + e0_sq, + epsilon_w, + kappa_c, + epsilon_cc, + kappa_string, + kappa_tube, + sa_fields_in, + sa_fields_out, + sa_gamma, + sa_d, + dt=dt, + use_stencil19_noether_current=use_stencil19_noether_current, + inv_dx2=inv_dx2, + enable_chi_floor=False, + ) + absorb = 1.0 - boundary_mask + safe_absorb = np.where(absorb > 0.0, absorb, 1.0) + undamped_chi = (chi_out - boundary_mask * chi0) / safe_absorb + source_density = np.zeros_like(chi_in) + total = N**3 + for channel in range(3): + selected = slice(channel * total, (channel + 1) * total) + source_density += psi_r_in[selected] ** 2 + psi_i_in[selected] ** 2 + source_density -= e0_sq + flat_force = potential_force( + chi_in, + model, + chi0=chi0, + lambda_h=lambda_self, + source_density=source_density, + ) + damping_half_step = 0.5 * relaxation_damping * dt + corrected = (undamped_chi + damping_half_step * chi_prev_in + dt**2 * flat_force) / ( + 1.0 + damping_half_step + ) + if enable_chi_floor: + np.clip(corrected, -chi0, None, out=corrected) + np.copyto( + chi_out, + boundary_mask * chi0 + absorb * corrected, + ) + if freeze_psi: + absorb3 = np.tile(absorb, 3) + np.copyto(psi_r_out, absorb3 * psi_r_in) + np.copyto(psi_r_prev_out, psi_r_in) + np.copyto(psi_i_out, absorb3 * psi_i_in) + np.copyto(psi_i_prev_out, psi_i_in) + def step_complex( self, psi_r_in: NDArray, @@ -151,14 +446,18 @@ def step_complex( chi0: float, e0_sq: float, epsilon_w: float, + *, + use_stencil19_noether_current: bool = False, + inv_dx2: float = 1.0, + enable_chi_floor: bool = True, ) -> None: Pr, Pi = psi_r_in, psi_i_in chi, chi_prev = chi_in, chi_prev_in chi_sq = chi * chi - lap_Pr = self._laplacian_3d(Pr, N) - lap_Pi = self._laplacian_3d(Pi, N) - lap_chi = self._laplacian_3d(chi, N) + lap_Pr = inv_dx2 * self._laplacian_3d(Pr, N) + lap_Pi = inv_dx2 * self._laplacian_3d(Pi, N) + lap_chi = inv_dx2 * self._laplacian_3d(chi, N) # GOV-01 Pr_new = 2.0 * Pr - psi_r_prev_in + dt2 * (lap_Pr - chi_sq * Pr) @@ -170,16 +469,26 @@ def step_complex( # j = Im(Ψ*·∇Ψ) via central differences on 3D grid Pr3 = Pr.reshape(N, N, N) Pi3 = Pi.reshape(N, N, N) - # Face-neighbor central differences - dPr_dx = np.roll(Pr3, -1, 0) - np.roll(Pr3, 1, 0) - dPr_dy = np.roll(Pr3, -1, 1) - np.roll(Pr3, 1, 1) - dPr_dz = np.roll(Pr3, -1, 2) - np.roll(Pr3, 1, 2) - dPi_dx = np.roll(Pi3, -1, 0) - np.roll(Pi3, 1, 0) - dPi_dy = np.roll(Pi3, -1, 1) - np.roll(Pi3, 1, 1) - dPi_dz = np.roll(Pi3, -1, 2) - np.roll(Pi3, 1, 2) - j_x = (Pr3 * dPi_dx - Pi3 * dPr_dx).ravel() - j_y = (Pr3 * dPi_dy - Pi3 * dPr_dy).ravel() - j_z = (Pr3 * dPi_dz - Pi3 * dPr_dz).ravel() + if use_stencil19_noether_current: + j_x_3d, j_y_3d, j_z_3d = noether_current_19pt_raw(Pr3, Pi3) + j_x = j_x_3d.ravel() + j_y = j_y_3d.ravel() + j_z = j_z_3d.ravel() + else: + # Historical face-only central differences. + dPr_dx = np.roll(Pr3, -1, 0) - np.roll(Pr3, 1, 0) + dPr_dy = np.roll(Pr3, -1, 1) - np.roll(Pr3, 1, 1) + dPr_dz = np.roll(Pr3, -1, 2) - np.roll(Pr3, 1, 2) + dPi_dx = np.roll(Pi3, -1, 0) - np.roll(Pi3, 1, 0) + dPi_dy = np.roll(Pi3, -1, 1) - np.roll(Pi3, 1, 1) + dPi_dz = np.roll(Pi3, -1, 2) - np.roll(Pi3, 1, 2) + j_x = (Pr3 * dPi_dx - Pi3 * dPr_dx).ravel() + j_y = (Pr3 * dPi_dy - Pi3 * dPr_dy).ravel() + j_z = (Pr3 * dPi_dz - Pi3 * dPr_dz).ravel() + inv_dx = float(np.sqrt(inv_dx2)) + j_x *= inv_dx + j_y *= inv_dx + j_z *= inv_dx j_total = 0.5 * (j_x + j_y + j_z) # GOV-02 v28.0 @@ -189,7 +498,8 @@ def step_complex( chi_accel -= 4.0 * lambda_self * chi * (chi_sq - chi0 * chi0) chi_new = 2.0 * chi - chi_prev + dt2 * chi_accel - np.clip(chi_new, -chi0, None, out=chi_new) + if enable_chi_floor: + np.clip(chi_new, -chi0, None, out=chi_new) # Absorbing boundary — damp both new and prev to prevent reflection. absorb = 1.0 - boundary_mask @@ -235,25 +545,29 @@ def step_color( sa_gamma: float = 0.1, sa_d: float = 4.9, dt: float = 0.02, + *, + use_stencil19_noether_current: bool = False, + inv_dx2: float = 1.0, + enable_chi_floor: bool = True, ) -> None: total = N**3 n_colors = 3 chi, chi_prev = chi_in, chi_prev_in chi_sq = chi * chi - psi_sq_total = np.zeros(total, dtype=np.float32) - j_total_acc = np.zeros(total, dtype=np.float32) - color_energy = np.zeros((n_colors, total), dtype=np.float32) + psi_sq_total = np.zeros(total, dtype=self.dtype) + j_total_acc = np.zeros(total, dtype=self.dtype) + color_energy = np.zeros((n_colors, total), dtype=self.dtype) # For CCV (v15 GOV-02): store per-color per-direction currents need_ccv = kappa_string > 0 if need_ccv: - j_per_color = np.zeros((n_colors, 3, total), dtype=np.float32) + j_per_color = np.zeros((n_colors, 3, total), dtype=self.dtype) # v15: precompute color average for cross-color coupling if epsilon_cc > 0: - Pr_avg = np.zeros(total, dtype=np.float32) - Pi_avg = np.zeros(total, dtype=np.float32) + Pr_avg = np.zeros(total, dtype=self.dtype) + Pi_avg = np.zeros(total, dtype=self.dtype) for a in range(n_colors): s = slice(a * total, (a + 1) * total) Pr_avg += psi_r_in[s] @@ -268,8 +582,8 @@ def step_color( Pr = psi_r_in[s] Pi = psi_i_in[s] - lap_Pr = self._laplacian_3d(Pr, N) - lap_Pi = self._laplacian_3d(Pi, N) + lap_Pr = inv_dx2 * self._laplacian_3d(Pr, N) + lap_Pi = inv_dx2 * self._laplacian_3d(Pi, N) # GOV-01 Pr_new = 2.0 * Pr - psi_r_prev_in[s] + dt2 * (lap_Pr - chi_sq * Pr) @@ -293,15 +607,25 @@ def step_color( # per-color momentum currents j_{a,d} = Pr * dPi/dd - Pi * dPr/dd Pr3 = Pr.reshape(N, N, N) Pi3 = Pi.reshape(N, N, N) - dPr_dx = np.roll(Pr3, -1, 0) - np.roll(Pr3, 1, 0) - dPr_dy = np.roll(Pr3, -1, 1) - np.roll(Pr3, 1, 1) - dPr_dz = np.roll(Pr3, -1, 2) - np.roll(Pr3, 1, 2) - dPi_dx = np.roll(Pi3, -1, 0) - np.roll(Pi3, 1, 0) - dPi_dy = np.roll(Pi3, -1, 1) - np.roll(Pi3, 1, 1) - dPi_dz = np.roll(Pi3, -1, 2) - np.roll(Pi3, 1, 2) - j_x = (Pr3 * dPi_dx - Pi3 * dPr_dx).ravel() - j_y = (Pr3 * dPi_dy - Pi3 * dPr_dy).ravel() - j_z = (Pr3 * dPi_dz - Pi3 * dPr_dz).ravel() + if use_stencil19_noether_current: + j_x_3d, j_y_3d, j_z_3d = noether_current_19pt_raw(Pr3, Pi3) + j_x = j_x_3d.ravel() + j_y = j_y_3d.ravel() + j_z = j_z_3d.ravel() + else: + dPr_dx = np.roll(Pr3, -1, 0) - np.roll(Pr3, 1, 0) + dPr_dy = np.roll(Pr3, -1, 1) - np.roll(Pr3, 1, 1) + dPr_dz = np.roll(Pr3, -1, 2) - np.roll(Pr3, 1, 2) + dPi_dx = np.roll(Pi3, -1, 0) - np.roll(Pi3, 1, 0) + dPi_dy = np.roll(Pi3, -1, 1) - np.roll(Pi3, 1, 1) + dPi_dz = np.roll(Pi3, -1, 2) - np.roll(Pi3, 1, 2) + j_x = (Pr3 * dPi_dx - Pi3 * dPr_dx).ravel() + j_y = (Pr3 * dPi_dy - Pi3 * dPr_dy).ravel() + j_z = (Pr3 * dPi_dz - Pi3 * dPr_dz).ravel() + inv_dx = float(np.sqrt(inv_dx2)) + j_x *= inv_dx + j_y *= inv_dx + j_z *= inv_dx j_total_acc += 0.5 * (j_x + j_y + j_z) if need_ccv: @@ -310,7 +634,7 @@ def step_color( j_per_color[a, 2] = j_z # v14: normalized color variance f_c → color_var_term - color_var_term = np.zeros(total, dtype=np.float32) + color_var_term = np.zeros(total, dtype=self.dtype) if kappa_c > 0: sum_sq = np.sum(color_energy**2, axis=0) total_sq = psi_sq_total * psi_sq_total @@ -322,12 +646,12 @@ def step_color( ), 0.0, ) - f_c = ((ratio - 1.0 / n_colors) * safe).astype(np.float32) - color_var_term = ((kappa_c / chi0) * chi * f_c * psi_sq_total).astype(np.float32) + f_c = ((ratio - 1.0 / n_colors) * safe).astype(self.dtype) + color_var_term = ((kappa_c / chi0) * chi * f_c * psi_sq_total).astype(self.dtype) # v15 GOV-02: color current variance (CCV) # CCV = Σ_d [ Σ_a j²_{a,d} - (1/N_c)(Σ_a j_{a,d})² ] - ccv_term = np.zeros(total, dtype=np.float32) + ccv_term = np.zeros(total, dtype=self.dtype) if need_ccv: for d in range(3): j_d = j_per_color[:, d, :] # shape (n_colors, total) @@ -337,7 +661,7 @@ def step_color( # v17: Helmholtz-smoothed S_a from |Ψ_a|² (replaces v16 Euler diffusion) # S̃_a(k) = γ/(γ + D·k²) · FT[|Ψ_a|²](k) — quasi-static, unconditionally stable - scv_term = np.zeros(total, dtype=np.float32) + scv_term = np.zeros(total, dtype=self.dtype) if kappa_tube > 0 and sa_fields_out is not None: kx = np.fft.fftfreq(N) * (2.0 * np.pi) ky = np.fft.fftfreq(N) * (2.0 * np.pi) @@ -345,12 +669,12 @@ def step_color( k_sq = kx[:, None, None] ** 2 + ky[None, :, None] ** 2 + kz[None, None, :] ** 2 h_filter = np.float64(sa_gamma) / (np.float64(sa_gamma) + np.float64(sa_d) * k_sq) - sa_sum = np.zeros(total, dtype=np.float32) - sa_sq_sum = np.zeros(total, dtype=np.float32) + sa_sum = np.zeros(total, dtype=self.dtype) + sa_sq_sum = np.zeros(total, dtype=self.dtype) for a in range(n_colors): psi_sq_hat = np.fft.rfftn(color_energy[a].reshape(N, N, N)) sa_3d = np.fft.irfftn(h_filter * psi_sq_hat, s=(N, N, N), axes=(0, 1, 2)) - sa_flat = np.clip(sa_3d, 0.0, None).astype(np.float32).ravel() + sa_flat = np.clip(sa_3d, 0.0, None).astype(self.dtype).ravel() np.copyto(sa_fields_out[a * total : (a + 1) * total], sa_flat) sa_sum += sa_flat sa_sq_sum += sa_flat * sa_flat @@ -358,7 +682,7 @@ def step_color( scv_term = sa_sq_sum - (1.0 / n_colors) * sa_sum**2 # GOV-02 v28.0 - lap_chi = self._laplacian_3d(chi, N) + lap_chi = inv_dx2 * self._laplacian_3d(chi, N) chi_source = (kappa / chi0) * chi * (psi_sq_total + epsilon_w * j_total_acc - e0_sq) chi_accel = ( lap_chi - chi_source - color_var_term - kappa_string * ccv_term - kappa_tube * scv_term @@ -367,7 +691,8 @@ def step_color( chi_accel -= 4.0 * lambda_self * chi * (chi_sq - chi0 * chi0) chi_new = 2.0 * chi - chi_prev + dt2 * chi_accel - np.clip(chi_new, -chi0, None, out=chi_new) + if enable_chi_floor: + np.clip(chi_new, -chi0, None, out=chi_new) # Absorbing boundary — damp both new and prev to prevent reflection. absorb3 = 1.0 - np.tile(boundary_mask, 3) @@ -381,8 +706,26 @@ def step_color( np.copyto(chi_out, chi_new) np.copyto(chi_prev_out, chi) - def to_numpy(self, arr: NDArray[np.float32]) -> NDArray[np.float32]: + def to_numpy(self, arr: NDArray[np.floating]) -> NDArray[np.floating]: return arr - def from_numpy(self, arr: NDArray) -> NDArray[np.float32]: - return arr.astype(np.float32) if arr.dtype != np.float32 else arr + def from_numpy(self, arr: NDArray) -> NDArray[np.floating]: + return arr.astype(self.dtype) if arr.dtype != self.dtype else arr + + def apply_complex_phase_map( + self, + psi_r: NDArray, + psi_i: NDArray, + phase_cos: NDArray, + phase_sin: NDArray, + ) -> None: + """Rotate a complex field in place by a local phase map.""" + + real = np.asarray(psi_r) + imag = np.asarray(psi_i) + cosv = np.asarray(phase_cos, dtype=self.dtype) + sinv = np.asarray(phase_sin, dtype=self.dtype) + new_real = real * cosv - imag * sinv + new_imag = real * sinv + imag * cosv + np.copyto(real, new_real) + np.copyto(imag, new_imag) diff --git a/lfm/core/backends/protocol.py b/lfm/core/backends/protocol.py index 16114ff..a5ed83a 100644 --- a/lfm/core/backends/protocol.py +++ b/lfm/core/backends/protocol.py @@ -18,7 +18,8 @@ class Backend(Protocol): """Interface for LFM compute backends (CPU or GPU). - Every backend operates on flattened float32 arrays using double-buffering. + Every backend operates on flattened arrays using the configured precision + and double-buffering. The step method reads from one set of buffers and writes to another, then the caller toggles which set is "current". """ @@ -28,6 +29,11 @@ def name(self) -> str: """Human-readable backend name, e.g. 'numpy' or 'cupy'.""" ... + @property + def dtype(self) -> object: + """NumPy dtype used for persistent state and scalar parameters.""" + ... + def allocate( self, N: int, @@ -59,7 +65,7 @@ def create_boundary_mask( N: int, boundary_fraction: float, ) -> object: - """Create spherical frozen boundary mask (flattened N³ float32).""" + """Create spherical frozen boundary mask using the state dtype.""" ... def step_real( @@ -79,10 +85,112 @@ def step_real( lambda_self: float, chi0: float, e0_sq: float, + *, + inv_dx2: float = 1.0, + enable_chi_floor: bool = True, ) -> None: """One leapfrog step for real E field (Level 0).""" ... + def step_real_gravity_recovery( + self, + psi_in: object, + psi_prev_in: object, + chi_in: object, + chi_prev_in: object, + boundary_mask: object, + psi_out: object, + psi_prev_out: object, + chi_out: object, + chi_prev_out: object, + N: int, + dt: float, + kappa: float, + lambda_self: float, + chi0: float, + e0_sq: float, + potential_model: int, + freeze_psi: bool, + relaxation_damping: float, + *, + inv_dx2: float = 1.0, + enable_chi_floor: bool = False, + ) -> None: + """One experiment-only local GOV-02 gravity-recovery step.""" + ... + + def step_complex_gravity_recovery( + self, + psi_r_in: object, + psi_r_prev_in: object, + psi_i_in: object, + psi_i_prev_in: object, + chi_in: object, + chi_prev_in: object, + boundary_mask: object, + psi_r_out: object, + psi_r_prev_out: object, + psi_i_out: object, + psi_i_prev_out: object, + chi_out: object, + chi_prev_out: object, + N: int, + dt: float, + kappa: float, + lambda_self: float, + chi0: float, + e0_sq: float, + potential_model: int, + freeze_psi: bool, + relaxation_damping: float, + *, + inv_dx2: float = 1.0, + enable_chi_floor: bool = False, + ) -> None: + """One complex flat-octic GOV-02 gravity-recovery step.""" + ... + + def step_color_gravity_recovery( + self, + psi_r_in: object, + psi_r_prev_in: object, + psi_i_in: object, + psi_i_prev_in: object, + chi_in: object, + chi_prev_in: object, + boundary_mask: object, + psi_r_out: object, + psi_r_prev_out: object, + psi_i_out: object, + psi_i_prev_out: object, + chi_out: object, + chi_prev_out: object, + N: int, + dt: float, + kappa: float, + lambda_self: float, + chi0: float, + e0_sq: float, + potential_model: int, + freeze_psi: bool, + relaxation_damping: float, + *, + epsilon_w: float = 0.0, + kappa_c: float = 0.0, + epsilon_cc: float = 0.0, + kappa_string: float = 0.0, + kappa_tube: float = 0.0, + sa_fields_in: object | None = None, + sa_fields_out: object | None = None, + sa_gamma: float = 0.1, + sa_d: float = 4.9, + use_stencil19_noether_current: bool = False, + inv_dx2: float = 1.0, + enable_chi_floor: bool = False, + ) -> None: + """One full three-channel flat-octic GOV-02 candidate step.""" + ... + def step_complex( self, psi_r_in: object, @@ -105,6 +213,19 @@ def step_complex( chi0: float, e0_sq: float, epsilon_w: float, + kappa_c: float = 0.0, + epsilon_cc: float = 0.0, + kappa_string: float = 0.0, + kappa_tube: float = 0.0, + sa_fields_in: object | None = None, + sa_fields_out: object | None = None, + sa_gamma: float = 0.1, + sa_d: float = 4.9, + dt: float = 0.02, + *, + use_stencil19_noether_current: bool = False, + inv_dx2: float = 1.0, + enable_chi_floor: bool = True, ) -> None: """One leapfrog step for complex Ψ field (Level 1).""" ... @@ -131,14 +252,28 @@ def step_color( chi0: float, e0_sq: float, epsilon_w: float, + *, + use_stencil19_noether_current: bool = False, + inv_dx2: float = 1.0, + enable_chi_floor: bool = True, ) -> None: """One leapfrog step for 3-color complex Ψₐ (Level 2).""" ... - def to_numpy(self, arr: object) -> NDArray[np.float32]: + def to_numpy(self, arr: object) -> NDArray[np.floating]: """Convert backend array to numpy (no-op for NumPy backend).""" ... def from_numpy(self, arr: NDArray) -> object: """Convert numpy array to backend-native format.""" ... + + def apply_complex_phase_map( + self, + psi_r: object, + psi_i: object, + phase_cos: object, + phase_sin: object, + ) -> None: + """Rotate a flattened complex field in place by a local phase map.""" + ... diff --git a/lfm/core/backends/remote_backend.py b/lfm/core/backends/remote_backend.py index 45010b6..f881ce2 100644 --- a/lfm/core/backends/remote_backend.py +++ b/lfm/core/backends/remote_backend.py @@ -2,9 +2,9 @@ LFM Remote Backend =================== -Implements the ``Backend`` protocol by dispatching simulation calls to the -``POST /v1/simulate_job`` endpoint on the WaveGuard API instead of running -locally. +Provides a direct job client for the ``POST /v1/simulate_job`` endpoint on +the WaveGuard API. Unlike the local NumPy and CuPy backends, this class does +not implement the step-by-step ``Backend`` protocol used by ``Simulation``. Configuration is read from environment variables (or ``configure_remote()``): @@ -23,7 +23,10 @@ ) backend = lfm.get_backend("remote") - # Then use lfm.Simulation normally — it will call the remote API + result = backend.run_steps(psi, chi, n_steps=100) + +The remote service currently accepts direct float32 jobs only. It does not +implement the local step-by-step protocol used by ``lfm.Simulation``. """ from __future__ import annotations @@ -73,10 +76,9 @@ def configure_remote( class RemoteBackend: """Backend implementation that executes GOV-01/02 on the WaveGuard cloud GPU. - Implements the minimal interface expected by ``lfm.Simulation`` - (``allocate``, ``step_real``, ``run_steps``). - - Most use-cases go through the higher-level ``run_job()`` method directly. + Use the higher-level ``run_job()`` or ``run_steps()`` methods directly. + This class intentionally does not implement the local ``Simulation`` + backend protocol because a remote job executes as one server request. """ def __init__( diff --git a/lfm/core/chi_potentials.py b/lfm/core/chi_potentials.py new file mode 100644 index 0000000..315b764 --- /dev/null +++ b/lfm/core/chi_potentials.py @@ -0,0 +1,83 @@ +"""Local chi-potential force laws for GOV-02 ablation experiments.""" + +from __future__ import annotations + +import numpy as np + +from lfm.config import ChiPotentialModel + + +def dimensionless_potential_derivative( + y: np.ndarray, + model: ChiPotentialModel, + source_ratio: np.ndarray | float = 0.0, +) -> np.ndarray: + """Return df/dy for V=lambda_h*chi0^4*f(y).""" + + y = np.asarray(y, dtype=np.float64) + model = ChiPotentialModel(model) + if model == ChiPotentialModel.CANONICAL_QUARTIC: + return 2.0 * y + if model in ( + ChiPotentialModel.FLAT_OCTIC, + ChiPotentialModel.NONLINEAR_GRADIENT, + ChiPotentialModel.VARIABLE_INERTIA, + ): + return 4.0 * y**3 + if model == ChiPotentialModel.FLAT_DODECIC: + return 6.0 * y**5 + if model == ChiPotentialModel.FLAT_POWER_8: + return 8.0 * y**7 + if model == ChiPotentialModel.FLAT_POWER_10: + return 10.0 * y**9 + if model == ChiPotentialModel.FLAT_POWER_12: + return 12.0 * y**11 + if model == ChiPotentialModel.SMOOTH_EXPONENTIAL: + exp_term = np.exp(-(y**2)) + return 2.0 * y * (1.0 - exp_term + y**2 * exp_term) + if model == ChiPotentialModel.RATIONAL_CROSSOVER: + return 2.0 * y**3 * (2.0 + y**2) / (1.0 + y**2) ** 2 + if model == ChiPotentialModel.HYPERBOLIC_CROSSOVER: + tanh_y = np.tanh(y) + sech_sq = 1.0 - tanh_y**2 + return 2.0 * y * tanh_y * (tanh_y + y * sech_sq) + if model == ChiPotentialModel.AMPLITUDE_STRENGTHENED: + return 4.0 * y**3 + 6.0 * y**5 + if model == ChiPotentialModel.SOURCE_DEPENDENT: + return 4.0 * y**3 + 2.0 * np.asarray(source_ratio) * y + if model == ChiPotentialModel.RADICAL_CROSSOVER: + return 4.0 * y**7 / np.sqrt(1.0 + y**8) + raise ValueError(f"unsupported chi potential model: {model}") + + +def potential_force( + chi: np.ndarray, + model: ChiPotentialModel, + *, + chi0: float, + lambda_h: float, + source_density: np.ndarray | float = 0.0, +) -> np.ndarray: + """Return -dV/dchi for a frozen local potential candidate.""" + + chi_array = np.asarray(chi, dtype=np.float64) + y = (chi_array**2 - chi0**2) / chi0**2 + source_ratio = np.asarray(source_density) / chi0**2 + derivative = dimensionless_potential_derivative( + y, + model, + source_ratio, + ) + return -2.0 * lambda_h * chi0**2 * chi_array * derivative + + +def variable_inertia( + chi: np.ndarray, + *, + chi0: float, +) -> np.ndarray: + """Return the K-family kinetic multiplier M(chi)=1+y^2.""" + + chi_array = np.asarray(chi, dtype=np.float64) + y = (chi_array**2 - chi0**2) / chi0**2 + return 1.0 + y**2 diff --git a/lfm/core/evolver.py b/lfm/core/evolver.py index ce65837..159455d 100644 --- a/lfm/core/evolver.py +++ b/lfm/core/evolver.py @@ -32,7 +32,12 @@ import numpy as np -from lfm.config import FieldLevel, SimulationConfig +from lfm.config import ( + BoundaryType, + ChiPotentialModel, + FieldLevel, + SimulationConfig, +) from lfm.core.backends import get_backend if TYPE_CHECKING: @@ -58,8 +63,15 @@ def __init__( config: SimulationConfig, backend: str = "auto", ) -> None: + if backend.lower() == "remote": + raise NotImplementedError( + "Simulation does not support the remote backend protocol; " + "use get_backend('remote').run_job() or run_steps() for " + "direct float32 remote jobs" + ) self.config = config - self.backend = get_backend(backend) + self.backend = get_backend(backend, precision=config.precision) + self.dtype = self.backend.dtype self.N = config.grid_size self.total = self.N**3 self.step = 0 @@ -83,6 +95,8 @@ def __init__( # Allocate arrays via backend self._init_arrays() + self._local_phase_clock_cos: NDArray | None = None + self._local_phase_clock_sin: NDArray | None = None def _init_arrays(self) -> None: """Allocate double-buffered arrays.""" @@ -98,17 +112,17 @@ def _init_arrays(self) -> None: xp = self.backend # Psi real part — A and B buffers - self.psi_r_A = xp.from_numpy(np.zeros(psi_size, dtype=np.float32)) - self.psi_r_prev_A = xp.from_numpy(np.zeros(psi_size, dtype=np.float32)) - self.psi_r_B = xp.from_numpy(np.zeros(psi_size, dtype=np.float32)) - self.psi_r_prev_B = xp.from_numpy(np.zeros(psi_size, dtype=np.float32)) + self.psi_r_A = xp.from_numpy(np.zeros(psi_size, dtype=self.dtype)) + self.psi_r_prev_A = xp.from_numpy(np.zeros(psi_size, dtype=self.dtype)) + self.psi_r_B = xp.from_numpy(np.zeros(psi_size, dtype=self.dtype)) + self.psi_r_prev_B = xp.from_numpy(np.zeros(psi_size, dtype=self.dtype)) # Psi imaginary part (zero for real field, but allocated for uniform API) if self._has_imag: - self.psi_i_A = xp.from_numpy(np.zeros(psi_size, dtype=np.float32)) - self.psi_i_prev_A = xp.from_numpy(np.zeros(psi_size, dtype=np.float32)) - self.psi_i_B = xp.from_numpy(np.zeros(psi_size, dtype=np.float32)) - self.psi_i_prev_B = xp.from_numpy(np.zeros(psi_size, dtype=np.float32)) + self.psi_i_A = xp.from_numpy(np.zeros(psi_size, dtype=self.dtype)) + self.psi_i_prev_A = xp.from_numpy(np.zeros(psi_size, dtype=self.dtype)) + self.psi_i_B = xp.from_numpy(np.zeros(psi_size, dtype=self.dtype)) + self.psi_i_prev_B = xp.from_numpy(np.zeros(psi_size, dtype=self.dtype)) else: self.psi_i_A = None # type: ignore[assignment] self.psi_i_prev_A = None # type: ignore[assignment] @@ -116,18 +130,21 @@ def _init_arrays(self) -> None: self.psi_i_prev_B = None # type: ignore[assignment] # Chi — A and B buffers - chi_init = np.full(total, cfg.chi0, dtype=np.float32) + chi_init = np.full(total, cfg.chi0, dtype=self.dtype) self.chi_A = xp.from_numpy(chi_init.copy()) self.chi_prev_A = xp.from_numpy(chi_init.copy()) self.chi_B = xp.from_numpy(chi_init.copy()) self.chi_prev_B = xp.from_numpy(chi_init.copy()) # Boundary mask - self.boundary_mask = xp.create_boundary_mask(N, cfg.boundary_fraction) + if cfg.boundary_type == BoundaryType.PERIODIC: + self.boundary_mask = xp.from_numpy(np.zeros(total, dtype=self.dtype)) + else: + self.boundary_mask = xp.create_boundary_mask(N, cfg.boundary_fraction) # S_a auxiliary fields for v16 flux-tube confinement (COLOR field level only) if cfg.sa_enabled and cfg.field_level == FieldLevel.COLOR: - sa_init = np.zeros(cfg.n_colors * total, dtype=np.float32) + sa_init = np.zeros(cfg.n_colors * total, dtype=self.dtype) self.sa_A = xp.from_numpy(sa_init.copy()) self.sa_B = xp.from_numpy(sa_init.copy()) else: @@ -215,6 +232,8 @@ def _step(self) -> None: cfg.lambda_self, cfg.chi0, cfg.e0_sq, + inv_dx2=1.0 / (cfg.dx * cfg.dx), + enable_chi_floor=cfg.enable_chi_floor, ) elif cfg.field_level == FieldLevel.COMPLEX: self.backend.step_complex( @@ -238,6 +257,9 @@ def _step(self) -> None: cfg.chi0, cfg.e0_sq, cfg.epsilon_w, + use_stencil19_noether_current=cfg.use_stencil19_noether_current, + inv_dx2=1.0 / (cfg.dx * cfg.dx), + enable_chi_floor=cfg.enable_chi_floor, ) else: # COLOR sa_in = ( @@ -276,17 +298,193 @@ def _step(self) -> None: sa_gamma=cfg.sa_gamma, sa_d=cfg.sa_d, dt=cfg.dt, + use_stencil19_noether_current=cfg.use_stencil19_noether_current, + inv_dx2=1.0 / (cfg.dx * cfg.dx), + enable_chi_floor=cfg.enable_chi_floor, ) self._use_buffer_A = not self._use_buffer_A + def evolve_gravity_recovery( + self, + steps: int, + potential_model: ChiPotentialModel, + *, + freeze_psi: bool = True, + relaxation_damping: float = 0.0, + dt_override: float | None = None, + callback=None, + ) -> None: + """Run the experiment-only local GOV-02 candidate evolution. + + This path is deliberately separate from :meth:`evolve`; canonical + production behavior is unchanged. The full candidate catalog is + available for the real register. The complex and three-channel + registers support the selected flat-octic candidate needed for + phase-stable, noninterfering live packets. Every path uses the + configured 19-point backend update. + """ + + if self.config.field_level not in { + FieldLevel.REAL, + FieldLevel.COMPLEX, + FieldLevel.COLOR, + }: + raise ValueError("gravity-recovery candidates require an LFM field register") + if not isinstance(steps, int) or steps < 0: + raise ValueError("steps must be a nonnegative integer") + if not np.isfinite(relaxation_damping) or relaxation_damping < 0.0: + raise ValueError("relaxation_damping must be finite and nonnegative") + model = ChiPotentialModel(potential_model) + if ( + self.config.field_level in {FieldLevel.COMPLEX, FieldLevel.COLOR} + and model != ChiPotentialModel.FLAT_OCTIC + ): + raise ValueError("multiquadrature gravity recovery supports FLAT_OCTIC") + dt = self.config.dt if dt_override is None else float(dt_override) + if not np.isfinite(dt) or dt <= 0.0: + raise ValueError("dt_override must be positive and finite") + report = self.config.report_interval + for _index in range(steps): + self._step_gravity_recovery( + model, + freeze_psi=freeze_psi, + relaxation_damping=relaxation_damping, + dt=dt, + ) + self.step += 1 + if callback is not None and report > 0 and self.step % report == 0: + callback(self, self.step) + + def _step_gravity_recovery( + self, + potential_model: ChiPotentialModel, + *, + freeze_psi: bool, + relaxation_damping: float, + dt: float, + ) -> None: + cfg = self.config + if self._use_buffer_A: + r_in, rp_in = self.psi_r_A, self.psi_r_prev_A + r_out, rp_out = self.psi_r_B, self.psi_r_prev_B + i_in, ip_in = self.psi_i_A, self.psi_i_prev_A + i_out, ip_out = self.psi_i_B, self.psi_i_prev_B + c_in, cp_in = self.chi_A, self.chi_prev_A + c_out, cp_out = self.chi_B, self.chi_prev_B + else: + r_in, rp_in = self.psi_r_B, self.psi_r_prev_B + r_out, rp_out = self.psi_r_A, self.psi_r_prev_A + i_in, ip_in = self.psi_i_B, self.psi_i_prev_B + i_out, ip_out = self.psi_i_A, self.psi_i_prev_A + c_in, cp_in = self.chi_B, self.chi_prev_B + c_out, cp_out = self.chi_A, self.chi_prev_A + if cfg.field_level == FieldLevel.REAL: + self.backend.step_real_gravity_recovery( + r_in, + rp_in, + c_in, + cp_in, + self.boundary_mask, + r_out, + rp_out, + c_out, + cp_out, + self._N, + dt, + cfg.kappa, + cfg.lambda_self, + cfg.chi0, + cfg.e0_sq, + int(potential_model), + freeze_psi, + relaxation_damping, + inv_dx2=1.0 / (cfg.dx * cfg.dx), + enable_chi_floor=cfg.enable_chi_floor, + ) + elif cfg.field_level == FieldLevel.COMPLEX: + if any(value is None for value in (i_in, ip_in, i_out, ip_out)): + raise RuntimeError("complex field buffers are missing") + self.backend.step_complex_gravity_recovery( + r_in, + rp_in, + i_in, + ip_in, + c_in, + cp_in, + self.boundary_mask, + r_out, + rp_out, + i_out, + ip_out, + c_out, + cp_out, + self._N, + dt, + cfg.kappa, + cfg.lambda_self, + cfg.chi0, + cfg.e0_sq, + int(potential_model), + freeze_psi, + relaxation_damping, + inv_dx2=1.0 / (cfg.dx * cfg.dx), + enable_chi_floor=cfg.enable_chi_floor, + ) + else: + if any(value is None for value in (i_in, ip_in, i_out, ip_out)): + raise RuntimeError("color field buffers are missing") + sa_in = ( + (self.sa_A if self._use_buffer_A else self.sa_B) if self.sa_A is not None else None + ) + sa_out = ( + (self.sa_B if self._use_buffer_A else self.sa_A) if self.sa_A is not None else None + ) + self.backend.step_color_gravity_recovery( + r_in, + rp_in, + i_in, + ip_in, + c_in, + cp_in, + self.boundary_mask, + r_out, + rp_out, + i_out, + ip_out, + c_out, + cp_out, + self._N, + dt, + cfg.kappa, + cfg.lambda_self, + cfg.chi0, + cfg.e0_sq, + int(potential_model), + freeze_psi, + relaxation_damping, + epsilon_w=cfg.epsilon_w, + kappa_c=cfg.kappa_c, + epsilon_cc=cfg.epsilon_cc, + kappa_string=cfg.kappa_string, + kappa_tube=cfg.kappa_tube, + sa_fields_in=sa_in, + sa_fields_out=sa_out, + sa_gamma=cfg.sa_gamma, + sa_d=cfg.sa_d, + use_stencil19_noether_current=(cfg.use_stencil19_noether_current), + inv_dx2=1.0 / (cfg.dx * cfg.dx), + enable_chi_floor=cfg.enable_chi_floor, + ) + self._use_buffer_A = not self._use_buffer_A + # --- Field accessors (return numpy arrays) --- def _current_buf(self) -> str: """Which buffer holds the most recent result.""" return "A" if self._use_buffer_A else "B" - def get_chi(self) -> NDArray[np.float32]: + def get_chi(self) -> NDArray[np.floating]: """Get current χ field as numpy array, shape (N, N, N).""" if self._use_buffer_A: flat = self.backend.to_numpy(self.chi_A) @@ -294,7 +492,7 @@ def get_chi(self) -> NDArray[np.float32]: flat = self.backend.to_numpy(self.chi_B) return flat.reshape(self.N, self.N, self.N) - def get_psi_real(self) -> NDArray[np.float32]: + def get_psi_real(self) -> NDArray[np.floating]: """Get real part of Ψ as numpy array. Shape: (N,N,N) for REAL/COMPLEX, (n_colors,N,N,N) for COLOR. @@ -308,7 +506,7 @@ def get_psi_real(self) -> NDArray[np.float32]: return flat.reshape(self.config.n_colors, self.N, self.N, self.N) return flat.reshape(self.N, self.N, self.N) - def get_psi_imag(self) -> NDArray[np.float32] | None: + def get_psi_imag(self) -> NDArray[np.floating] | None: """Get imaginary part of Ψ. None for REAL field level.""" if not self._has_imag: return None @@ -321,7 +519,7 @@ def get_psi_imag(self) -> NDArray[np.float32] | None: return flat.reshape(self.config.n_colors, self.N, self.N, self.N) return flat.reshape(self.N, self.N, self.N) - def get_psi_real_prev(self) -> NDArray[np.float32]: + def get_psi_real_prev(self) -> NDArray[np.floating]: """Get previous-timestep Ψ_real as numpy, same shape as get_psi_real.""" if self._use_buffer_A: flat = self.backend.to_numpy(self.psi_r_prev_A) @@ -331,7 +529,7 @@ def get_psi_real_prev(self) -> NDArray[np.float32]: return flat.reshape(self.config.n_colors, self.N, self.N, self.N) return flat.reshape(self.N, self.N, self.N) - def get_psi_imag_prev(self) -> NDArray[np.float32] | None: + def get_psi_imag_prev(self) -> NDArray[np.floating] | None: """Get previous-timestep Ψ_imag. None for REAL field level.""" if not self._has_imag: return None @@ -343,7 +541,7 @@ def get_psi_imag_prev(self) -> NDArray[np.float32] | None: return flat.reshape(self.config.n_colors, self.N, self.N, self.N) return flat.reshape(self.N, self.N, self.N) - def get_energy_density(self) -> NDArray[np.float32]: + def get_energy_density(self) -> NDArray[np.floating]: """Compute |Ψ|² = Σₐ(Pr² + Pi²), shape (N, N, N).""" pr = self.get_psi_real() e2 = np.sum(pr**2, axis=0) if pr.ndim == 4 else pr**2 @@ -352,6 +550,122 @@ def get_energy_density(self) -> NDArray[np.float32]: e2 += np.sum(pi**2, axis=0) if pi.ndim == 4 else pi**2 return e2 + def get_chi_prev(self) -> NDArray[np.floating]: + """Get previous-timestep chi as a NumPy array.""" + if self._use_buffer_A: + flat = self.backend.to_numpy(self.chi_prev_A) + else: + flat = self.backend.to_numpy(self.chi_prev_B) + return flat.reshape(self.N, self.N, self.N) + + def get_boundary_mask(self) -> NDArray[np.floating]: + """Get the fixed absorption/freeze mask as a NumPy array.""" + flat = self.backend.to_numpy(self.boundary_mask) + return flat.reshape(self.N, self.N, self.N) + + def set_boundary_mask(self, arr: NDArray) -> None: + """Set an input-independent boundary mask before evolution starts. + + Mask values use the production-kernel convention: zero is an active + cell and one is a fully frozen/absorbing cell. Fractional values in + ``[0, 1]`` are allowed for graded absorption. The geometry is frozen + after the first evolution step so it cannot act as a live controller. + """ + if self.step != 0: + raise RuntimeError("boundary geometry can only be set before evolution") + host = np.asarray(arr, dtype=self.dtype) + if host.shape != (self.N, self.N, self.N): + raise ValueError( + f"boundary mask must have shape ({self.N}, {self.N}, {self.N}), got {host.shape}" + ) + if not np.isfinite(host).all(): + raise ValueError("boundary mask must contain only finite values") + if np.any(host < 0.0) or np.any(host > 1.0): + raise ValueError("boundary mask values must lie in [0, 1]") + data = self.backend.from_numpy(host.ravel()) + self.boundary_mask[:] = data + + def set_local_phase_clock_map( + self, + dwell_steps: NDArray, + unit_phase_rad: float, + enable_mask: NDArray | None = None, + ) -> None: + """Set a precomputed local phase-clock map. + + ``dwell_steps`` declares the local Noether dwell class for each active + complex field cell. A 3D array applies to every complex/color + component; a full field-shaped array can address components separately. + The map is loaded once and stored in backend-native arrays. + """ + + if not self._has_imag: + raise ValueError("local phase clocks require a complex field level") + if self.step != 0: + raise RuntimeError("local phase clock maps must be set before evolution") + if not np.isfinite(unit_phase_rad): + raise ValueError("unit_phase_rad must be finite") + + dwell = np.asarray(dwell_steps, dtype=np.float64) + expected_3d = (self.N, self.N, self.N) + expected_full = ( + (self.config.n_colors, self.N, self.N, self.N) + if self.config.field_level == FieldLevel.COLOR + else expected_3d + ) + if dwell.shape == expected_3d and self.config.field_level == FieldLevel.COLOR: + dwell = np.broadcast_to(dwell[None, :, :, :], expected_full).copy() + elif dwell.shape != expected_full: + raise ValueError( + f"dwell_steps must have shape {expected_3d} or {expected_full}, got {dwell.shape}" + ) + if not np.isfinite(dwell).all(): + raise ValueError("dwell_steps must contain only finite values") + + if enable_mask is not None: + mask = np.asarray(enable_mask, dtype=np.float64) + if mask.shape == expected_3d and self.config.field_level == FieldLevel.COLOR: + mask = np.broadcast_to(mask[None, :, :, :], expected_full).copy() + elif mask.shape != expected_full: + raise ValueError( + "enable_mask must have shape " + f"{expected_3d} or {expected_full}, got {mask.shape}" + ) + if not np.isfinite(mask).all(): + raise ValueError("enable_mask must contain only finite values") + if np.any(mask < 0.0) or np.any(mask > 1.0): + raise ValueError("enable_mask values must lie in [0, 1]") + dwell = dwell * mask + + phase = dwell * float(unit_phase_rad) + phase_cos = np.cos(phase).astype(self.dtype, copy=False).ravel() + phase_sin = np.sin(phase).astype(self.dtype, copy=False).ravel() + self._local_phase_clock_cos = self.backend.from_numpy(phase_cos) + self._local_phase_clock_sin = self.backend.from_numpy(phase_sin) + + def apply_local_phase_clock_map(self) -> None: + """Apply the stored local phase-clock map to complex phase space.""" + + if not self._has_imag: + raise ValueError("local phase clocks require a complex field level") + if self._local_phase_clock_cos is None or self._local_phase_clock_sin is None: + raise RuntimeError("local phase clock map has not been set") + + for real, imag in ( + (self.psi_r_A, self.psi_i_A), + (self.psi_r_B, self.psi_i_B), + (self.psi_r_prev_A, self.psi_i_prev_A), + (self.psi_r_prev_B, self.psi_i_prev_B), + ): + if imag is None: + raise RuntimeError("imaginary buffer missing for complex field") + self.backend.apply_complex_phase_map( + real, + imag, + self._local_phase_clock_cos, + self._local_phase_clock_sin, + ) + def set_psi_real(self, arr: NDArray) -> None: """Set the real part of Ψ on both buffers. @@ -360,7 +674,7 @@ def set_psi_real(self, arr: NDArray) -> None: arr : ndarray Shape (N,N,N) for REAL/COMPLEX, (n_colors,N,N,N) for COLOR. """ - flat = arr.astype(np.float32).ravel() + flat = arr.astype(self.dtype).ravel() data = self.backend.from_numpy(flat) # Set on current buffer (both current and prev for clean start) for buf in [self.psi_r_A, self.psi_r_B]: @@ -378,7 +692,7 @@ def set_psi_imag(self, arr: NDArray) -> None: """Set the imaginary part of Ψ on both buffers.""" if not self._has_imag: raise ValueError("Cannot set imaginary part for REAL field level") - flat = arr.astype(np.float32).ravel() + flat = arr.astype(self.dtype).ravel() data = self.backend.from_numpy(flat) for buf in [self.psi_i_A, self.psi_i_B]: if hasattr(buf, "copy_"): @@ -405,7 +719,7 @@ def set_psi_real_prev(self, arr: NDArray) -> None: arr : ndarray Shape (N,N,N) for REAL/COMPLEX, (n_colors,N,N,N) for COLOR. """ - flat = arr.astype(np.float32).ravel() + flat = arr.astype(self.dtype).ravel() data = self.backend.from_numpy(flat) for buf in [self.psi_r_prev_A, self.psi_r_prev_B]: if hasattr(buf, "copy_"): @@ -420,7 +734,7 @@ def set_psi_imag_prev(self, arr: NDArray) -> None: """ if not self._has_imag: raise ValueError("Cannot set imaginary part for REAL field level") - flat = arr.astype(np.float32).ravel() + flat = arr.astype(self.dtype).ravel() data = self.backend.from_numpy(flat) for buf in [self.psi_i_prev_A, self.psi_i_prev_B]: if hasattr(buf, "copy_"): @@ -441,7 +755,7 @@ def set_psi_real_current(self, arr: NDArray) -> None: arr : ndarray Shape (N,N,N) for REAL/COMPLEX, (n_colors,N,N,N) for COLOR. """ - flat = arr.astype(np.float32).ravel() + flat = arr.astype(self.dtype).ravel() data = self.backend.from_numpy(flat) buf = self.psi_r_A if self._use_buffer_A else self.psi_r_B if hasattr(buf, "copy_"): @@ -456,7 +770,7 @@ def set_psi_imag_current(self, arr: NDArray) -> None: """ if not self._has_imag: raise ValueError("Cannot set imaginary part for REAL field level") - flat = arr.astype(np.float32).ravel() + flat = arr.astype(self.dtype).ravel() data = self.backend.from_numpy(flat) buf = self.psi_i_A if self._use_buffer_A else self.psi_i_B if hasattr(buf, "copy_"): @@ -466,7 +780,7 @@ def set_psi_imag_current(self, arr: NDArray) -> None: def set_chi(self, arr: NDArray) -> None: """Set χ field on all four buffers (current + prev). Shape (N, N, N).""" - flat = arr.astype(np.float32).ravel() + flat = arr.astype(self.dtype).ravel() data = self.backend.from_numpy(flat) for buf in [self.chi_A, self.chi_B]: if hasattr(buf, "copy_"): @@ -481,7 +795,7 @@ def set_chi(self, arr: NDArray) -> None: def set_chi_current(self, arr: NDArray) -> None: """Set *only* the current-timestep χ buffers.""" - flat = arr.astype(np.float32).ravel() + flat = arr.astype(self.dtype).ravel() data = self.backend.from_numpy(flat) for buf in [self.chi_A, self.chi_B]: if hasattr(buf, "copy_"): @@ -496,7 +810,7 @@ def set_chi_prev(self, arr: NDArray) -> None: time derivative (dχ/dt ≠ 0), essential for moving the χ-well of a velocity-boosted soliton. """ - flat = arr.astype(np.float32).ravel() + flat = arr.astype(self.dtype).ravel() data = self.backend.from_numpy(flat) for buf in [self.chi_prev_A, self.chi_prev_B]: if hasattr(buf, "copy_"): @@ -504,7 +818,7 @@ def set_chi_prev(self, arr: NDArray) -> None: else: np.copyto(buf, data) - def get_sa_fields(self) -> NDArray[np.float32] | None: + def get_sa_fields(self) -> NDArray[np.floating] | None: """Get S_a auxiliary fields as numpy array, shape (3, N, N, N). Returns None if SA confinement is not enabled (kappa_tube == 0). @@ -527,7 +841,7 @@ def set_sa_fields(self, arr: NDArray) -> None: "SA fields not allocated — set kappa_tube > 0 in SimulationConfig" " before creating the Evolver." ) - flat = arr.astype(np.float32).ravel() + flat = arr.astype(self.dtype).ravel() data = self.backend.from_numpy(flat) for buf in [self.sa_A, self.sa_B]: if hasattr(buf, "copy_"): diff --git a/lfm/core/stencils.py b/lfm/core/stencils.py index 7fdff0d..4e39941 100644 --- a/lfm/core/stencils.py +++ b/lfm/core/stencils.py @@ -71,6 +71,197 @@ def laplacian_19pt(field: NDArray[np.floating]) -> NDArray[np.floating]: return STENCIL_FACE_WEIGHT * faces + STENCIL_EDGE_WEIGHT * edges + STENCIL_CENTER_WEIGHT * field +def gradient_19pt( + field: NDArray[np.floating], + dx: float = 1.0, +) -> tuple[ + NDArray[np.floating], + NDArray[np.floating], + NDArray[np.floating], +]: + """Return the isotropic site-centred gradient paired with the 19-point grid. + + Face differences carry weight ``1/3`` and the two edge planes touching + each axis carry weight ``1/6``. The final factor of one half converts the + symmetric two-cell difference to a derivative. Periodic boundaries match + :func:`laplacian_19pt` and the LIMIT-02 FFT solver. + """ + if field.ndim != 3: + raise ValueError("field must be a 3-D array") + if dx <= 0.0: + raise ValueError("dx must be positive") + + gradients = [] + for axis in range(3): + plus = np.roll(field, -1, axis=axis) + minus = np.roll(field, 1, axis=axis) + directional = STENCIL_FACE_WEIGHT * (plus - minus) + + other_axes = [candidate for candidate in range(3) if candidate != axis] + for other_axis in other_axes: + edge_difference = np.zeros_like(field) + for other_shift in (-1, 1): + plus_edge = np.roll( + np.roll(field, -1, axis=axis), + other_shift, + axis=other_axis, + ) + minus_edge = np.roll( + np.roll(field, 1, axis=axis), + other_shift, + axis=other_axis, + ) + edge_difference += plus_edge - minus_edge + directional += STENCIL_EDGE_WEIGHT * edge_difference + + gradients.append(directional / (2.0 * dx)) + return gradients[0], gradients[1], gradients[2] + + +def eigenvalue_19pt( + kx: NDArray[np.floating], + ky: NDArray[np.floating], + kz: NDArray[np.floating], +) -> NDArray[np.floating]: + """Return the spectral eigenvalue of the 19-point stencil. + + The result matches :func:`laplacian_19pt` exactly on a periodic grid + with dx = 1. It is useful for FFT Poisson solves whose equilibrium + must be consistent with the same lattice operator used for evolution. + """ + face = ( + (2.0 * np.cos(kx) - 2.0) / 3.0 + + (2.0 * np.cos(ky) - 2.0) / 3.0 + + (2.0 * np.cos(kz) - 2.0) / 3.0 + ) + edge = ( + np.cos(kx + ky) + + np.cos(kx - ky) + + np.cos(kx + kz) + + np.cos(kx - kz) + + np.cos(ky + kz) + + np.cos(ky - kz) + - 6.0 + ) / 3.0 + return face + edge + + +def laplacian_27pt(field: NDArray[np.floating]) -> NDArray[np.floating]: + """Compute the ablation-only 27-point isotropic Laplacian. + + Uses 6 face neighbors (weight 4/9), 12 edge neighbors (weight 1/9), + and 8 corner neighbors (weight 1/36). The center weight is -38/9. + This operator is provided for explicitly labeled stencil ablations; + it is not the canonical LFM propagation default. + """ + faces = ( + np.roll(field, 1, axis=0) + + np.roll(field, -1, axis=0) + + np.roll(field, 1, axis=1) + + np.roll(field, -1, axis=1) + + np.roll(field, 1, axis=2) + + np.roll(field, -1, axis=2) + ) + edges = ( + np.roll(np.roll(field, 1, axis=0), 1, axis=1) + + np.roll(np.roll(field, 1, axis=0), -1, axis=1) + + np.roll(np.roll(field, -1, axis=0), 1, axis=1) + + np.roll(np.roll(field, -1, axis=0), -1, axis=1) + + np.roll(np.roll(field, 1, axis=0), 1, axis=2) + + np.roll(np.roll(field, 1, axis=0), -1, axis=2) + + np.roll(np.roll(field, -1, axis=0), 1, axis=2) + + np.roll(np.roll(field, -1, axis=0), -1, axis=2) + + np.roll(np.roll(field, 1, axis=1), 1, axis=2) + + np.roll(np.roll(field, 1, axis=1), -1, axis=2) + + np.roll(np.roll(field, -1, axis=1), 1, axis=2) + + np.roll(np.roll(field, -1, axis=1), -1, axis=2) + ) + corners = np.zeros_like(field) + for shift_x in (-1, 1): + for shift_y in (-1, 1): + for shift_z in (-1, 1): + corners += np.roll( + field, + shift=(shift_x, shift_y, shift_z), + axis=(0, 1, 2), + ) + return (4.0 / 9.0) * faces + (1.0 / 9.0) * edges + (1.0 / 36.0) * corners - (38.0 / 9.0) * field + + +def eigenvalue_27pt( + kx: NDArray[np.floating], + ky: NDArray[np.floating], + kz: NDArray[np.floating], +) -> NDArray[np.floating]: + """Return the spectral eigenvalue of the ablation-only 27-point stencil.""" + cos_x = np.cos(kx) + cos_y = np.cos(ky) + cos_z = np.cos(kz) + faces = (8.0 / 9.0) * (cos_x + cos_y + cos_z) + edges = (4.0 / 9.0) * (cos_x * cos_y + cos_x * cos_z + cos_y * cos_z) + corners = (2.0 / 9.0) * cos_x * cos_y * cos_z + return faces + edges + corners - (38.0 / 9.0) + + +def noether_current_19pt_raw( + psi_real: NDArray[np.floating], + psi_imag: NDArray[np.floating], +) -> tuple[ + NDArray[np.floating], + NDArray[np.floating], + NDArray[np.floating], +]: + """Return raw site-centered current components for the 19-point stencil. + + This is the face-and-edge link current paired with + :func:`laplacian_19pt`. For a plane wave with amplitude ``A`` it gives + + ``J_x = 2 A^2 sin(k_x) (1 + cos(k_y) + cos(k_z)) / 3`` + + and cyclic permutations. Equivalently, each raw component is minus the + corresponding derivative of the 19-point stencil eigenvalue times + ``A^2``. The production current convention applies a factor of one half + when forming the physical scalar source. + + The result is an observable for the optional current-feedback extension. + It does not promote that extension into the bare GOV-02 action. + """ + if psi_real.shape != psi_imag.shape: + raise ValueError("psi_real and psi_imag must have identical shapes") + if psi_real.ndim != 3: + raise ValueError("19-point Noether current requires 3D fields") + + def shifted(field: NDArray[np.floating], dx: int, dy: int, dz: int): + return np.roll(field, shift=(-dx, -dy, -dz), axis=(0, 1, 2)) + + def directional_difference(field: NDArray[np.floating], axis: int): + plus = [0, 0, 0] + minus = [0, 0, 0] + plus[axis] = 1 + minus[axis] = -1 + result = STENCIL_FACE_WEIGHT * (shifted(field, *plus) - shifted(field, *minus)) + + other_axes = [candidate for candidate in range(3) if candidate != axis] + for other_axis in other_axes: + edge_sum = np.zeros_like(field) + for other_sign in (-1, 1): + plus_edge = plus.copy() + minus_edge = minus.copy() + plus_edge[other_axis] = other_sign + minus_edge[other_axis] = other_sign + edge_sum += shifted(field, *plus_edge) + edge_sum -= shifted(field, *minus_edge) + result += STENCIL_EDGE_WEIGHT * edge_sum + return result + + currents = [] + for axis in range(3): + d_real = directional_difference(psi_real, axis) + d_imag = directional_difference(psi_imag, axis) + currents.append(psi_real * d_imag - psi_imag * d_real) + return currents[0], currents[1], currents[2] + + def laplacian_7pt(field: NDArray[np.floating]) -> NDArray[np.floating]: """Compute standard 7-point Laplacian on a 3D periodic grid. diff --git a/lfm/experiment/__init__.py b/lfm/experiment/__init__.py index 3cfe8f4..2f73d0b 100644 --- a/lfm/experiment/__init__.py +++ b/lfm/experiment/__init__.py @@ -42,6 +42,17 @@ """ from lfm.experiment.barrier import Barrier, Slit +from lfm.experiment.charge_coupling import ( + ChargeCouplingParameters, + ChargeCouplingStep, + canonical_charge_density, + charge_coupled_hamiltonian, + charge_coupled_rates, + charge_frequency, + charge_frequency_derivative, + step_charge_coupled_lfm, + total_canonical_charge, +) from lfm.experiment.collision import CollisionResult, collision from lfm.experiment.common import ExperimentConfig, ExperimentResult, midplane_slice from lfm.experiment.detector import DetectorScreen @@ -53,6 +64,11 @@ EntanglementSuiteResult, entanglement, ) +from lfm.experiment.limit_orbit import ( + integrate_limit02_two_body, + summarize_limit02_orbit, + sweep_limit02_orbits, +) from lfm.experiment.ringdown import ( DEFAULT_RINGDOWN_K_MODES, Next5FalsificationResult, @@ -65,6 +81,8 @@ __all__ = [ "Barrier", "Slit", + "ChargeCouplingParameters", + "ChargeCouplingStep", "DetectorScreen", "ContinuousSource", "Dispersion", @@ -73,6 +91,11 @@ "DoubleSlit", "collision", "CollisionResult", + "canonical_charge_density", + "charge_coupled_hamiltonian", + "charge_coupled_rates", + "charge_frequency", + "charge_frequency_derivative", "DEFAULT_RINGDOWN_K_MODES", "entanglement", "EntanglementResult", @@ -83,6 +106,11 @@ "ExperimentConfig", "ExperimentResult", "midplane_slice", + "integrate_limit02_two_body", + "summarize_limit02_orbit", + "sweep_limit02_orbits", + "step_charge_coupled_lfm", + "total_canonical_charge", "next5_falsification_projection_v2", "qnm_mode_projection_check", ] diff --git a/lfm/experiment/charge_coupling.py b/lfm/experiment/charge_coupling.py new file mode 100644 index 0000000..7f3e571 --- /dev/null +++ b/lfm/experiment/charge_coupling.py @@ -0,0 +1,254 @@ +"""Experimental same-action temporal charge coupling for the C3 LFM field. + +This module does not change canonical Simulation defaults. It implements a +local Hamiltonian candidate on the existing six real C3 coordinates and chi. +The update is implicit midpoint because the candidate is momentum dependent. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + +from lfm.analysis.energy_current import ( + BareHamiltonRates, + BareLFMParameters, + BareLFMState, + bare_hamilton_rates, + bare_site_energy, +) + + +@dataclass(frozen=True) +class ChargeCouplingParameters: + """Parameters for the experimental covariant temporal coupling.""" + + bare: BareLFMParameters = BareLFMParameters(chi_potential="flat_octic") + coupling: float = 0.0 + midpoint_tolerance: float = 1.0e-11 + midpoint_max_iterations: int = 20 + + def __post_init__(self) -> None: + if not np.isfinite(self.coupling): + raise ValueError("coupling must be finite") + if not np.isfinite(self.midpoint_tolerance) or self.midpoint_tolerance <= 0.0: + raise ValueError("midpoint_tolerance must be positive and finite") + if self.midpoint_max_iterations < 1: + raise ValueError("midpoint_max_iterations must be positive") + + +@dataclass(frozen=True) +class ChargeCouplingStep: + """One implicit-midpoint result and its convergence certificate.""" + + state: BareLFMState + iterations: int + relative_residual: float + + +def _validated_charge_state( + state: BareLFMState, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + wave = np.asarray(state.wave, dtype=np.float64) + wave_p = np.asarray(state.wave_momentum, dtype=np.float64) + chi = np.asarray(state.chi, dtype=np.float64) + chi_p = np.asarray(state.chi_momentum, dtype=np.float64) + if wave.ndim != 4 or wave.shape[0] < 2 or wave.shape[0] % 2 != 0: + raise ValueError("wave must contain interleaved real/imaginary pairs") + if wave_p.shape != wave.shape: + raise ValueError("wave and wave_momentum shapes must match") + if chi.ndim != 3 or chi_p.shape != chi.shape: + raise ValueError("chi and chi_momentum must share a 3-D shape") + if wave.shape[1:] != chi.shape: + raise ValueError("wave and chi spatial shapes must match") + return wave, wave_p, chi, chi_p + + +def charge_frequency( + chi: np.ndarray, + parameters: ChargeCouplingParameters, +) -> np.ndarray: + """Return f(chi)=g_q(chi^2-chi0^2)/(2 chi0).""" + + chi_values = np.asarray(chi, dtype=np.float64) + chi0 = parameters.bare.chi0 + return parameters.coupling * (chi_values**2 - chi0**2) / (2.0 * chi0) + + +def charge_frequency_derivative( + chi: np.ndarray, + parameters: ChargeCouplingParameters, +) -> np.ndarray: + """Return df/dchi for the experimental coupling.""" + + return parameters.coupling * np.asarray(chi, dtype=np.float64) / parameters.bare.chi0 + + +def canonical_charge_density(state: BareLFMState) -> np.ndarray: + """Return the global-phase Noether charge density in canonical variables.""" + + wave, wave_p, _chi, _chi_p = _validated_charge_state(state) + result = np.zeros(wave.shape[1:], dtype=np.float64) + for component in range(0, wave.shape[0], 2): + u = wave[component] + v = wave[component + 1] + p_u = wave_p[component] + p_v = wave_p[component + 1] + result += u * p_v - v * p_u + return result + + +def total_canonical_charge( + state: BareLFMState, + parameters: ChargeCouplingParameters, +) -> float: + """Return the physical-volume integral of canonical charge density.""" + + return float(np.sum(canonical_charge_density(state))) * parameters.bare.spacing**3 + + +def charge_coupled_rates( + state: BareLFMState, + parameters: ChargeCouplingParameters, +) -> BareHamiltonRates: + """Return Hamilton's equations for H=H_bare-f(chi)Q.""" + + wave, wave_p, chi, chi_p = _validated_charge_state(state) + bare_rates = bare_hamilton_rates( + wave, + wave_p, + chi, + chi_p, + parameters.bare, + ) + wave_rate = np.asarray(bare_rates.wave, dtype=np.float64).copy() + momentum_rate = np.asarray(bare_rates.wave_momentum, dtype=np.float64).copy() + f_value = charge_frequency(chi, parameters) + for component in range(0, wave.shape[0], 2): + u = wave[component] + v = wave[component + 1] + p_u = wave_p[component] + p_v = wave_p[component + 1] + wave_rate[component] += f_value * v + wave_rate[component + 1] -= f_value * u + momentum_rate[component] += f_value * p_v + momentum_rate[component + 1] -= f_value * p_u + charge = canonical_charge_density(state) + chi_momentum_rate = ( + np.asarray(bare_rates.chi_momentum, dtype=np.float64) + + charge_frequency_derivative(chi, parameters) * charge + ) + return BareHamiltonRates( + wave=wave_rate, + wave_momentum=momentum_rate, + chi=np.asarray(bare_rates.chi, dtype=np.float64), + chi_momentum=chi_momentum_rate, + ) + + +def charge_coupled_hamiltonian( + state: BareLFMState, + parameters: ChargeCouplingParameters, +) -> float: + """Return the exact spatially discretized candidate Hamiltonian.""" + + wave, wave_p, chi, chi_p = _validated_charge_state(state) + density = bare_site_energy( + wave, + wave_p, + chi, + chi_p, + parameters.bare, + ) + density = density - charge_frequency(chi, parameters) * canonical_charge_density(state) + return float(np.sum(density)) * parameters.bare.spacing**3 + + +def _state_add_rates( + state: BareLFMState, + rates: BareHamiltonRates, + factor: float, +) -> BareLFMState: + wave, wave_p, chi, chi_p = _validated_charge_state(state) + return BareLFMState( + wave=wave + factor * rates.wave, + wave_momentum=wave_p + factor * rates.wave_momentum, + chi=chi + factor * rates.chi, + chi_momentum=chi_p + factor * rates.chi_momentum, + ) + + +def _state_midpoint(left: BareLFMState, right: BareLFMState) -> BareLFMState: + left_wave, left_p, left_chi, left_chi_p = _validated_charge_state(left) + right_wave, right_p, right_chi, right_chi_p = _validated_charge_state(right) + return BareLFMState( + wave=0.5 * (left_wave + right_wave), + wave_momentum=0.5 * (left_p + right_p), + chi=0.5 * (left_chi + right_chi), + chi_momentum=0.5 * (left_chi_p + right_chi_p), + ) + + +def _state_relative_difference(left: BareLFMState, right: BareLFMState) -> float: + left_values = _validated_charge_state(left) + right_values = _validated_charge_state(right) + numerator = max( + float(np.max(np.abs(a - b))) for a, b in zip(left_values, right_values, strict=False) + ) + denominator = max( + 1.0, + *(float(np.max(np.abs(value))) for value in right_values), + ) + return numerator / denominator + + +def step_charge_coupled_lfm( + state: BareLFMState, + dt: float, + parameters: ChargeCouplingParameters, +) -> ChargeCouplingStep: + """Advance the local candidate with second-order implicit midpoint.""" + + if not np.isfinite(dt) or dt == 0.0: + raise ValueError("dt must be finite and nonzero") + _validated_charge_state(state) + guess = _state_add_rates( + state, + charge_coupled_rates(state, parameters), + dt, + ) + residual = float("inf") + for iteration in range(1, parameters.midpoint_max_iterations + 1): + midpoint = _state_midpoint(state, guess) + candidate = _state_add_rates( + state, + charge_coupled_rates(midpoint, parameters), + dt, + ) + residual = _state_relative_difference(candidate, guess) + guess = candidate + if residual <= parameters.midpoint_tolerance: + return ChargeCouplingStep( + state=guess, + iterations=iteration, + relative_residual=residual, + ) + raise RuntimeError( + "implicit midpoint failed to converge: " + f"residual={residual:.6e}, " + f"iterations={parameters.midpoint_max_iterations}" + ) + + +__all__ = [ + "ChargeCouplingParameters", + "ChargeCouplingStep", + "canonical_charge_density", + "charge_coupled_hamiltonian", + "charge_coupled_rates", + "charge_frequency", + "charge_frequency_derivative", + "step_charge_coupled_lfm", + "total_canonical_charge", +] diff --git a/lfm/experiment/collision.py b/lfm/experiment/collision.py index 1c1b51a..ce194d2 100644 --- a/lfm/experiment/collision.py +++ b/lfm/experiment/collision.py @@ -489,7 +489,7 @@ def _build_collision_sim( np.clip(chi_template, 0.01, None, out=chi_template) dchi_template = chi_template - np.float32(geo.chi0) # Use chi_min as approximate eigenvalue (wave frequency inside well) - eigenvalue = float(max(chi_template.min(), 1.0)) + eigenvalue = max(float(chi_template.min()), 1.0) if verbose: print( f" Poisson-only ready: chi_min={chi_template.min():.4f} " diff --git a/lfm/experiment/dispersion.py b/lfm/experiment/dispersion.py index 7e2b371..b05f7b3 100644 --- a/lfm/experiment/dispersion.py +++ b/lfm/experiment/dispersion.py @@ -18,9 +18,11 @@ Substituting and solving for K_z: - cos(ωΔt) = 1 − Δt²(χ₀² + 2 − 2 cos K_z) + cos(omega*dt) = 1 - (dt^2/2)*(chi0^2 + 2 - 2*cos(K_z)) -For Δt = 1 (continuum-like, ω in rad/step): cos K_z = 1 − (ω² − χ₀²)/2 +Equivalently, the exact inverse on the principal stable branch is:: + + cos(K_z) = 1 - ((2/dt^2)*(1 - cos(omega*dt)) - chi0^2)/2 Group velocity (cells per step): @@ -82,8 +84,8 @@ def dispersion( ---------- omega : float or None Drive frequency in rad / time-unit. Must satisfy the - propagation condition: ``omega > chi0`` (otherwise the wave is - evanescent). + exact discrete propagation condition: ``omega`` must exceed the + leapfrog mass-gap phase (otherwise the wave is evanescent). wavelength : float or None Desired wavelength in grid cells. Converted to *k_z* first, then the matching ``omega`` is computed. @@ -101,7 +103,7 @@ def dispersion( Raises ------ ValueError - If the wave is evanescent (omega ≤ chi0) or the requested + If the wave is evanescent or the requested parameters violate the Nyquist / CFL limits. Examples @@ -116,24 +118,34 @@ def dispersion( raise ValueError("Provide exactly one of omega= or wavelength=") if wavelength is not None: - # wavelength → k_z → omega + # wavelength -> k_z -> exact leapfrog omega if wavelength <= 2.0: raise ValueError(f"wavelength={wavelength} < 2 cells (Nyquist limit)") k_z = 2.0 * math.pi / wavelength - # From 19-point stencil: ω² = χ₀² + 2(1 − cos K_z) (Δx=1 units) - omega_sq = chi0**2 + 2.0 * (1.0 - math.cos(k_z)) - if omega_sq <= 0: - raise ValueError("Evanescent: computed ω² ≤ 0") - omega = math.sqrt(omega_sq) + spatial_omega_sq = chi0**2 + 2.0 * (1.0 - math.cos(k_z)) + cos_omega_dt = 1.0 - 0.5 * dt * dt * spatial_omega_sq + if not -1.0 <= cos_omega_dt <= 1.0: + raise ValueError( + "Unstable: the requested wavelength violates the exact leapfrog phase bound" + ) + omega = math.acos(cos_omega_dt) / dt else: assert omega is not None - # omega → k_z - if omega <= abs(chi0): + # exact leapfrog omega -> k_z + omega_dt = omega * dt + if not 0.0 < omega_dt < math.pi: + raise ValueError( + "omega*dt must lie strictly between zero and pi for the " + "principal stable leapfrog branch" + ) + mass_phase = math.acos(1.0 - 0.5 * dt * dt * chi0**2) / dt + if omega <= mass_phase: raise ValueError( - f"Evanescent: omega={omega} ≤ chi0={chi0}. " + f"Evanescent: omega={omega} <= discrete mass gap={mass_phase}. " f"Wave cannot propagate; increase omega or decrease chi0." ) - cos_kz = 1.0 - (omega**2 - chi0**2) / 2.0 + effective_omega_sq = 2.0 * (1.0 - math.cos(omega_dt)) / (dt * dt) + cos_kz = 1.0 - 0.5 * (effective_omega_sq - chi0**2) if cos_kz < -1.0 or cos_kz > 1.0: raise ValueError( f"cos(K_z) = {cos_kz:.4f} out of range [-1, 1]. " @@ -144,16 +156,16 @@ def dispersion( wavelength_out = 2.0 * math.pi / k_z if k_z > 1e-15 else float("inf") # Phase and group velocities (cells per step) - # v_phase = ω/k in cells/time → multiply by dt for cells/step + # v_phase = omega/k in cells/time; multiply by dt for cells/step v_phase = (omega / k_z * dt) if k_z > 1e-15 else float("inf") - # v_group = dω/dk = sin(k_z) / [sin(ω·dt) / dt] (exact discrete) + # v_group = domega/dk = sin(k_z) / [sin(omega*dt) / dt] sin_kz = math.sin(k_z) omega_dt = omega * dt if abs(math.sin(omega_dt)) > 1e-15: v_group = sin_kz * dt / math.sin(omega_dt) else: - # Small-angle limit: sin(ω·dt) ≈ ω·dt + # Small-angle limit: sin(omega*dt) is approximately omega*dt v_group = sin_kz / omega return Dispersion( diff --git a/lfm/experiment/euclidean_r2.py b/lfm/experiment/euclidean_r2.py new file mode 100644 index 0000000..f443537 --- /dev/null +++ b/lfm/experiment/euclidean_r2.py @@ -0,0 +1,255 @@ +"""Local Euclidean diagnostic sampler for the unchanged full-R2 LFM action. + +This module is an analysis entrance tool, not a replacement for live leapfrog +evolution. It samples the Euclidean continuation of the local GOV-01/GOV-02 +Hamiltonian with the canonical 19-point spatial quadratic form. No gauge link, +Maxwell term, source, or nonlocal update is introduced. +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import Literal + +import numpy as np + +from lfm.constants import CHI0, KAPPA, LAMBDA_H +from lfm.core.stencils import eigenvalue_19pt + +Offset = tuple[int, int, int] + +EuclideanRestoringModel = Literal["canonical_quartic", "flat_octic"] + + +def _offset3(values: list[int]) -> Offset: + return (values[0], values[1], values[2]) + + +@dataclass(frozen=True) +class EuclideanR2Config: + """Configuration for one finite periodic full-R2 Euclidean chain.""" + + linear_size: int + model: EuclideanRestoringModel + seed: int + psi_step: float = 0.055 + phi_step_quartic: float = 0.075 + phi_step_flat_octic: float = 0.55 + + def __post_init__(self) -> None: + if self.linear_size < 4 or self.linear_size % 2: + raise ValueError("linear_size must be even and at least four") + if self.model not in ("canonical_quartic", "flat_octic"): + raise ValueError("unsupported restoring model") + if self.psi_step <= 0.0: + raise ValueError("psi_step must be positive") + + +def _spatial_offsets() -> tuple[tuple[tuple[int, int, int], float], ...]: + values: list[tuple[tuple[int, int, int], float]] = [] + for axis in range(3): + for sign in (-1, 1): + offset = [0, 0, 0] + offset[axis] = sign + values.append((_offset3(offset), 1.0 / 3.0)) + for axis_a, axis_b in ((0, 1), (0, 2), (1, 2)): + for sign_a in (-1, 1): + for sign_b in (-1, 1): + offset = [0, 0, 0] + offset[axis_a] = sign_a + offset[axis_b] = sign_b + values.append((_offset3(offset), 1.0 / 6.0)) + return tuple(values) + + +SPATIAL_OFFSETS = _spatial_offsets() +INCIDENT_WEIGHT = 6.0 +B_CHI = CHI0 / KAPPA + + +def integrated_autocorrelation(values: np.ndarray) -> float: + """Return a positive-window integrated autocorrelation estimate.""" + + series = np.asarray(values, dtype=float) + series = series - np.mean(series) + variance = float(np.mean(series**2)) + if variance <= 0.0: + return 0.5 + tau = 0.5 + maximum_lag = min(len(series) // 3, 50) + for lag in range(1, maximum_lag + 1): + correlation = float(np.mean(series[:-lag] * series[lag:]) / variance) + if correlation <= 0.0: + break + tau += correlation + return tau + + +def frozen_chi_gaussian_real_variance(size: int) -> float: + """Exact finite-volume real-component variance with chi frozen at chi0.""" + + points = 2.0 * math.pi * np.arange(size, dtype=float) / size + p0, kx, ky, kz = np.meshgrid(points, points, points, points, indexing="ij") + time_stiffness = 4.0 * np.sin(0.5 * p0) ** 2 + spatial_stiffness = -eigenvalue_19pt(kx, ky, kz) + return float(np.mean(1.0 / (time_stiffness + spatial_stiffness + CHI0**2))) + + +class EuclideanR2Sampler: + """Checkerboard Metropolis sampler for the local full-R2 Euclidean action.""" + + def __init__(self, config: EuclideanR2Config) -> None: + self.config = config + size = config.linear_size + self.rng = np.random.default_rng(config.seed) + self.psi = np.zeros((size, size, size, size, 3), dtype=np.complex128) + self.phi = np.zeros((size, size, size, size), dtype=np.float64) + self.psi_step = float(config.psi_step) + self.phi_step = float( + config.phi_step_quartic + if config.model == "canonical_quartic" + else config.phi_step_flat_octic + ) + coordinates = np.indices((size, size, size, size)) + masks = [] + for color in range(16): + mask = np.ones((size, size, size, size), dtype=bool) + for axis in range(4): + mask &= (coordinates[axis] & 1) == ((color >> axis) & 1) + masks.append(mask) + self._masks = tuple(masks) + + @property + def chi(self) -> np.ndarray: + """Return the physical chi field represented by the rescaled sampler field.""" + + return CHI0 + self.phi / math.sqrt(B_CHI) + + def _neighbor_sum(self, field: np.ndarray) -> np.ndarray: + value = np.roll(field, 1, axis=0) + np.roll(field, -1, axis=0) + for offset, weight in SPATIAL_OFFSETS: + value = value + weight * np.roll( + field, + shift=offset, + axis=(1, 2, 3), + ) + return value + + def _chi_potential(self, chi: np.ndarray) -> np.ndarray: + delta = chi**2 - CHI0**2 + if self.config.model == "canonical_quartic": + return B_CHI * LAMBDA_H * delta**2 + return B_CHI * LAMBDA_H * delta**4 / CHI0**4 + + def _update_psi(self, mask: np.ndarray) -> tuple[int, int]: + old = self.psi[mask] + delta = ( + self.psi_step + * (self.rng.normal(size=old.shape) + 1j * self.rng.normal(size=old.shape)) + / math.sqrt(2.0) + ) + new = old + delta + summed_neighbors = self._neighbor_sum(self.psi)[mask] + old_norm = np.sum(np.abs(old) ** 2, axis=-1) + new_norm = np.sum(np.abs(new) ** 2, axis=-1) + link_delta = 0.5 * INCIDENT_WEIGHT * (new_norm - old_norm) + link_delta -= np.real(np.sum(np.conj(delta) * summed_neighbors, axis=-1)) + chi = self.chi[mask] + action_delta = link_delta + 0.5 * chi**2 * (new_norm - old_norm) + accepted = np.log(self.rng.random(size=action_delta.shape)) < -action_delta + old[accepted] = new[accepted] + self.psi[mask] = old + return int(np.sum(accepted)), int(accepted.size) + + def _update_phi(self, mask: np.ndarray) -> tuple[int, int]: + old = self.phi[mask] + new = old + self.phi_step * self.rng.normal(size=old.shape) + delta = new - old + summed_neighbors = self._neighbor_sum(self.phi)[mask] + link_delta = 0.5 * INCIDENT_WEIGHT * (new**2 - old**2) + link_delta -= delta * summed_neighbors + density = np.sum(np.abs(self.psi[mask]) ** 2, axis=-1) + old_chi = CHI0 + old / math.sqrt(B_CHI) + new_chi = CHI0 + new / math.sqrt(B_CHI) + action_delta = link_delta + action_delta += 0.5 * density * (new_chi**2 - old_chi**2) + action_delta += self._chi_potential(new_chi) - self._chi_potential(old_chi) + accepted = np.log(self.rng.random(size=action_delta.shape)) < -action_delta + old[accepted] = new[accepted] + self.phi[mask] = old + return int(np.sum(accepted)), int(accepted.size) + + def sweep(self) -> dict[str, int]: + """Perform one local 16-color detailed-balance sweep.""" + + totals = { + "psi_accept": 0, + "psi_total": 0, + "phi_accept": 0, + "phi_total": 0, + } + for mask in self._masks: + accepted, total = self._update_psi(mask) + totals["psi_accept"] += accepted + totals["psi_total"] += total + accepted, total = self._update_phi(mask) + totals["phi_accept"] += accepted + totals["phi_total"] += total + return totals + + def warmup(self, sweeps: int, tune_every: int = 20) -> None: + """Warm the chain while adapting proposal widths; production is untouched.""" + + if sweeps < 1 or tune_every < 1: + raise ValueError("warmup settings must be positive") + block = { + "psi_accept": 0, + "psi_total": 0, + "phi_accept": 0, + "phi_total": 0, + } + for sweep_index in range(sweeps): + result = self.sweep() + for key, value in result.items(): + block[key] += value + if (sweep_index + 1) % tune_every: + continue + psi_rate = block["psi_accept"] / block["psi_total"] + phi_rate = block["phi_accept"] / block["phi_total"] + if psi_rate > 0.58: + self.psi_step *= 1.10 + elif psi_rate < 0.42: + self.psi_step *= 0.90 + if phi_rate > 0.58: + self.phi_step *= 1.10 + elif phi_rate < 0.42: + self.phi_step *= 0.90 + for key in block: + block[key] = 0 + + def thinned_sweep(self, count: int) -> dict[str, int]: + """Perform production sweeps and return combined acceptance counts.""" + + if count < 1: + raise ValueError("count must be positive") + totals = { + "psi_accept": 0, + "psi_total": 0, + "phi_accept": 0, + "phi_total": 0, + } + for _ in range(count): + result = self.sweep() + for key, value in result.items(): + totals[key] += value + return totals + + +__all__ = [ + "EuclideanR2Config", + "EuclideanR2Sampler", + "EuclideanRestoringModel", + "frozen_chi_gaussian_real_variance", + "integrated_autocorrelation", +] diff --git a/lfm/experiment/gravity_recovery.py b/lfm/experiment/gravity_recovery.py new file mode 100644 index 0000000..d295da8 --- /dev/null +++ b/lfm/experiment/gravity_recovery.py @@ -0,0 +1,706 @@ +"""Local diagnostics for the experiment-only GOV-02 gravity recovery study. + +This module contains observables and energy accounting. Evolution remains in +``Simulation``/``Evolver`` so experiments do not duplicate GOV-01/GOV-02 +loops. No routine here performs an inverse solve or inserts a target profile. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass + +import numpy as np +from scipy.optimize import curve_fit + +from lfm.analysis.energy_current import stencil_links +from lfm.config import ChiPotentialModel +from lfm.constants import CHI0, KAPPA, LAMBDA_H +from lfm.core.stencils import gradient_19pt, laplacian_19pt + + +@dataclass(frozen=True) +class ChiCandidate: + """Frozen metadata for one local stabilization candidate.""" + + model: ChiPotentialModel + family: str + label: str + conservative: bool + gov01_compatible: bool + modifies_gradient: bool = False + modifies_inertia: bool = False + source_dependent: bool = False + + +_CANDIDATES = ( + ChiCandidate( + ChiPotentialModel.CANONICAL_QUARTIC, + "A", + "canonical_quartic_mexican_hat", + True, + True, + ), + ChiCandidate( + ChiPotentialModel.FLAT_OCTIC, + "B", + "geometry_normalized_flat_octic", + True, + True, + ), + ChiCandidate( + ChiPotentialModel.FLAT_DODECIC, + "C", + "geometry_normalized_flat_dodecic", + True, + True, + ), + ChiCandidate( + ChiPotentialModel.FLAT_POWER_8, + "D", + "geometry_normalized_y_power_8", + True, + True, + ), + ChiCandidate( + ChiPotentialModel.FLAT_POWER_10, + "D", + "geometry_normalized_y_power_10", + True, + True, + ), + ChiCandidate( + ChiPotentialModel.FLAT_POWER_12, + "D", + "geometry_normalized_y_power_12", + True, + True, + ), + ChiCandidate( + ChiPotentialModel.SMOOTH_EXPONENTIAL, + "E", + "smooth_exponential_crossover", + True, + True, + ), + ChiCandidate( + ChiPotentialModel.RATIONAL_CROSSOVER, + "F", + "rational_flat_crossover", + True, + True, + ), + ChiCandidate( + ChiPotentialModel.HYPERBOLIC_CROSSOVER, + "G", + "hyperbolic_tangent_crossover", + True, + True, + ), + ChiCandidate( + ChiPotentialModel.NONLINEAR_GRADIENT, + "H", + "flat_octic_plus_nonlinear_link_gradient", + True, + True, + modifies_gradient=True, + ), + ChiCandidate( + ChiPotentialModel.AMPLITUDE_STRENGTHENED, + "I", + "amplitude_strengthened_flat_well", + True, + True, + ), + ChiCandidate( + ChiPotentialModel.SOURCE_DEPENDENT, + "J", + "local_source_dependent_stabilization", + True, + False, + source_dependent=True, + ), + ChiCandidate( + ChiPotentialModel.VARIABLE_INERTIA, + "K", + "local_variable_inertia_stabilization", + True, + True, + modifies_inertia=True, + ), + ChiCandidate( + ChiPotentialModel.RADICAL_CROSSOVER, + "L", + "local_radical_crossover", + True, + True, + ), +) + + +def positive_frequency_previous_layers( + psi_real: np.ndarray, + psi_imag: np.ndarray, + chi: np.ndarray, + *, + dt: float, + dx: float = 1.0, + polynomial_degree: int = 12, +) -> tuple[np.ndarray, np.ndarray, dict[str, float | int]]: + """Construct positive-frequency leapfrog Cauchy data locally. + + For ``A=-Delta_19+chi^2``, a leapfrog eigenmode obeys + ``cos(theta)=1-dt^2*A/2``. The previous layer of a positive-frequency + mode is therefore ``exp(+i theta) psi``. The sine factor is evaluated as + a finite Chebyshev polynomial of ``A``. Each polynomial application is a + composition of local 19-point stencil operations; no inverse or spectral + force solver is used. + """ + + real = np.asarray(psi_real, dtype=np.float64) + imag = np.asarray(psi_imag, dtype=np.float64) + chi_array = np.asarray(chi, dtype=np.float64) + if real.shape != imag.shape: + raise ValueError("real and imaginary fields must have the same shape") + if real.shape[-3:] != chi_array.shape: + raise ValueError("field spatial shape must match chi") + if not np.isfinite(dt) or dt <= 0.0: + raise ValueError("dt must be positive and finite") + if not np.isfinite(dx) or dx <= 0.0: + raise ValueError("dx must be positive and finite") + if polynomial_degree < 1: + raise ValueError("polynomial_degree must be positive") + + spatial_shape = chi_array.shape + leading_shape = real.shape[:-3] + channel_count = int(np.prod(leading_shape)) if leading_shape else 1 + real_channels = real.reshape((channel_count,) + spatial_shape) + imag_channels = imag.reshape((channel_count,) + spatial_shape) + chi_sq = chi_array**2 + inverse_dx_sq = 1.0 / dx**2 + + def apply_a(values: np.ndarray) -> np.ndarray: + return -inverse_dx_sq * laplacian_19pt(values) + chi_sq * values + + minimum_eigenvalue = max(float(np.min(chi_sq)), 1.0e-12) + maximum_eigenvalue = float(np.max(chi_sq)) + 8.0 * inverse_dx_sq + if 0.25 * dt**2 * maximum_eigenvalue >= 1.0: + raise ValueError("dt exceeds the positive-frequency CFL interval") + midpoint = 0.5 * (minimum_eigenvalue + maximum_eigenvalue) + half_width = 0.5 * (maximum_eigenvalue - minimum_eigenvalue) + + def sine_frequency(normalized: np.ndarray) -> np.ndarray: + eigenvalue = midpoint + half_width * normalized + return np.sqrt(eigenvalue * (1.0 - 0.25 * dt**2 * eigenvalue)) + + coefficients = np.polynomial.chebyshev.chebinterpolate( # type: ignore[type-var] + sine_frequency, + polynomial_degree, + ) + + def apply_x(values: np.ndarray) -> np.ndarray: + return (apply_a(values) - midpoint * values) / half_width + + def apply_sine_frequency(values: np.ndarray) -> np.ndarray: + next_term = np.zeros_like(values) + next_next_term = np.zeros_like(values) + for index in range(polynomial_degree, 0, -1): + current = 2.0 * apply_x(next_term) - next_next_term + coefficients[index] * values + next_next_term = next_term + next_term = current + return apply_x(next_term) - next_next_term + coefficients[0] * values + + previous_real = np.zeros_like(real_channels) + previous_imag = np.zeros_like(imag_channels) + for channel in range(channel_count): + current_real = real_channels[channel] + current_imag = imag_channels[channel] + cosine_real = current_real - 0.5 * dt**2 * apply_a(current_real) + cosine_imag = current_imag - 0.5 * dt**2 * apply_a(current_imag) + sine_real = dt * apply_sine_frequency(current_real) + sine_imag = dt * apply_sine_frequency(current_imag) + previous_real[channel] = cosine_real - sine_imag + previous_imag[channel] = cosine_imag + sine_real + metadata: dict[str, float | int] = { + "polynomial_degree": polynomial_degree, + "minimum_operator_eigenvalue_bound": minimum_eigenvalue, + "maximum_operator_eigenvalue_bound": maximum_eigenvalue, + "maximum_chebyshev_coefficient": float(np.max(np.abs(coefficients))), + "local_stencil_radius_upper_bound": polynomial_degree, + } + return ( + previous_real.reshape(real.shape), + previous_imag.reshape(imag.shape), + metadata, + ) + + +def gravity_recovery_candidates() -> tuple[ChiCandidate, ...]: + """Return the frozen candidate catalog.""" + + return _CANDIDATES + + +def candidate_manifest() -> list[dict[str, object]]: + """Return JSON-serializable candidate declarations.""" + + return [ + { + **asdict(candidate), + "model": int(candidate.model), + "model_name": candidate.model.name, + } + for candidate in _CANDIDATES + ] + + +def _dimensionless_y( + chi: np.ndarray, + chi0: float, +) -> np.ndarray: + return (np.asarray(chi, dtype=np.float64) ** 2 - chi0**2) / chi0**2 + + +def dimensionless_potential( + y: np.ndarray, + model: ChiPotentialModel, + source_ratio: np.ndarray | float = 0.0, +) -> np.ndarray: + """Return f(y) where V=Lambda_H*chi0^4*f(y).""" + + y = np.asarray(y, dtype=np.float64) + model = ChiPotentialModel(model) + if model == ChiPotentialModel.CANONICAL_QUARTIC: + return y**2 + if model in ( + ChiPotentialModel.FLAT_OCTIC, + ChiPotentialModel.NONLINEAR_GRADIENT, + ChiPotentialModel.VARIABLE_INERTIA, + ): + return y**4 + if model == ChiPotentialModel.FLAT_DODECIC: + return y**6 + if model == ChiPotentialModel.FLAT_POWER_8: + return y**8 + if model == ChiPotentialModel.FLAT_POWER_10: + return y**10 + if model == ChiPotentialModel.FLAT_POWER_12: + return y**12 + if model == ChiPotentialModel.SMOOTH_EXPONENTIAL: + return y**2 * (1.0 - np.exp(-(y**2))) + if model == ChiPotentialModel.RATIONAL_CROSSOVER: + return y**4 / (1.0 + y**2) + if model == ChiPotentialModel.HYPERBOLIC_CROSSOVER: + return (y * np.tanh(y)) ** 2 + if model == ChiPotentialModel.AMPLITUDE_STRENGTHENED: + return y**4 * (1.0 + y**2) + if model == ChiPotentialModel.SOURCE_DEPENDENT: + return y**4 + np.asarray(source_ratio) * y**2 + if model == ChiPotentialModel.RADICAL_CROSSOVER: + return np.sqrt(1.0 + y**8) - 1.0 + raise ValueError(f"unsupported chi potential model: {model}") + + +def dimensionless_potential_derivative( + y: np.ndarray, + model: ChiPotentialModel, + source_ratio: np.ndarray | float = 0.0, +) -> np.ndarray: + """Return df/dy for the frozen candidate family.""" + + y = np.asarray(y, dtype=np.float64) + model = ChiPotentialModel(model) + if model == ChiPotentialModel.CANONICAL_QUARTIC: + return 2.0 * y + if model in ( + ChiPotentialModel.FLAT_OCTIC, + ChiPotentialModel.NONLINEAR_GRADIENT, + ChiPotentialModel.VARIABLE_INERTIA, + ): + return 4.0 * y**3 + if model == ChiPotentialModel.FLAT_DODECIC: + return 6.0 * y**5 + if model == ChiPotentialModel.FLAT_POWER_8: + return 8.0 * y**7 + if model == ChiPotentialModel.FLAT_POWER_10: + return 10.0 * y**9 + if model == ChiPotentialModel.FLAT_POWER_12: + return 12.0 * y**11 + if model == ChiPotentialModel.SMOOTH_EXPONENTIAL: + exp_term = np.exp(-(y**2)) + return 2.0 * y * (1.0 - exp_term + y**2 * exp_term) + if model == ChiPotentialModel.RATIONAL_CROSSOVER: + return 2.0 * y**3 * (2.0 + y**2) / (1.0 + y**2) ** 2 + if model == ChiPotentialModel.HYPERBOLIC_CROSSOVER: + tanh_y = np.tanh(y) + sech_sq = 1.0 - tanh_y**2 + return 2.0 * y * tanh_y * (tanh_y + y * sech_sq) + if model == ChiPotentialModel.AMPLITUDE_STRENGTHENED: + return 4.0 * y**3 + 6.0 * y**5 + if model == ChiPotentialModel.SOURCE_DEPENDENT: + return 4.0 * y**3 + 2.0 * np.asarray(source_ratio) * y + if model == ChiPotentialModel.RADICAL_CROSSOVER: + return 4.0 * y**7 / np.sqrt(1.0 + y**8) + raise ValueError(f"unsupported chi potential model: {model}") + + +def potential_density( + chi: np.ndarray, + model: ChiPotentialModel, + *, + chi0: float = CHI0, + lambda_h: float = LAMBDA_H, + source_density: np.ndarray | float = 0.0, +) -> np.ndarray: + """Return the local self-potential density.""" + + y = _dimensionless_y(chi, chi0) + source_ratio = np.asarray(source_density) / chi0**2 + return lambda_h * chi0**4 * dimensionless_potential(y, model, source_ratio) + + +def potential_force( + chi: np.ndarray, + model: ChiPotentialModel, + *, + chi0: float = CHI0, + lambda_h: float = LAMBDA_H, + source_density: np.ndarray | float = 0.0, +) -> np.ndarray: + """Return -dV/dchi, the local acceleration contribution.""" + + chi_array = np.asarray(chi, dtype=np.float64) + y = _dimensionless_y(chi_array, chi0) + source_ratio = np.asarray(source_density) / chi0**2 + derivative = dimensionless_potential_derivative( + y, + model, + source_ratio, + ) + return -2.0 * lambda_h * chi0**2 * chi_array * derivative + + +def potential_second_derivative_at_vacuum( + model: ChiPotentialModel, + *, + chi0: float = CHI0, + lambda_h: float = LAMBDA_H, +) -> float: + """Return V''(chi0) using a symmetric high-accuracy finite difference.""" + + step = 1.0e-4 * chi0 + values = np.asarray([chi0 - step, chi0, chi0 + step]) + potential = potential_density( + values, + model, + chi0=chi0, + lambda_h=lambda_h, + ) + return float((potential[2] - 2.0 * potential[1] + potential[0]) / step**2) + + +def variable_inertia( + chi: np.ndarray, + *, + chi0: float = CHI0, +) -> np.ndarray: + """Return the K-family local kinetic multiplier M(chi)=1+y^2.""" + + y = _dimensionless_y(chi, chi0) + return 1.0 + y**2 + + +def chi_hamiltonian( + chi: np.ndarray, + chi_prev: np.ndarray, + source_density: np.ndarray, + model: ChiPotentialModel, + *, + dt: float, + dx: float = 1.0, + chi0: float = CHI0, + kappa: float = KAPPA, + lambda_h: float = LAMBDA_H, + e0_sq: float = 0.0, +) -> dict[str, float]: + """Return the conservative chi-subsystem Hamiltonian component ledger.""" + + chi = np.asarray(chi, dtype=np.float64) + chi_prev = np.asarray(chi_prev, dtype=np.float64) + source_density = np.asarray(source_density, dtype=np.float64) + velocity = (chi - chi_prev) / dt + inertia = ( + variable_inertia(chi, chi0=chi0) if model == ChiPotentialModel.VARIABLE_INERTIA else 1.0 + ) + kinetic = float(np.sum(0.5 * inertia * velocity**2)) + gradient = 0.0 + nonlinear_gradient = 0.0 + for offset, weight in stencil_links("19", oriented=False): + neighbor = np.roll( + chi, + shift=tuple(-value for value in offset), + axis=(0, 1, 2), + ) + difference = (neighbor - chi) / dx + gradient += float(np.sum(0.5 * weight * difference**2)) + if model == ChiPotentialModel.NONLINEAR_GRADIENT: + nonlinear_gradient += float(np.sum(0.25 * weight * difference**4 / chi0**2)) + potential = float( + np.sum( + potential_density( + chi, + model, + chi0=chi0, + lambda_h=lambda_h, + source_density=source_density - e0_sq, + ) + ) + ) + source = float(np.sum(0.5 * (kappa / chi0) * (source_density - e0_sq) * chi**2)) + total = kinetic + gradient + nonlinear_gradient + potential + source + return { + "kinetic": kinetic, + "gradient": gradient, + "nonlinear_gradient": nonlinear_gradient, + "potential": potential, + "source": source, + "total": total, + } + + +def radial_shell_profile( + values: np.ndarray, + *, + center: tuple[float, float, float] | None = None, +) -> dict[str, np.ndarray]: + """Return unit-width spherical-shell means, standard deviations, counts.""" + + values = np.asarray(values, dtype=np.float64) + if values.ndim != 3: + raise ValueError("values must be a 3D field") + shape = values.shape + if center is None: + center = tuple((size - 1.0) / 2.0 for size in shape) + coordinates = np.indices(shape, dtype=np.float64) + radius = np.sqrt(sum((coordinates[axis] - center[axis]) ** 2 for axis in range(3))) + shell = np.floor(radius + 0.5).astype(np.int32) + max_shell = int(shell.max()) + flat_shell = shell.ravel() + flat_values = values.ravel() + counts = np.bincount(flat_shell, minlength=max_shell + 1) + sums = np.bincount( + flat_shell, + weights=flat_values, + minlength=max_shell + 1, + ) + sums_sq = np.bincount( + flat_shell, + weights=flat_values**2, + minlength=max_shell + 1, + ) + means = np.divide( + sums, + counts, + out=np.full_like(sums, np.nan, dtype=np.float64), + where=counts > 0, + ) + variances = ( + np.divide( + sums_sq, + counts, + out=np.full_like(sums_sq, np.nan, dtype=np.float64), + where=counts > 0, + ) + - means**2 + ) + return { + "radius": np.arange(max_shell + 1, dtype=np.float64), + "mean": means, + "std": np.sqrt(np.maximum(variances, 0.0)), + "count": counts, + } + + +def _linear_fit( + design: np.ndarray, + values: np.ndarray, +) -> tuple[np.ndarray, float]: + coefficients, *_ = np.linalg.lstsq(design, values, rcond=None) + predicted = design @ coefficients + residual = float(np.sum((values - predicted) ** 2)) + total = float(np.sum((values - np.mean(values)) ** 2)) + r_squared = 1.0 - residual / total if total > 0.0 else 1.0 + return coefficients, r_squared + + +def fit_inverse_r( + radius: np.ndarray, + profile: np.ndarray, + *, + r_min: float, + r_max: float, +) -> dict[str, float]: + """Fit profile=A/r+B on a predeclared radial window.""" + + radius = np.asarray(radius, dtype=np.float64) + profile = np.asarray(profile, dtype=np.float64) + keep = (radius >= r_min) & (radius <= r_max) & np.isfinite(profile) & (radius > 0.0) + if np.count_nonzero(keep) < 4: + raise ValueError("inverse-r fit requires at least four shells") + r = radius[keep] + values = profile[keep] + design = np.column_stack((1.0 / r, np.ones_like(r))) + coefficients, r_squared = _linear_fit(design, values) + return { + "amplitude": float(coefficients[0]), + "offset": float(coefficients[1]), + "r_squared": r_squared, + "point_count": int(r.size), + "r_min": float(r_min), + "r_max": float(r_max), + } + + +def fit_power_law( + radius: np.ndarray, + magnitude: np.ndarray, + *, + r_min: float, + r_max: float, +) -> dict[str, float]: + """Fit magnitude=C*r^slope on a predeclared radial window.""" + + radius = np.asarray(radius, dtype=np.float64) + magnitude = np.asarray(magnitude, dtype=np.float64) + keep = (radius >= r_min) & (radius <= r_max) & np.isfinite(magnitude) & (magnitude > 0.0) + if np.count_nonzero(keep) < 4: + raise ValueError("power-law fit requires at least four positive shells") + log_r = np.log(radius[keep]) + log_magnitude = np.log(magnitude[keep]) + design = np.column_stack((log_r, np.ones_like(log_r))) + coefficients, r_squared = _linear_fit(design, log_magnitude) + return { + "slope": float(coefficients[0]), + "log_amplitude": float(coefficients[1]), + "r_squared": r_squared, + "point_count": int(log_r.size), + "r_min": float(r_min), + "r_max": float(r_max), + } + + +def fit_yukawa( + radius: np.ndarray, + profile: np.ndarray, + *, + r_min: float, + r_max: float, +) -> dict[str, float]: + """Fit profile=A*exp(-r/L)/r+B without an inverse field solve.""" + + radius = np.asarray(radius, dtype=np.float64) + profile = np.asarray(profile, dtype=np.float64) + keep = (radius >= r_min) & (radius <= r_max) & np.isfinite(profile) & (radius > 0.0) + if np.count_nonzero(keep) < 5: + raise ValueError("Yukawa fit requires at least five shells") + r = radius[keep] + values = profile[keep] + + def model( + radius_value: np.ndarray, + amplitude: float, + length: float, + offset: float, + ) -> np.ndarray: + return amplitude * np.exp(-radius_value / length) / radius_value + offset + + amplitude_guess = float((values[0] - values[-1]) * r[0]) + offset_guess = float(values[-1]) + parameters, _ = curve_fit( + model, + r, + values, + p0=(amplitude_guess, max(1.0, 0.25 * r_max), offset_guess), + bounds=( + (-np.inf, 0.05, -np.inf), + (np.inf, 10.0 * r_max, np.inf), + ), + maxfev=50_000, + ) + predicted = model(r, *parameters) + residual = float(np.sum((values - predicted) ** 2)) + total = float(np.sum((values - np.mean(values)) ** 2)) + r_squared = 1.0 - residual / total if total > 0.0 else 1.0 + return { + "amplitude": float(parameters[0]), + "screening_length": float(parameters[1]), + "offset": float(parameters[2]), + "r_squared": r_squared, + "point_count": int(r.size), + "r_min": float(r_min), + "r_max": float(r_max), + } + + +def profile_observables( + chi: np.ndarray, + *, + chi0: float = CHI0, + dx: float = 1.0, + center: tuple[float, float, float] | None = None, + r_min: float, + r_max: float, +) -> dict[str, object]: + """Measure profile, acceleration proxy, flux, and angular anisotropy.""" + + chi = np.asarray(chi, dtype=np.float64) + delta = chi - chi0 + profile = radial_shell_profile(delta, center=center) + profile["radius"] = profile["radius"] * dx + grad_x, grad_y, grad_z = gradient_19pt(chi, dx=dx) + magnitude = np.sqrt(grad_x**2 + grad_y**2 + grad_z**2) + acceleration = radial_shell_profile(magnitude, center=center) + acceleration["radius"] = acceleration["radius"] * dx + inverse_r = fit_inverse_r( + profile["radius"], + profile["mean"], + r_min=r_min, + r_max=r_max, + ) + power_law = fit_power_law( + acceleration["radius"], + acceleration["mean"], + r_min=r_min, + r_max=r_max, + ) + yukawa = fit_yukawa( + profile["radius"], + profile["mean"], + r_min=r_min, + r_max=r_max, + ) + keep = ( + (acceleration["radius"] >= r_min) + & (acceleration["radius"] <= r_max) + & np.isfinite(acceleration["mean"]) + ) + flux = acceleration["radius"][keep] ** 2 * acceleration["mean"][keep] + flux_relative_spread = float(np.std(flux) / max(abs(float(np.mean(flux))), 1.0e-30)) + profile_anisotropy = float( + np.nanmax( + np.divide( + profile["std"][keep], + np.maximum(np.abs(profile["mean"][keep]), 1.0e-30), + ) + ) + ) + return { + "inverse_r_fit": inverse_r, + "acceleration_power_fit": power_law, + "yukawa_fit": yukawa, + "shell_flux_relative_spread": flux_relative_spread, + "profile_anisotropy_max": profile_anisotropy, + "radial_profile": {key: np.asarray(value).tolist() for key, value in profile.items()}, + "acceleration_profile": { + key: np.asarray(value).tolist() for key, value in acceleration.items() + }, + } diff --git a/lfm/experiment/limit_orbit.py b/lfm/experiment/limit_orbit.py new file mode 100644 index 0000000..e1d1229 --- /dev/null +++ b/lfm/experiment/limit_orbit.py @@ -0,0 +1,287 @@ +"""Two-body centre dynamics in the macroscopic LIMIT-02 LFM regime.""" + +from __future__ import annotations + +import math +from typing import Any + +import numpy as np + +from lfm.constants import CHI0 +from lfm.fields.macroscopic import ( + Limit02BodyProfile, + limit02_acceleration_from_profile, +) + + +def _two_body_accelerations( + heavy: Limit02BodyProfile, + light: Limit02BodyProfile, + heavy_position: np.ndarray, + light_position: np.ndarray, + *, + chi0: float, + c: float, +) -> tuple[np.ndarray, np.ndarray]: + heavy_from_light = heavy_position - light_position + light_from_heavy = light_position - heavy_position + heavy_acceleration = limit02_acceleration_from_profile( + light, + heavy_from_light, + chi0=chi0, + c=c, + ) + light_acceleration = limit02_acceleration_from_profile( + heavy, + light_from_heavy, + chi0=chi0, + c=c, + ) + return heavy_acceleration, light_acceleration + + +def _orbit_row( + step: int, + dt: float, + heavy: Limit02BodyProfile, + light: Limit02BodyProfile, + heavy_position: np.ndarray, + light_position: np.ndarray, + heavy_velocity: np.ndarray, + light_velocity: np.ndarray, + heavy_acceleration: np.ndarray, + light_acceleration: np.ndarray, +) -> dict[str, float | int]: + relative = light_position - heavy_position + separation = float(np.linalg.norm(relative)) + unit = relative / max(separation, 1.0e-30) + total_mass = heavy.mass + light.mass + center = (heavy.mass * heavy_position + light.mass * light_position) / total_mass + momentum = heavy.mass * heavy_velocity + light.mass * light_velocity + return { + "step": int(step), + "time": float(step * dt), + "heavy_x": float(heavy_position[0]), + "heavy_y": float(heavy_position[1]), + "heavy_z": float(heavy_position[2]), + "light_x": float(light_position[0]), + "light_y": float(light_position[1]), + "light_z": float(light_position[2]), + "heavy_vx": float(heavy_velocity[0]), + "heavy_vy": float(heavy_velocity[1]), + "heavy_vz": float(heavy_velocity[2]), + "light_vx": float(light_velocity[0]), + "light_vy": float(light_velocity[1]), + "light_vz": float(light_velocity[2]), + "heavy_ax": float(heavy_acceleration[0]), + "heavy_ay": float(heavy_acceleration[1]), + "heavy_az": float(heavy_acceleration[2]), + "light_ax": float(light_acceleration[0]), + "light_ay": float(light_acceleration[1]), + "light_az": float(light_acceleration[2]), + "heavy_inward_acceleration": float(np.dot(heavy_acceleration, unit)), + "light_inward_acceleration": float(np.dot(light_acceleration, -unit)), + "separation": separation, + "bearing_rad": float(math.atan2(relative[1], relative[0])), + "center_x": float(center[0]), + "center_y": float(center[1]), + "center_z": float(center[2]), + "momentum_x": float(momentum[0]), + "momentum_y": float(momentum[1]), + "momentum_z": float(momentum[2]), + } + + +def integrate_limit02_two_body( + heavy: Limit02BodyProfile, + light: Limit02BodyProfile, + *, + initial_separation: float, + light_tangential_speed: float, + dt: float, + steps: int, + sample_every: int, + domain_center: tuple[float, float, float] | None = None, + chi0: float = CHI0, + c: float = 1.0, +) -> list[dict[str, float | int]]: + """Integrate two centres using only sampled LIMIT-02 chi geometry.""" + if heavy.grid_size != light.grid_size: + raise ValueError("body profiles must use the same grid") + if initial_separation <= heavy.radius + light.radius: + raise ValueError("initial bodies must not overlap") + if dt <= 0.0: + raise ValueError("dt must be positive") + if steps <= 0: + raise ValueError("steps must be positive") + if sample_every <= 0: + raise ValueError("sample_every must be positive") + if abs(light_tangential_speed) >= c: + raise ValueError("tangential speed must remain below c") + + if domain_center is None: + midpoint = float(heavy.grid_size // 2) + domain_center = (midpoint, midpoint, midpoint) + center = np.asarray(domain_center, dtype=np.float64) + total_mass = heavy.mass + light.mass + separation_vector = np.asarray( + [initial_separation, 0.0, 0.0], + dtype=np.float64, + ) + heavy_position = center - (light.mass / total_mass) * separation_vector + light_position = center + (heavy.mass / total_mass) * separation_vector + heavy_velocity = np.asarray( + [ + 0.0, + -light_tangential_speed * light.mass / heavy.mass, + 0.0, + ], + dtype=np.float64, + ) + light_velocity = np.asarray( + [0.0, light_tangential_speed, 0.0], + dtype=np.float64, + ) + + heavy_acceleration, light_acceleration = _two_body_accelerations( + heavy, + light, + heavy_position, + light_position, + chi0=chi0, + c=c, + ) + rows = [ + _orbit_row( + 0, + dt, + heavy, + light, + heavy_position, + light_position, + heavy_velocity, + light_velocity, + heavy_acceleration, + light_acceleration, + ) + ] + + dt_sq_half = 0.5 * dt * dt + for step in range(1, steps + 1): + heavy_position = heavy_position + dt * heavy_velocity + dt_sq_half * heavy_acceleration + light_position = light_position + dt * light_velocity + dt_sq_half * light_acceleration + new_heavy_acceleration, new_light_acceleration = _two_body_accelerations( + heavy, + light, + heavy_position, + light_position, + chi0=chi0, + c=c, + ) + heavy_velocity = heavy_velocity + 0.5 * dt * (heavy_acceleration + new_heavy_acceleration) + light_velocity = light_velocity + 0.5 * dt * (light_acceleration + new_light_acceleration) + heavy_acceleration = new_heavy_acceleration + light_acceleration = new_light_acceleration + + if step % sample_every == 0 or step == steps: + rows.append( + _orbit_row( + step, + dt, + heavy, + light, + heavy_position, + light_position, + heavy_velocity, + light_velocity, + heavy_acceleration, + light_acceleration, + ) + ) + return rows + + +def summarize_limit02_orbit( + rows: list[dict[str, float | int]], +) -> dict[str, Any]: + """Summarize a continuous reduced-LFM two-body trajectory.""" + if len(rows) < 2: + raise ValueError("at least two trajectory rows are required") + separations = np.asarray( + [float(row["separation"]) for row in rows], + dtype=np.float64, + ) + bearings = np.unwrap( + np.asarray( + [float(row["bearing_rad"]) for row in rows], + dtype=np.float64, + ) + ) + increments = np.diff(bearings) + sweep_deg = float(np.degrees(bearings[-1] - bearings[0])) + nonzero = increments[np.abs(increments) > 1.0e-12] + if nonzero.size == 0 or abs(sweep_deg) < 1.0e-12: + direction_fraction = 0.0 + else: + net_sign = 1.0 if sweep_deg > 0.0 else -1.0 + direction_fraction = float(np.mean(np.sign(nonzero) == net_sign)) + + centers = np.asarray( + [[row["center_x"], row["center_y"], row["center_z"]] for row in rows], + dtype=np.float64, + ) + momenta = np.asarray( + [[row["momentum_x"], row["momentum_y"], row["momentum_z"]] for row in rows], + dtype=np.float64, + ) + center_drift = np.linalg.norm(centers - centers[0], axis=1) + momentum_drift = np.linalg.norm(momenta - momenta[0], axis=1) + initial = float(separations[0]) + return { + "samples": len(rows), + "initial_separation": initial, + "final_separation": float(separations[-1]), + "separation_change": float(separations[-1] - initial), + "final_separation_ratio": float(separations[-1] / initial), + "minimum_separation": float(np.min(separations)), + "maximum_separation": float(np.max(separations)), + "separation_spread_ratio": float((np.max(separations) - np.min(separations)) / initial), + "angular_sweep_deg": sweep_deg, + "orbit_direction_fraction": direction_fraction, + "max_center_drift": float(np.max(center_drift)), + "max_momentum_drift": float(np.max(momentum_drift)), + "initial_heavy_inward_acceleration": float(rows[0]["heavy_inward_acceleration"]), + "initial_light_inward_acceleration": float(rows[0]["light_inward_acceleration"]), + } + + +def sweep_limit02_orbits( + heavy: Limit02BodyProfile, + light: Limit02BodyProfile, + speeds: list[float], + **integrator_kwargs: Any, +) -> list[dict[str, Any]]: + """Run a declared tangential-speed screen in the reduced LFM model.""" + cases = [] + for speed in speeds: + rows = integrate_limit02_two_body( + heavy, + light, + light_tangential_speed=float(speed), + **integrator_kwargs, + ) + cases.append( + { + "light_tangential_speed": float(speed), + "rows": rows, + "summary": summarize_limit02_orbit(rows), + } + ) + return cases + + +__all__ = [ + "integrate_limit02_two_body", + "summarize_limit02_orbit", + "sweep_limit02_orbits", +] diff --git a/lfm/fields/__init__.py b/lfm/fields/__init__.py index b8b82b7..2ab652b 100644 --- a/lfm/fields/__init__.py +++ b/lfm/fields/__init__.py @@ -26,10 +26,26 @@ from lfm.fields.boosted import boosted_soliton from lfm.fields.equilibrium import ( equilibrate_chi, + equilibrate_chi_19pt, equilibrate_from_fields, + equilibrate_from_fields_19pt, poisson_solve_fft, + poisson_solve_fft_19pt, +) +from lfm.fields.light import ( + planar_r1_light_packet, + r1_light_acceleration, + r1_light_step, + r1_vacuum_subtracted_potential, + spherical_phase_source, +) +from lfm.fields.macroscopic import ( + Limit02BodyProfile, + build_limit02_body_profile, + limit02_acceleration_from_profile, + periodic_trilinear_sample, + smooth_spherical_density, ) -from lfm.fields.light import spherical_phase_source from lfm.fields.random import seed_noise, uniform_chi from lfm.fields.soliton import gaussian_soliton, place_solitons, wave_kick from lfm.fields.spinor import ( @@ -45,8 +61,11 @@ "wave_kick", "boosted_soliton", "poisson_solve_fft", + "poisson_solve_fft_19pt", "equilibrate_chi", + "equilibrate_chi_19pt", "equilibrate_from_fields", + "equilibrate_from_fields_19pt", "seed_noise", "uniform_chi", "tetrahedral_positions", @@ -62,4 +81,14 @@ "apply_rotation_z", # light "spherical_phase_source", + "planar_r1_light_packet", + "r1_vacuum_subtracted_potential", + "r1_light_acceleration", + "r1_light_step", + # macroscopic LIMIT-02 + "Limit02BodyProfile", + "smooth_spherical_density", + "build_limit02_body_profile", + "periodic_trilinear_sample", + "limit02_acceleration_from_profile", ] diff --git a/lfm/fields/equilibrium.py b/lfm/fields/equilibrium.py index 53aa4aa..e30cb25 100644 --- a/lfm/fields/equilibrium.py +++ b/lfm/fields/equilibrium.py @@ -13,11 +13,12 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast import numpy as np from lfm.constants import CHI0, KAPPA +from lfm.core.stencils import eigenvalue_19pt if TYPE_CHECKING: from numpy.typing import NDArray @@ -26,7 +27,7 @@ def poisson_solve_fft( source: NDArray[np.floating], N: int, -) -> NDArray[np.float32]: +) -> NDArray[np.floating]: """Solve ∇²φ = source on a periodic N³ grid via FFT. Returns φ with DC component = 0 (background = 0). @@ -40,9 +41,11 @@ def poisson_solve_fft( Returns ------- - ndarray of float32, shape (N, N, N) - Solution φ with zero mean. + floating-point ndarray, shape (N, N, N) + Solution φ with zero mean and the source precision (float32 or + float64). """ + out_dtype = np.float64 if np.dtype(source.dtype) == np.dtype(np.float64) else np.float32 src_hat = np.fft.rfftn(source) kx = np.fft.fftfreq(N) * 2.0 * np.pi @@ -56,7 +59,32 @@ def poisson_solve_fft( phi_hat = -src_hat / K2 phi_hat[0, 0, 0] = 0.0 - return np.fft.irfftn(phi_hat, s=(N, N, N), axes=(0, 1, 2)).astype(np.float32) + return np.fft.irfftn(phi_hat, s=(N, N, N), axes=(0, 1, 2)).astype(out_dtype) + + +def poisson_solve_fft_19pt( + source: NDArray[np.floating], + N: int | None = None, + dx: float = 1.0, +) -> NDArray[np.floating]: + """Solve L19(phi) = source with the exact 19-point stencil symbol.""" + if source.ndim != 3: + raise ValueError("source must have shape (N, N, N)") + N = int(N or source.shape[0]) + if source.shape != (N, N, N): + raise ValueError("source shape must match N") + out_dtype = np.float64 if np.dtype(source.dtype) == np.dtype(np.float64) else np.float32 + + src_hat = np.fft.rfftn(source.astype(np.float64)) + kx = np.fft.fftfreq(N) * 2.0 * np.pi + ky = np.fft.fftfreq(N) * 2.0 * np.pi + kz = np.fft.rfftfreq(N) * 2.0 * np.pi + KX, KY, KZ = np.meshgrid(kx, ky, kz, indexing="ij") + lam = eigenvalue_19pt(KX, KY, KZ) / (dx * dx) + lam[0, 0, 0] = 1.0 + phi_hat = src_hat / lam + phi_hat[0, 0, 0] = 0.0 + return np.fft.irfftn(phi_hat, s=(N, N, N), axes=(0, 1, 2)).astype(out_dtype) def equilibrate_chi( @@ -65,7 +93,7 @@ def equilibrate_chi( kappa: float = KAPPA, e0_sq: float = 0.0, boundary_mask: NDArray[np.bool_] | None = None, -) -> NDArray[np.float32]: +) -> NDArray[np.floating]: """Compute Poisson-equilibrated χ from energy density |Ψ|². Solves GOV-04: ∇²δχ = κ(|Ψ|² − E₀²), then χ = χ₀ + δχ. @@ -85,28 +113,46 @@ def equilibrate_chi( Returns ------- - ndarray of float32, shape (N, N, N) - Equilibrated χ field. + floating-point ndarray, shape (N, N, N) + Equilibrated χ field with the input precision. """ N = psi_sq.shape[0] + out_dtype = np.float64 if np.dtype(psi_sq.dtype) == np.dtype(np.float64) else np.float32 rhs = kappa * (psi_sq - e0_sq) delta_chi = poisson_solve_fft(rhs, N) - chi = (chi0 + delta_chi).astype(np.float32) + chi = (chi0 + delta_chi).astype(out_dtype) if boundary_mask is not None: chi[boundary_mask] = chi0 + return cast("NDArray[np.floating]", chi) + + +def equilibrate_chi_19pt( + psi_sq: NDArray[np.floating], + chi0: float = CHI0, + kappa: float = KAPPA, + e0_sq: float = 0.0, + boundary_mask: NDArray[np.bool_] | None = None, +) -> NDArray[np.floating]: + """Compute chi equilibrium with a 19-point-consistent Poisson solve.""" + N = psi_sq.shape[0] + rhs = kappa * (psi_sq - e0_sq) + delta_chi = poisson_solve_fft_19pt(rhs, N) + chi = (chi0 + delta_chi).astype(delta_chi.dtype, copy=False) + if boundary_mask is not None: + chi[boundary_mask] = chi0 return chi def equilibrate_from_fields( - psi_r: NDArray[np.float32], - psi_i: NDArray[np.float32] | None = None, + psi_r: NDArray[np.floating], + psi_i: NDArray[np.floating] | None = None, chi0: float = CHI0, kappa: float = KAPPA, e0_sq: float = 0.0, boundary_mask: NDArray[np.bool_] | None = None, -) -> NDArray[np.float32]: +) -> NDArray[np.floating]: """Compute equilibrated χ directly from Ψ field components. Handles all field levels: @@ -116,9 +162,9 @@ def equilibrate_from_fields( Parameters ---------- - psi_r : ndarray of float32 + psi_r : floating-point ndarray Real part of Ψ. - psi_i : ndarray of float32 or None + psi_i : floating-point ndarray or None Imaginary part (None for real fields). chi0, kappa, e0_sq : float Physics parameters. @@ -127,8 +173,8 @@ def equilibrate_from_fields( Returns ------- - ndarray of float32, shape (N, N, N) - Equilibrated χ field. + floating-point ndarray, shape (N, N, N) + Equilibrated χ field with the field precision. """ if psi_r.ndim == 3: # Single component: (N, N, N) @@ -144,3 +190,26 @@ def equilibrate_from_fields( raise ValueError(f"Unexpected psi_r shape: {psi_r.shape}") return equilibrate_chi(psi_sq, chi0, kappa, e0_sq, boundary_mask) + + +def equilibrate_from_fields_19pt( + psi_r: NDArray[np.floating], + psi_i: NDArray[np.floating] | None = None, + chi0: float = CHI0, + kappa: float = KAPPA, + e0_sq: float = 0.0, + boundary_mask: NDArray[np.bool_] | None = None, +) -> NDArray[np.floating]: + """Compute chi from fields using the 19-point-consistent Poisson solve.""" + if psi_r.ndim == 3: + psi_sq = psi_r**2 + if psi_i is not None: + psi_sq = psi_sq + psi_i**2 + elif psi_r.ndim == 4: + psi_sq = np.sum(psi_r**2, axis=0) + if psi_i is not None: + psi_sq = psi_sq + np.sum(psi_i**2, axis=0) + else: + raise ValueError(f"Unexpected psi_r shape: {psi_r.shape}") + + return equilibrate_chi_19pt(psi_sq, chi0, kappa, e0_sq, boundary_mask) diff --git a/lfm/fields/light.py b/lfm/fields/light.py index fffd736..31fabbf 100644 --- a/lfm/fields/light.py +++ b/lfm/fields/light.py @@ -47,14 +47,26 @@ from __future__ import annotations -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, cast import numpy as np +from lfm.constants import CHI0 +from lfm.core.stencils import laplacian_19pt + if TYPE_CHECKING: from numpy.typing import NDArray +def _runtime_float_dtype(*arrays: object) -> type[np.float32] | type[np.float64]: + """Use float64 only when a caller explicitly supplies float64 arrays.""" + for arr in arrays: + dtype = getattr(arr, "dtype", None) + if dtype is not None and np.dtype(dtype) == np.dtype(np.float64): + return np.float64 + return np.float32 + + def spherical_phase_source( N: int, center: tuple[float, float, float], @@ -141,3 +153,136 @@ def _shell(r_centre: float) -> NDArray[np.float64]: (shell_tm1 * cos_p).astype(np.float32), (shell_tm1 * sin_p).astype(np.float32), ) + + +def planar_r1_light_packet( + N: int, + center: tuple[float, float, float], + sigma: tuple[float, float, float], + carrier_k: float, + amplitude: float = 0.20, + dt: float = 0.32, + c_speed: float = 1.0, + axis: int = 0, + dtype: type[np.float32] | type[np.float64] = np.float32, +) -> tuple[NDArray[np.floating], NDArray[np.floating], NDArray[np.floating], NDArray[np.floating]]: + """Return a localized R1 complex packet moving in the +axis direction.""" + if axis not in (0, 1, 2): + raise ValueError("axis must be 0, 1, or 2") + out_dtype = np.dtype(dtype).type + if out_dtype not in (np.float32, np.float64): + raise ValueError("dtype must be np.float32 or np.float64") + + coords = np.arange(N, dtype=out_dtype) + grids = np.meshgrid(coords, coords, coords, indexing="ij") + center_arr = np.asarray(center, dtype=out_dtype) + sigma_arr = np.asarray(sigma, dtype=out_dtype) + if np.any(sigma_arr <= 0.0): + raise ValueError("sigma values must be positive") + + def _packet( + packet_center: NDArray[np.floating], + ) -> tuple[NDArray[np.floating], NDArray[np.floating]]: + radius_sq = np.zeros((N, N, N), dtype=out_dtype) + for idx, grid in enumerate(grids): + radius_sq += ((grid - packet_center[idx]) / sigma_arr[idx]) ** 2 + envelope = amplitude * np.exp(-0.5 * radius_sq) + phase = carrier_k * (grids[axis] - packet_center[axis]) + return ( + (envelope * np.cos(phase)).astype(out_dtype), + (envelope * np.sin(phase)).astype(out_dtype), + ) + + prev_center = center_arr.copy() + prev_center[axis] -= c_speed * dt + psi_r, psi_i = _packet(center_arr) + psi_r_prev, psi_i_prev = _packet(prev_center) + return psi_r, psi_i, psi_r_prev, psi_i_prev + + +def r1_vacuum_subtracted_potential( + chi: NDArray[np.floating], + chi0: float = CHI0, +) -> NDArray[np.floating]: + """Return chi^2 - chi0^2 for the massless R1 light perturbation.""" + out_dtype = _runtime_float_dtype(chi) + chi_f = chi.astype(out_dtype, copy=False) + potential = chi_f * chi_f - out_dtype(chi0 * chi0) # type: ignore[operator] + return cast("NDArray[np.floating]", potential.astype(out_dtype)) + + +def r1_light_acceleration( + psi_r: NDArray[np.floating], + psi_i: NDArray[np.floating], + chi: NDArray[np.floating] | None = None, + chi0: float = CHI0, + c_speed: float = 1.0, + vacuum_subtracted: bool = True, +) -> tuple[NDArray[np.floating], NDArray[np.floating]]: + """Compute the R1 light acceleration for one leapfrog update. + + With chi=None this is the flat U(1) massless phase/current sector. + With chi provided and vacuum_subtracted=True the potential is + chi^2 - chi0^2, so uniform vacuum remains massless while nonuniform + chi affects the full complex R1 field. + """ + out_dtype = _runtime_float_dtype(psi_r, psi_i, chi) + acc_r = (c_speed * c_speed * laplacian_19pt(psi_r)).astype(out_dtype) + acc_i = (c_speed * c_speed * laplacian_19pt(psi_i)).astype(out_dtype) + if chi is None: + return cast("tuple[NDArray[np.floating], NDArray[np.floating]]", (acc_r, acc_i)) + + chi_f = chi.astype(out_dtype, copy=False) + if vacuum_subtracted: + potential = r1_vacuum_subtracted_potential(cast("NDArray[np.floating]", chi_f), chi0) + else: + potential = (chi_f * chi_f).astype(out_dtype) # type: ignore[operator] + return cast( + "tuple[NDArray[np.floating], NDArray[np.floating]]", + ( + (acc_r - potential * psi_r).astype(out_dtype), # type: ignore[operator] + (acc_i - potential * psi_i).astype(out_dtype), # type: ignore[operator] + ), + ) + + +def r1_light_step( + psi_r: NDArray[np.floating], + psi_i: NDArray[np.floating], + psi_r_prev: NDArray[np.floating], + psi_i_prev: NDArray[np.floating], + dt: float, + chi: NDArray[np.floating] | None = None, + chi0: float = CHI0, + c_speed: float = 1.0, + vacuum_subtracted: bool = True, + sponge: NDArray[np.floating] | None = None, +) -> tuple[NDArray[np.floating], NDArray[np.floating], NDArray[np.floating], NDArray[np.floating]]: + """Advance one leapfrog step for a massless R1 light packet.""" + out_dtype = _runtime_float_dtype(psi_r, psi_i, psi_r_prev, psi_i_prev, chi) + acc_r, acc_i = r1_light_acceleration( + psi_r, + psi_i, + chi=chi, + chi0=chi0, + c_speed=c_speed, + vacuum_subtracted=vacuum_subtracted, + ) + dt2 = out_dtype(dt * dt) + psi_r_next = (2.0 * psi_r - psi_r_prev + dt2 * acc_r).astype(out_dtype) + psi_i_next = (2.0 * psi_i - psi_i_prev + dt2 * acc_i).astype(out_dtype) + psi_r_prev_next = psi_r.astype(out_dtype, copy=True) + psi_i_prev_next = psi_i.astype(out_dtype, copy=True) + + if sponge is not None: + sponge_f = sponge.astype(out_dtype, copy=False) + psi_r_next = (psi_r_next * sponge_f).astype(out_dtype) # type: ignore[operator] + psi_i_next = (psi_i_next * sponge_f).astype(out_dtype) # type: ignore[operator] + psi_r_prev_next = (psi_r_prev_next * sponge_f).astype(out_dtype) # type: ignore[operator] + psi_i_prev_next = (psi_i_prev_next * sponge_f).astype(out_dtype) # type: ignore[operator] + + return cast( + "tuple[NDArray[np.floating], NDArray[np.floating], " + "NDArray[np.floating], NDArray[np.floating]]", + (psi_r_next, psi_i_next, psi_r_prev_next, psi_i_prev_next), + ) diff --git a/lfm/fields/macroscopic.py b/lfm/fields/macroscopic.py new file mode 100644 index 0000000..1d010c3 --- /dev/null +++ b/lfm/fields/macroscopic.py @@ -0,0 +1,189 @@ +"""Macroscopic density bodies in the quasi-static LIMIT-02 regime. + +These helpers intentionally discard microscopic phase and color registers. +They represent rigid extended bodies by real density fields, solve the +19-point weak-field LIMIT-02 equation, and sample the resulting chi geometry. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, cast + +import numpy as np + +from lfm.constants import CHI0, KAPPA +from lfm.core.stencils import gradient_19pt +from lfm.fields.equilibrium import equilibrate_chi_19pt + +if TYPE_CHECKING: + from numpy.typing import NDArray + + +def _tuple3_float(values: tuple[float, float, float]) -> tuple[float, float, float]: + items = tuple(float(value) for value in values) + if len(items) != 3: + raise ValueError("expected a 3-vector") + return cast("tuple[float, float, float]", items) + + +@dataclass(frozen=True) +class Limit02BodyProfile: + """A rigid spherical density and its isolated LIMIT-02 chi geometry.""" + + grid_size: int + center: tuple[float, float, float] + radius: float + mass: float + density: NDArray[np.float64] + chi_delta: NDArray[np.float64] + gradient_x: NDArray[np.float64] + gradient_y: NDArray[np.float64] + gradient_z: NDArray[np.float64] + + +def smooth_spherical_density( + grid_size: int, + center: tuple[float, float, float], + radius: float, + mass: float, +) -> NDArray[np.float64]: + """Return a compact smooth spherical density normalized to ``mass``. + + The unnormalized radial profile is ``(1 - r^2 / R^2)^2`` inside + ``r < R`` and zero outside. Periodic minimum-image distances are used so + the same helper can translate a body across a periodic LIMIT-02 domain. + """ + if grid_size < 8: + raise ValueError("grid_size must be at least 8") + if radius <= 0.0: + raise ValueError("radius must be positive") + if radius >= grid_size / 2.0: + raise ValueError("radius must be smaller than half the grid") + if mass <= 0.0: + raise ValueError("mass must be positive") + if len(center) != 3: + raise ValueError("center must contain three coordinates") + + axes = [] + for coordinate in center: + delta = np.arange(grid_size, dtype=np.float64) - float(coordinate) + delta = (delta + grid_size / 2.0) % grid_size - grid_size / 2.0 + axes.append(delta) + dx, dy, dz = np.meshgrid(*axes, indexing="ij") + radius_sq = dx * dx + dy * dy + dz * dz + scaled = radius_sq / (radius * radius) + density = np.where(scaled < 1.0, (1.0 - scaled) ** 2, 0.0) + normalization = float(np.sum(density)) + if normalization <= 0.0: + raise ValueError("radius is too small to resolve a nonzero body") + density *= mass / normalization + return density.astype(np.float64, copy=False) + + +def build_limit02_body_profile( + grid_size: int, + radius: float, + mass: float, + *, + center: tuple[float, float, float] | None = None, + chi0: float = CHI0, + kappa: float = KAPPA, + dx: float = 1.0, +) -> Limit02BodyProfile: + """Build one spherical body's isolated 19-point LIMIT-02 field profile.""" + if center is None: + midpoint = float(grid_size // 2) + center = (midpoint, midpoint, midpoint) + density = smooth_spherical_density(grid_size, center, radius, mass) + chi = equilibrate_chi_19pt( + density, + chi0=chi0, + kappa=kappa, + ).astype(np.float64, copy=False) + chi_delta = chi - float(chi0) + gradient_x, gradient_y, gradient_z = gradient_19pt( + chi_delta, + dx=dx, + ) + return Limit02BodyProfile( + grid_size=grid_size, + center=_tuple3_float(center), + radius=float(radius), + mass=float(mass), + density=density, + chi_delta=chi_delta, + gradient_x=np.asarray(gradient_x, dtype=np.float64), + gradient_y=np.asarray(gradient_y, dtype=np.float64), + gradient_z=np.asarray(gradient_z, dtype=np.float64), + ) + + +def periodic_trilinear_sample( + field: NDArray[np.floating], + point: tuple[float, float, float] | NDArray[np.floating], +) -> float: + """Trilinearly sample a periodic 3-D scalar field.""" + values = np.asarray(field) + if values.ndim != 3 or not (values.shape[0] == values.shape[1] == values.shape[2]): + raise ValueError("field must be a cubic 3-D array") + coordinates = np.asarray(point, dtype=np.float64) + if coordinates.shape != (3,): + raise ValueError("point must contain three coordinates") + + size = values.shape[0] + wrapped = np.mod(coordinates, float(size)) + lower = np.floor(wrapped).astype(int) + fraction = wrapped - lower + upper = (lower + 1) % size + + result = 0.0 + for bx in (0, 1): + ix = lower[0] if bx == 0 else upper[0] + wx = (1.0 - fraction[0]) if bx == 0 else fraction[0] + for by in (0, 1): + iy = lower[1] if by == 0 else upper[1] + wy = (1.0 - fraction[1]) if by == 0 else fraction[1] + for bz in (0, 1): + iz = lower[2] if bz == 0 else upper[2] + wz = (1.0 - fraction[2]) if bz == 0 else fraction[2] + result += wx * wy * wz * float(values[ix, iy, iz]) + return float(result) + + +def limit02_acceleration_from_profile( + source: Limit02BodyProfile, + displacement_from_source: tuple[float, float, float] | NDArray[np.floating], + *, + chi0: float = CHI0, + c: float = 1.0, +) -> NDArray[np.float64]: + """Sample the weak-field WKB acceleration from a source's chi profile. + + ``displacement_from_source`` points from the source centre to the target. + No analytic radial or inverse-square force is used. + """ + if chi0 <= 0.0: + raise ValueError("chi0 must be positive") + displacement = np.asarray(displacement_from_source, dtype=np.float64) + if displacement.shape != (3,): + raise ValueError("displacement must contain three coordinates") + sample_point = np.asarray(source.center, dtype=np.float64) + displacement + gradient = np.asarray( + [ + periodic_trilinear_sample(source.gradient_x, sample_point), + periodic_trilinear_sample(source.gradient_y, sample_point), + periodic_trilinear_sample(source.gradient_z, sample_point), + ], + dtype=np.float64, + ) + return -(c * c / chi0) * gradient + + +__all__ = [ + "Limit02BodyProfile", + "build_limit02_body_profile", + "limit02_acceleration_from_profile", + "periodic_trilinear_sample", + "smooth_spherical_density", +] diff --git a/lfm/foundations/__init__.py b/lfm/foundations/__init__.py new file mode 100644 index 0000000..6315587 --- /dev/null +++ b/lfm/foundations/__init__.py @@ -0,0 +1,280 @@ +"""Foundational LFM revision candidates. + +Modules in this package are executable research prototypes. They are not +part of the canonical two-register Simulation until an explicit promotion +gate updates that status. +""" + +from lfm.foundations.r3_link_frame import ( + R3_ACTION_ID, + R3_REGISTER_ID, + R3LinkFrameParameters, + R3ProductLink, + chiral_frame_curvature, + frame_shape_acceleration, + internal_covariant_difference, + product_plaquette_energy, + r3_action_declaration, + r3_action_fingerprint, + transform_internal_matter, + transform_product_link, +) +from lfm.foundations.r3_link_frame_live import ( + R3_LIVE_ACTION_ID, + R3_LIVE_REGISTER_ID, + R3LiveParameters, + R3LiveState, + group_constraint_errors, + potential_energy_and_rates, + r3_live_action_declaration, + r3_live_action_fingerprint, + reverse_momenta, + so4_generators, + state_distance, + step_r3_live, + su3_generators, + total_hamiltonian, + triangle_loops, +) +from lfm.foundations.r4_color_static import ( + R4ColorStaticState, + R4FixedColorElectricState, + color_gauss_divergence, + r4_color_electric_site_density, + r4_color_flux_observables, + r4_color_incident_flux_sq, + r4_color_static_energy, + r4_color_vacuum_instability_flux_sq, + relax_r4_chi_at_fixed_color_electric, + relax_r4_color_static, + solve_color_gauss_minimum, +) +from lfm.foundations.r4_gauge_spectrum import ( + directional_link_inertia, + face_square_cycles, + face_square_fourier_hessian, + gauge_link_spectrum, + oriented_cycle_fourier_coefficients, + transverse_mode_speeds, + triangle_fourier_hessian, +) +from lfm.foundations.r4_quantum_color import ( + R4MagneticCompetitionBound, + R4QuantumColorCoefficients, + creutz_ratio_from_log_transfer, + fundamental_flux_energy, + local_su3_gauge_transform, + log_wilson_transfer, + minimum_link_distance, + r4_magnetic_competition_bound, + r4_quantum_color_coefficients, + su3_fundamental_algebra_audit, + weighted_loop_incidence, +) +from lfm.foundations.r4_unified_live import ( + R4_ACTION_ID, + R4_REGISTER_ID, + R4FrameScalarState, + R4Parameters, + R4State, + color_dielectric, + r4_action_declaration, + r4_action_fingerprint, + r4_frame_scalar_energy, + step_r4, + step_r4_frame_scalar, + su2_generators, +) +from lfm.foundations.r4_unified_live import ( + group_constraint_errors as r4_group_constraint_errors, +) +from lfm.foundations.r4_unified_live import ( + potential_energy_and_rates as r4_potential_energy_and_rates, +) +from lfm.foundations.r4_unified_live import ( + reverse_momenta as reverse_r4_momenta, +) +from lfm.foundations.r4_unified_live import ( + state_distance as r4_state_distance, +) +from lfm.foundations.r4_unified_live import ( + total_hamiltonian as r4_total_hamiltonian, +) +from lfm.foundations.r5_u1_static import ( + R5U1StaticState, + periodic_point_pair_charge, + solve_u1_gauss_minimum, +) +from lfm.foundations.r5_unified_live import ( + R5_ACTION_ID, + R5_REGISTER_ID, + R5Parameters, + R5Rates, + R5State, + r5_action_declaration, + r5_action_fingerprint, + step_r5, +) +from lfm.foundations.r5_unified_live import ( + group_constraint_errors as r5_group_constraint_errors, +) +from lfm.foundations.r5_unified_live import ( + kinetic_energy as r5_kinetic_energy, +) +from lfm.foundations.r5_unified_live import ( + potential_energy_and_rates as r5_potential_energy_and_rates, +) +from lfm.foundations.r5_unified_live import ( + reverse_momenta as reverse_r5_momenta, +) +from lfm.foundations.r5_unified_live import ( + state_distance as r5_state_distance, +) +from lfm.foundations.r5_unified_live import ( + total_hamiltonian as r5_total_hamiltonian, +) +from lfm.foundations.r5_unified_live import ( + vacuum_state as r5_vacuum_state, +) +from lfm.foundations.r6_unified_live import ( + R6_ACTION_ID, + R6_REGISTER_ID, + R6Parameters, + R6R4Parameters, + R6Rates, + R6State, + r6_action_declaration, + r6_action_fingerprint, + step_r6, +) +from lfm.foundations.r6_unified_live import ( + group_constraint_errors as r6_group_constraint_errors, +) +from lfm.foundations.r6_unified_live import ( + kinetic_energy as r6_kinetic_energy, +) +from lfm.foundations.r6_unified_live import ( + potential_energy_and_rates as r6_potential_energy_and_rates, +) +from lfm.foundations.r6_unified_live import ( + reverse_momenta as reverse_r6_momenta, +) +from lfm.foundations.r6_unified_live import ( + state_distance as r6_state_distance, +) +from lfm.foundations.r6_unified_live import ( + total_hamiltonian as r6_total_hamiltonian, +) +from lfm.foundations.r6_unified_live import ( + vacuum_state as r6_vacuum_state, +) + +__all__ = [ + "R3_ACTION_ID", + "R3_REGISTER_ID", + "R3LinkFrameParameters", + "R3ProductLink", + "internal_covariant_difference", + "transform_internal_matter", + "transform_product_link", + "chiral_frame_curvature", + "product_plaquette_energy", + "frame_shape_acceleration", + "r3_action_declaration", + "r3_action_fingerprint", + "R3_LIVE_ACTION_ID", + "R3_LIVE_REGISTER_ID", + "R3LiveParameters", + "R3LiveState", + "group_constraint_errors", + "potential_energy_and_rates", + "r3_live_action_declaration", + "r3_live_action_fingerprint", + "reverse_momenta", + "so4_generators", + "state_distance", + "step_r3_live", + "su3_generators", + "total_hamiltonian", + "triangle_loops", + "R4_ACTION_ID", + "R4_REGISTER_ID", + "R4FrameScalarState", + "R4Parameters", + "R4State", + "color_dielectric", + "r4_group_constraint_errors", + "r4_potential_energy_and_rates", + "r4_action_declaration", + "r4_action_fingerprint", + "r4_frame_scalar_energy", + "reverse_r4_momenta", + "r4_state_distance", + "step_r4", + "step_r4_frame_scalar", + "su2_generators", + "r4_total_hamiltonian", + "R4ColorStaticState", + "R4FixedColorElectricState", + "color_gauss_divergence", + "r4_color_electric_site_density", + "r4_color_flux_observables", + "r4_color_incident_flux_sq", + "r4_color_static_energy", + "r4_color_vacuum_instability_flux_sq", + "relax_r4_color_static", + "relax_r4_chi_at_fixed_color_electric", + "solve_color_gauss_minimum", + "R4MagneticCompetitionBound", + "R4QuantumColorCoefficients", + "creutz_ratio_from_log_transfer", + "fundamental_flux_energy", + "log_wilson_transfer", + "local_su3_gauge_transform", + "minimum_link_distance", + "r4_magnetic_competition_bound", + "r4_quantum_color_coefficients", + "su3_fundamental_algebra_audit", + "weighted_loop_incidence", + "directional_link_inertia", + "face_square_cycles", + "face_square_fourier_hessian", + "gauge_link_spectrum", + "oriented_cycle_fourier_coefficients", + "transverse_mode_speeds", + "triangle_fourier_hessian", + "R5_ACTION_ID", + "R5_REGISTER_ID", + "R5Parameters", + "R5Rates", + "R5State", + "r5_group_constraint_errors", + "r5_kinetic_energy", + "r5_potential_energy_and_rates", + "r5_action_declaration", + "r5_action_fingerprint", + "reverse_r5_momenta", + "r5_state_distance", + "step_r5", + "r5_total_hamiltonian", + "r5_vacuum_state", + "R5U1StaticState", + "periodic_point_pair_charge", + "solve_u1_gauss_minimum", + "R6_ACTION_ID", + "R6_REGISTER_ID", + "R6Parameters", + "R6R4Parameters", + "R6Rates", + "R6State", + "r6_group_constraint_errors", + "r6_kinetic_energy", + "r6_potential_energy_and_rates", + "r6_action_declaration", + "r6_action_fingerprint", + "reverse_r6_momenta", + "r6_state_distance", + "step_r6", + "r6_total_hamiltonian", + "r6_vacuum_state", +] diff --git a/lfm/foundations/parsimonious_domain_wall.py b/lfm/foundations/parsimonious_domain_wall.py new file mode 100644 index 0000000..1d121b5 --- /dev/null +++ b/lfm/foundations/parsimonious_domain_wall.py @@ -0,0 +1,485 @@ +"""Local domain-wall weak completion of the experimental P4F action.""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import asdict, dataclass, field +from typing import TYPE_CHECKING, cast + +import numpy as np + +from lfm.foundations.parsimonious_four_force import ( + P4FParameters, + inactive_frame_error, + p4f_action_declaration, +) +from lfm.foundations.parsimonious_four_force import ( + total_hamiltonian as p4f_total_hamiltonian, +) +from lfm.foundations.parsimonious_four_force import ( + vacuum_state as p4f_vacuum_state, +) +from lfm.foundations.r3_link_frame_live import ( + _dagger, + _link_and_shape_drift, + _link_table, + _neighbor, + _oriented_link, + _weighted_bare_kinetic_drift, +) +from lfm.foundations.r4_unified_live import ( + _extra_gauge_kinetic_drift, + _weak_matter_kinetic_drift, + su2_generators, +) +from lfm.foundations.r4_unified_live import ( + reverse_momenta as reverse_r4_momenta, +) +from lfm.foundations.r4_unified_live import ( + state_distance as r4_state_distance, +) +from lfm.foundations.r5_unified_live import _potential_kick + +if TYPE_CHECKING: + from lfm.foundations.r6_unified_live import R6State + +P4F_DW_ACTION_ID = "LFM-P4F-DOMAIN-WALL-WEAK-EXPERIMENT-v1" +P4F_DW_REGISTER_ID = "P4F-DW=(P4F,DomainWallPhi_s,DomainWallPi_s)" + +Offset = tuple[int, int, int] + + +def _offset3(values: tuple[int, ...]) -> Offset: + return (values[0], values[1], values[2]) + + +def _euclidean_spin_matrices() -> tuple[ + tuple[np.ndarray, np.ndarray, np.ndarray], + np.ndarray, + np.ndarray, +]: + sigma_x = np.array([[0, 1], [1, 0]], dtype=np.complex128) + sigma_y = np.array([[0, -1.0j], [1.0j, 0]], dtype=np.complex128) + sigma_z = np.array([[1, 0], [0, -1]], dtype=np.complex128) + identity2 = np.eye(2, dtype=np.complex128) + zero2 = np.zeros((2, 2), dtype=np.complex128) + spatial = [] + for sigma in (sigma_x, sigma_y, sigma_z): + spatial.append( + np.block( + [ + [zero2, 1.0j * sigma], + [-1.0j * sigma, zero2], + ] + ) + ) + gamma5 = np.block([[identity2, zero2], [zero2, -identity2]]) + identity4 = np.eye(4, dtype=np.complex128) + return ( + cast("tuple[np.ndarray, np.ndarray, np.ndarray]", tuple(spatial)), + 0.5 * (identity4 - gamma5), + 0.5 * (identity4 + gamma5), + ) + + +_SPATIAL_GAMMAS, _P_MINUS, _P_PLUS = _euclidean_spin_matrices() + + +@dataclass(frozen=True) +class P4FDWParameters: + """Parameters for the local internal-chain completion.""" + + p4f: P4FParameters = field(default_factory=P4FParameters) + internal_depth: int = 19 + color_multiplicity: int = 3 + guard_coefficient: float = 1.0 + overlap_rho: float = 1.0 + single_wall_chiral: bool = True + + def __post_init__(self) -> None: + if self.internal_depth < 2: + raise ValueError("internal_depth must be at least two") + if self.color_multiplicity != 3: + raise ValueError("P4F-DW requires three color copies") + if self.guard_coefficient != 1.0: + raise ValueError("the action-derived guard coefficient is one") + if self.overlap_rho != 1.0: + raise ValueError("the audited overlap rho is one") + if not self.single_wall_chiral: + raise ValueError("P4F-DW requires the one-wall chiral register") + if self.p4f.r6.r4.r3.stencil != "19": + raise ValueError("P4F-DW v1 requires stencil19") + + +@dataclass +class P4FDWState: + """P4F phase space plus a local domain-wall matter chain.""" + + base: R6State + domain_wall_field: np.ndarray + domain_wall_momentum: np.ndarray + + def copy(self) -> P4FDWState: + return P4FDWState( + base=self.base.copy(), + domain_wall_field=self.domain_wall_field.copy(), + domain_wall_momentum=self.domain_wall_momentum.copy(), + ) + + +def vacuum_state( + size: int, + parameters: P4FDWParameters = P4FDWParameters(), +) -> P4FDWState: + base = p4f_vacuum_state(size, parameters.p4f) + shape = ( + size, + size, + size, + parameters.internal_depth, + 4, + 2, + parameters.color_multiplicity, + ) + return P4FDWState( + base=base, + domain_wall_field=np.zeros(shape, dtype=np.complex128), + domain_wall_momentum=np.zeros(shape, dtype=np.complex128), + ) + + +def _validate( + state: P4FDWState, + parameters: P4FDWParameters, +) -> None: + sites = state.base.r3.chi.shape + expected = sites + ( + parameters.internal_depth, + 4, + 2, + parameters.color_multiplicity, + ) + for name in ("domain_wall_field", "domain_wall_momentum"): + values = np.asarray(getattr(state, name)) + if values.shape != expected: + raise ValueError(f"{name} must have shape {expected}") + if not np.all(np.isfinite(values)): + raise ValueError(f"{name} contains a non-finite value") + removed_mirror = _apply_spin( + _P_PLUS, + values[..., -1:, :, :, :], + ) + if float(np.max(np.abs(removed_mirror))) > 1.0e-12: + raise ValueError(f"{name} contains the excluded right-wall mirror mode") + if inactive_frame_error(state.base) != 0.0: + raise ValueError("P4F-DW compatibility frame must remain vacuum") + + +def _apply_weak( + link: np.ndarray, + values: np.ndarray, +) -> np.ndarray: + return np.einsum( + "...ij,...sajc->...saic", + link, + values, + optimize=True, + ) + + +def _apply_spin( + matrix: np.ndarray, + values: np.ndarray, +) -> np.ndarray: + return np.einsum( + "ab,...sbic->...saic", + matrix, + values, + optimize=True, + ) + + +def project_domain_wall_register(values: np.ndarray) -> np.ndarray: + """Project onto the one-wall chiral domain-wall phase space.""" + + projected = np.asarray(values).copy() + projected[..., -1:, :, :, :] = _apply_spin( + _P_MINUS, + projected[..., -1:, :, :, :], + ) + return projected + + +def _spatial_parts( + values: np.ndarray, + weak_links: np.ndarray, +) -> tuple[np.ndarray, np.ndarray]: + unique, _ = _link_table("19") + guard = np.zeros_like(values) + centered = np.zeros_like(values) + for index, (offset, weight) in enumerate(unique): + forward_link = weak_links[..., index, :, :] + backward_link = _oriented_link( + weak_links, + _offset3(tuple(-item for item in offset)), + complex_group=True, + ) + forward = _apply_weak( + forward_link, + _neighbor(values, offset), + ) + backward = _apply_weak( + backward_link, + _neighbor(values, _offset3(tuple(-item for item in offset))), + ) + guard += weight * (2.0 * values - forward - backward) + if index < 3: + centered += 0.5 * _apply_spin( + _SPATIAL_GAMMAS[index], + forward - backward, + ) + return centered, guard + + +def apply_domain_wall( + values: np.ndarray, + weak_links: np.ndarray, + parameters: P4FDWParameters, +) -> np.ndarray: + """Apply the local domain-wall operator.""" + + registered = project_domain_wall_register(values) + centered, guard = _spatial_parts(registered, weak_links) + result = ( + centered + + parameters.guard_coefficient * guard + + (1.0 - parameters.overlap_rho) * registered + ) + result[..., :-1, :, :, :] -= _apply_spin( + _P_MINUS, + registered[..., 1:, :, :, :], + ) + result[..., 1:, :, :, :] -= _apply_spin( + _P_PLUS, + registered[..., :-1, :, :, :], + ) + return result + + +def apply_domain_wall_adjoint( + values: np.ndarray, + weak_links: np.ndarray, + parameters: P4FDWParameters, +) -> np.ndarray: + """Apply the exact adjoint of the local domain-wall operator.""" + + centered, guard = _spatial_parts(values, weak_links) + result = ( + -centered + parameters.guard_coefficient * guard + (1.0 - parameters.overlap_rho) * values + ) + result[..., 1:, :, :, :] -= _apply_spin( + _P_MINUS, + values[..., :-1, :, :, :], + ) + result[..., :-1, :, :, :] -= _apply_spin( + _P_PLUS, + values[..., 1:, :, :, :], + ) + return project_domain_wall_register(result) + + +def domain_wall_potential_and_rates( + state: P4FDWState, + parameters: P4FDWParameters = P4FDWParameters(), +) -> tuple[float, np.ndarray, np.ndarray]: + """Return domain-wall energy, matter rate, and weak-electric rate.""" + + _validate(state, parameters) + field = state.domain_wall_field + if not np.any(field): + return ( + 0.0, + np.zeros_like(field), + np.zeros_like(state.base.weak_electric), + ) + links = state.base.weak_links + output = apply_domain_wall(field, links, parameters) + energy = 0.5 * float(np.sum(np.abs(output) ** 2)) + matter_rate = -apply_domain_wall_adjoint( + output, + links, + parameters, + ) + electric_rate = np.zeros_like(state.base.weak_electric) + unique, _ = _link_table("19") + generators = su2_generators() + for index, (offset, weight) in enumerate(unique): + link = links[..., index, :, :] + field_target = _neighbor(field, offset) + output_target = _neighbor(output, offset) + for generator_index, generator in enumerate(generators): + link_variation = 1.0j * generator @ link + dagger_variation = _dagger(link_variation) + forward_variation = _apply_weak( + link_variation, + field_target, + ) + backward_variation = _apply_weak( + dagger_variation, + field, + ) + output_variation_at_base = -weight * forward_variation + output_variation_at_target = -weight * backward_variation + if index < 3: + output_variation_at_base += 0.5 * _apply_spin( + _SPATIAL_GAMMAS[index], + forward_variation, + ) + output_variation_at_target -= 0.5 * _apply_spin( + _SPATIAL_GAMMAS[index], + backward_variation, + ) + derivative = np.real( + np.sum( + np.conj(output) * output_variation_at_base, + axis=(-4, -3, -2, -1), + ) + + np.sum( + np.conj(output_target) * output_variation_at_target, + axis=(-4, -3, -2, -1), + ) + ) + electric_rate[..., index, generator_index] -= derivative + return energy, matter_rate, electric_rate + + +def total_hamiltonian( + state: P4FDWState, + parameters: P4FDWParameters = P4FDWParameters(), +) -> tuple[float, dict[str, float]]: + _validate(state, parameters) + base_energy, parts = p4f_total_hamiltonian( + state.base, + parameters.p4f, + ) + domain_wall_potential, _, _ = domain_wall_potential_and_rates( + state, + parameters, + ) + domain_wall_kinetic = 0.5 * float(np.sum(np.abs(state.domain_wall_momentum) ** 2)) + components = dict(parts) + components["domain_wall_potential"] = domain_wall_potential + components["domain_wall_kinetic"] = domain_wall_kinetic + return ( + base_energy + domain_wall_potential + domain_wall_kinetic, + components, + ) + + +def _combined_kick( + state: P4FDWState, + duration: float, + parameters: P4FDWParameters, +) -> None: + _potential_kick( + state.base, + duration, + parameters.p4f.r6.r5, + ) + _, matter_rate, electric_rate = domain_wall_potential_and_rates( + state, + parameters, + ) + state.domain_wall_momentum += duration * matter_rate + state.domain_wall_momentum = project_domain_wall_register(state.domain_wall_momentum) + state.base.weak_electric += duration * electric_rate + + +def step_p4f_domain_wall( + state: P4FDWState, + dt: float, + parameters: P4FDWParameters = P4FDWParameters(), +) -> None: + """Advance one symmetric local Hamiltonian split.""" + + if not np.isfinite(dt) or dt <= 0.0: + raise ValueError("dt must be positive and finite") + _validate(state, parameters) + base = state.base + r4 = parameters.p4f.r6.r4 + half = 0.5 * dt + _combined_kick(state, half, parameters) + _link_and_shape_drift(base.r3, half, r4.r3) + _extra_gauge_kinetic_drift(base, half, r4) + _weighted_bare_kinetic_drift(base.r3, dt, r4.r3) + _weak_matter_kinetic_drift(base, dt, r4) + state.domain_wall_field += dt * state.domain_wall_momentum + state.domain_wall_field = project_domain_wall_register(state.domain_wall_field) + _extra_gauge_kinetic_drift(base, half, r4) + _link_and_shape_drift(base.r3, half, r4.r3) + _combined_kick(state, half, parameters) + if inactive_frame_error(base) != 0.0: + raise RuntimeError("inactive frame changed under P4F-DW evolution") + + +def reverse_momenta(state: P4FDWState) -> P4FDWState: + reversed_state = state.copy() + reverse_r4_momenta(reversed_state.base) + reversed_state.domain_wall_momentum *= -1.0 + return reversed_state + + +def state_distance( + first: P4FDWState, + second: P4FDWState, +) -> float: + numerator = r4_state_distance(first.base, second.base) ** 2 + denominator = 1.0 + for name in ("domain_wall_field", "domain_wall_momentum"): + first_values = np.asarray(getattr(first, name)) + second_values = np.asarray(getattr(second, name)) + numerator += float(np.sum(np.abs(first_values - second_values) ** 2)) + denominator += float(np.sum(np.abs(first_values) ** 2)) + return float(np.sqrt(numerator / denominator)) + + +def p4f_domain_wall_action_declaration( + parameters: P4FDWParameters = P4FDWParameters(), +) -> dict[str, object]: + return { + "action_id": P4F_DW_ACTION_ID, + "register_id": P4F_DW_REGISTER_ID, + "canonical_status": "EXPERIMENT_ONLY_UNPROMOTED", + "parameters": asdict(parameters), + "retained_action": p4f_action_declaration(parameters.p4f), + "added_registers": [ + "local_domain_wall_field", + "local_domain_wall_conjugate_momentum", + ], + "added_terms": [ + "half_domain_wall_momentum_norm_squared", + "half_domain_wall_operator_norm_squared", + "reciprocal_SU2_link_current_from_action_variation", + "one_wall_Pminus_chiral_phase_space_constraint", + ], + "locality": { + "spatial": "stencil19_site_and_link_hops", + "internal": "nearest_neighbor_open_chain", + "mirror_removal": ("local_Pplus_constraint_at_terminal_internal_wall"), + "inverse_solver": False, + "target_force": False, + }, + "paper_45_update_authorized": False, + } + + +def p4f_domain_wall_action_fingerprint( + parameters: P4FDWParameters = P4FDWParameters(), +) -> str: + encoded = json.dumps( + p4f_domain_wall_action_declaration(parameters), + sort_keys=True, + separators=(",", ":"), + ).encode("ascii") + return hashlib.sha256(encoded).hexdigest() diff --git a/lfm/foundations/parsimonious_four_force.py b/lfm/foundations/parsimonious_four_force.py new file mode 100644 index 0000000..51c27e2 --- /dev/null +++ b/lfm/foundations/parsimonious_four_force.py @@ -0,0 +1,222 @@ +"""Experiment-only parsimonious four-force LFM action. + +The action keeps the R6 local U(1), SU(2), and SU(3) connection machinery, +disables the independent SO(4) frame carrier, and uses the flat-octic chi +potential for the scalar gravity channel. Dormant frame arrays remain in the +shared experimental state container only for implementation compatibility. +They carry no action, momentum, source, or evolution in this action family. +""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import asdict, dataclass, field + +import numpy as np + +from lfm.foundations.r3_link_frame_live import R3LiveParameters +from lfm.foundations.r6_unified_live import ( + R6Parameters, + R6R4Parameters, + R6Rates, + R6State, + step_r6, +) +from lfm.foundations.r6_unified_live import ( + kinetic_energy as r6_kinetic_energy, +) +from lfm.foundations.r6_unified_live import ( + potential_energy_and_rates as r6_potential_energy_and_rates, +) +from lfm.foundations.r6_unified_live import ( + total_hamiltonian as r6_total_hamiltonian, +) +from lfm.foundations.r6_unified_live import ( + vacuum_state as r6_vacuum_state, +) + +P4F_ACTION_ID = "LFM-P4F-FLAT-CHI-LOCAL-GAUGE-EXPERIMENT-v1" +P4F_REGISTER_ID = "P4F=(Psi3,Pi3,chi,pchi,U1,E1,SU2L,E2,H,pH,SU3,E3)" + + +def _p4f_r3_parameters() -> R3LiveParameters: + return R3LiveParameters( + stencil="19", + frame_enabled=False, + chi_potential="flat_octic", + ) + + +@dataclass(frozen=True) +class P4FParameters: + """Frozen parameters for the parsimonious action candidate.""" + + r6: R6Parameters = field( + default_factory=lambda: R6Parameters(r4=R6R4Parameters(r3=_p4f_r3_parameters())) + ) + + def __post_init__(self) -> None: + r3 = self.r6.r4.r3 + if r3.frame_enabled: + raise ValueError("P4F prohibits the independent SO(4) frame") + if r3.chi_potential != "flat_octic": + raise ValueError("P4F requires the flat-octic chi potential") + if r3.stencil != "19": + raise ValueError("P4F v1 freezes the canonical 19-point graph") + + +def vacuum_state( + size: int, + parameters: P4FParameters = P4FParameters(), +) -> R6State: + return r6_vacuum_state(size, parameters.r6) + + +def potential_energy_and_rates( + state: R6State, + parameters: P4FParameters = P4FParameters(), +) -> tuple[float, R6Rates, dict[str, float]]: + return r6_potential_energy_and_rates(state, parameters.r6) + + +def kinetic_energy( + state: R6State, + parameters: P4FParameters = P4FParameters(), +) -> tuple[float, dict[str, float]]: + return r6_kinetic_energy(state, parameters.r6) + + +def total_hamiltonian( + state: R6State, + parameters: P4FParameters = P4FParameters(), +) -> tuple[float, dict[str, float]]: + return r6_total_hamiltonian(state, parameters.r6) + + +def inactive_frame_error(state: R6State) -> float: + """Return the largest departure of the compatibility frame from vacuum.""" + + base = state.r3 + identity = np.eye(4) + return max( + float(np.max(np.abs(base.shape))), + float(np.max(np.abs(base.shape_momentum))), + float(np.max(np.abs(base.frame_electric))), + float(np.max(np.abs(base.frame_links - identity))), + ) + + +def step_p4f( + state: R6State, + dt: float, + parameters: P4FParameters = P4FParameters(), +) -> None: + if inactive_frame_error(state) != 0.0: + raise ValueError("P4F compatibility frame must remain exact vacuum") + step_r6(state, dt, parameters.r6) + if inactive_frame_error(state) != 0.0: + raise RuntimeError("inactive frame changed under P4F evolution") + + +def p4f_action_declaration( + parameters: P4FParameters = P4FParameters(), +) -> dict[str, object]: + r3 = parameters.r6.r4.r3 + return { + "action_id": P4F_ACTION_ID, + "register_id": P4F_REGISTER_ID, + "canonical_status": "EXPERIMENT_ONLY_UNPROMOTED", + "parameters": asdict(parameters), + "site_terms": [ + "covariant_GOV01_matter", + "flat_octic_GOV02_chi", + "chi_squared_universal_matter_coupling", + "chi_weighted_SU2_orientation_alignment", + ], + "link_terms": [ + "compact_U1_electric_and_loop_energy", + "compact_SU2L_electric_and_loop_energy", + "compact_SU3_electric_and_loop_energy", + "local_positive_chi_color_dielectric", + ], + "reductions": { + "gravity_only": ( + "identity gauge links, zero link electric fields, zero weak " + "matter, fixed weak orientation" + ), + "bare_flat_octic": ("all connection sectors at exact identity vacuum"), + }, + "inactive_compatibility_registers": [ + "SO4 frame shape", + "SO4 frame links", + "SO4 frame electric momenta", + ], + "derivation_status": { + "flat_octic_range": "numerically_supported_not_unique", + "link_transformation_laws": "derived_from_local_covariance", + "link_dynamics": "leading_local_positive_Hamiltonian", + "group_selection": "motivated_not_unique", + "all_force_closure": "pending_strict_live_gate", + }, + "constants": { + "chi0": r3.chi0, + "kappa": r3.kappa, + "lambda_h": r3.lambda_h, + "epsilon_w": r3.epsilon_w, + }, + "forbidden_mechanisms_used": [], + "paper_45_update_authorized": False, + } + + +def p4f_action_fingerprint( + parameters: P4FParameters = P4FParameters(), +) -> str: + encoded = json.dumps( + p4f_action_declaration(parameters), + sort_keys=True, + separators=(",", ":"), + ).encode("ascii") + return hashlib.sha256(encoded).hexdigest() + + +def flat_octic_minimality_ledger() -> dict[str, object]: + """Audit the minimal gapless bounded monomial well in ``chi**2``. + + The audit is deliberately limited to analytic one-monomial potentials + whose leading departure from either vacuum is a power of + ``z = chi**2 - chi0**2``. It does not assert uniqueness among all smooth + local potentials. + """ + + rows = [] + for z_power in range(2, 7): + nonnegative = (z_power % 2) == 0 + gapless = z_power > 2 + rows.append( + { + "z_power": z_power, + "field_degree": 2 * z_power, + "nonnegative_for_both_signs_of_z": nonnegative, + "vacuum_hessian_vanishes": gapless, + "admissible": nonnegative and gapless, + } + ) + admissible = [row for row in rows if row["admissible"]] + minimum = min(int(row["z_power"]) for row in admissible) + return { + "assumptions": [ + "local analytic potential", + "Z2 symmetry through z=chi**2-chi0**2", + "vacua at plus_or_minus_chi0", + "bounded below on both sides of the vacuum", + "vanishing vacuum Hessian for an unscreened linear response", + "one leading monomial in z", + ], + "rows": rows, + "minimal_admissible_z_power": minimum, + "minimal_admissible_field_degree": 2 * minimum, + "normalization_identity": ("lambda_h*chi0**4*(z/chi0**2)**4=lambda_h*z**4/chi0**4"), + "uniqueness_boundary": ("minimal only within the declared analytic monomial class"), + } diff --git a/lfm/foundations/r3_link_frame.py b/lfm/foundations/r3_link_frame.py new file mode 100644 index 0000000..12ba5d9 --- /dev/null +++ b/lfm/foundations/r3_link_frame.py @@ -0,0 +1,340 @@ +"""Executable R3 link-frame action prototype for LFM. + +R3 retains the canonical matter and radial-chi sectors and adds: + +- a traceless symmetric four-direction frame-shape register; +- an oriented SO(4) frame-comparison link; +- an oriented U(1) phase link; and +- an oriented SU(3) color-frame link. + +The link variables make neighbor comparison local-covariant and give loop +holonomy a positive local energy. The SO(4) curvature admits two chiral +three-component pieces, providing a bounded parity-sensitive location for +the existing epsilon_W parameter. + +This module is a foundational candidate. It is not enabled in Simulation and +does not by itself establish live force recovery, confinement, weak chirality, +or a canonical change. +""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import asdict, dataclass + +import numpy as np + +from lfm.analysis.frame_completion import ( + FRAME_COMPONENT_COUNT, + frame_projectors, + rest_energy_source, +) +from lfm.constants import C_DEFAULT, CHI0, EPSILON_W, KAPPA, LAMBDA_H + +R3_REGISTER_ID = "R3=(Psi_a,chi,S_AB,UFrame_ij,U1_ij,U3_ij)" +R3_ACTION_ID = "LFM-R3-LINK-FRAME-CANDIDATE-v1" + + +def _unitarity_error(matrix: np.ndarray) -> float: + identity = np.eye(matrix.shape[0], dtype=matrix.dtype) + return float(np.max(np.abs(matrix @ matrix.conj().T - identity))) + + +@dataclass(frozen=True) +class R3LinkFrameParameters: + """Parameters of the single declared R3 candidate action.""" + + chi0: float = CHI0 + kappa: float = KAPPA + lambda_h: float = LAMBDA_H + wave_speed: float = C_DEFAULT + epsilon_w: float = EPSILON_W + phase_link_stiffness: float = 1.0 + color_link_stiffness: float = 1.0 + frame_link_stiffness: float | None = None + + @property + def frame_inertia(self) -> float: + """Return the common scale/shape inertia hypothesis B=chi0/kappa.""" + + return self.chi0 / self.kappa + + @property + def radial_mass_sq(self) -> float: + """Return the retained radial Mexican-hat curvature.""" + + return 8.0 * self.lambda_h * self.chi0**2 + + @property + def effective_frame_link_stiffness(self) -> float: + """Return the declared frame-link stiffness.""" + + if self.frame_link_stiffness is None: + return self.frame_inertia * self.wave_speed**2 + return self.frame_link_stiffness + + def __post_init__(self) -> None: + positive = ( + self.chi0, + self.kappa, + self.lambda_h, + self.wave_speed, + self.phase_link_stiffness, + self.color_link_stiffness, + ) + if not all(np.isfinite(value) and value > 0.0 for value in positive): + raise ValueError("R3 positive parameters must be finite") + if not np.isfinite(self.epsilon_w) or abs(self.epsilon_w) >= 1.0: + raise ValueError("epsilon_w must satisfy abs(epsilon_w)<1") + if self.frame_link_stiffness is not None and ( + not np.isfinite(self.frame_link_stiffness) or self.frame_link_stiffness <= 0.0 + ): + raise ValueError("frame_link_stiffness must be positive") + + +@dataclass(frozen=True) +class R3ProductLink: + """One oriented frame, phase, and color transport link.""" + + frame: np.ndarray + phase: complex + color: np.ndarray + + def __post_init__(self) -> None: + frame = np.asarray(self.frame, dtype=np.float64) + color = np.asarray(self.color, dtype=np.complex128) + phase = complex(self.phase) + if frame.shape != (4, 4): + raise ValueError("frame link must have shape (4,4)") + if color.shape != (3, 3): + raise ValueError("color link must have shape (3,3)") + if _unitarity_error(frame) > 1.0e-10: + raise ValueError("frame link must be orthogonal") + if float(np.linalg.det(frame)) <= 0.0: + raise ValueError("frame link must be orientation preserving") + if abs(abs(phase) - 1.0) > 1.0e-10: + raise ValueError("phase link must have unit magnitude") + if _unitarity_error(color) > 1.0e-10: + raise ValueError("color link must be unitary") + if abs(np.linalg.det(color) - 1.0) > 1.0e-10: + raise ValueError("color link must have determinant one") + object.__setattr__(self, "frame", frame) + object.__setattr__(self, "phase", phase) + object.__setattr__(self, "color", color) + + @classmethod + def identity(cls) -> R3ProductLink: + """Return the identity transport.""" + + return cls( + frame=np.eye(4), + phase=1.0 + 0.0j, + color=np.eye(3, dtype=np.complex128), + ) + + def reverse(self) -> R3ProductLink: + """Return the exactly constrained reverse-oriented link.""" + + return R3ProductLink( + frame=self.frame.T, + phase=np.conj(self.phase), + color=self.color.conj().T, + ) + + def compose(self, other: R3ProductLink) -> R3ProductLink: + """Return the ordered product of two compatible transports.""" + + return R3ProductLink( + frame=self.frame @ other.frame, + phase=self.phase * other.phase, + color=self.color @ other.color, + ) + + +def transform_internal_matter( + matter: np.ndarray, + *, + phase: complex, + color: np.ndarray, +) -> np.ndarray: + """Apply one local U(1) x SU(3) re-basing to a color triplet.""" + + vector = np.asarray(matter, dtype=np.complex128) + matrix = np.asarray(color, dtype=np.complex128) + if vector.shape != (3,) or matrix.shape != (3, 3): + raise ValueError("matter must be (3,) and color must be (3,3)") + return complex(phase) * (matrix @ vector) + + +def transform_product_link( + link: R3ProductLink, + *, + frame_i: np.ndarray, + frame_j: np.ndarray, + phase_i: complex, + phase_j: complex, + color_i: np.ndarray, + color_j: np.ndarray, +) -> R3ProductLink: + """Apply independent local endpoint changes of basis.""" + + left_frame = np.asarray(frame_i, dtype=np.float64) + right_frame = np.asarray(frame_j, dtype=np.float64) + left_color = np.asarray(color_i, dtype=np.complex128) + right_color = np.asarray(color_j, dtype=np.complex128) + return R3ProductLink( + frame=left_frame @ link.frame @ right_frame.T, + phase=complex(phase_i) * link.phase * np.conj(complex(phase_j)), + color=left_color @ link.color @ right_color.conj().T, + ) + + +def internal_covariant_difference( + matter_i: np.ndarray, + matter_j: np.ndarray, + link_ij: R3ProductLink, +) -> np.ndarray: + """Compare neighboring phase/color triplets in the local i basis.""" + + left = np.asarray(matter_i, dtype=np.complex128) + right = np.asarray(matter_j, dtype=np.complex128) + if left.shape != (3,) or right.shape != (3,): + raise ValueError("matter values must have shape (3,)") + transported = link_ij.phase * (link_ij.color @ right) + return transported - left + + +def chiral_frame_curvature( + frame_holonomy: np.ndarray, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Split infinitesimal SO(4) curvature into two chiral 3-vectors.""" + + matrix = np.asarray(frame_holonomy, dtype=np.float64) + if matrix.shape != (4, 4): + raise ValueError("frame_holonomy must have shape (4,4)") + omega = 0.5 * (matrix - matrix.T) + temporal = np.asarray([omega[0, 1], omega[0, 2], omega[0, 3]]) + spatial = np.asarray([omega[2, 3], omega[3, 1], omega[1, 2]]) + plus = (temporal + spatial) / np.sqrt(2.0) + minus = (temporal - spatial) / np.sqrt(2.0) + return plus, minus, omega + + +def product_plaquette_energy( + holonomy: R3ProductLink, + parameters: R3LinkFrameParameters = R3LinkFrameParameters(), +) -> dict[str, float]: + """Return positive local loop energies for the declared R3 action.""" + + identity4 = np.eye(4) + symmetric_mismatch = 0.5 * (holonomy.frame + holonomy.frame.T) - identity4 + plus, minus, _ = chiral_frame_curvature(holonomy.frame) + frame_stiffness = parameters.effective_frame_link_stiffness + frame_even = 0.5 * frame_stiffness * float(np.sum(symmetric_mismatch**2)) + frame_chiral = ( + 0.5 + * frame_stiffness + * ( + (1.0 + parameters.epsilon_w) * float(plus @ plus) + + (1.0 - parameters.epsilon_w) * float(minus @ minus) + ) + ) + phase = parameters.phase_link_stiffness * (1.0 - float(np.real(holonomy.phase))) + color = parameters.color_link_stiffness * (3.0 - float(np.real(np.trace(holonomy.color)))) + total = frame_even + frame_chiral + phase + color + return { + "frame_even": frame_even, + "frame_chiral": frame_chiral, + "phase": phase, + "color": color, + "total": total, + } + + +def frame_shape_acceleration( + laplacian_shape: np.ndarray, + bare_energy_density: np.ndarray, + shape: np.ndarray, + parameters: R3LinkFrameParameters = R3LinkFrameParameters(), +) -> np.ndarray: + """Return the candidate sourced R3 frame-shape acceleration.""" + + laplacian = np.asarray(laplacian_shape, dtype=np.float64) + field = np.asarray(shape, dtype=np.float64) + density = np.asarray(bare_energy_density, dtype=np.float64) + if laplacian.shape != field.shape or field.shape[-1] != FRAME_COMPONENT_COUNT: + raise ValueError("shape arrays must match with final dimension 10") + if density.shape != field.shape[:-1]: + raise ValueError("bare_energy_density must match spatial shape") + _, shape_projector = frame_projectors() + projected_laplacian = np.einsum( + "ij,...j->...i", + shape_projector, + laplacian, + ) + source = shape_projector @ rest_energy_source() + clock_factor = np.exp(field[..., 0]) + return ( + parameters.wave_speed**2 * projected_laplacian + - clock_factor[..., np.newaxis] + * density[..., np.newaxis] + * source + / parameters.frame_inertia + ) + + +def r3_action_declaration( + parameters: R3LinkFrameParameters = R3LinkFrameParameters(), +) -> dict[str, object]: + """Return the machine-readable single-action declaration.""" + + payload = asdict(parameters) + payload["frame_inertia"] = parameters.frame_inertia + payload["radial_mass_sq"] = parameters.radial_mass_sq + payload["effective_frame_link_stiffness"] = parameters.effective_frame_link_stiffness + return { + "action_id": R3_ACTION_ID, + "register_id": R3_REGISTER_ID, + "canonical_status": "UNPROMOTED_FOUNDATIONAL_CANDIDATE", + "retained_sectors": [ + "bare_GOV01_matter", + "bare_GOV02_radial_chi", + "full_mexican_hat", + ], + "added_site_registers": ["traceless_frame_shape_S_AB"], + "added_link_registers": [ + "SO4_frame_transport", + "U1_phase_transport", + "SU3_color_transport", + ], + "neighbor_term": "norm(U1_ij*U3_ij*Psi_j-Psi_i)^2", + "loop_terms": [ + "positive_SO4_chiral_frame_mismatch", + "positive_U1_plaquette_mismatch", + "positive_SU3_plaquette_mismatch", + ], + "weak_location": ("bounded parity weighting of the two SO4 chiral curvature pieces"), + "parameters": payload, + "known_open_items": [ + "frame normalization derivation", + "live link Hamilton equations", + "weak matter representation and mediator mass", + "strong confinement and running", + "quantitative long-range force recovery", + "integrated all-four evolution", + ], + } + + +def r3_action_fingerprint( + parameters: R3LinkFrameParameters = R3LinkFrameParameters(), +) -> str: + """Return a stable fingerprint of the declared candidate action.""" + + encoded = json.dumps( + r3_action_declaration(parameters), + sort_keys=True, + separators=(",", ":"), + ).encode("ascii") + return hashlib.sha256(encoded).hexdigest() diff --git a/lfm/foundations/r3_link_frame_live.py b/lfm/foundations/r3_link_frame_live.py new file mode 100644 index 0000000..77ba3c9 --- /dev/null +++ b/lfm/foundations/r3_link_frame_live.py @@ -0,0 +1,961 @@ +"""Live Hamiltonian evolution for the experimental R3 link-frame register. + +The autonomous local Hamiltonian evolves complex three-color matter, radial +chi, a traceless symmetric frame shape, compact U(1)/SU(3)/SO(4) links, and +all conjugate momenta. It contains no target-force update, inverse +Laplacian, prescribed trajectory, or canonical promotion. +""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import asdict, dataclass +from functools import lru_cache +from typing import cast + +import numpy as np +from scipy.linalg import expm + +from lfm.analysis.energy_current import Offset, stencil_links +from lfm.constants import C_DEFAULT, CHI0, EPSILON_W, KAPPA, LAMBDA_H + +R3_LIVE_ACTION_ID = "LFM-R3-LINK-FRAME-LIVE-EXPERIMENT-v2" +R3_LIVE_REGISTER_ID = "R3Live=(Psi_a,Pi_a,chi,p_chi,S_AB,P_AB,UFrame,EFrame,U1,E1,U3,E3)" + + +def _offset3(values: tuple[int, ...]) -> Offset: + return (values[0], values[1], values[2]) + + +def _neighbor(values: np.ndarray, offset: Offset) -> np.ndarray: + return np.roll( + values, + shift=tuple(-value for value in offset), + axis=(0, 1, 2), + ) + + +def _scatter_from_base(values: np.ndarray, offset: Offset) -> np.ndarray: + return np.roll(values, shift=offset, axis=(0, 1, 2)) + + +def _dagger(values: np.ndarray) -> np.ndarray: + return np.swapaxes(values.conj(), -1, -2) + + +def _transpose(values: np.ndarray) -> np.ndarray: + return np.swapaxes(values, -1, -2) + + +def _tracefree_symmetric(values: np.ndarray) -> np.ndarray: + symmetric = 0.5 * (values + _transpose(values)) + trace = np.trace(symmetric, axis1=-2, axis2=-1) / 4.0 + return symmetric - trace[..., np.newaxis, np.newaxis] * np.eye(4) + + +def _temporal_shape_projector() -> np.ndarray: + source = np.zeros((4, 4), dtype=np.float64) + source[0, 0] = 1.0 + return _tracefree_symmetric(source) + + +@lru_cache(maxsize=1) +def su3_generators() -> np.ndarray: + """Return Hermitian traceless generators T_a=lambda_a/2.""" + + zero = np.zeros((3, 3), dtype=np.complex128) + generators: list[np.ndarray] = [] + + def add(entries: tuple[tuple[int, int, complex], ...]) -> None: + matrix = zero.copy() + for row, column, value in entries: + matrix[row, column] = value + generators.append(0.5 * matrix) + + add(((0, 1, 1.0), (1, 0, 1.0))) + add(((0, 1, -1.0j), (1, 0, 1.0j))) + add(((0, 0, 1.0), (1, 1, -1.0))) + add(((0, 2, 1.0), (2, 0, 1.0))) + add(((0, 2, -1.0j), (2, 0, 1.0j))) + add(((1, 2, 1.0), (2, 1, 1.0))) + add(((1, 2, -1.0j), (2, 1, 1.0j))) + add( + ( + (0, 0, 1.0 / np.sqrt(3.0)), + (1, 1, 1.0 / np.sqrt(3.0)), + (2, 2, -2.0 / np.sqrt(3.0)), + ) + ) + return np.stack(generators) + + +@lru_cache(maxsize=1) +def so4_generators() -> np.ndarray: + """Return six unit-Frobenius antisymmetric SO(4) generators.""" + + generators = [] + scale = 1.0 / np.sqrt(2.0) + for first in range(4): + for second in range(first + 1, 4): + matrix = np.zeros((4, 4), dtype=np.float64) + matrix[first, second] = scale + matrix[second, first] = -scale + generators.append(matrix) + return np.stack(generators) + + +@lru_cache(maxsize=4) +def _link_table( + stencil: str, +) -> tuple[ + tuple[tuple[Offset, float], ...], + dict[Offset, tuple[int, bool]], +]: + unique = stencil_links(stencil, oriented=False) + table: dict[Offset, tuple[int, bool]] = {} + for index, (offset, _) in enumerate(unique): + table[offset] = (index, False) + reverse = _offset3(tuple(-value for value in offset)) + table[reverse] = (index, True) + return unique, table + + +@lru_cache(maxsize=2) +def triangle_loops( + stencil: str, +) -> tuple[tuple[Offset, Offset, Offset, float], ...]: + """Return one orientation of every local three-link loop type.""" + + unique, table = _link_table(stencil) + weights = {offset: weight for offset, weight in stencil_links(stencil)} + offsets = tuple(table) + candidates: set[tuple[Offset, Offset, Offset]] = set() + for first in offsets: + for second in offsets: + third = _offset3(tuple(-(first[axis] + second[axis]) for axis in range(3))) + if third not in table: + continue + triple = cast("tuple[Offset, Offset, Offset]", tuple(sorted((first, second, third)))) + reverse = cast( + "tuple[Offset, Offset, Offset]", + tuple(sorted(_offset3(tuple(-value for value in item)) for item in triple)), + ) + candidates.add(min(triple, reverse)) + loops = [] + for triple in sorted(candidates): + first, second, third = triple + weight = (weights[first] * weights[second] * weights[third]) ** (1.0 / 3.0) + loops.append((first, second, third, weight)) + if not unique or not loops: + raise RuntimeError("stencil must contain links and local loops") + return tuple(loops) + + +@dataclass(frozen=True) +class R3LiveParameters: + """Frozen parameters of the live experimental action.""" + + chi0: float = CHI0 + kappa: float = KAPPA + lambda_h: float = LAMBDA_H + wave_speed: float = C_DEFAULT + epsilon_w: float = EPSILON_W + phase_stiffness: float = 1.0 + color_stiffness: float = 1.0 + phase_inertia: float = 1.0 + color_inertia: float = 1.0 + stencil: str = "19" + frame_enabled: bool = True + chi_potential: str = "quartic" + + @property + def frame_inertia(self) -> float: + return self.chi0 / self.kappa + + @property + def frame_stiffness(self) -> float: + return self.frame_inertia * self.wave_speed**2 + + def __post_init__(self) -> None: + positive = ( + self.chi0, + self.kappa, + self.lambda_h, + self.wave_speed, + self.phase_stiffness, + self.color_stiffness, + self.phase_inertia, + self.color_inertia, + ) + if not all(np.isfinite(value) and value > 0.0 for value in positive): + raise ValueError("live R3 parameters must be positive and finite") + if not np.isfinite(self.epsilon_w) or abs(self.epsilon_w) >= 1.0: + raise ValueError("epsilon_w must satisfy abs(epsilon_w)<1") + if self.chi_potential not in {"quartic", "flat_octic"}: + raise ValueError("chi_potential must be 'quartic' or 'flat_octic'") + _link_table(self.stencil) + triangle_loops(self.stencil) + + +@dataclass +class R3LiveState: + """Complete site and oriented-link phase space of the live experiment.""" + + matter: np.ndarray + matter_momentum: np.ndarray + chi: np.ndarray + chi_momentum: np.ndarray + shape: np.ndarray + shape_momentum: np.ndarray + phase_links: np.ndarray + phase_electric: np.ndarray + color_links: np.ndarray + color_electric: np.ndarray + frame_links: np.ndarray + frame_electric: np.ndarray + + @classmethod + def vacuum( + cls, + size: int, + parameters: R3LiveParameters = R3LiveParameters(), + ) -> R3LiveState: + """Return the exact periodic R3 vacuum.""" + + if size < 2: + raise ValueError("size must be at least two") + link_count = len(_link_table(parameters.stencil)[0]) + sites = (size, size, size) + return cls( + matter=np.zeros(sites + (3,), dtype=np.complex128), + matter_momentum=np.zeros(sites + (3,), dtype=np.complex128), + chi=np.full(sites, parameters.chi0, dtype=np.float64), + chi_momentum=np.zeros(sites, dtype=np.float64), + shape=np.zeros(sites + (4, 4), dtype=np.float64), + shape_momentum=np.zeros(sites + (4, 4), dtype=np.float64), + phase_links=np.ones(sites + (link_count,), dtype=np.complex128), + phase_electric=np.zeros(sites + (link_count,), dtype=np.float64), + color_links=np.broadcast_to( + np.eye(3, dtype=np.complex128), + sites + (link_count, 3, 3), + ).copy(), + color_electric=np.zeros( + sites + (link_count, 8), + dtype=np.float64, + ), + frame_links=np.broadcast_to( + np.eye(4, dtype=np.float64), + sites + (link_count, 4, 4), + ).copy(), + frame_electric=np.zeros( + sites + (link_count, 6), + dtype=np.float64, + ), + ) + + def copy(self) -> R3LiveState: + return R3LiveState( + **{name: np.asarray(getattr(self, name)).copy() for name in self.__dataclass_fields__} + ) + + +@dataclass +class R3MomentumRates: + matter: np.ndarray + chi: np.ndarray + shape: np.ndarray + phase_electric: np.ndarray + color_electric: np.ndarray + frame_electric: np.ndarray + + +def _validate_state( + state: R3LiveState, + parameters: R3LiveParameters, +) -> tuple[int, int, int]: + sites = state.chi.shape + if len(sites) != 3 or min(sites) < 2: + raise ValueError("chi must have a three-dimensional lattice shape") + link_count = len(_link_table(parameters.stencil)[0]) + expected = { + "matter": sites + (3,), + "matter_momentum": sites + (3,), + "chi_momentum": sites, + "shape": sites + (4, 4), + "shape_momentum": sites + (4, 4), + "phase_links": sites + (link_count,), + "phase_electric": sites + (link_count,), + "color_links": sites + (link_count, 3, 3), + "color_electric": sites + (link_count, 8), + "frame_links": sites + (link_count, 4, 4), + "frame_electric": sites + (link_count, 6), + } + for name, shape in expected.items(): + values = np.asarray(getattr(state, name)) + if values.shape != shape: + raise ValueError(f"{name} must have shape {shape}") + if not np.all(np.isfinite(values)): + raise ValueError(f"{name} contains a non-finite value") + return sites + + +def _stencil_for_links(links: np.ndarray) -> str: + return "19" if links.shape[3] == 9 else "27" + + +def _oriented_link( + links: np.ndarray, + offset: Offset, + *, + complex_group: bool, +) -> np.ndarray: + _, table = _link_table(_stencil_for_links(links)) + index, reverse = table[offset] + selected = links[..., index, :, :] if links.ndim >= 6 else links[..., index] + if not reverse: + return selected + shifted = _neighbor(selected, offset) + if selected.ndim >= 5: + return _dagger(shifted) if complex_group else _transpose(shifted) + return shifted.conj() + + +def _accumulate_oriented_gradient( + target: np.ndarray, + offset: Offset, + gradient: np.ndarray, + *, + stencil: str, + complex_group: bool, +) -> None: + _, table = _link_table(stencil) + index, reverse = table[offset] + if not reverse: + target[..., index, :, :] += gradient + return + converted = _dagger(gradient) if complex_group else _transpose(gradient) + target[..., index, :, :] += _scatter_from_base(converted, offset) + + +def _accumulate_oriented_phase_gradient( + target: np.ndarray, + offset: Offset, + gradient: np.ndarray, + *, + stencil: str, +) -> None: + _, table = _link_table(stencil) + index, reverse = table[offset] + if not reverse: + target[..., index] += gradient + return + target[..., index] += _scatter_from_base(gradient.conj(), offset) + + +def _frame_loop_energy_gradient( + holonomy: np.ndarray, + coefficient: float, + epsilon_w: float, +) -> tuple[np.ndarray, np.ndarray]: + identity = np.eye(4) + symmetric = 0.5 * (holonomy + _transpose(holonomy)) - identity + omega = 0.5 * (holonomy - _transpose(holonomy)) + temporal = np.stack( + (omega[..., 0, 1], omega[..., 0, 2], omega[..., 0, 3]), + axis=-1, + ) + spatial = np.stack( + (omega[..., 2, 3], omega[..., 3, 1], omega[..., 1, 2]), + axis=-1, + ) + plus = (temporal + spatial) / np.sqrt(2.0) + minus = (temporal - spatial) / np.sqrt(2.0) + energy = ( + 0.5 + * coefficient + * ( + np.sum(symmetric**2, axis=(-2, -1)) + + (1.0 + epsilon_w) * np.sum(plus**2, axis=-1) + + (1.0 - epsilon_w) * np.sum(minus**2, axis=-1) + ) + ) + gradient = coefficient * symmetric + temporal_derivative = coefficient * (temporal + epsilon_w * spatial) + spatial_derivative = coefficient * (spatial + epsilon_w * temporal) + pairs = ( + (0, 1, temporal_derivative[..., 0]), + (0, 2, temporal_derivative[..., 1]), + (0, 3, temporal_derivative[..., 2]), + (2, 3, spatial_derivative[..., 0]), + (3, 1, spatial_derivative[..., 1]), + (1, 2, spatial_derivative[..., 2]), + ) + for first, second, value in pairs: + gradient[..., first, second] += 0.5 * value + gradient[..., second, first] -= 0.5 * value + return energy, gradient + + +def _loop_products( + links: np.ndarray, + first: Offset, + second: Offset, + third: Offset, + *, + complex_group: bool, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + first_link = _oriented_link( + links, + first, + complex_group=complex_group, + ) + second_link = _neighbor( + _oriented_link(links, second, complex_group=complex_group), + first, + ) + first_second = _offset3(tuple(first[axis] + second[axis] for axis in range(3))) + third_link = _neighbor( + _oriented_link(links, third, complex_group=complex_group), + first_second, + ) + if links.ndim >= 6: + holonomy = first_link @ second_link @ third_link + else: + holonomy = first_link * second_link * third_link + return first_link, second_link, third_link, holonomy + + +def _scatter_gradient_to_base( + gradient: np.ndarray, + base_shift: Offset, +) -> np.ndarray: + return _scatter_from_base(gradient, base_shift) + + +def potential_energy_and_rates( + state: R3LiveState, + parameters: R3LiveParameters = R3LiveParameters(), +) -> tuple[float, R3MomentumRates, dict[str, float]]: + """Return coordinate energy and all reciprocal momentum rates.""" + + _validate_state(state, parameters) + unique, _ = _link_table(parameters.stencil) + sites = state.chi.shape + matter_rate = np.zeros_like(state.matter) + chi_rate = np.zeros_like(state.chi) + shape_rate = np.zeros_like(state.shape) + phase_rate = np.zeros_like(state.phase_electric) + color_rate = np.zeros_like(state.color_electric) + frame_rate = np.zeros_like(state.frame_electric) + color_gradient = np.zeros_like(state.color_links) + frame_gradient = np.zeros_like(state.frame_links) + phase_gradient = np.zeros_like(state.phase_links) + q = ( + np.exp(state.shape[..., 0, 0]) + if parameters.frame_enabled + else np.ones(sites, dtype=np.float64) + ) + potential_source_density = np.zeros(sites, dtype=np.float64) + components = { + "matter_gradient": 0.0, + "chi_gradient": 0.0, + "onsite": 0.0, + "shape_gradient": 0.0, + "phase_loop": 0.0, + "color_loop": 0.0, + "frame_loop": 0.0, + } + color_generators = su3_generators() + frame_generators = so4_generators() + + for index, (offset, weight) in enumerate(unique): + matter_j = _neighbor(state.matter, offset) + q_j = _neighbor(q, offset) + phase = state.phase_links[..., index] + color = state.color_links[..., index, :, :] + transported = phase[..., np.newaxis] * np.einsum( + "...ab,...b->...a", + color, + matter_j, + ) + difference = transported - state.matter + norm_sq = np.sum(np.abs(difference) ** 2, axis=-1) + source_half = 0.25 * parameters.wave_speed**2 * weight * norm_sq + energy_density = (q + q_j) * source_half + components["matter_gradient"] += float(np.sum(energy_density)) + potential_source_density += source_half + potential_source_density += _scatter_from_base(source_half, offset) + force_scale = 0.5 * parameters.wave_speed**2 * weight * (q + q_j) + matter_rate += force_scale[..., np.newaxis] * difference + neighbor_force = ( + -force_scale[..., np.newaxis] + * np.conj(phase)[..., np.newaxis] + * np.einsum( + "...ab,...b->...a", + _dagger(color), + difference, + ) + ) + matter_rate += _scatter_from_base(neighbor_force, offset) + phase_derivative = force_scale * np.real( + np.sum( + np.conj(difference) * (1.0j * transported), + axis=-1, + ) + ) + phase_rate[..., index] -= phase_derivative + for generator_index, generator in enumerate(color_generators): + variation = 1.0j * np.einsum( + "ab,...b->...a", + generator, + transported, + ) + derivative = force_scale * np.real(np.sum(np.conj(difference) * variation, axis=-1)) + color_rate[..., index, generator_index] -= derivative + + chi_j = _neighbor(state.chi, offset) + chi_difference = chi_j - state.chi + chi_source_half = ( + 0.25 * parameters.frame_inertia * parameters.wave_speed**2 * weight * chi_difference**2 + ) + chi_energy_density = (q + q_j) * chi_source_half + components["chi_gradient"] += float(np.sum(chi_energy_density)) + potential_source_density += chi_source_half + potential_source_density += _scatter_from_base( + chi_source_half, + offset, + ) + chi_force_scale = ( + 0.5 * parameters.frame_inertia * parameters.wave_speed**2 * weight * (q + q_j) + ) + chi_force = chi_force_scale * chi_difference + chi_rate += chi_force + chi_rate += _scatter_from_base(-chi_force, offset) + + if parameters.frame_enabled: + shape_j = _neighbor(state.shape, offset) + frame = state.frame_links[..., index, :, :] + transported_shape = frame @ shape_j @ _transpose(frame) + shape_difference = transported_shape - state.shape + shape_norm_sq = np.sum( + shape_difference**2, + axis=(-2, -1), + ) + shape_scale = parameters.frame_stiffness * weight + components["shape_gradient"] += float(np.sum(0.5 * shape_scale * shape_norm_sq)) + shape_rate += shape_scale * shape_difference + neighbor_shape_force = -shape_scale * (_transpose(frame) @ shape_difference @ frame) + shape_rate += _scatter_from_base( + neighbor_shape_force, + offset, + ) + for generator_index, generator in enumerate(frame_generators): + variation = generator @ transported_shape - transported_shape @ generator + derivative = shape_scale * np.sum( + shape_difference * variation, + axis=(-2, -1), + ) + frame_rate[..., index, generator_index] -= derivative + + matter_norm_sq = np.sum(np.abs(state.matter) ** 2, axis=-1) + interaction = 0.5 * state.chi**2 * matter_norm_sq + chi_delta = state.chi**2 - parameters.chi0**2 + if parameters.chi_potential == "quartic": + radial = parameters.frame_inertia * parameters.lambda_h * chi_delta**2 + radial_force = 4.0 * parameters.frame_inertia * parameters.lambda_h * state.chi * chi_delta + else: + radial = parameters.frame_inertia * parameters.lambda_h * chi_delta**4 / parameters.chi0**4 + radial_force = ( + 8.0 + * parameters.frame_inertia + * parameters.lambda_h + * state.chi + * chi_delta**3 + / parameters.chi0**4 + ) + onsite = interaction + radial + components["onsite"] = float(np.sum(q * onsite)) + potential_source_density += onsite + matter_rate -= (q * state.chi**2)[..., np.newaxis] * state.matter + chi_rate -= q * (state.chi * matter_norm_sq + radial_force) + if parameters.frame_enabled: + shape_rate -= (q * potential_source_density)[ + ..., np.newaxis, np.newaxis + ] * _temporal_shape_projector() + + identity3 = np.eye(3, dtype=np.complex128) + for first, second, third, loop_weight in triangle_loops(parameters.stencil): + phase_one, phase_two, phase_three, phase_holonomy = _loop_products( + state.phase_links, + first, + second, + third, + complex_group=True, + ) + phase_coefficient = parameters.phase_stiffness * loop_weight + components["phase_loop"] += float( + np.sum(phase_coefficient * (1.0 - np.real(phase_holonomy))) + ) + phase_h_gradient = np.full( + sites, + -phase_coefficient, + dtype=np.complex128, + ) + phase_gradients = ( + phase_h_gradient * np.conj(phase_two * phase_three), + np.conj(phase_one) * phase_h_gradient * np.conj(phase_three), + np.conj(phase_one * phase_two) * phase_h_gradient, + ) + base_shifts: tuple[Offset, Offset, Offset] = ( + (0, 0, 0), + first, + _offset3(tuple(first[axis] + second[axis] for axis in range(3))), + ) + for offset, base_shift, gradient in zip( + (first, second, third), + base_shifts, + phase_gradients, + strict=True, + ): + gradient_at_base = _scatter_gradient_to_base( + gradient, + base_shift, + ) + _accumulate_oriented_phase_gradient( + phase_gradient, + offset, + gradient_at_base, + stencil=parameters.stencil, + ) + + color_one, color_two, color_three, color_holonomy = _loop_products( + state.color_links, + first, + second, + third, + complex_group=True, + ) + color_coefficient = parameters.color_stiffness * loop_weight + color_loop_density = color_coefficient * ( + 3.0 - np.real(np.trace(color_holonomy, axis1=-2, axis2=-1)) + ) + components["color_loop"] += float(np.sum(color_loop_density)) + color_h_gradient = np.broadcast_to( + -color_coefficient * identity3, + color_holonomy.shape, + ) + color_gradients = ( + color_h_gradient @ _dagger(color_two @ color_three), + _dagger(color_one) @ color_h_gradient @ _dagger(color_three), + _dagger(color_one @ color_two) @ color_h_gradient, + ) + for offset, base_shift, gradient in zip( + (first, second, third), + base_shifts, + color_gradients, + strict=True, + ): + gradient_at_base = _scatter_gradient_to_base( + gradient, + base_shift, + ) + _accumulate_oriented_gradient( + color_gradient, + offset, + gradient_at_base, + stencil=parameters.stencil, + complex_group=True, + ) + + if parameters.frame_enabled: + frame_one, frame_two, frame_three, frame_holonomy = _loop_products( + state.frame_links, + first, + second, + third, + complex_group=False, + ) + frame_energy, frame_h_gradient = _frame_loop_energy_gradient( + frame_holonomy, + parameters.frame_stiffness * loop_weight, + parameters.epsilon_w, + ) + components["frame_loop"] += float(np.sum(frame_energy)) + frame_gradients = ( + frame_h_gradient @ _transpose(frame_two @ frame_three), + _transpose(frame_one) @ frame_h_gradient @ _transpose(frame_three), + _transpose(frame_one @ frame_two) @ frame_h_gradient, + ) + for offset, base_shift, gradient in zip( + (first, second, third), + base_shifts, + frame_gradients, + strict=True, + ): + gradient_at_base = _scatter_gradient_to_base( + gradient, + base_shift, + ) + _accumulate_oriented_gradient( + frame_gradient, + offset, + gradient_at_base, + stencil=parameters.stencil, + complex_group=False, + ) + + for index in range(len(unique)): + phase = state.phase_links[..., index] + derivative = np.real(np.conj(phase_gradient[..., index]) * (1.0j * phase)) + phase_rate[..., index] -= derivative + color = state.color_links[..., index, :, :] + for generator_index, generator in enumerate(color_generators): + variation = 1.0j * generator @ color + derivative = np.real( + np.sum( + np.conj(color_gradient[..., index, :, :]) * variation, + axis=(-2, -1), + ) + ) + color_rate[..., index, generator_index] -= derivative + if parameters.frame_enabled: + frame = state.frame_links[..., index, :, :] + for generator_index, generator in enumerate(frame_generators): + variation = generator @ frame + derivative = np.sum( + frame_gradient[..., index, :, :] * variation, + axis=(-2, -1), + ) + frame_rate[..., index, generator_index] -= derivative + + shape_rate = _tracefree_symmetric(shape_rate) + potential = float(sum(components.values())) + rates = R3MomentumRates( + matter=matter_rate, + chi=chi_rate, + shape=shape_rate, + phase_electric=phase_rate, + color_electric=color_rate, + frame_electric=frame_rate, + ) + return potential, rates, components + + +def kinetic_energy( + state: R3LiveState, + parameters: R3LiveParameters = R3LiveParameters(), +) -> tuple[float, dict[str, float]]: + """Return the exact momentum-dependent Hamiltonian pieces.""" + + _validate_state(state, parameters) + q = np.exp(state.shape[..., 0, 0]) if parameters.frame_enabled else np.ones_like(state.chi) + bare_density = 0.5 * np.sum( + np.abs(state.matter_momentum) ** 2, axis=-1 + ) + state.chi_momentum**2 / (2.0 * parameters.frame_inertia) + components = { + "weighted_bare_kinetic": float(np.sum(q * bare_density)), + "shape_kinetic": ( + float(np.sum(state.shape_momentum**2) / (2.0 * parameters.frame_inertia)) + if parameters.frame_enabled + else 0.0 + ), + "phase_electric": float(np.sum(state.phase_electric**2) / (2.0 * parameters.phase_inertia)), + "color_electric": float(np.sum(state.color_electric**2) / (2.0 * parameters.color_inertia)), + "frame_electric": ( + float(np.sum(state.frame_electric**2) / (2.0 * parameters.frame_inertia)) + if parameters.frame_enabled + else 0.0 + ), + } + return float(sum(components.values())), components + + +def total_hamiltonian( + state: R3LiveState, + parameters: R3LiveParameters = R3LiveParameters(), +) -> tuple[float, dict[str, float]]: + kinetic, kinetic_parts = kinetic_energy(state, parameters) + potential, _, potential_parts = potential_energy_and_rates( + state, + parameters, + ) + parts = {**kinetic_parts, **potential_parts} + return kinetic + potential, parts + + +def _potential_kick( + state: R3LiveState, + duration: float, + parameters: R3LiveParameters, +) -> None: + _, rates, _ = potential_energy_and_rates(state, parameters) + state.matter_momentum += duration * rates.matter + state.chi_momentum += duration * rates.chi + if parameters.frame_enabled: + state.shape_momentum += duration * rates.shape + state.phase_electric += duration * rates.phase_electric + state.color_electric += duration * rates.color_electric + if parameters.frame_enabled: + state.frame_electric += duration * rates.frame_electric + + +def _link_and_shape_drift( + state: R3LiveState, + duration: float, + parameters: R3LiveParameters, +) -> None: + if parameters.frame_enabled: + state.shape += duration * state.shape_momentum / parameters.frame_inertia + state.shape = _tracefree_symmetric(state.shape) + color_generators = su3_generators() + frame_generators = so4_generators() + link_count = state.phase_links.shape[3] + color_is_live = bool(np.any(state.color_electric != 0.0)) + frame_is_live = parameters.frame_enabled and bool(np.any(state.frame_electric != 0.0)) + for index in range(link_count): + state.phase_links[..., index] *= np.exp( + 1.0j * duration * state.phase_electric[..., index] / parameters.phase_inertia + ) + for site in np.ndindex(state.chi.shape): + if color_is_live: + color_algebra = np.einsum( + "a,aij->ij", + state.color_electric[site + (index,)], + color_generators, + ) + state.color_links[site + (index,)] = ( + expm(1.0j * duration * color_algebra / parameters.color_inertia) + @ state.color_links[site + (index,)] + ) + if frame_is_live: + frame_algebra = np.einsum( + "a,aij->ij", + state.frame_electric[site + (index,)], + frame_generators, + ) + state.frame_links[site + (index,)] = ( + expm(duration * frame_algebra / parameters.frame_inertia) + @ state.frame_links[site + (index,)] + ) + + +def _weighted_bare_kinetic_drift( + state: R3LiveState, + duration: float, + parameters: R3LiveParameters, +) -> None: + q = np.exp(state.shape[..., 0, 0]) if parameters.frame_enabled else np.ones_like(state.chi) + kinetic_density = 0.5 * np.sum( + np.abs(state.matter_momentum) ** 2, axis=-1 + ) + state.chi_momentum**2 / (2.0 * parameters.frame_inertia) + state.matter += duration * q[..., np.newaxis] * state.matter_momentum + state.chi += duration * q * state.chi_momentum / parameters.frame_inertia + if parameters.frame_enabled: + state.shape_momentum -= (duration * q * kinetic_density)[ + ..., np.newaxis, np.newaxis + ] * _temporal_shape_projector() + + +def step_r3_live( + state: R3LiveState, + dt: float, + parameters: R3LiveParameters = R3LiveParameters(), +) -> None: + """Advance one reversible second-order Hamiltonian splitting step.""" + + if not np.isfinite(dt) or dt <= 0.0: + raise ValueError("dt must be positive and finite") + _validate_state(state, parameters) + half = 0.5 * dt + _potential_kick(state, half, parameters) + _link_and_shape_drift(state, half, parameters) + _weighted_bare_kinetic_drift(state, dt, parameters) + _link_and_shape_drift(state, half, parameters) + _potential_kick(state, half, parameters) + + +def reverse_momenta(state: R3LiveState) -> None: + """Apply the exact experimental time-reversal momentum involution.""" + + state.matter_momentum *= -1.0 + state.chi_momentum *= -1.0 + state.shape_momentum *= -1.0 + state.phase_electric *= -1.0 + state.color_electric *= -1.0 + state.frame_electric *= -1.0 + + +def group_constraint_errors(state: R3LiveState) -> dict[str, float]: + """Return maximum compact-link and shape-constraint violations.""" + + phase = float(np.max(np.abs(np.abs(state.phase_links) - 1.0))) + color_identity = state.color_links @ _dagger(state.color_links) + color_unitarity = float(np.max(np.abs(color_identity - np.eye(3)))) + color_determinant = float(np.max(np.abs(np.linalg.det(state.color_links) - 1.0))) + frame_identity = state.frame_links @ _transpose(state.frame_links) + frame_orthogonality = float(np.max(np.abs(frame_identity - np.eye(4)))) + frame_determinant = float(np.max(np.abs(np.linalg.det(state.frame_links) - 1.0))) + shape_symmetry = float(np.max(np.abs(state.shape - _transpose(state.shape)))) + shape_trace = float(np.max(np.abs(np.trace(state.shape, axis1=-2, axis2=-1)))) + return { + "phase_unit": phase, + "color_unitarity": color_unitarity, + "color_determinant": color_determinant, + "frame_orthogonality": frame_orthogonality, + "frame_determinant": frame_determinant, + "shape_symmetry": shape_symmetry, + "shape_trace": shape_trace, + } + + +def state_distance(left: R3LiveState, right: R3LiveState) -> float: + """Return a relative Euclidean distance over the complete live state.""" + + numerator = 0.0 + denominator = 0.0 + for name in left.__dataclass_fields__: + left_values = np.asarray(getattr(left, name)) + right_values = np.asarray(getattr(right, name)) + numerator += float(np.sum(np.abs(left_values - right_values) ** 2)) + denominator += float(np.sum(np.abs(left_values) ** 2)) + return float(np.sqrt(numerator / max(denominator, 1.0))) + + +def r3_live_action_declaration( + parameters: R3LiveParameters = R3LiveParameters(), +) -> dict[str, object]: + """Return a machine-readable declaration of the live experiment.""" + + return { + "action_id": R3_LIVE_ACTION_ID, + "register_id": R3_LIVE_REGISTER_ID, + "canonical_status": "EXPERIMENT_ONLY_UNPROMOTED", + "parameters": asdict(parameters), + "frame_inertia": parameters.frame_inertia, + "frame_enabled": parameters.frame_enabled, + "chi_potential": parameters.chi_potential, + "site_terms": [ + "q_times_bare_GOV01_GOV02_Hamiltonian", + "traceless_frame_shape_kinetic_and_gradient", + ], + "link_terms": [ + "compact_electric_kinetic", + "covariant_matter_neighbor_energy", + "positive_triangle_holonomy_energy", + ], + "integrator": "symmetric_Hpotential_Hlink_Hbare_split", + "forbidden_mechanisms_used": [], + "paper_45_update_authorized": False, + } + + +def r3_live_action_fingerprint( + parameters: R3LiveParameters = R3LiveParameters(), +) -> str: + encoded = json.dumps( + r3_live_action_declaration(parameters), + sort_keys=True, + separators=(",", ":"), + ).encode("ascii") + return hashlib.sha256(encoded).hexdigest() diff --git a/lfm/foundations/r4_color_static.py b/lfm/foundations/r4_color_static.py new file mode 100644 index 0000000..e181d40 --- /dev/null +++ b/lfm/foundations/r4_color_static.py @@ -0,0 +1,509 @@ +"""Static Cartan-sector variational tools for the R4 color dielectric. + +The routines minimize the existing R4 electric-plus-chi Hamiltonian at fixed +external color charge. They do not add a potential, flux path, or string +tension. The electric field is obtained from the minimum-energy periodic +Gauss constraint for the current local dielectric. +""" + +from __future__ import annotations + +from dataclasses import dataclass +from typing import cast + +import numpy as np +from scipy.sparse.linalg import LinearOperator, cg + +from lfm.core.stencils import laplacian_19pt, laplacian_27pt +from lfm.foundations.r3_link_frame_live import _link_table +from lfm.foundations.r4_unified_live import ( + R4Parameters, + color_dielectric, +) + + +@dataclass +class R4ColorStaticState: + """One relaxed R4 Cartan electric/chi configuration.""" + + chi: np.ndarray + potential: np.ndarray + electric: np.ndarray + charge: np.ndarray + gauss_residual: float + energy: float + energy_parts: dict[str, float] + iterations: int + + +@dataclass +class R4FixedColorElectricState: + """R4 chi minimum for a fixed divergence-free color electric field.""" + + chi: np.ndarray + electric: np.ndarray + gauss_residual: float + energy: float + energy_parts: dict[str, float] + effective_g_squared: float + iterations: int + + +def r4_color_vacuum_instability_flux_sq( + parameters: R4Parameters = R4Parameters(), +) -> float: + """Return the local incident-flux threshold for chi=chi0 instability.""" + + chi0 = parameters.r3.chi0 + kappa = parameters.r3.kappa + return float( + 4.0 + * parameters.r3.frame_inertia + * parameters.r3.lambda_h + * kappa**2 + * chi0**4 + / (1.0 - kappa) + ) + + +def _laplacian(values: np.ndarray, stencil: str) -> np.ndarray: + if stencil == "19": + return laplacian_19pt(values) + if stencil == "27": + return laplacian_27pt(values) + raise ValueError("stencil must be '19' or '27'") + + +def _link_dielectric( + chi: np.ndarray, + parameters: R4Parameters, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + epsilon, derivative = color_dielectric(chi, parameters) + unique, _ = _link_table(parameters.stencil) + links = np.stack( + [ + 0.5 + * ( + epsilon + + np.roll( + epsilon, + shift=tuple(-value for value in offset), + axis=(0, 1, 2), + ) + ) + for offset, _ in unique + ], + axis=-1, + ) + return epsilon, derivative, links + + +def _electric_from_potential( + potential: np.ndarray, + link_epsilon: np.ndarray, + parameters: R4Parameters, +) -> np.ndarray: + unique, _ = _link_table(parameters.stencil) + electric = np.empty(potential.shape + (len(unique),)) + for index, (offset, _) in enumerate(unique): + neighbor = np.roll( + potential, + shift=tuple(-value for value in offset), + axis=(0, 1, 2), + ) + electric[..., index] = link_epsilon[..., index] * (potential - neighbor) + return electric + + +def color_gauss_divergence( + electric: np.ndarray, + parameters: R4Parameters, +) -> np.ndarray: + """Return periodic outgoing-minus-incoming Cartan electric flux.""" + + unique, _ = _link_table(parameters.stencil) + expected = electric.shape[:-1] + (len(unique),) + if electric.shape != expected: + raise ValueError("electric link count does not match stencil") + divergence = np.zeros(electric.shape[:-1], dtype=np.float64) + for index, (offset, _) in enumerate(unique): + outgoing = electric[..., index] + incoming = np.roll( + outgoing, + shift=offset, + axis=(0, 1, 2), + ) + divergence += outgoing - incoming + return divergence + + +def r4_color_incident_flux_sq( + electric: np.ndarray, + parameters: R4Parameters = R4Parameters(), +) -> np.ndarray: + """Return sum of squared outgoing and incoming Cartan link flux.""" + + unique, _ = _link_table(parameters.stencil) + if electric.ndim != 4 or electric.shape[-1] != len(unique): + raise ValueError("electric must match the R4 link graph") + result = np.sum(electric**2, axis=-1) + for index, (offset, _) in enumerate(unique): + result += np.roll( + electric[..., index] ** 2, + shift=offset, + axis=(0, 1, 2), + ) + return result + + +def solve_color_gauss_minimum( + chi: np.ndarray, + charge: np.ndarray, + parameters: R4Parameters = R4Parameters(), + *, + tolerance: float = 1.0e-10, + initial_potential: np.ndarray | None = None, +) -> tuple[np.ndarray, np.ndarray, float]: + """Return the minimum-electric-energy field satisfying Gauss charge.""" + + chi_values = np.asarray(chi, dtype=np.float64) + charge_values = np.asarray(charge, dtype=np.float64) + if chi_values.ndim != 3 or charge_values.shape != chi_values.shape: + raise ValueError("chi and charge must share a 3D shape") + if abs(float(np.sum(charge_values))) > 1.0e-10: + raise ValueError("periodic color charge must sum to zero") + if tolerance <= 0.0 or not np.isfinite(tolerance): + raise ValueError("tolerance must be positive and finite") + _, _, link_epsilon = _link_dielectric( + chi_values, + parameters, + ) + shape = chi_values.shape + count = int(np.prod(shape)) + + def matvec(vector: np.ndarray) -> np.ndarray: + potential = vector.reshape(shape) + electric = _electric_from_potential( + potential, + link_epsilon, + parameters, + ) + result = color_gauss_divergence( + electric, + parameters, + ) + result += np.mean(potential) + return result.reshape(-1) + + operator = LinearOperator( + (count, count), + matvec=matvec, + dtype=np.float64, + ) + guess = ( + np.zeros(shape, dtype=np.float64) + if initial_potential is None + else np.asarray(initial_potential, dtype=np.float64) + ) + if guess.shape != shape: + raise ValueError("initial_potential must match chi") + solution, info = cg( + operator, + charge_values.reshape(-1), + x0=guess.reshape(-1), + rtol=tolerance, + atol=0.0, + maxiter=20 * count, + ) + if info != 0: + raise RuntimeError(f"color Gauss iteration did not converge: {info}") + potential = solution.reshape(shape) + potential -= np.mean(potential) + electric = _electric_from_potential( + potential, + link_epsilon, + parameters, + ) + residual = ( + color_gauss_divergence( + electric, + parameters, + ) + - charge_values + ) + scale = max(float(np.max(np.abs(charge_values))), 1.0) + residual_norm = float(np.max(np.abs(residual)) / scale) + return potential, electric, residual_norm + + +def r4_color_static_energy( + chi: np.ndarray, + electric: np.ndarray, + parameters: R4Parameters = R4Parameters(), +) -> tuple[float, dict[str, float], np.ndarray]: + """Return constrained R4 Cartan energy and its site density.""" + + chi_values = np.asarray(chi, dtype=np.float64) + _, _, link_epsilon = _link_dielectric( + chi_values, + parameters, + ) + if electric.shape != link_epsilon.shape: + raise ValueError("electric must match the R4 link graph") + link_density = electric**2 / (2.0 * link_epsilon) + electric_energy = float(np.sum(link_density)) + radial_density = ( + parameters.r3.frame_inertia + * parameters.r3.lambda_h + * (chi_values**2 - parameters.r3.chi0**2) ** 2 + ) + radial_energy = float(np.sum(radial_density)) + laplacian = _laplacian(chi_values, parameters.stencil) + gradient_density = -0.5 * parameters.r3.frame_stiffness * chi_values * laplacian + gradient_energy = float(np.sum(gradient_density)) + site_electric = r4_color_electric_site_density( + chi_values, + electric, + parameters, + ) + site_density = site_electric + radial_density + gradient_density + parts = { + "color_electric": electric_energy, + "chi_radial": radial_energy, + "chi_gradient": gradient_energy, + } + return float(sum(parts.values())), parts, site_density + + +def r4_color_electric_site_density( + chi: np.ndarray, + electric: np.ndarray, + parameters: R4Parameters = R4Parameters(), +) -> np.ndarray: + """Assign half of each R4 Cartan link energy to either endpoint.""" + + chi_values = np.asarray(chi, dtype=np.float64) + _, _, link_epsilon = _link_dielectric( + chi_values, + parameters, + ) + if electric.shape != link_epsilon.shape: + raise ValueError("electric must match the R4 link graph") + link_density = electric**2 / (2.0 * link_epsilon) + site_electric = 0.5 * np.sum(link_density, axis=-1) + unique, _ = _link_table(parameters.stencil) + for index, (offset, _) in enumerate(unique): + site_electric += 0.5 * np.roll( + link_density[..., index], + shift=offset, + axis=(0, 1, 2), + ) + return site_electric + + +def r4_color_flux_observables( + state: R4ColorStaticState, + parameters: R4Parameters = R4Parameters(), +) -> dict[str, float]: + """Return path-independent point-pair flux/dielectric observables.""" + + nonzero = np.argwhere(np.abs(state.charge) > 0.0) + if nonzero.shape != (2, 3): + raise ValueError("flux observables require exactly two point charges") + difference = nonzero[1] - nonzero[0] + axes = np.flatnonzero(difference != 0) + if axes.size != 1: + raise ValueError("point charges must differ along one lattice axis") + longitudinal_axis = int(axes[0]) + transverse_axes = [axis for axis in range(3) if axis != longitudinal_axis] + density = r4_color_electric_site_density( + state.chi, + state.electric, + parameters, + ) + coordinates = np.indices(state.chi.shape, dtype=np.float64) + center = 0.5 * (nonzero[0] + nonzero[1]) + transverse_sq = np.zeros_like(state.chi) + for axis in transverse_axes: + displacement = np.abs(coordinates[axis] - center[axis]) + displacement = np.minimum( + displacement, + state.chi.shape[axis] - displacement, + ) + transverse_sq += displacement**2 + total = max(float(np.sum(density)), 1.0e-30) + transverse_rms = float(np.sqrt(np.sum(density * transverse_sq) / total)) + epsilon, _ = color_dielectric(state.chi, parameters) + density_flat = density.reshape(-1) + epsilon_flat = epsilon.reshape(-1) + if np.std(density_flat) == 0.0 or np.std(epsilon_flat) == 0.0: + correlation = 0.0 + else: + correlation = float(np.corrcoef(density_flat, epsilon_flat)[0, 1]) + return { + "transverse_flux_rms": transverse_rms, + "flux_dielectric_correlation": correlation, + "epsilon_min": float(np.min(epsilon)), + "epsilon_max": float(np.max(epsilon)), + "chi_min": float(np.min(state.chi)), + "chi_max": float(np.max(state.chi)), + } + + +def _chi_energy_gradient( + chi: np.ndarray, + electric: np.ndarray, + parameters: R4Parameters, +) -> np.ndarray: + epsilon, epsilon_derivative, link_epsilon = _link_dielectric( + chi, + parameters, + ) + del epsilon + gradient = 4.0 * parameters.r3.frame_inertia * parameters.r3.lambda_h * chi * ( + chi**2 - parameters.r3.chi0**2 + ) - parameters.r3.frame_stiffness * _laplacian(chi, parameters.stencil) + unique, _ = _link_table(parameters.stencil) + for index, (offset, _) in enumerate(unique): + endpoint = ( + -0.25 * electric[..., index] ** 2 * epsilon_derivative / link_epsilon[..., index] ** 2 + ) + gradient += endpoint + neighbor_endpoint = ( + -0.25 + * electric[..., index] ** 2 + * np.roll( + epsilon_derivative, + shift=tuple(-value for value in offset), + axis=(0, 1, 2), + ) + / link_epsilon[..., index] ** 2 + ) + gradient += np.roll( + neighbor_endpoint, + shift=offset, + axis=(0, 1, 2), + ) + return gradient + + +def relax_r4_color_static( + charge: np.ndarray, + parameters: R4Parameters = R4Parameters(), + *, + seed: int, + initial_chi_noise: float, + chi_iterations: int, + chi_step: float, + gauss_tolerance: float, + gauss_block: int = 10, +) -> R4ColorStaticState: + """Relax chi from an unbiased seed while enforcing color Gauss law.""" + + charge_values = np.asarray(charge, dtype=np.float64) + if charge_values.ndim != 3: + raise ValueError("charge must have a 3D shape") + if chi_iterations < 1 or gauss_block < 1: + raise ValueError("iteration counts must be positive") + if initial_chi_noise < 0.0 or chi_step <= 0.0: + raise ValueError("noise must be nonnegative and step positive") + rng = np.random.default_rng(seed) + chi = cast( + "np.ndarray", + parameters.r3.chi0 + initial_chi_noise * rng.normal(size=charge_values.shape), + ) + potential: np.ndarray = np.zeros_like(charge_values) + electric: np.ndarray = np.zeros( + charge_values.shape + (len(_link_table(parameters.stencil)[0]),), + dtype=np.float64, + ) + residual = float("inf") + for iteration in range(chi_iterations): + if iteration % gauss_block == 0: + potential, electric, residual = solve_color_gauss_minimum( + chi, + charge_values, + parameters, + tolerance=gauss_tolerance, + initial_potential=potential, + ) + chi -= chi_step * _chi_energy_gradient( + chi, + electric, + parameters, + ) + if not np.all(np.isfinite(chi)): + raise FloatingPointError("chi relaxation became non-finite") + potential, electric, residual = solve_color_gauss_minimum( + chi, + charge_values, + parameters, + tolerance=gauss_tolerance, + initial_potential=potential, + ) + energy, parts, _ = r4_color_static_energy( + chi, + electric, + parameters, + ) + return R4ColorStaticState( + chi=chi, + potential=potential, + electric=electric, + charge=charge_values.copy(), + gauss_residual=residual, + energy=energy, + energy_parts=parts, + iterations=chi_iterations, + ) + + +def relax_r4_chi_at_fixed_color_electric( + electric: np.ndarray, + parameters: R4Parameters = R4Parameters(), + *, + seed: int, + initial_chi_noise: float, + chi_iterations: int, + chi_step: float, +) -> R4FixedColorElectricState: + """Relax the existing R4 chi energy around a fixed color flux probe.""" + + electric_values = np.asarray(electric, dtype=np.float64) + expected_links = len(_link_table(parameters.stencil)[0]) + if electric_values.ndim != 4 or electric_values.shape[-1] != expected_links: + raise ValueError("electric must match one 3D R4 link graph") + if chi_iterations < 1 or chi_step <= 0.0: + raise ValueError("iterations and chi_step must be positive") + if initial_chi_noise < 0.0: + raise ValueError("initial_chi_noise must be nonnegative") + rng = np.random.default_rng(seed) + chi = parameters.r3.chi0 + initial_chi_noise * rng.normal(size=electric_values.shape[:-1]) + for _ in range(chi_iterations): + chi -= chi_step * _chi_energy_gradient( + chi, + electric_values, + parameters, + ) + if not np.all(np.isfinite(chi)): + raise FloatingPointError("fixed-flux chi relaxation became non-finite") + energy, parts, _ = r4_color_static_energy( + chi, + electric_values, + parameters, + ) + _, _, link_epsilon = _link_dielectric(chi, parameters) + electric_norm = float(np.sum(electric_values**2)) + effective_g_squared = float( + np.sum(electric_values**2 / link_epsilon) / max(electric_norm, 1.0e-30) + ) + gauss = color_gauss_divergence(electric_values, parameters) + return R4FixedColorElectricState( + chi=chi, + electric=electric_values.copy(), + gauss_residual=float(np.max(np.abs(gauss))), + energy=energy, + energy_parts=parts, + effective_g_squared=effective_g_squared, + iterations=chi_iterations, + ) diff --git a/lfm/foundations/r4_gauge_spectrum.py b/lfm/foundations/r4_gauge_spectrum.py new file mode 100644 index 0000000..379d195 --- /dev/null +++ b/lfm/foundations/r4_gauge_spectrum.py @@ -0,0 +1,162 @@ +"""Fourier audits for the compact gauge-link loop complex used by R4.""" + +from __future__ import annotations + +import numpy as np + +from lfm.foundations.r3_link_frame_live import ( + _link_table, + triangle_loops, +) + +Offset = tuple[int, int, int] + + +def _offset3(values: tuple[int, ...]) -> Offset: + return (values[0], values[1], values[2]) + + +def _reverse(offset: Offset) -> Offset: + return _offset3(tuple(-value for value in offset)) + + +def oriented_cycle_fourier_coefficients( + cycle: tuple[Offset, ...], + wavevector: tuple[float, float, float], + stencil: str, +) -> np.ndarray: + """Return the linear holonomy coefficients of one translated cycle.""" + + unique, table = _link_table(stencil) + coefficients = np.zeros(len(unique), dtype=np.complex128) + shift = (0, 0, 0) + for offset in cycle: + index, reverse = table[offset] + if reverse: + stored_base = _offset3(tuple(shift[axis] + offset[axis] for axis in range(3))) + sign = -1.0 + else: + stored_base = shift + sign = 1.0 + phase = sum(wavevector[axis] * stored_base[axis] for axis in range(3)) + coefficients[index] += sign * np.exp(1.0j * phase) + shift = _offset3(tuple(shift[axis] + offset[axis] for axis in range(3))) + if shift != (0, 0, 0): + raise ValueError("cycle offsets must close") + return coefficients + + +def triangle_fourier_hessian( + stencil: str, + wavevector: tuple[float, float, float], +) -> np.ndarray: + """Return the R4 triangle-loop magnetic Hessian at one momentum.""" + + link_count = len(_link_table(stencil)[0]) + hessian = np.zeros( + (link_count, link_count), + dtype=np.complex128, + ) + for first, second, third, weight in triangle_loops(stencil): + coefficients = oriented_cycle_fourier_coefficients( + (first, second, third), + wavevector, + stencil, + ) + hessian += weight * np.outer( + coefficients.conj(), + coefficients, + ) + return hessian + + +def face_square_cycles() -> tuple[tuple[Offset, ...], ...]: + """Return the three positively oriented axial face squares.""" + + axes: tuple[Offset, ...] = ( + (1, 0, 0), + (0, 1, 0), + (0, 0, 1), + ) + result = [] + for first in range(3): + for second in range(first + 1, 3): + a = axes[first] + b = axes[second] + result.append((a, b, _reverse(a), _reverse(b))) + return tuple(result) + + +def directional_link_inertia(stencil: str) -> float: + """Return the isotropic squared link projection count.""" + + unique, _ = _link_table(stencil) + counts = [float(sum(offset[axis] ** 2 for offset, _ in unique)) for axis in range(3)] + if max(counts) - min(counts) > 1.0e-12: + raise RuntimeError("link inventory is not directionally isotropic") + return counts[0] + + +def face_square_fourier_hessian( + stencil: str, + wavevector: tuple[float, float, float], +) -> np.ndarray: + """Return the geometry-normalized axial face-square Hessian.""" + + link_count = len(_link_table(stencil)[0]) + hessian = np.zeros( + (link_count, link_count), + dtype=np.complex128, + ) + coefficient = directional_link_inertia(stencil) + for cycle in face_square_cycles(): + coefficients = oriented_cycle_fourier_coefficients( + cycle, + wavevector, + stencil, + ) + hessian += coefficient * np.outer( + coefficients.conj(), + coefficients, + ) + return hessian + + +def gauge_link_spectrum( + stencil: str, + wavevector: tuple[float, float, float], + *, + include_face_squares: bool, +) -> np.ndarray: + """Return sorted magnetic eigenvalues for one compact-link momentum.""" + + hessian = triangle_fourier_hessian(stencil, wavevector) + if include_face_squares: + hessian += face_square_fourier_hessian(stencil, wavevector) + return np.linalg.eigvalsh(hessian).real + + +def transverse_mode_speeds( + stencil: str, + wavevector: tuple[float, float, float], + *, + include_face_squares: bool, +) -> tuple[float, float]: + """Return the two lowest positive physical-mode speeds.""" + + momentum = float(np.linalg.norm(wavevector)) + if momentum <= 0.0: + raise ValueError("wavevector must be nonzero") + eigenvalues = gauge_link_spectrum( + stencil, + wavevector, + include_face_squares=include_face_squares, + ) + tolerance = max(1.0e-12, momentum**2 * 1.0e-8) + positive = eigenvalues[eigenvalues > tolerance] + if positive.size < 2: + return 0.0, 0.0 + return ( + float(np.sqrt(positive[0]) / momentum), + float(np.sqrt(positive[1]) / momentum), + ) diff --git a/lfm/foundations/r4_quantum_color.py b/lfm/foundations/r4_quantum_color.py new file mode 100644 index 0000000..787268c --- /dev/null +++ b/lfm/foundations/r4_quantum_color.py @@ -0,0 +1,295 @@ +"""Quantum compact-link diagnostics for the experimental R4 color sector. + +This module quantizes the SU(3) link and electric registers already present +in R4. It does not add a color potential, flux path, string tension, or +confinement register. The strong-coupling electric spectrum follows from +the R4 Hamiltonian coefficient and the SU(3) generators used by the live +evolution. +""" + +from __future__ import annotations + +from collections import deque +from dataclasses import dataclass +from typing import cast + +import numpy as np + +from lfm.foundations.r3_link_frame_live import ( + _link_table, + su3_generators, + triangle_loops, +) +from lfm.foundations.r4_unified_live import ( + R4Parameters, + R4State, + color_dielectric, +) + + +@dataclass(frozen=True) +class R4QuantumColorCoefficients: + """Vacuum coefficients of the compact SU(3) link Hamiltonian.""" + + epsilon_vacuum: float + electric_coefficient: float + magnetic_coefficient: float + g_squared_from_electric: float + inverse_g_squared_from_magnetic: float + fundamental_casimir: float + fundamental_flux_slope: float + + +@dataclass(frozen=True) +class R4MagneticCompetitionBound: + """Local norm bound on magnetic dressing of one flux link.""" + + stencil: str + max_weighted_loop_incidence: float + su3_loop_range: float + magnetic_bound_per_link: float + electric_flux_slope: float + residual_positive_slope: float + + +def local_su3_gauge_transform( + state: R4State, + transformations: np.ndarray, + parameters: R4Parameters = R4Parameters(), +) -> R4State: + """Apply an arbitrary site-local SU(3) transformation to one R4 state.""" + + gauge = np.asarray(transformations, dtype=np.complex128) + expected = state.r3.chi.shape + (3, 3) + if gauge.shape != expected: + raise ValueError("transformations must provide one 3x3 matrix per site") + identity = np.eye(3, dtype=np.complex128) + unitary_error = float(np.max(np.abs(np.swapaxes(gauge.conj(), -1, -2) @ gauge - identity))) + determinant_error = float(np.max(np.abs(np.linalg.det(gauge) - 1.0))) + if unitary_error > 1.0e-10 or determinant_error > 1.0e-10: + raise ValueError("transformations must be site-local SU(3) matrices") + transformed = state.copy() + transformed.r3.matter = np.einsum( + "...ij,...j->...i", + gauge, + state.r3.matter, + ) + transformed.r3.matter_momentum = np.einsum( + "...ij,...j->...i", + gauge, + state.r3.matter_momentum, + ) + generators = su3_generators() + unique, _ = _link_table(parameters.stencil) + for index, (offset, _) in enumerate(unique): + gauge_neighbor = np.roll( + gauge, + shift=tuple(-value for value in offset), + axis=(0, 1, 2), + ) + transformed.r3.color_links[..., index, :, :] = ( + gauge + @ state.r3.color_links[..., index, :, :] + @ np.swapaxes(gauge_neighbor.conj(), -1, -2) + ) + electric_matrix = np.einsum( + "...a,aij->...ij", + state.r3.color_electric[..., index, :], + generators, + ) + rotated_electric = gauge @ electric_matrix @ np.swapaxes(gauge.conj(), -1, -2) + transformed.r3.color_electric[..., index, :] = ( + 2.0 + * np.einsum( + "aij,...ji->...a", + generators, + rotated_electric, + ).real + ) + return transformed + + +def su3_fundamental_algebra_audit() -> dict[str, object]: + """Audit generator normalization and derive the fundamental Casimir.""" + + generators = su3_generators() + gram = np.einsum( + "aij,bji->ab", + generators, + generators, + ).real + casimir_matrix = np.einsum( + "aij,ajk->ik", + generators, + generators, + ) + eigenvalues = np.linalg.eigvalsh(casimir_matrix).real + normalization_error = float(np.max(np.abs(gram - 0.5 * np.eye(generators.shape[0])))) + casimir_spread = float(np.max(eigenvalues) - np.min(eigenvalues)) + return { + "generator_count": int(generators.shape[0]), + "normalization_error": normalization_error, + "casimir_eigenvalues": [float(value) for value in eigenvalues], + "casimir": float(np.mean(eigenvalues)), + "casimir_spread": casimir_spread, + } + + +def r4_quantum_color_coefficients( + parameters: R4Parameters = R4Parameters(), +) -> R4QuantumColorCoefficients: + """Read the quantum-link coefficients directly from the R4 vacuum.""" + + epsilon, _ = color_dielectric( + np.asarray(parameters.r3.chi0), + parameters, + ) + epsilon_vacuum = float(epsilon) + electric_coefficient = 1.0 / (2.0 * parameters.r3.color_inertia * epsilon_vacuum) + magnetic_coefficient = parameters.r3.color_stiffness * epsilon_vacuum + g_squared = 2.0 * electric_coefficient + inverse_g_squared = magnetic_coefficient + casimir = float(cast("float", su3_fundamental_algebra_audit()["casimir"])) + return R4QuantumColorCoefficients( + epsilon_vacuum=epsilon_vacuum, + electric_coefficient=electric_coefficient, + magnetic_coefficient=magnetic_coefficient, + g_squared_from_electric=g_squared, + inverse_g_squared_from_magnetic=inverse_g_squared, + fundamental_casimir=casimir, + fundamental_flux_slope=electric_coefficient * casimir, + ) + + +def _reverse(offset: tuple[int, int, int]) -> tuple[int, int, int]: + return (-offset[0], -offset[1], -offset[2]) + + +def weighted_loop_incidence( + stencil: str, +) -> dict[tuple[int, int, int], float]: + """Return weighted R4 triangle-loop incidence for each link class.""" + + unique, _ = _link_table(stencil) + result: dict[tuple[int, int, int], float] = {} + for offset, _ in unique: + reverse = _reverse(offset) + incidence = 0.0 + for first, second, third, weight in triangle_loops(stencil): + incidence += weight * sum(edge in (offset, reverse) for edge in (first, second, third)) + result[offset] = float(incidence) + return result + + +def r4_magnetic_competition_bound( + parameters: R4Parameters = R4Parameters(), +) -> R4MagneticCompetitionBound: + """Bound local magnetic dressing using the actual R4 loop inventory. + + For U in SU(3), Re Tr(U) is at least -3/2, so the range of the positive + R4 loop operator 3-Re Tr(U) is 9/2. Multiplying that exact range by the + weighted incidence gives the largest local magnetic energy change + supported on one flux link. + """ + + coefficients = r4_quantum_color_coefficients(parameters) + incidence = weighted_loop_incidence(parameters.stencil) + max_incidence = max(incidence.values()) + su3_loop_range = 4.5 + magnetic_bound = coefficients.magnetic_coefficient * su3_loop_range * max_incidence + return R4MagneticCompetitionBound( + stencil=parameters.stencil, + max_weighted_loop_incidence=max_incidence, + su3_loop_range=su3_loop_range, + magnetic_bound_per_link=magnetic_bound, + electric_flux_slope=coefficients.fundamental_flux_slope, + residual_positive_slope=(coefficients.fundamental_flux_slope - magnetic_bound), + ) + + +def minimum_link_distance( + displacement: tuple[int, int, int], + stencil: str, +) -> int: + """Return the graph distance using the live R4 link inventory.""" + + target = tuple(int(value) for value in displacement) + if target == (0, 0, 0): + return 0 + if any(abs(value) > 64 for value in target): + raise ValueError("displacement is too large for the exact audit") + unique, _ = _link_table(stencil) + moves = tuple(offset for base, _ in unique for offset in (base, _reverse(base))) + margin = max(abs(value) for value in target) + 2 + lower = tuple(min(0, value) - margin for value in target) + upper = tuple(max(0, value) + margin for value in target) + queue: deque[tuple[tuple[int, int, int], int]] = deque([((0, 0, 0), 0)]) + visited = {(0, 0, 0)} + while queue: + site, distance = queue.popleft() + for move in moves: + neighbor = ( + site[0] + move[0], + site[1] + move[1], + site[2] + move[2], + ) + if neighbor == target: + return distance + 1 + if neighbor in visited: + continue + if not all(lower[axis] <= neighbor[axis] <= upper[axis] for axis in range(3)): + continue + visited.add(neighbor) + queue.append((neighbor, distance + 1)) + raise RuntimeError("target was not reachable on the link graph") + + +def fundamental_flux_energy( + displacement: tuple[int, int, int], + parameters: R4Parameters = R4Parameters(), +) -> float: + """Return the leading compact-link energy required by Gauss law.""" + + distance = minimum_link_distance(displacement, parameters.stencil) + slope = r4_quantum_color_coefficients(parameters).fundamental_flux_slope + return float(slope * distance) + + +def log_wilson_transfer( + spatial_distance: int, + euclidean_time: float, + parameters: R4Parameters = R4Parameters(), +) -> float: + """Return log W(R,T) in the controlled electric strong-coupling limit.""" + + if spatial_distance < 0 or euclidean_time < 0.0: + raise ValueError("Wilson extents must be nonnegative") + energy = fundamental_flux_energy( + (int(spatial_distance), 0, 0), + parameters, + ) + return float(-energy * euclidean_time) + + +def creutz_ratio_from_log_transfer( + spatial_distance: int, + euclidean_time: float, + time_increment: float, + parameters: R4Parameters = R4Parameters(), +) -> float: + """Return the Creutz area coefficient without exponential underflow.""" + + if spatial_distance < 1 or euclidean_time <= 0.0: + raise ValueError("positive Wilson extents are required") + if time_increment <= 0.0: + raise ValueError("time_increment must be positive") + r = int(spatial_distance) + t = float(euclidean_time) + dt = float(time_increment) + log_ratio = ( + log_wilson_transfer(r + 1, t + dt, parameters) + + log_wilson_transfer(r, t, parameters) + - log_wilson_transfer(r + 1, t, parameters) + - log_wilson_transfer(r, t + dt, parameters) + ) + return float(-log_ratio / dt) diff --git a/lfm/foundations/r4_unified_live.py b/lfm/foundations/r4_unified_live.py new file mode 100644 index 0000000..9e79154 --- /dev/null +++ b/lfm/foundations/r4_unified_live.py @@ -0,0 +1,1074 @@ +"""Live R4 experiment extending R3 with weak isospin and color dielectric. + +R4 retains the complete live R3 register and adds a left-isospin doublet, +compact SU(2) links, and an SU(2)-valued site orientation whose radial +magnitude is the existing chi field. The covariant orientation-alignment +energy generates a weak-link gap without inputting a mediator mass. + +The color electric and magnetic energies use a positive chi-dependent +dielectric. This is a local variational confinement candidate; no target +potential or string tension is supplied. +""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import asdict, dataclass, field +from functools import lru_cache + +import numpy as np +from scipy.linalg import expm + +from lfm.core.stencils import laplacian_19pt, laplacian_27pt +from lfm.foundations.r3_link_frame_live import ( + R3LiveParameters, + R3LiveState, + R3MomentumRates, + _accumulate_oriented_gradient, + _accumulate_oriented_phase_gradient, + _dagger, + _link_and_shape_drift, + _link_table, + _loop_products, + _neighbor, + _scatter_from_base, + _scatter_gradient_to_base, + _temporal_shape_projector, + _tracefree_symmetric, + _validate_state, + _weighted_bare_kinetic_drift, + so4_generators, + su3_generators, + triangle_loops, +) +from lfm.foundations.r3_link_frame_live import ( + group_constraint_errors as r3_group_constraint_errors, +) +from lfm.foundations.r3_link_frame_live import ( + kinetic_energy as r3_kinetic_energy, +) +from lfm.foundations.r3_link_frame_live import ( + potential_energy_and_rates as r3_potential_energy_and_rates, +) +from lfm.foundations.r3_link_frame_live import ( + state_distance as r3_state_distance, +) + +Offset = tuple[int, int, int] + + +def _offset3(values: tuple[int, ...]) -> Offset: + return (values[0], values[1], values[2]) + + +R4_ACTION_ID = "LFM-R4-UNIFIED-LIVE-EXPERIMENT-v1" +R4_REGISTER_ID = "R4=(R3Live,PsiL_s,PiL_s,W_ij,EW_ij,H_i,EH_i)" + + +@lru_cache(maxsize=1) +def su2_generators() -> np.ndarray: + """Return Pauli generators sigma_a/2.""" + + return 0.5 * np.asarray( + [ + [[0.0, 1.0], [1.0, 0.0]], + [[0.0, -1.0j], [1.0j, 0.0]], + [[1.0, 0.0], [0.0, -1.0]], + ], + dtype=np.complex128, + ) + + +@dataclass(frozen=True) +class R4Parameters: + """Parameters derived from the existing LFM constant set.""" + + r3: R3LiveParameters = field(default_factory=R3LiveParameters) + + @property + def weak_stiffness(self) -> float: + return 1.0 / self.r3.epsilon_w + + @property + def weak_inertia(self) -> float: + return 1.0 + + @property + def higgs_inertia(self) -> float: + return self.r3.frame_inertia + + @property + def higgs_alignment(self) -> float: + return self.r3.epsilon_w + + @property + def stencil(self) -> str: + return self.r3.stencil + + +@dataclass +class R4State: + """Complete R4 phase space.""" + + r3: R3LiveState + weak_matter: np.ndarray + weak_momentum: np.ndarray + weak_links: np.ndarray + weak_electric: np.ndarray + higgs_orientation: np.ndarray + higgs_electric: np.ndarray + + @classmethod + def vacuum( + cls, + size: int, + parameters: R4Parameters = R4Parameters(), + ) -> R4State: + base = R3LiveState.vacuum(size, parameters.r3) + sites = base.chi.shape + link_count = base.phase_links.shape[3] + return cls( + r3=base, + weak_matter=np.zeros(sites + (2,), dtype=np.complex128), + weak_momentum=np.zeros(sites + (2,), dtype=np.complex128), + weak_links=np.broadcast_to( + np.eye(2, dtype=np.complex128), + sites + (link_count, 2, 2), + ).copy(), + weak_electric=np.zeros( + sites + (link_count, 3), + dtype=np.float64, + ), + higgs_orientation=np.broadcast_to( + np.eye(2, dtype=np.complex128), + sites + (2, 2), + ).copy(), + higgs_electric=np.zeros(sites + (3,), dtype=np.float64), + ) + + def copy(self) -> R4State: + return R4State( + r3=self.r3.copy(), + weak_matter=self.weak_matter.copy(), + weak_momentum=self.weak_momentum.copy(), + weak_links=self.weak_links.copy(), + weak_electric=self.weak_electric.copy(), + higgs_orientation=self.higgs_orientation.copy(), + higgs_electric=self.higgs_electric.copy(), + ) + + +@dataclass +class R4Rates: + r3: R3MomentumRates + weak_matter: np.ndarray + weak_electric: np.ndarray + higgs_electric: np.ndarray + + +@dataclass +class R4FrameScalarState: + """Exact single-polarization invariant sector of the R4 frame shape.""" + + shape_amplitude: np.ndarray + shape_momentum: np.ndarray + + @classmethod + def vacuum(cls, size: int) -> R4FrameScalarState: + if not isinstance(size, int) or size < 2: + raise ValueError("size must be an integer >= 2") + shape = (size, size, size) + return cls( + shape_amplitude=np.zeros(shape, dtype=np.float64), + shape_momentum=np.zeros(shape, dtype=np.float64), + ) + + def copy(self) -> R4FrameScalarState: + return R4FrameScalarState( + shape_amplitude=self.shape_amplitude.copy(), + shape_momentum=self.shape_momentum.copy(), + ) + + +def _frame_scalar_laplacian( + values: np.ndarray, + stencil: str, +) -> np.ndarray: + if stencil == "19": + return laplacian_19pt(values) + if stencil == "27": + return laplacian_27pt(values) + raise ValueError("stencil must be '19' or '27'") + + +def _validate_frame_scalar( + state: R4FrameScalarState, + source_density: np.ndarray | None, +) -> np.ndarray: + amplitude = np.asarray(state.shape_amplitude) + momentum = np.asarray(state.shape_momentum) + if amplitude.ndim != 3 or momentum.shape != amplitude.shape: + raise ValueError("frame scalar amplitude and momentum must share a 3D shape") + if not np.all(np.isfinite(amplitude)) or not np.all(np.isfinite(momentum)): + raise ValueError("frame scalar state contains non-finite values") + if source_density is None: + return np.zeros_like(amplitude) + source = np.asarray(source_density, dtype=np.float64) + if source.shape != amplitude.shape: + raise ValueError("source_density must match the scalar frame shape") + if not np.all(np.isfinite(source)) or np.any(source < 0.0): + raise ValueError("source_density must be finite and nonnegative") + return source + + +def r4_frame_scalar_energy( + state: R4FrameScalarState, + parameters: R4Parameters = R4Parameters(), + *, + source_density: np.ndarray | None = None, +) -> tuple[float, dict[str, float]]: + """Return the exact R4 energy on the scalar frame invariant sector.""" + + source = _validate_frame_scalar(state, source_density) + amplitude = state.shape_amplitude + momentum = state.shape_momentum + polarization_norm_sq = 3.0 / 4.0 + laplacian = _frame_scalar_laplacian( + amplitude, + parameters.stencil, + ) + kinetic = float( + polarization_norm_sq * np.sum(momentum**2) / (2.0 * parameters.r3.frame_inertia) + ) + gradient = float( + -0.5 * polarization_norm_sq * parameters.r3.frame_stiffness * np.sum(amplitude * laplacian) + ) + source_energy = float(np.sum(source * np.exp(0.75 * amplitude))) + parts = { + "frame_scalar_kinetic": kinetic, + "frame_scalar_gradient": gradient, + "fixed_positive_source": source_energy, + } + return float(sum(parts.values())), parts + + +def step_r4_frame_scalar( + state: R4FrameScalarState, + dt: float, + parameters: R4Parameters = R4Parameters(), + *, + source_density: np.ndarray | None = None, +) -> None: + """Advance the exact scalar frame sector with local R4 leapfrog. + + A supplied source is a fixed positive-energy response probe. It uses the + same exp(S00) coupling as R4 but is not an unsupported dynamical body. + """ + + if not np.isfinite(dt) or dt <= 0.0: + raise ValueError("dt must be positive and finite") + source = _validate_frame_scalar(state, source_density) + half = 0.5 * dt + + def kick(duration: float) -> None: + state.shape_momentum += duration * ( + parameters.r3.frame_stiffness + * _frame_scalar_laplacian( + state.shape_amplitude, + parameters.stencil, + ) + - source * np.exp(0.75 * state.shape_amplitude) + ) + + kick(half) + state.shape_amplitude += dt * state.shape_momentum / parameters.r3.frame_inertia + kick(half) + + +def _validate_r4(state: R4State, parameters: R4Parameters) -> None: + sites = _validate_state(state.r3, parameters.r3) + link_count = state.r3.phase_links.shape[3] + expected = { + "weak_matter": sites + (2,), + "weak_momentum": sites + (2,), + "weak_links": sites + (link_count, 2, 2), + "weak_electric": sites + (link_count, 3), + "higgs_orientation": sites + (2, 2), + "higgs_electric": sites + (3,), + } + for name, shape in expected.items(): + values = np.asarray(getattr(state, name)) + if values.shape != shape: + raise ValueError(f"{name} must have shape {shape}") + if not np.all(np.isfinite(values)): + raise ValueError(f"{name} contains non-finite values") + + +def color_dielectric( + chi: np.ndarray, + parameters: R4Parameters = R4Parameters(), +) -> tuple[np.ndarray, np.ndarray]: + """Return positive color permittivity and its chi derivative.""" + + ratio = np.asarray(chi, dtype=np.float64) / parameters.r3.chi0 + displacement = 1.0 - ratio**2 + floor = parameters.r3.kappa + epsilon = floor + (1.0 - floor) * displacement**2 + derivative = ( + -4.0 + * (1.0 - floor) + * np.asarray(chi, dtype=np.float64) + * displacement + / parameters.r3.chi0**2 + ) + return epsilon, derivative + + +def _link_average(values: np.ndarray, offset: tuple[int, int, int]) -> np.ndarray: + return 0.5 * (values + _neighbor(values, offset)) + + +def _frame_weight(base: R3LiveState, parameters: R4Parameters) -> np.ndarray: + if parameters.r3.frame_enabled: + return np.exp(base.shape[..., 0, 0]) + return np.ones_like(base.chi) + + +def _add_phase_color_weight_corrections( + state: R4State, + parameters: R4Parameters, + base_rates: R3MomentumRates, + components: dict[str, float], +) -> float: + """Weight R3 phase/color loop energy by q and color dielectric.""" + + base = state.r3 + q = _frame_weight(base, parameters) + epsilon, epsilon_derivative = color_dielectric(base.chi, parameters) + phase_gradient = np.zeros_like(base.phase_links) + color_gradient = np.zeros_like(base.color_links) + phase_correction = 0.0 + color_correction = 0.0 + color_generators = su3_generators() + identity3 = np.eye(3, dtype=np.complex128) + + for first, second, third, loop_weight in triangle_loops(parameters.stencil): + phase_one, phase_two, phase_three, phase_holonomy = _loop_products( + base.phase_links, + first, + second, + third, + complex_group=True, + ) + phase_bare = parameters.r3.phase_stiffness * loop_weight * (1.0 - np.real(phase_holonomy)) + phase_factor = q + phase_correction += float(np.sum((phase_factor - 1.0) * phase_bare)) + if parameters.r3.frame_enabled: + base_rates.shape -= (q * phase_bare)[ + ..., np.newaxis, np.newaxis + ] * _temporal_shape_projector() + phase_h_gradient = ( + -(phase_factor - 1.0) * parameters.r3.phase_stiffness * loop_weight + ).astype(np.complex128) + phase_gradients = ( + phase_h_gradient * np.conj(phase_two * phase_three), + np.conj(phase_one) * phase_h_gradient * np.conj(phase_three), + np.conj(phase_one * phase_two) * phase_h_gradient, + ) + base_shifts: tuple[Offset, Offset, Offset] = ( + (0, 0, 0), + first, + _offset3(tuple(first[axis] + second[axis] for axis in range(3))), + ) + for offset, base_shift, gradient in zip( + (first, second, third), + base_shifts, + phase_gradients, + strict=True, + ): + _accumulate_oriented_phase_gradient( + phase_gradient, + offset, + _scatter_gradient_to_base(gradient, base_shift), + stencil=parameters.stencil, + ) + + color_one, color_two, color_three, color_holonomy = _loop_products( + base.color_links, + first, + second, + third, + complex_group=True, + ) + color_bare = ( + parameters.r3.color_stiffness + * loop_weight + * (3.0 - np.real(np.trace(color_holonomy, axis1=-2, axis2=-1))) + ) + color_factor = q * epsilon + color_correction += float(np.sum((color_factor - 1.0) * color_bare)) + if parameters.r3.frame_enabled: + base_rates.shape -= (q * epsilon * color_bare)[ + ..., np.newaxis, np.newaxis + ] * _temporal_shape_projector() + base_rates.chi -= q * epsilon_derivative * color_bare + color_h_gradient = (-(color_factor - 1.0) * parameters.r3.color_stiffness * loop_weight)[ + ..., np.newaxis, np.newaxis + ] * identity3 + color_gradients = ( + color_h_gradient @ _dagger(color_two @ color_three), + _dagger(color_one) @ color_h_gradient @ _dagger(color_three), + _dagger(color_one @ color_two) @ color_h_gradient, + ) + for offset, base_shift, gradient in zip( + (first, second, third), + base_shifts, + color_gradients, + strict=True, + ): + _accumulate_oriented_gradient( + color_gradient, + offset, + _scatter_gradient_to_base(gradient, base_shift), + stencil=parameters.stencil, + complex_group=True, + ) + + for index in range(base.phase_links.shape[3]): + phase = base.phase_links[..., index] + derivative = np.real(np.conj(phase_gradient[..., index]) * (1.0j * phase)) + base_rates.phase_electric[..., index] -= derivative + color = base.color_links[..., index, :, :] + for generator_index, generator in enumerate(color_generators): + variation = 1.0j * generator @ color + derivative = np.real( + np.sum( + np.conj(color_gradient[..., index, :, :]) * variation, + axis=(-2, -1), + ) + ) + base_rates.color_electric[..., index, generator_index] -= derivative + components["phase_loop_weight_correction"] = phase_correction + components["color_loop_dielectric_correction"] = color_correction + return phase_correction + color_correction + + +def potential_energy_and_rates( + state: R4State, + parameters: R4Parameters = R4Parameters(), +) -> tuple[float, R4Rates, dict[str, float]]: + """Return the complete R4 coordinate energy and momentum rates.""" + + _validate_r4(state, parameters) + base_energy, base_rates, base_parts = r3_potential_energy_and_rates( + state.r3, + parameters.r3, + ) + weak_rate = np.zeros_like(state.weak_matter) + weak_electric_rate = np.zeros_like(state.weak_electric) + higgs_electric_rate = np.zeros_like(state.higgs_electric) + q = _frame_weight(state.r3, parameters) + weak_source_density = np.zeros_like(q) + components = dict(base_parts) + correction = _add_phase_color_weight_corrections( + state, + parameters, + base_rates, + components, + ) + weak_gradient_energy = 0.0 + weak_onsite_energy = 0.0 + higgs_alignment_energy = 0.0 + weak_loop_energy = 0.0 + weak_generators = su2_generators() + unique, _ = _link_table(parameters.stencil) + + for index, (offset, weight) in enumerate(unique): + matter_j = _neighbor(state.weak_matter, offset) + q_j = _neighbor(q, offset) + weak_link = state.weak_links[..., index, :, :] + phase = state.r3.phase_links[..., index] + transported = phase[..., np.newaxis] * np.einsum( + "...ab,...b->...a", + weak_link, + matter_j, + ) + difference = transported - state.weak_matter + norm_sq = np.sum(np.abs(difference) ** 2, axis=-1) + source_half = 0.25 * parameters.r3.wave_speed**2 * weight * norm_sq + weak_gradient_energy += float(np.sum((q + q_j) * source_half)) + weak_source_density += source_half + weak_source_density += _scatter_from_base(source_half, offset) + force_scale = 0.5 * parameters.r3.wave_speed**2 * weight * (q + q_j) + weak_rate += force_scale[..., np.newaxis] * difference + neighbor_force = ( + -force_scale[..., np.newaxis] + * np.conj(phase)[..., np.newaxis] + * np.einsum( + "...ab,...b->...a", + _dagger(weak_link), + difference, + ) + ) + weak_rate += _scatter_from_base(neighbor_force, offset) + phase_derivative = force_scale * np.real( + np.sum( + np.conj(difference) * (1.0j * transported), + axis=-1, + ) + ) + base_rates.phase_electric[..., index] -= phase_derivative + for generator_index, generator in enumerate(weak_generators): + variation = 1.0j * np.einsum( + "ab,...b->...a", + generator, + transported, + ) + derivative = force_scale * np.real(np.sum(np.conj(difference) * variation, axis=-1)) + weak_electric_rate[..., index, generator_index] -= derivative + + higgs_j = _neighbor(state.higgs_orientation, offset) + chi_j = _neighbor(state.r3.chi, offset) + transported_higgs = weak_link @ higgs_j + higgs_difference = transported_higgs - state.higgs_orientation + higgs_norm_sq = np.sum( + np.abs(higgs_difference) ** 2, + axis=(-2, -1), + ) + chi_sq_sum = state.r3.chi**2 + chi_j**2 + q_sum = q + q_j + alignment_coefficient = 0.125 * parameters.higgs_alignment * q_sum * chi_sq_sum + alignment_density = alignment_coefficient * higgs_norm_sq + higgs_alignment_energy += float(np.sum(alignment_density)) + alignment_force_scale = 0.25 * parameters.higgs_alignment * q_sum * chi_sq_sum + for generator_index, generator in enumerate(weak_generators): + left_variation = -1.0j * generator @ state.higgs_orientation + left_derivative = alignment_force_scale * np.real( + np.sum( + np.conj(higgs_difference) * left_variation, + axis=(-2, -1), + ) + ) + higgs_electric_rate[..., generator_index] -= left_derivative + right_variation = weak_link @ (1.0j * generator @ higgs_j) + right_derivative = alignment_force_scale * np.real( + np.sum( + np.conj(higgs_difference) * right_variation, + axis=(-2, -1), + ) + ) + higgs_electric_rate[..., generator_index] += _scatter_from_base( + -right_derivative, + offset, + ) + link_variation = 1.0j * generator @ transported_higgs + link_derivative = alignment_force_scale * np.real( + np.sum( + np.conj(higgs_difference) * link_variation, + axis=(-2, -1), + ) + ) + weak_electric_rate[..., index, generator_index] -= link_derivative + chi_derivative = 0.25 * parameters.higgs_alignment * q_sum * state.r3.chi * higgs_norm_sq + base_rates.chi -= chi_derivative + neighbor_chi_derivative = 0.25 * parameters.higgs_alignment * q_sum * chi_j * higgs_norm_sq + base_rates.chi += _scatter_from_base( + -neighbor_chi_derivative, + offset, + ) + endpoint_source = 0.125 * parameters.higgs_alignment * chi_sq_sum * higgs_norm_sq + weak_source_density += endpoint_source + weak_source_density += _scatter_from_base(endpoint_source, offset) + + weak_norm_sq = np.sum(np.abs(state.weak_matter) ** 2, axis=-1) + weak_onsite = 0.5 * state.r3.chi**2 * weak_norm_sq + weak_onsite_energy = float(np.sum(q * weak_onsite)) + weak_source_density += weak_onsite + weak_rate -= (q * state.r3.chi**2)[..., np.newaxis] * state.weak_matter + base_rates.chi -= q * state.r3.chi * weak_norm_sq + + weak_gradient = np.zeros_like(state.weak_links) + identity2 = np.eye(2, dtype=np.complex128) + for first, second, third, loop_weight in triangle_loops(parameters.stencil): + first_link, second_link, third_link, holonomy = _loop_products( + state.weak_links, + first, + second, + third, + complex_group=True, + ) + bare_density = ( + parameters.weak_stiffness + * loop_weight + * (2.0 - np.real(np.trace(holonomy, axis1=-2, axis2=-1))) + ) + weak_loop_energy += float(np.sum(q * bare_density)) + if parameters.r3.frame_enabled: + base_rates.shape -= (q * bare_density)[ + ..., np.newaxis, np.newaxis + ] * _temporal_shape_projector() + holonomy_gradient = (-q * parameters.weak_stiffness * loop_weight)[ + ..., np.newaxis, np.newaxis + ] * identity2 + gradients = ( + holonomy_gradient @ _dagger(second_link @ third_link), + _dagger(first_link) @ holonomy_gradient @ _dagger(third_link), + _dagger(first_link @ second_link) @ holonomy_gradient, + ) + base_shifts: tuple[Offset, Offset, Offset] = ( + (0, 0, 0), + first, + _offset3(tuple(first[axis] + second[axis] for axis in range(3))), + ) + for offset, base_shift, gradient in zip( + (first, second, third), + base_shifts, + gradients, + strict=True, + ): + _accumulate_oriented_gradient( + weak_gradient, + offset, + _scatter_gradient_to_base(gradient, base_shift), + stencil=parameters.stencil, + complex_group=True, + ) + for index in range(state.weak_links.shape[3]): + link = state.weak_links[..., index, :, :] + for generator_index, generator in enumerate(weak_generators): + variation = 1.0j * generator @ link + derivative = np.real( + np.sum( + np.conj(weak_gradient[..., index, :, :]) * variation, + axis=(-2, -1), + ) + ) + weak_electric_rate[..., index, generator_index] -= derivative + + if parameters.r3.frame_enabled: + base_rates.shape -= (q * weak_source_density)[ + ..., np.newaxis, np.newaxis + ] * _temporal_shape_projector() + base_rates.shape = _tracefree_symmetric(base_rates.shape) + components.update( + { + "weak_matter_gradient": weak_gradient_energy, + "weak_matter_onsite": weak_onsite_energy, + "higgs_alignment": higgs_alignment_energy, + "weak_loop": weak_loop_energy, + } + ) + total = ( + base_energy + + correction + + weak_gradient_energy + + weak_onsite_energy + + higgs_alignment_energy + + weak_loop_energy + ) + return ( + total, + R4Rates( + r3=base_rates, + weak_matter=weak_rate, + weak_electric=weak_electric_rate, + higgs_electric=higgs_electric_rate, + ), + components, + ) + + +def _gauge_kinetic_replacements( + state: R4State, + parameters: R4Parameters, +) -> tuple[float, dict[str, float]]: + base = state.r3 + q = _frame_weight(base, parameters) + epsilon, _ = color_dielectric(base.chi, parameters) + unique, _ = _link_table(parameters.stencil) + phase = 0.0 + color = 0.0 + frame = 0.0 + weak = 0.0 + for index, (offset, _) in enumerate(unique): + q_link = _link_average(q, offset) + epsilon_link = _link_average(epsilon, offset) + phase += float( + np.sum( + q_link * base.phase_electric[..., index] ** 2 / (2.0 * parameters.r3.phase_inertia) + ) + ) + color += float( + np.sum( + q_link[..., np.newaxis] + * base.color_electric[..., index, :] ** 2 + / (2.0 * parameters.r3.color_inertia * epsilon_link[..., np.newaxis]) + ) + ) + if parameters.r3.frame_enabled: + frame += float( + np.sum( + q_link[..., np.newaxis] + * base.frame_electric[..., index, :] ** 2 + / (2.0 * parameters.r3.frame_inertia) + ) + ) + weak += float( + np.sum( + q_link[..., np.newaxis] + * state.weak_electric[..., index, :] ** 2 + / (2.0 * parameters.weak_inertia) + ) + ) + higgs = float( + np.sum(q[..., np.newaxis] * state.higgs_electric**2) / (2.0 * parameters.higgs_inertia) + ) + return phase + color + frame + weak + higgs, { + "phase_electric_weighted": phase, + "color_electric_dielectric": color, + "frame_electric_weighted": frame, + "weak_electric_weighted": weak, + "higgs_orientation_kinetic": higgs, + } + + +def kinetic_energy( + state: R4State, + parameters: R4Parameters = R4Parameters(), +) -> tuple[float, dict[str, float]]: + """Return the complete positive R4 kinetic energy.""" + + _validate_r4(state, parameters) + base_energy, base_parts = r3_kinetic_energy(state.r3, parameters.r3) + q = _frame_weight(state.r3, parameters) + weak_matter = float(np.sum(q * 0.5 * np.sum(np.abs(state.weak_momentum) ** 2, axis=-1))) + replacement, replacement_parts = _gauge_kinetic_replacements( + state, + parameters, + ) + old_gauge = ( + base_parts["phase_electric"] + base_parts["color_electric"] + base_parts["frame_electric"] + ) + parts = dict(base_parts) + parts.update(replacement_parts) + parts["weak_matter_kinetic"] = weak_matter + parts["replaced_R3_gauge_electric"] = -old_gauge + total = base_energy - old_gauge + replacement + weak_matter + return total, parts + + +def total_hamiltonian( + state: R4State, + parameters: R4Parameters = R4Parameters(), +) -> tuple[float, dict[str, float]]: + kinetic, kinetic_parts = kinetic_energy(state, parameters) + potential, _, potential_parts = potential_energy_and_rates( + state, + parameters, + ) + return kinetic + potential, {**kinetic_parts, **potential_parts} + + +def _potential_kick( + state: R4State, + duration: float, + parameters: R4Parameters, +) -> None: + _, rates, _ = potential_energy_and_rates(state, parameters) + base = state.r3 + base.matter_momentum += duration * rates.r3.matter + base.chi_momentum += duration * rates.r3.chi + if parameters.r3.frame_enabled: + base.shape_momentum += duration * rates.r3.shape + base.phase_electric += duration * rates.r3.phase_electric + base.color_electric += duration * rates.r3.color_electric + if parameters.r3.frame_enabled: + base.frame_electric += duration * rates.r3.frame_electric + state.weak_momentum += duration * rates.weak_matter + state.weak_electric += duration * rates.weak_electric + state.higgs_electric += duration * rates.higgs_electric + + +def _extra_gauge_kinetic_drift( + state: R4State, + duration: float, + parameters: R4Parameters, +) -> None: + base = state.r3 + q = _frame_weight(base, parameters) + epsilon, epsilon_derivative = color_dielectric(base.chi, parameters) + unique, _ = _link_table(parameters.stencil) + weak_generators = su2_generators() + color_generators = su3_generators() + frame_generators = so4_generators() + source_density = np.zeros_like(q) + color_is_live = bool(np.any(base.color_electric != 0.0)) + frame_is_live = parameters.r3.frame_enabled and bool(np.any(base.frame_electric != 0.0)) + weak_is_live = bool(np.any(state.weak_electric != 0.0)) + + for index, (offset, _) in enumerate(unique): + q_link = _link_average(q, offset) + epsilon_link = _link_average(epsilon, offset) + epsilon_derivative_j = _neighbor(epsilon_derivative, offset) + phase_coefficient = q_link - 1.0 + base.phase_links[..., index] *= np.exp( + 1.0j + * duration + * phase_coefficient + * base.phase_electric[..., index] + / parameters.r3.phase_inertia + ) + phase_density = base.phase_electric[..., index] ** 2 / (4.0 * parameters.r3.phase_inertia) + source_density += phase_density + source_density += _scatter_from_base(phase_density, offset) + + color_factor = q_link / epsilon_link + color_density = np.sum( + base.color_electric[..., index, :] ** 2, + axis=-1, + ) / (4.0 * parameters.r3.color_inertia * epsilon_link) + source_density += color_density + source_density += _scatter_from_base(color_density, offset) + color_energy_sq = np.sum( + base.color_electric[..., index, :] ** 2, + axis=-1, + ) + chi_rate_i = ( + q_link + * color_energy_sq + * epsilon_derivative + / (4.0 * parameters.r3.color_inertia * epsilon_link**2) + ) + chi_rate_j = ( + q_link + * color_energy_sq + * epsilon_derivative_j + / (4.0 * parameters.r3.color_inertia * epsilon_link**2) + ) + base.chi_momentum += duration * chi_rate_i + base.chi_momentum += _scatter_from_base( + duration * chi_rate_j, + offset, + ) + + if parameters.r3.frame_enabled: + frame_density = np.sum( + base.frame_electric[..., index, :] ** 2, + axis=-1, + ) / (4.0 * parameters.r3.frame_inertia) + source_density += frame_density + source_density += _scatter_from_base(frame_density, offset) + + weak_density = np.sum( + state.weak_electric[..., index, :] ** 2, + axis=-1, + ) / (4.0 * parameters.weak_inertia) + source_density += weak_density + source_density += _scatter_from_base(weak_density, offset) + + if color_is_live: + active_color_sites = np.argwhere( + np.any( + base.color_electric[..., index, :] != 0.0, + axis=-1, + ) + ) + for site_values in active_color_sites: + site = tuple(int(value) for value in site_values) + color_algebra = np.einsum( + "a,aij->ij", + base.color_electric[site + (index,)], + color_generators, + ) + base.color_links[site + (index,)] = ( + expm( + 1.0j + * duration + * (color_factor[site] - 1.0) + * color_algebra + / parameters.r3.color_inertia + ) + @ base.color_links[site + (index,)] + ) + if frame_is_live: + active_frame_sites = np.argwhere( + np.any( + base.frame_electric[..., index, :] != 0.0, + axis=-1, + ) + ) + for site_values in active_frame_sites: + site = tuple(int(value) for value in site_values) + frame_algebra = np.einsum( + "a,aij->ij", + base.frame_electric[site + (index,)], + frame_generators, + ) + base.frame_links[site + (index,)] = ( + expm( + duration + * (q_link[site] - 1.0) + * frame_algebra + / parameters.r3.frame_inertia + ) + @ base.frame_links[site + (index,)] + ) + if weak_is_live: + active_weak_sites = np.argwhere( + np.any( + state.weak_electric[..., index, :] != 0.0, + axis=-1, + ) + ) + for site_values in active_weak_sites: + site = tuple(int(value) for value in site_values) + weak_algebra = np.einsum( + "a,aij->ij", + state.weak_electric[site + (index,)], + weak_generators, + ) + state.weak_links[site + (index,)] = ( + expm(1.0j * duration * q_link[site] * weak_algebra / parameters.weak_inertia) + @ state.weak_links[site + (index,)] + ) + + if np.any(state.higgs_electric != 0.0): + for site in np.ndindex(base.chi.shape): + higgs_algebra = np.einsum( + "a,aij->ij", + state.higgs_electric[site], + weak_generators, + ) + state.higgs_orientation[site] = ( + expm(1.0j * duration * q[site] * higgs_algebra / parameters.higgs_inertia) + @ state.higgs_orientation[site] + ) + higgs_density = np.sum(state.higgs_electric**2, axis=-1) / (2.0 * parameters.higgs_inertia) + source_density += higgs_density + if parameters.r3.frame_enabled: + base.shape_momentum -= (duration * q * source_density)[ + ..., np.newaxis, np.newaxis + ] * _temporal_shape_projector() + + +def _weak_matter_kinetic_drift( + state: R4State, + duration: float, + parameters: R4Parameters, +) -> None: + q = _frame_weight(state.r3, parameters) + density = 0.5 * np.sum( + np.abs(state.weak_momentum) ** 2, + axis=-1, + ) + state.weak_matter += duration * q[..., np.newaxis] * state.weak_momentum + if parameters.r3.frame_enabled: + state.r3.shape_momentum -= (duration * q * density)[ + ..., np.newaxis, np.newaxis + ] * _temporal_shape_projector() + + +def step_r4( + state: R4State, + dt: float, + parameters: R4Parameters = R4Parameters(), +) -> None: + """Advance one symmetric second-order R4 Hamiltonian split.""" + + if not np.isfinite(dt) or dt <= 0.0: + raise ValueError("dt must be positive and finite") + _validate_r4(state, parameters) + half = 0.5 * dt + _potential_kick(state, half, parameters) + _link_and_shape_drift(state.r3, half, parameters.r3) + _extra_gauge_kinetic_drift(state, half, parameters) + _weighted_bare_kinetic_drift(state.r3, dt, parameters.r3) + _weak_matter_kinetic_drift(state, dt, parameters) + _extra_gauge_kinetic_drift(state, half, parameters) + _link_and_shape_drift(state.r3, half, parameters.r3) + _potential_kick(state, half, parameters) + + +def reverse_momenta(state: R4State) -> None: + """Reverse every R4 canonical momentum.""" + + state.r3.matter_momentum *= -1.0 + state.r3.chi_momentum *= -1.0 + state.r3.shape_momentum *= -1.0 + state.r3.phase_electric *= -1.0 + state.r3.color_electric *= -1.0 + state.r3.frame_electric *= -1.0 + state.weak_momentum *= -1.0 + state.weak_electric *= -1.0 + state.higgs_electric *= -1.0 + + +def group_constraint_errors(state: R4State) -> dict[str, float]: + """Return compact-group errors for the complete R3 plus R4 register.""" + + weak_identity = state.weak_links @ _dagger(state.weak_links) + higgs_identity = state.higgs_orientation @ _dagger(state.higgs_orientation) + return { + **r3_group_constraint_errors(state.r3), + "weak_unitarity": float(np.max(np.abs(weak_identity - np.eye(2)))), + "weak_determinant": float(np.max(np.abs(np.linalg.det(state.weak_links) - 1.0))), + "higgs_unitarity": float(np.max(np.abs(higgs_identity - np.eye(2)))), + "higgs_determinant": float(np.max(np.abs(np.linalg.det(state.higgs_orientation) - 1.0))), + } + + +def state_distance(left: R4State, right: R4State) -> float: + numerator = r3_state_distance(left.r3, right.r3) ** 2 + denominator = 1.0 + for name in ( + "weak_matter", + "weak_momentum", + "weak_links", + "weak_electric", + "higgs_orientation", + "higgs_electric", + ): + left_values = np.asarray(getattr(left, name)) + right_values = np.asarray(getattr(right, name)) + numerator += float(np.sum(np.abs(left_values - right_values) ** 2)) + denominator += float(np.sum(np.abs(left_values) ** 2)) + return float(np.sqrt(numerator / denominator)) + + +def r4_action_declaration( + parameters: R4Parameters = R4Parameters(), +) -> dict[str, object]: + return { + "action_id": R4_ACTION_ID, + "register_id": R4_REGISTER_ID, + "canonical_status": "EXPERIMENT_ONLY_UNPROMOTED", + "parameters": asdict(parameters), + "derived_parameters": { + "weak_stiffness": parameters.weak_stiffness, + "weak_inertia": parameters.weak_inertia, + "higgs_inertia": parameters.higgs_inertia, + "higgs_alignment": parameters.higgs_alignment, + "dielectric_floor": parameters.r3.kappa, + }, + "added_terms": [ + "left_doublet_covariant_neighbor_energy", + "positive_SU2_plaquette_energy", + "positive_chi_Higgs_orientation_alignment", + "positive_chi_color_dielectric", + "total_added_energy_frame_source", + ], + "forbidden_mechanisms_used": [], + "paper_45_update_authorized": False, + } + + +def r4_action_fingerprint( + parameters: R4Parameters = R4Parameters(), +) -> str: + encoded = json.dumps( + r4_action_declaration(parameters), + sort_keys=True, + separators=(",", ":"), + ).encode("ascii") + return hashlib.sha256(encoded).hexdigest() diff --git a/lfm/foundations/r5_u1_static.py b/lfm/foundations/r5_u1_static.py new file mode 100644 index 0000000..3cf8af0 --- /dev/null +++ b/lfm/foundations/r5_u1_static.py @@ -0,0 +1,124 @@ +"""Gauss-constrained static U(1) probes for the experimental R5 action.""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np +from scipy.sparse.linalg import LinearOperator, cg + +from lfm.foundations.r3_link_frame_live import _link_table +from lfm.foundations.r4_color_static import color_gauss_divergence +from lfm.foundations.r5_unified_live import R5Parameters + + +@dataclass +class R5U1StaticState: + """Minimum R5 U(1) electric energy satisfying a periodic Gauss source.""" + + potential: np.ndarray + electric: np.ndarray + charge: np.ndarray + gauss_residual: float + electric_energy: float + + +def solve_u1_gauss_minimum( + charge: np.ndarray, + parameters: R5Parameters = R5Parameters(), + *, + tolerance: float = 1.0e-11, + initial_potential: np.ndarray | None = None, +) -> R5U1StaticState: + """Minimize the existing R5 U(1) electric energy under Gauss law.""" + + charge_values = np.asarray(charge, dtype=np.float64) + if charge_values.ndim != 3: + raise ValueError("charge must be a 3D array") + if abs(float(np.sum(charge_values))) > 1.0e-10: + raise ValueError("periodic U1 charge must sum to zero") + if tolerance <= 0.0: + raise ValueError("tolerance must be positive") + unique, _ = _link_table(parameters.stencil) + shape = charge_values.shape + count = int(np.prod(shape)) + + def electric_from_potential(potential: np.ndarray) -> np.ndarray: + electric = np.empty(shape + (len(unique),), dtype=np.float64) + for index, (offset, _) in enumerate(unique): + neighbor = np.roll( + potential, + shift=tuple(-value for value in offset), + axis=(0, 1, 2), + ) + electric[..., index] = potential - neighbor + return electric + + def matvec(vector: np.ndarray) -> np.ndarray: + potential = np.asarray(vector, dtype=np.float64).reshape(shape) + electric = electric_from_potential(potential) + divergence = color_gauss_divergence( + electric, + parameters.r4, + ) + divergence += np.mean(potential) + return divergence.reshape(-1) + + operator = LinearOperator( + (count, count), + matvec=matvec, + dtype=np.float64, + ) + guess = ( + np.zeros(shape, dtype=np.float64) + if initial_potential is None + else np.asarray(initial_potential, dtype=np.float64) + ) + if guess.shape != shape: + raise ValueError("initial_potential must match charge") + solution, info = cg( + operator, + charge_values.reshape(-1), + x0=guess.reshape(-1), + rtol=tolerance, + atol=0.0, + maxiter=20 * count, + ) + if info != 0: + raise RuntimeError(f"U1 Gauss iteration did not converge: {info}") + potential = solution.reshape(shape) + potential -= np.mean(potential) + electric = electric_from_potential(potential) + residual = color_gauss_divergence(electric, parameters.r4) - charge_values + scale = max(float(np.max(np.abs(charge_values))), 1.0) + return R5U1StaticState( + potential=potential, + electric=electric, + charge=charge_values.copy(), + gauss_residual=float(np.max(np.abs(residual)) / scale), + electric_energy=float(0.5 * np.sum(electric**2)), + ) + + +def periodic_point_pair_charge( + size: int, + separation: int, + relative_sign: int, +) -> np.ndarray: + """Return two unit point charges with the required neutral background.""" + + if size < 5 or separation < 1 or separation >= size // 2: + raise ValueError("point pair must fit inside half the periodic box") + if relative_sign not in (-1, 1): + raise ValueError("relative_sign must be -1 or +1") + charge = np.full( + (size, size, size), + -(1.0 + relative_sign) / size**3, + dtype=np.float64, + ) + center = size // 2 + left = center - separation // 2 + right = left + separation + charge[left, center, center] += 1.0 + charge[right, center, center] += float(relative_sign) + return charge diff --git a/lfm/foundations/r5_unified_live.py b/lfm/foundations/r5_unified_live.py new file mode 100644 index 0000000..8e405ea --- /dev/null +++ b/lfm/foundations/r5_unified_live.py @@ -0,0 +1,678 @@ +"""Experimental R5 action with geometry-complete compact-link curvature. + +R5 keeps the complete R4 register and adds no new field. It retains the +R4 triangle holonomies, which constrain diagonal and corner links, and +adds the three axial face-square holonomies required for independent +spatial curvature. Their coefficient is the directional link inertia +count of the selected stencil: 5 for the 19 graph and 9 for the 27 graph. +""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import asdict, dataclass, field + +import numpy as np + +from lfm.foundations.r3_link_frame_live import ( + _accumulate_oriented_gradient, + _accumulate_oriented_phase_gradient, + _dagger, + _frame_loop_energy_gradient, + _link_and_shape_drift, + _neighbor, + _oriented_link, + _scatter_gradient_to_base, + _temporal_shape_projector, + _tracefree_symmetric, + _transpose, + _weighted_bare_kinetic_drift, + so4_generators, + su3_generators, +) +from lfm.foundations.r4_gauge_spectrum import ( + directional_link_inertia, + face_square_cycles, +) +from lfm.foundations.r4_unified_live import ( + R4Parameters, + R4Rates, + R4State, + _extra_gauge_kinetic_drift, + _validate_r4, + _weak_matter_kinetic_drift, + color_dielectric, + group_constraint_errors, # noqa: F401 - public R5 re-export + reverse_momenta, # noqa: F401 - public R5 re-export + state_distance, # noqa: F401 - public R5 re-export + su2_generators, +) +from lfm.foundations.r4_unified_live import ( + kinetic_energy as r4_kinetic_energy, +) +from lfm.foundations.r4_unified_live import ( + potential_energy_and_rates as r4_potential_energy_and_rates, +) + +Offset = tuple[int, int, int] + + +def _offset3(values: tuple[int, ...]) -> Offset: + return (values[0], values[1], values[2]) + + +R5_ACTION_ID = "LFM-R5-GEOMETRY-COMPLETE-CURVATURE-EXPERIMENT-v1" +R5_REGISTER_ID = "R5=R4(no_new_registers)" +R5State = R4State +R5Rates = R4Rates + + +@dataclass(frozen=True) +class R5Parameters: + """R5 parameters, all inherited or counted from the R4 link graph.""" + + r4: R4Parameters = field(default_factory=R4Parameters) + + @property + def stencil(self) -> str: + return self.r4.stencil + + @property + def square_coefficient(self) -> float: + return directional_link_inertia(self.stencil) + + +def vacuum_state( + size: int, + parameters: R5Parameters = R5Parameters(), +) -> R5State: + """Return the exact R5 vacuum on the selected graph.""" + + return R4State.vacuum(size, parameters.r4) + + +def _cycle_products( + links: np.ndarray, + cycle: tuple[tuple[int, int, int], ...], + *, + complex_group: bool, +) -> tuple[ + tuple[np.ndarray, ...], + tuple[tuple[int, int, int], ...], + np.ndarray, +]: + factors = [] + base_shifts = [] + shift = (0, 0, 0) + for offset in cycle: + oriented = _oriented_link( + links, + offset, + complex_group=complex_group, + ) + factor = oriented if shift == (0, 0, 0) else _neighbor(oriented, shift) + factors.append(factor) + base_shifts.append(shift) + shift = _offset3(tuple(shift[axis] + offset[axis] for axis in range(3))) + if shift != (0, 0, 0): + raise ValueError("face-square cycle must close") + holonomy = factors[0] + for factor in factors[1:]: + holonomy = holonomy @ factor if holonomy.ndim >= 5 else holonomy * factor + return tuple(factors), tuple(base_shifts), holonomy + + +def _matrix_cycle_gradients( + factors: tuple[np.ndarray, ...], + holonomy_gradient: np.ndarray, + *, + complex_group: bool, +) -> tuple[np.ndarray, ...]: + gradients = [] + for selected in range(len(factors)): + before = None + for factor in factors[:selected]: + before = factor if before is None else before @ factor + after = None + for factor in factors[selected + 1 :]: + after = factor if after is None else after @ factor + gradient = holonomy_gradient + if before is not None: + left = _dagger(before) if complex_group else _transpose(before) + gradient = left @ gradient + if after is not None: + right = _dagger(after) if complex_group else _transpose(after) + gradient = gradient @ right + gradients.append(gradient) + return tuple(gradients) + + +def _phase_cycle_gradients( + factors: tuple[np.ndarray, ...], + holonomy_gradient: np.ndarray, +) -> tuple[np.ndarray, ...]: + gradients = [] + for selected in range(len(factors)): + before = np.ones_like(holonomy_gradient) + for factor in factors[:selected]: + before *= factor + after = np.ones_like(holonomy_gradient) + for factor in factors[selected + 1 :]: + after *= factor + gradients.append(np.conj(before) * holonomy_gradient * np.conj(after)) + return tuple(gradients) + + +def _active_square_sectors( + state: R5State, + parameters: R5Parameters, +) -> tuple[str, ...]: + active = [] + if np.any(state.r3.phase_links != 1.0 + 0.0j): + active.append("phase") + identities = [ + ("color", state.r3.color_links, np.eye(3)), + ("weak", state.weak_links, np.eye(2)), + ] + if parameters.r4.r3.frame_enabled: + identities.append(("frame", state.r3.frame_links, np.eye(4))) + for name, links, identity in identities: + if np.any(links != identity): + active.append(name) + return tuple(active) + + +def _frame_weight(state: R5State, parameters: R5Parameters) -> np.ndarray: + if parameters.r4.r3.frame_enabled: + return np.exp(state.r3.shape[..., 0, 0]) + return np.ones_like(state.r3.chi) + + +def _add_single_face_square_curvature( + state: R5State, + parameters: R5Parameters, + rates: R5Rates, + components: dict[str, float], + sector: str, +) -> float: + """Fast exact square correction when only one compact group is active.""" + + base = state.r3 + r4 = parameters.r4 + coefficient = parameters.square_coefficient + q = _frame_weight(state, parameters) + frame_enabled = r4.r3.frame_enabled + names = { + "phase": "phase_face_square", + "color": "color_face_square", + "frame": "frame_face_square", + "weak": "weak_face_square", + } + for name in names.values(): + components[name] = 0.0 + if sector == "phase": + gradient_total = np.zeros_like(base.phase_links) + for cycle in face_square_cycles(): + factors, shifts, holonomy = _cycle_products( + base.phase_links, + cycle, + complex_group=True, + ) + bare = r4.r3.phase_stiffness * coefficient * (1.0 - np.real(holonomy)) + components[names[sector]] += float(np.sum(q * bare)) + if frame_enabled: + rates.r3.shape -= (q * bare)[ + ..., np.newaxis, np.newaxis + ] * _temporal_shape_projector() + holonomy_gradient = (-q * r4.r3.phase_stiffness * coefficient).astype(np.complex128) + for offset, shift, gradient in zip( + cycle, + shifts, + _phase_cycle_gradients(factors, holonomy_gradient), + strict=True, + ): + _accumulate_oriented_phase_gradient( + gradient_total, + offset, + _scatter_gradient_to_base(gradient, shift), + stencil=parameters.stencil, + ) + for index in range(base.phase_links.shape[3]): + link = base.phase_links[..., index] + rates.r3.phase_electric[..., index] -= np.real( + np.conj(gradient_total[..., index]) * (1.0j * link) + ) + if frame_enabled: + rates.r3.shape = _tracefree_symmetric(rates.r3.shape) + return float(components[names[sector]]) + + if sector == "color": + links = base.color_links + gradient_total = np.zeros_like(links) + epsilon, epsilon_derivative = color_dielectric(base.chi, r4) + identity = np.eye(3, dtype=np.complex128) + stiffness = r4.r3.color_stiffness + generators = su3_generators() + complex_group = True + elif sector == "weak": + links = state.weak_links + gradient_total = np.zeros_like(links) + epsilon = None + epsilon_derivative = None + identity = np.eye(2, dtype=np.complex128) + stiffness = r4.weak_stiffness + generators = su2_generators() + complex_group = True + elif sector == "frame": + if not frame_enabled: + raise ValueError("frame square sector is disabled") + links = base.frame_links + gradient_total = np.zeros_like(links) + epsilon = None + epsilon_derivative = None + identity = np.eye(4, dtype=np.float64) + stiffness = r4.r3.frame_stiffness + generators = so4_generators() + complex_group = False + else: + raise ValueError("unknown compact square sector") + + for cycle in face_square_cycles(): + factors, shifts, holonomy = _cycle_products( + links, + cycle, + complex_group=complex_group, + ) + if sector == "frame": + energy_density, holonomy_gradient = _frame_loop_energy_gradient( + holonomy, + stiffness * coefficient, + r4.r3.epsilon_w, + ) + components[names[sector]] += float(np.sum(energy_density)) + else: + dimension = identity.shape[0] + bare = ( + stiffness + * coefficient + * (float(dimension) - np.real(np.trace(holonomy, axis1=-2, axis2=-1))) + ) + factor = q if sector == "weak" else q * epsilon + components[names[sector]] += float(np.sum(factor * bare)) + if frame_enabled: + rates.r3.shape -= (factor * bare)[..., np.newaxis, np.newaxis] * ( + _temporal_shape_projector() + ) + if sector == "color": + rates.r3.chi -= q * epsilon_derivative * bare + holonomy_gradient = (-factor * stiffness * coefficient)[ + ..., np.newaxis, np.newaxis + ] * identity + for offset, shift, gradient in zip( + cycle, + shifts, + _matrix_cycle_gradients( + factors, + holonomy_gradient, + complex_group=complex_group, + ), + strict=True, + ): + _accumulate_oriented_gradient( + gradient_total, + offset, + _scatter_gradient_to_base(gradient, shift), + stencil=parameters.stencil, + complex_group=complex_group, + ) + for index in range(links.shape[3]): + link = links[..., index, :, :] + for generator_index, generator in enumerate(generators): + variation = 1.0j * generator @ link if complex_group else generator @ link + derivative = np.sum( + ( + np.conj(gradient_total[..., index, :, :]) + if complex_group + else gradient_total[..., index, :, :] + ) + * variation, + axis=(-2, -1), + ) + derivative = np.real(derivative) + if sector == "color": + rates.r3.color_electric[..., index, generator_index] -= derivative + elif sector == "weak": + rates.weak_electric[..., index, generator_index] -= derivative + else: + rates.r3.frame_electric[..., index, generator_index] -= derivative + if frame_enabled: + rates.r3.shape = _tracefree_symmetric(rates.r3.shape) + return float(components[names[sector]]) + + +def _add_face_square_curvature( + state: R5State, + parameters: R5Parameters, + rates: R5Rates, + components: dict[str, float], +) -> float: + base = state.r3 + r4 = parameters.r4 + coefficient = parameters.square_coefficient + q = _frame_weight(state, parameters) + frame_enabled = r4.r3.frame_enabled + epsilon, epsilon_derivative = color_dielectric(base.chi, r4) + phase_gradient = np.zeros_like(base.phase_links) + color_gradient = np.zeros_like(base.color_links) + frame_gradient = np.zeros_like(base.frame_links) + weak_gradient = np.zeros_like(state.weak_links) + identity3 = np.eye(3, dtype=np.complex128) + identity2 = np.eye(2, dtype=np.complex128) + square_energy = { + "phase_face_square": 0.0, + "color_face_square": 0.0, + "frame_face_square": 0.0, + "weak_face_square": 0.0, + } + for cycle in face_square_cycles(): + phase_factors, base_shifts, phase_holonomy = _cycle_products( + base.phase_links, + cycle, + complex_group=True, + ) + phase_bare = r4.r3.phase_stiffness * coefficient * (1.0 - np.real(phase_holonomy)) + square_energy["phase_face_square"] += float(np.sum(q * phase_bare)) + if frame_enabled: + rates.r3.shape -= (q * phase_bare)[ + ..., np.newaxis, np.newaxis + ] * _temporal_shape_projector() + phase_h_gradient = (-q * r4.r3.phase_stiffness * coefficient).astype(np.complex128) + for offset, base_shift, gradient in zip( + cycle, + base_shifts, + _phase_cycle_gradients( + phase_factors, + phase_h_gradient, + ), + strict=True, + ): + _accumulate_oriented_phase_gradient( + phase_gradient, + offset, + _scatter_gradient_to_base(gradient, base_shift), + stencil=parameters.stencil, + ) + + color_factors, _, color_holonomy = _cycle_products( + base.color_links, + cycle, + complex_group=True, + ) + color_bare = ( + r4.r3.color_stiffness + * coefficient + * (3.0 - np.real(np.trace(color_holonomy, axis1=-2, axis2=-1))) + ) + square_energy["color_face_square"] += float(np.sum(q * epsilon * color_bare)) + if frame_enabled: + rates.r3.shape -= (q * epsilon * color_bare)[..., np.newaxis, np.newaxis] * ( + _temporal_shape_projector() + ) + rates.r3.chi -= q * epsilon_derivative * color_bare + color_h_gradient = (-q * epsilon * r4.r3.color_stiffness * coefficient)[ + ..., np.newaxis, np.newaxis + ] * identity3 + for offset, base_shift, gradient in zip( + cycle, + base_shifts, + _matrix_cycle_gradients( + color_factors, + color_h_gradient, + complex_group=True, + ), + strict=True, + ): + _accumulate_oriented_gradient( + color_gradient, + offset, + _scatter_gradient_to_base(gradient, base_shift), + stencil=parameters.stencil, + complex_group=True, + ) + + if frame_enabled: + frame_factors, _, frame_holonomy = _cycle_products( + base.frame_links, + cycle, + complex_group=False, + ) + frame_energy, frame_h_gradient = _frame_loop_energy_gradient( + frame_holonomy, + r4.r3.frame_stiffness * coefficient, + r4.r3.epsilon_w, + ) + square_energy["frame_face_square"] += float(np.sum(frame_energy)) + for offset, base_shift, gradient in zip( + cycle, + base_shifts, + _matrix_cycle_gradients( + frame_factors, + frame_h_gradient, + complex_group=False, + ), + strict=True, + ): + _accumulate_oriented_gradient( + frame_gradient, + offset, + _scatter_gradient_to_base(gradient, base_shift), + stencil=parameters.stencil, + complex_group=False, + ) + + weak_factors, _, weak_holonomy = _cycle_products( + state.weak_links, + cycle, + complex_group=True, + ) + weak_bare = ( + r4.weak_stiffness + * coefficient + * (2.0 - np.real(np.trace(weak_holonomy, axis1=-2, axis2=-1))) + ) + square_energy["weak_face_square"] += float(np.sum(q * weak_bare)) + if frame_enabled: + rates.r3.shape -= (q * weak_bare)[ + ..., np.newaxis, np.newaxis + ] * _temporal_shape_projector() + weak_h_gradient = (-q * r4.weak_stiffness * coefficient)[ + ..., np.newaxis, np.newaxis + ] * identity2 + for offset, base_shift, gradient in zip( + cycle, + base_shifts, + _matrix_cycle_gradients( + weak_factors, + weak_h_gradient, + complex_group=True, + ), + strict=True, + ): + _accumulate_oriented_gradient( + weak_gradient, + offset, + _scatter_gradient_to_base(gradient, base_shift), + stencil=parameters.stencil, + complex_group=True, + ) + + for index in range(base.phase_links.shape[3]): + phase = base.phase_links[..., index] + rates.r3.phase_electric[..., index] -= np.real( + np.conj(phase_gradient[..., index]) * (1.0j * phase) + ) + color = base.color_links[..., index, :, :] + for generator_index, generator in enumerate(su3_generators()): + variation = 1.0j * generator @ color + rates.r3.color_electric[..., index, generator_index] -= np.real( + np.sum( + np.conj(color_gradient[..., index, :, :]) * variation, + axis=(-2, -1), + ) + ) + if frame_enabled: + frame = base.frame_links[..., index, :, :] + for generator_index, generator in enumerate(so4_generators()): + variation = generator @ frame + rates.r3.frame_electric[..., index, generator_index] -= np.sum( + frame_gradient[..., index, :, :] * variation, + axis=(-2, -1), + ) + weak = state.weak_links[..., index, :, :] + for generator_index, generator in enumerate(su2_generators()): + variation = 1.0j * generator @ weak + rates.weak_electric[..., index, generator_index] -= np.real( + np.sum( + np.conj(weak_gradient[..., index, :, :]) * variation, + axis=(-2, -1), + ) + ) + if frame_enabled: + rates.r3.shape = _tracefree_symmetric(rates.r3.shape) + components.update(square_energy) + return float(sum(square_energy.values())) + + +def potential_energy_and_rates( + state: R5State, + parameters: R5Parameters = R5Parameters(), +) -> tuple[float, R5Rates, dict[str, float]]: + """Return the complete R5 potential, forces, and component ledger.""" + + energy, rates, components = r4_potential_energy_and_rates( + state, + parameters.r4, + ) + active_sectors = _active_square_sectors(state, parameters) + if not active_sectors: + for name in ( + "phase_face_square", + "color_face_square", + "frame_face_square", + "weak_face_square", + ): + components[name] = 0.0 + correction = 0.0 + elif len(active_sectors) == 1: + correction = _add_single_face_square_curvature( + state, + parameters, + rates, + components, + active_sectors[0], + ) + else: + correction = _add_face_square_curvature( + state, + parameters, + rates, + components, + ) + return energy + correction, rates, components + + +def kinetic_energy( + state: R5State, + parameters: R5Parameters = R5Parameters(), +) -> tuple[float, dict[str, float]]: + return r4_kinetic_energy(state, parameters.r4) + + +def total_hamiltonian( + state: R5State, + parameters: R5Parameters = R5Parameters(), +) -> tuple[float, dict[str, float]]: + kinetic, kinetic_parts = kinetic_energy(state, parameters) + potential, _, potential_parts = potential_energy_and_rates( + state, + parameters, + ) + return kinetic + potential, {**kinetic_parts, **potential_parts} + + +def _potential_kick( + state: R5State, + duration: float, + parameters: R5Parameters, +) -> None: + _, rates, _ = potential_energy_and_rates(state, parameters) + base = state.r3 + base.matter_momentum += duration * rates.r3.matter + base.chi_momentum += duration * rates.r3.chi + if parameters.r4.r3.frame_enabled: + base.shape_momentum += duration * rates.r3.shape + base.phase_electric += duration * rates.r3.phase_electric + base.color_electric += duration * rates.r3.color_electric + if parameters.r4.r3.frame_enabled: + base.frame_electric += duration * rates.r3.frame_electric + state.weak_momentum += duration * rates.weak_matter + state.weak_electric += duration * rates.weak_electric + state.higgs_electric += duration * rates.higgs_electric + + +def step_r5( + state: R5State, + dt: float, + parameters: R5Parameters = R5Parameters(), +) -> None: + """Advance one symmetric second-order R5 Hamiltonian split.""" + + if not np.isfinite(dt) or dt <= 0.0: + raise ValueError("dt must be positive and finite") + _validate_r4(state, parameters.r4) + half = 0.5 * dt + _potential_kick(state, half, parameters) + _link_and_shape_drift(state.r3, half, parameters.r4.r3) + _extra_gauge_kinetic_drift(state, half, parameters.r4) + _weighted_bare_kinetic_drift(state.r3, dt, parameters.r4.r3) + _weak_matter_kinetic_drift(state, dt, parameters.r4) + _extra_gauge_kinetic_drift(state, half, parameters.r4) + _link_and_shape_drift(state.r3, half, parameters.r4.r3) + _potential_kick(state, half, parameters) + + +def r5_action_declaration( + parameters: R5Parameters = R5Parameters(), +) -> dict[str, object]: + return { + "action_id": R5_ACTION_ID, + "register_id": R5_REGISTER_ID, + "canonical_status": "EXPERIMENT_ONLY_UNPROMOTED", + "parameters": asdict(parameters), + "derived_parameters": { + "face_square_coefficient": parameters.square_coefficient, + "coefficient_rule": "sum_unique_links_offset_axis_squared", + }, + "retained_terms": ["complete_R4_action"], + "added_terms": [ + "geometry_normalized_U1_face_square_curvature", + "geometry_normalized_SU2_face_square_curvature", + "geometry_normalized_SU3_face_square_curvature", + "geometry_normalized_SO4_face_square_curvature", + ], + "new_registers": [], + "forbidden_mechanisms_used": [], + "paper_45_update_authorized": False, + } + + +def r5_action_fingerprint( + parameters: R5Parameters = R5Parameters(), +) -> str: + encoded = json.dumps( + r5_action_declaration(parameters), + sort_keys=True, + separators=(",", ":"), + ).encode("ascii") + return hashlib.sha256(encoded).hexdigest() diff --git a/lfm/foundations/r6_unified_live.py b/lfm/foundations/r6_unified_live.py new file mode 100644 index 0000000..9c8a98f --- /dev/null +++ b/lfm/foundations/r6_unified_live.py @@ -0,0 +1,138 @@ +"""Experimental R6 action with causal weak kinetic normalization. + +R6 is the R5 action with no new register and no new potential term. The +weak electric inertia is set equal to the already-derived weak magnetic +stiffness, 1/epsilon_W. This makes the weak characteristic speed the same +unit speed as every other live carrier. +""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import asdict, dataclass, field + +from lfm.foundations.r4_unified_live import ( + R4Parameters, + R4Rates, + R4State, +) +from lfm.foundations.r5_unified_live import ( + R5Parameters, + group_constraint_errors, # noqa: F401 - public R6 re-export + reverse_momenta, # noqa: F401 - public R6 re-export + state_distance, # noqa: F401 - public R6 re-export + step_r5, +) +from lfm.foundations.r5_unified_live import ( + kinetic_energy as r5_kinetic_energy, +) +from lfm.foundations.r5_unified_live import ( + potential_energy_and_rates as r5_potential_energy_and_rates, +) +from lfm.foundations.r5_unified_live import ( + total_hamiltonian as r5_total_hamiltonian, +) + +R6_ACTION_ID = "LFM-R6-CAUSAL-WEAK-NORMALIZATION-EXPERIMENT-v1" +R6_REGISTER_ID = "R6=R5=R4(no_new_registers)" +R6State = R4State +R6Rates = R4Rates + + +@dataclass(frozen=True) +class R6R4Parameters(R4Parameters): + """R4 parameter set with Lorentz-matched weak link inertia.""" + + @property + def weak_inertia(self) -> float: + return self.weak_stiffness + + +@dataclass(frozen=True) +class R6Parameters: + """Complete R6 parameter set.""" + + r4: R6R4Parameters = field(default_factory=R6R4Parameters) + + @property + def r5(self) -> R5Parameters: + return R5Parameters(r4=self.r4) + + @property + def stencil(self) -> str: + return self.r4.stencil + + @property + def square_coefficient(self) -> float: + return self.r5.square_coefficient + + +def vacuum_state( + size: int, + parameters: R6Parameters = R6Parameters(), +) -> R6State: + return R4State.vacuum(size, parameters.r4) + + +def potential_energy_and_rates( + state: R6State, + parameters: R6Parameters = R6Parameters(), +) -> tuple[float, R6Rates, dict[str, float]]: + return r5_potential_energy_and_rates(state, parameters.r5) + + +def kinetic_energy( + state: R6State, + parameters: R6Parameters = R6Parameters(), +) -> tuple[float, dict[str, float]]: + return r5_kinetic_energy(state, parameters.r5) + + +def total_hamiltonian( + state: R6State, + parameters: R6Parameters = R6Parameters(), +) -> tuple[float, dict[str, float]]: + return r5_total_hamiltonian(state, parameters.r5) + + +def step_r6( + state: R6State, + dt: float, + parameters: R6Parameters = R6Parameters(), +) -> None: + step_r5(state, dt, parameters.r5) + + +def r6_action_declaration( + parameters: R6Parameters = R6Parameters(), +) -> dict[str, object]: + return { + "action_id": R6_ACTION_ID, + "register_id": R6_REGISTER_ID, + "canonical_status": "EXPERIMENT_ONLY_UNPROMOTED", + "parameters": asdict(parameters), + "derived_parameters": { + "face_square_coefficient": parameters.square_coefficient, + "weak_stiffness": parameters.r4.weak_stiffness, + "weak_inertia": parameters.r4.weak_inertia, + "weak_speed_squared": (parameters.r4.weak_stiffness / parameters.r4.weak_inertia), + }, + "retained_terms": ["complete_R5_action"], + "changed_terms": ["weak_electric_inertia_equals_weak_magnetic_stiffness"], + "new_registers": [], + "new_potential_terms": [], + "forbidden_mechanisms_used": [], + "paper_45_update_authorized": False, + } + + +def r6_action_fingerprint( + parameters: R6Parameters = R6Parameters(), +) -> str: + encoded = json.dumps( + r6_action_declaration(parameters), + sort_keys=True, + separators=(",", ":"), + ).encode("ascii") + return hashlib.sha256(encoded).hexdigest() diff --git a/lfm/particles/__init__.py b/lfm/particles/__init__.py index 1350ea5..c90ce50 100644 --- a/lfm/particles/__init__.py +++ b/lfm/particles/__init__.py @@ -1,5 +1,5 @@ """ -lfm.particles — Particle Catalog and Eigenmode Solver +lfm.particles - Particle Catalog and Eigenmode Solver ====================================================== Provides the particle specification dataclass, the canonical particle @@ -119,6 +119,30 @@ measure_momentum_density, measure_velocity, ) +from lfm.particles.noether import ( + CartesianFixedChargeEnergy, + CartesianNoetherSolution, + CartesianNoetherState, + RadialNoetherEnergy, + RadialNoetherSolution, + RadialNoetherSweepResult, + cartesian_fixed_charge_energy_and_gradient, + cartesian_localization_metrics, + cartesian_noether_charge, + cartesian_noether_hamiltonian, + cartesian_stationary_residual, + lift_radial_noether_state, + make_radial_bag_guess, + prolong_radial_fields, + prolong_radial_solution, + radial_fixed_charge_energy_and_gradient, + radial_fixed_charge_hessian, + radial_shell_geometry, + solve_cartesian_noether_soliton, + solve_radial_noether_soliton, + sparse_newton_polish_radial, + sweep_radial_noether_solitons, +) from lfm.particles.solver import ( SolitonSolution, boost_fields, @@ -126,6 +150,14 @@ solve_eigenmode, ylm_seed, ) +from lfm.particles.stationary import ( + StationaryBranchPoint, + SupportRemovalPoint, + continue_stationary_branch, + continue_support_removal, + solve_stationary_branch_point, + solve_support_removal_point, +) __all__ = [ "Particle", @@ -232,6 +264,29 @@ "measure_center_of_energy", "measure_momentum_density", "measure_velocity", + # Fixed-Noether-charge radial discovery solver + "RadialNoetherEnergy", + "RadialNoetherSolution", + "RadialNoetherSweepResult", + "CartesianNoetherState", + "CartesianFixedChargeEnergy", + "CartesianNoetherSolution", + "radial_shell_geometry", + "radial_fixed_charge_energy_and_gradient", + "radial_fixed_charge_hessian", + "make_radial_bag_guess", + "prolong_radial_fields", + "prolong_radial_solution", + "solve_radial_noether_soliton", + "sparse_newton_polish_radial", + "sweep_radial_noether_solitons", + "lift_radial_noether_state", + "cartesian_fixed_charge_energy_and_gradient", + "solve_cartesian_noether_soliton", + "cartesian_stationary_residual", + "cartesian_noether_charge", + "cartesian_noether_hamiltonian", + "cartesian_localization_metrics", # Phase 4: Composite systems "AtomState", "MoleculeState", @@ -245,4 +300,11 @@ # Phase 6: Collision "CollisionSetup", "create_collision", + # Bare stationary branch solver + "StationaryBranchPoint", + "SupportRemovalPoint", + "continue_support_removal", + "continue_stationary_branch", + "solve_support_removal_point", + "solve_stationary_branch_point", ] diff --git a/lfm/particles/noether.py b/lfm/particles/noether.py new file mode 100644 index 0000000..60586a4 --- /dev/null +++ b/lfm/particles/noether.py @@ -0,0 +1,1476 @@ +"""Fixed-Noether-charge radial solitons of the bare LFM action. + +This module is deliberately particle-name agnostic. A converged result is a +scalar soliton candidate, not an electron. The radial solver is a continuum +discovery and refinement tool; positive candidates still require confirmation +with the canonical three-dimensional 19-point operator and live evolution. +""" + +from __future__ import annotations + +import warnings +from dataclasses import dataclass +from typing import TYPE_CHECKING, cast + +import numpy as np +import scipy.sparse as sp +from scipy.optimize import minimize, root +from scipy.sparse.linalg import MatrixRankWarning, spsolve + +from lfm.constants import CHI0, KAPPA, LAMBDA_H +from lfm.core.stencils import laplacian_19pt + +if TYPE_CHECKING: + from collections.abc import Callable + + +@dataclass(frozen=True) +class RadialNoetherEnergy: + """Energy ledger for one radial fixed-charge configuration.""" + + total: float + temporal: float + matter_gradient: float + matter_mass: float + chi_gradient: float + chi_potential: float + norm: float + omega: float + energy_per_charge: float + + +@dataclass(frozen=True) +class RadialNoetherSolution: + """One optimized radial fixed-Noether-charge configuration.""" + + radius: float + dx: float + r: np.ndarray + phi: np.ndarray + chi: np.ndarray + target_charge: float + charge: float + energy: RadialNoetherEnergy + rms_radius: float + half_charge_radius: float + stationary_relative_residual: float + phi_relative_residual: float + chi_relative_residual: float + optimizer_converged: bool + optimizer_status: int + optimizer_iterations: int + polisher_converged: bool + polisher_iterations: int + sparse_polisher_converged: bool + sparse_polisher_iterations: int + message: str + + +@dataclass(frozen=True) +class RadialNoetherSweepResult: + """One labeled case from a radial solver sweep.""" + + case_id: str + solution: RadialNoetherSolution + + +@dataclass(frozen=True) +class CartesianNoetherState: + """Complete two-layer leapfrog state for one lifted scalar candidate.""" + + psi_real: np.ndarray + psi_real_prev: np.ndarray + psi_imag: np.ndarray + psi_imag_prev: np.ndarray + chi: np.ndarray + chi_prev: np.ndarray + center: tuple[float, float, float] + velocity: tuple[float, float, float] + omega: float + dx: float + dt: float + + +@dataclass(frozen=True) +class CartesianFixedChargeEnergy: + """Energy ledger for one Cartesian fixed-charge configuration.""" + + total: float + temporal: float + matter_gradient: float + matter_mass: float + chi_gradient: float + chi_potential: float + norm: float + omega: float + energy_per_charge: float + + +@dataclass(frozen=True) +class CartesianNoetherSolution: + """One optimized Cartesian fixed-Noether-charge configuration.""" + + phi: np.ndarray + chi: np.ndarray + target_charge: float + charge: float + dx: float + energy: CartesianFixedChargeEnergy + stationary_relative_residual: float + phi_relative_residual: float + chi_relative_residual: float + optimizer_converged: bool + optimizer_status: int + optimizer_iterations: int + function_evaluations: int + message: str + + +def _case_float(case: dict[str, object], key: str, default: float | None = None) -> float: + value = case[key] if default is None else case.get(key, default) + return float(cast("float | int | str", value)) + + +def _case_int(case: dict[str, object], key: str, default: int) -> int: + return int(cast("float | int | str", case.get(key, default))) + + +def _tuple3_float(values: tuple[float, float, float] | np.ndarray) -> tuple[float, float, float]: + items = tuple(float(value) for value in values) + if len(items) != 3: + raise ValueError("expected a 3-vector") + return cast("tuple[float, float, float]", items) + + +def radial_shell_geometry(radius: float, dx: float) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """Return cell centers, shell volumes, and radial face areas.""" + if radius <= 0.0: + raise ValueError("radius must be positive") + if dx <= 0.0: + raise ValueError("dx must be positive") + cells_float = radius / dx + cells = int(round(cells_float)) + if cells < 8 or not np.isclose(cells * dx, radius, rtol=0.0, atol=1.0e-12): + raise ValueError("radius/dx must be an integer of at least 8") + faces = np.arange(cells + 1, dtype=np.float64) * dx + centers = (np.arange(cells, dtype=np.float64) + 0.5) * dx + volumes = (4.0 * np.pi / 3.0) * (faces[1:] ** 3 - faces[:-1] ** 3) + face_areas = 4.0 * np.pi * faces**2 + return centers, volumes, face_areas + + +def _edge_energy_and_gradient( + field: np.ndarray, + face_areas: np.ndarray, + dx: float, + outer_boundary: float, +) -> tuple[float, np.ndarray]: + gradient = np.zeros_like(field) + energy = 0.0 + if field.size > 1: + conductance = face_areas[1:-1] / dx + differences = field[1:] - field[:-1] + energy += 0.5 * float(np.sum(conductance * differences * differences)) + edge_force = conductance * differences + gradient[:-1] -= edge_force + gradient[1:] += edge_force + + outer_conductance = face_areas[-1] / (0.5 * dx) + outer_difference = field[-1] - outer_boundary + energy += 0.5 * outer_conductance * outer_difference * outer_difference + gradient[-1] += outer_conductance * outer_difference + return energy, gradient + + +def _edge_hessian( + cells: int, + face_areas: np.ndarray, + dx: float, +) -> sp.csr_matrix: + diagonal = np.zeros(cells, dtype=np.float64) + off_diagonal = np.zeros(max(cells - 1, 0), dtype=np.float64) + if cells > 1: + conductance = face_areas[1:-1] / dx + diagonal[:-1] += conductance + diagonal[1:] += conductance + off_diagonal[:] = -conductance + diagonal[-1] += face_areas[-1] / (0.5 * dx) + return sp.diags( + (off_diagonal, diagonal, off_diagonal), + offsets=(-1, 0, 1), + shape=(cells, cells), + format="csr", + ) + + +def radial_fixed_charge_energy_and_gradient( + variables: np.ndarray, + *, + target_charge: float, + radius: float, + dx: float, + chi0: float = CHI0, + kappa: float = KAPPA, + lambda_h: float = LAMBDA_H, +) -> tuple[RadialNoetherEnergy, np.ndarray]: + """Evaluate the canonical fixed-charge energy and analytic gradient.""" + if target_charge <= 0.0: + raise ValueError("target_charge must be positive") + if chi0 <= 0.0 or kappa <= 0.0 or lambda_h <= 0.0: + raise ValueError("canonical couplings must be positive") + + _, volumes, face_areas = radial_shell_geometry(radius, dx) + cells = volumes.size + values = np.asarray(variables, dtype=np.float64) + if values.shape != (2 * cells,): + raise ValueError(f"variables must have shape {(2 * cells,)}") + phi = values[:cells] + chi = values[cells:] + norm = float(np.dot(volumes, phi * phi)) + if not np.isfinite(norm) or norm <= 1.0e-300: + raise ValueError("matter norm must be finite and positive") + + b_value = chi0 / kappa + omega = target_charge / norm + temporal = target_charge * target_charge / (2.0 * norm) + matter_gradient, grad_phi_edges = _edge_energy_and_gradient( + phi, + face_areas, + dx, + 0.0, + ) + chi_gradient_raw, grad_chi_edges = _edge_energy_and_gradient( + chi, + face_areas, + dx, + chi0, + ) + matter_mass = 0.5 * float(np.dot(volumes, chi * chi * phi * phi)) + potential_density = (chi * chi - chi0 * chi0) ** 2 + chi_potential = b_value * lambda_h * float(np.dot(volumes, potential_density)) + chi_gradient = b_value * chi_gradient_raw + total = temporal + matter_gradient + matter_mass + chi_gradient + chi_potential + + grad_phi = grad_phi_edges + volumes * chi * chi * phi - omega * omega * volumes * phi + grad_chi = ( + b_value * grad_chi_edges + + volumes * chi * phi * phi + + 4.0 * b_value * lambda_h * volumes * chi * (chi * chi - chi0 * chi0) + ) + gradient = np.concatenate((grad_phi, grad_chi)) + ledger = RadialNoetherEnergy( + total=total, + temporal=temporal, + matter_gradient=matter_gradient, + matter_mass=matter_mass, + chi_gradient=chi_gradient, + chi_potential=chi_potential, + norm=norm, + omega=omega, + energy_per_charge=total / target_charge, + ) + return ledger, gradient + + +def radial_fixed_charge_hessian( + variables: np.ndarray, + *, + target_charge: float, + radius: float, + dx: float, + chi0: float = CHI0, + kappa: float = KAPPA, + lambda_h: float = LAMBDA_H, +) -> sp.csr_matrix: + """Return the analytic Hessian of the reduced fixed-charge energy.""" + _, volumes, face_areas = radial_shell_geometry(radius, dx) + cells = volumes.size + values = np.asarray(variables, dtype=np.float64) + if values.shape != (2 * cells,): + raise ValueError(f"variables must have shape {(2 * cells,)}") + phi = values[:cells] + chi = values[cells:] + norm = float(np.dot(volumes, phi * phi)) + if not np.isfinite(norm) or norm <= 1.0e-300: + raise ValueError("matter norm must be finite and positive") + + b_value = chi0 / kappa + omega = target_charge / norm + edge_hessian = _edge_hessian(cells, face_areas, dx) + volume_phi = volumes * phi + charge_rank_one = (4.0 * omega * omega / norm) * np.outer( + volume_phi, + volume_phi, + ) + phi_block = ( + edge_hessian + + sp.diags(volumes * (chi * chi - omega * omega), format="csr") + + sp.csr_matrix(charge_rank_one) + ) + cross_block = sp.diags(2.0 * volumes * chi * phi, format="csr") + chi_diagonal = volumes * ( + phi * phi + 4.0 * b_value * lambda_h * (3.0 * chi * chi - chi0 * chi0) + ) + chi_block = b_value * edge_hessian + sp.diags( + chi_diagonal, + format="csr", + ) + return sp.bmat( + ( + (phi_block, cross_block), + (cross_block, chi_block), + ), + format="csr", + ) + + +def make_radial_bag_guess( + *, + target_charge: float, + radius: float, + dx: float, + core_radius: float, + omega_guess: float, + chi_depth_fraction: float = 0.9, + chi0: float = CHI0, +) -> np.ndarray: + """Construct an unsupported smooth radial guess with the requested charge.""" + if not 0.0 < core_radius < radius: + raise ValueError("core_radius must lie inside the domain") + if not 0.0 < omega_guess < chi0: + raise ValueError("omega_guess must lie between zero and chi0") + if not 0.0 < chi_depth_fraction < 2.0: + raise ValueError("chi_depth_fraction must lie in (0, 2)") + r, volumes, _ = radial_shell_geometry(radius, dx) + + envelope = np.exp(-0.5 * (r / core_radius) ** 4) + target_norm = target_charge / omega_guess + amplitude = np.sqrt(target_norm / float(np.dot(volumes, envelope * envelope))) + phi = amplitude * envelope + chi = chi0 * (1.0 - chi_depth_fraction * np.exp(-0.5 * (r / core_radius) ** 4)) + return np.concatenate((phi, chi)) + + +def prolong_radial_fields( + *, + radius: float, + source_r: np.ndarray, + phi: np.ndarray, + chi: np.ndarray, + new_dx: float, + chi0: float = CHI0, +) -> np.ndarray: + """Prolong cell-centered radial fields without clipping or resetting.""" + old_r = np.asarray(source_r, dtype=np.float64) + old_phi = np.asarray(phi, dtype=np.float64) + old_chi = np.asarray(chi, dtype=np.float64) + if old_r.ndim != 1 or old_r.size < 2: + raise ValueError("source_r must be a one-dimensional radial grid") + if old_phi.shape != old_r.shape or old_chi.shape != old_r.shape: + raise ValueError("source fields must match source_r") + if not np.all(np.diff(old_r) > 0.0): + raise ValueError("source_r must be strictly increasing") + new_r, _, _ = radial_shell_geometry(radius, new_dx) + prolonged_phi = np.interp( + new_r, + old_r, + old_phi, + left=float(old_phi[0]), + right=0.0, + ) + prolonged_chi = np.interp( + new_r, + old_r, + old_chi, + left=float(old_chi[0]), + right=chi0, + ) + return np.concatenate((prolonged_phi, prolonged_chi)) + + +def prolong_radial_solution( + solution: RadialNoetherSolution, + *, + new_dx: float, + chi0: float = CHI0, +) -> np.ndarray: + """Prolong a cell-centered radial solution without clipping or resetting.""" + return prolong_radial_fields( + radius=solution.radius, + source_r=solution.r, + phi=solution.phi, + chi=solution.chi, + new_dx=new_dx, + chi0=chi0, + ) + + +def _stationary_residuals( + phi: np.ndarray, + chi: np.ndarray, + *, + target_charge: float, + radius: float, + dx: float, + chi0: float, + kappa: float, + lambda_h: float, +) -> tuple[float, float, float]: + variables = np.concatenate((phi, chi)) + ledger, gradient = radial_fixed_charge_energy_and_gradient( + variables, + target_charge=target_charge, + radius=radius, + dx=dx, + chi0=chi0, + kappa=kappa, + lambda_h=lambda_h, + ) + _, volumes, _ = radial_shell_geometry(radius, dx) + cells = phi.size + phi_equation = gradient[:cells] / volumes + b_value = chi0 / kappa + chi_equation = gradient[cells:] / (b_value * volumes) + + phi_scale = max( + float(np.sqrt(np.dot(volumes, (ledger.omega * ledger.omega * phi) ** 2))), + 1.0e-300, + ) + phi_residual = float(np.sqrt(np.dot(volumes, phi_equation * phi_equation))) / phi_scale + chi_scale_field = ( + 4.0 * lambda_h * chi * (chi * chi - chi0 * chi0) + (kappa / chi0) * chi * phi * phi + ) + chi_scale = max( + float(np.sqrt(np.dot(volumes, chi_scale_field * chi_scale_field))), + 4.0 * lambda_h * chi0**3 * np.sqrt(float(np.sum(volumes))) * 1.0e-12, + ) + chi_residual = float(np.sqrt(np.dot(volumes, chi_equation * chi_equation))) / chi_scale + return max(phi_residual, chi_residual), phi_residual, chi_residual + + +def sparse_newton_polish_radial( + variables: np.ndarray, + *, + target_charge: float, + radius: float, + dx: float, + chi0: float = CHI0, + kappa: float = KAPPA, + lambda_h: float = LAMBDA_H, + residual_tolerance: float = 1.0e-9, + max_iterations: int = 80, +) -> tuple[np.ndarray, bool, int, str]: + """Damped sparse Newton polish of the same fixed-charge energy.""" + current = np.asarray(variables, dtype=np.float64).copy() + cells = current.size // 2 + if current.shape != (2 * cells,) or cells < 8: + raise ValueError("variables must contain two radial fields") + + for iteration in range(max_iterations + 1): + residual, _, _ = _stationary_residuals( + current[:cells], + current[cells:], + target_charge=target_charge, + radius=radius, + dx=dx, + chi0=chi0, + kappa=kappa, + lambda_h=lambda_h, + ) + if residual <= residual_tolerance: + return current, True, iteration, "stationary residual converged" + if iteration == max_iterations: + break + + ledger, gradient = radial_fixed_charge_energy_and_gradient( + current, + target_charge=target_charge, + radius=radius, + dx=dx, + chi0=chi0, + kappa=kappa, + lambda_h=lambda_h, + ) + hessian = radial_fixed_charge_hessian( + current, + target_charge=target_charge, + radius=radius, + dx=dx, + chi0=chi0, + kappa=kappa, + lambda_h=lambda_h, + ) + diagonal_scale = np.maximum(np.abs(hessian.diagonal()), 1.0) + accepted = False + for damping in (0.0, 1.0e-12, 1.0e-10, 1.0e-8, 1.0e-6, 1.0e-4): + system = ( + hessian + if damping == 0.0 + else hessian + sp.diags(damping * diagonal_scale, format="csr") + ) + with warnings.catch_warnings(): + warnings.simplefilter("error", MatrixRankWarning) + try: + delta = spsolve(system, -gradient) + except (MatrixRankWarning, RuntimeError, ValueError): + continue + if not np.all(np.isfinite(delta)): + continue + + step = 1.0 + while step >= 1.0e-10: + candidate = current + step * delta + try: + candidate_ledger, _ = radial_fixed_charge_energy_and_gradient( + candidate, + target_charge=target_charge, + radius=radius, + dx=dx, + chi0=chi0, + kappa=kappa, + lambda_h=lambda_h, + ) + candidate_residual, _, _ = _stationary_residuals( + candidate[:cells], + candidate[cells:], + target_charge=target_charge, + radius=radius, + dx=dx, + chi0=chi0, + kappa=kappa, + lambda_h=lambda_h, + ) + except ValueError: + step *= 0.5 + continue + energy_ok = candidate_ledger.total <= ledger.total * (1.0 + 1.0e-13) + if candidate_residual < residual and energy_ok: + current = candidate + accepted = True + break + step *= 0.5 + if accepted: + break + if not accepted: + return ( + current, + False, + iteration, + "damped sparse Newton line search failed", + ) + return current, False, max_iterations, "sparse Newton iteration limit reached" + + +def solve_radial_noether_soliton( + *, + target_charge: float, + radius: float, + dx: float, + core_radius: float, + omega_guess: float, + chi_depth_fraction: float = 0.9, + initial_variables: np.ndarray | None = None, + chi0: float = CHI0, + kappa: float = KAPPA, + lambda_h: float = LAMBDA_H, + max_iterations: int = 2000, + gradient_tolerance: float = 1.0e-9, + polish_tolerance: float = 1.0e-11, + sparse_polish: bool = True, + sparse_polish_tolerance: float = 1.0e-9, + sparse_polish_max_iterations: int = 80, +) -> RadialNoetherSolution: + """Minimize the canonical radial Hamiltonian at fixed Noether charge. + + No field is clipped or renormalized during optimization. The charge + constraint is represented by the exact reduced term Q^2/(2*N), where + N is the spatial matter norm. + """ + r, volumes, _ = radial_shell_geometry(radius, dx) + if initial_variables is None: + initial = make_radial_bag_guess( + target_charge=target_charge, + radius=radius, + dx=dx, + core_radius=core_radius, + omega_guess=omega_guess, + chi_depth_fraction=chi_depth_fraction, + chi0=chi0, + ) + else: + initial = np.asarray(initial_variables, dtype=np.float64).copy() + if initial.shape != (2 * r.size,): + raise ValueError("initial_variables has the wrong shape") + + free_energy = target_charge * chi0 + cells = r.size + phi_scale = max(float(np.max(np.abs(initial[:cells]))), 1.0) + chi_scale = chi0 + variable_scale = np.concatenate( + ( + np.full(cells, phi_scale, dtype=np.float64), + np.full(cells, chi_scale, dtype=np.float64), + ) + ) + scaled_initial = initial / variable_scale + + def objective(scaled_values: np.ndarray) -> tuple[float, np.ndarray]: + values = scaled_values * variable_scale + ledger, gradient = radial_fixed_charge_energy_and_gradient( + values, + target_charge=target_charge, + radius=radius, + dx=dx, + chi0=chi0, + kappa=kappa, + lambda_h=lambda_h, + ) + return ledger.total / free_energy, gradient * variable_scale / free_energy + + result = minimize( + objective, + scaled_initial, + method="L-BFGS-B", + jac=True, + options={ + "maxiter": int(max_iterations), + "gtol": float(gradient_tolerance), + "ftol": 1.0e-14, + "maxls": 50, + "maxcor": 30, + }, + ) + optimized = np.asarray(result.x, dtype=np.float64) * variable_scale + optimized_ledger, _ = radial_fixed_charge_energy_and_gradient( + optimized, + target_charge=target_charge, + radius=radius, + dx=dx, + chi0=chi0, + kappa=kappa, + lambda_h=lambda_h, + ) + optimized_residual, _, _ = _stationary_residuals( + optimized[:cells], + optimized[cells:], + target_charge=target_charge, + radius=radius, + dx=dx, + chi0=chi0, + kappa=kappa, + lambda_h=lambda_h, + ) + + polish_scale = np.concatenate( + ( + np.full( + cells, + max(float(np.max(np.abs(optimized[:cells]))), 1.0), + dtype=np.float64, + ), + np.full(cells, chi0, dtype=np.float64), + ) + ) + + def stationarity(scaled_values: np.ndarray) -> np.ndarray: + values = scaled_values * polish_scale + _, gradient = radial_fixed_charge_energy_and_gradient( + values, + target_charge=target_charge, + radius=radius, + dx=dx, + chi0=chi0, + kappa=kappa, + lambda_h=lambda_h, + ) + return gradient * polish_scale / free_energy + + polisher_success = False + polisher_iterations = 0 + polisher_message = "not run" + polished_values = optimized.copy() + try: + polished = root( + stationarity, + optimized / polish_scale, + method="krylov", + options={ + "fatol": float(polish_tolerance), + "maxiter": min(int(max_iterations), 500), + "disp": False, + }, + ) + polished_values = np.asarray(polished.x, dtype=np.float64) * polish_scale + polisher_success = bool(polished.success) + polisher_iterations = int(getattr(polished, "nit", 0)) + polisher_message = str(polished.message) + except (ArithmeticError, RuntimeError, ValueError) as error: + polisher_message = f"{type(error).__name__}: {error}" + + use_polished = False + if np.all(np.isfinite(polished_values)): + try: + polished_ledger, _ = radial_fixed_charge_energy_and_gradient( + polished_values, + target_charge=target_charge, + radius=radius, + dx=dx, + chi0=chi0, + kappa=kappa, + lambda_h=lambda_h, + ) + polished_residual, _, _ = _stationary_residuals( + polished_values[:cells], + polished_values[cells:], + target_charge=target_charge, + radius=radius, + dx=dx, + chi0=chi0, + kappa=kappa, + lambda_h=lambda_h, + ) + use_polished = bool( + polished_residual < optimized_residual + and polished_ledger.total <= optimized_ledger.total * (1.0 + 1.0e-9) + ) + except ValueError: + use_polished = False + + final_values = polished_values if use_polished else optimized + sparse_polisher_converged = False + sparse_polisher_iterations = 0 + sparse_polisher_message = "not run" + if sparse_polish: + ( + sparse_values, + sparse_polisher_converged, + sparse_polisher_iterations, + sparse_polisher_message, + ) = sparse_newton_polish_radial( + final_values, + target_charge=target_charge, + radius=radius, + dx=dx, + chi0=chi0, + kappa=kappa, + lambda_h=lambda_h, + residual_tolerance=sparse_polish_tolerance, + max_iterations=sparse_polish_max_iterations, + ) + sparse_residual, _, _ = _stationary_residuals( + sparse_values[:cells], + sparse_values[cells:], + target_charge=target_charge, + radius=radius, + dx=dx, + chi0=chi0, + kappa=kappa, + lambda_h=lambda_h, + ) + current_residual, _, _ = _stationary_residuals( + final_values[:cells], + final_values[cells:], + target_charge=target_charge, + radius=radius, + dx=dx, + chi0=chi0, + kappa=kappa, + lambda_h=lambda_h, + ) + if sparse_residual < current_residual: + final_values = sparse_values + phi = final_values[:cells] + chi = final_values[cells:] + ledger, _ = radial_fixed_charge_energy_and_gradient( + final_values, + target_charge=target_charge, + radius=radius, + dx=dx, + chi0=chi0, + kappa=kappa, + lambda_h=lambda_h, + ) + residual, phi_residual, chi_residual = _stationary_residuals( + phi, + chi, + target_charge=target_charge, + radius=radius, + dx=dx, + chi0=chi0, + kappa=kappa, + lambda_h=lambda_h, + ) + density_weight = volumes * phi * phi + cumulative = np.cumsum(density_weight) + half_index = int(np.searchsorted(cumulative, 0.5 * ledger.norm, side="left")) + half_index = min(half_index, r.size - 1) + rms_radius = np.sqrt(float(np.dot(density_weight, r * r)) / ledger.norm) + charge = ledger.omega * ledger.norm + return RadialNoetherSolution( + radius=float(radius), + dx=float(dx), + r=r, + phi=phi, + chi=chi, + target_charge=float(target_charge), + charge=float(charge), + energy=ledger, + rms_radius=float(rms_radius), + half_charge_radius=float(r[half_index]), + stationary_relative_residual=float(residual), + phi_relative_residual=float(phi_residual), + chi_relative_residual=float(chi_residual), + optimizer_converged=bool(result.success), + optimizer_status=int(result.status), + optimizer_iterations=int(result.nit), + polisher_converged=bool(polisher_success and use_polished), + polisher_iterations=polisher_iterations, + sparse_polisher_converged=bool(sparse_polisher_converged), + sparse_polisher_iterations=int(sparse_polisher_iterations), + message=( + f"optimizer: {result.message}; polisher: {polisher_message}; " + f"polished_result_used={use_polished}; " + f"sparse_polisher: {sparse_polisher_message}" + ), + ) + + +def sweep_radial_noether_solitons( + cases: list[dict[str, object]], +) -> list[RadialNoetherSweepResult]: + """Run a declared radial solver case list through one library entry point.""" + results: list[RadialNoetherSweepResult] = [] + required = { + "case_id", + "target_charge", + "radius", + "dx", + "core_radius", + "omega_guess", + } + for case in cases: + missing = required - set(case) + if missing: + raise ValueError(f"case is missing keys: {sorted(missing)}") + initial_value = case.get("initial_variables") + initial_variables = ( + None if initial_value is None else np.asarray(initial_value, dtype=np.float64) + ) + solution = solve_radial_noether_soliton( + target_charge=_case_float(case, "target_charge"), + radius=_case_float(case, "radius"), + dx=_case_float(case, "dx"), + core_radius=_case_float(case, "core_radius"), + omega_guess=_case_float(case, "omega_guess"), + chi_depth_fraction=_case_float(case, "chi_depth_fraction", 0.9), + initial_variables=initial_variables, + max_iterations=_case_int(case, "max_iterations", 2000), + gradient_tolerance=_case_float(case, "gradient_tolerance", 1.0e-9), + polish_tolerance=_case_float(case, "polish_tolerance", 1.0e-11), + sparse_polish=bool(case.get("sparse_polish", True)), + sparse_polish_tolerance=_case_float(case, "sparse_polish_tolerance", 1.0e-9), + sparse_polish_max_iterations=_case_int(case, "sparse_polish_max_iterations", 80), + ) + results.append( + RadialNoetherSweepResult( + case_id=str(case["case_id"]), + solution=solution, + ) + ) + return results + + +def _cartesian_axis(grid_size: int, dx: float) -> np.ndarray: + if grid_size < 8: + raise ValueError("grid_size must be at least 8") + if dx <= 0.0: + raise ValueError("dx must be positive") + return (np.arange(grid_size, dtype=np.float64) - 0.5 * (grid_size - 1)) * dx + + +def _interpolate_radial_profile( + radius_grid: np.ndarray, + source_r: np.ndarray, + values: np.ndarray, + outer_value: float, +) -> np.ndarray: + return np.interp( + radius_grid.ravel(), + source_r, + values, + left=float(values[0]), + right=float(outer_value), + ).reshape(radius_grid.shape) + + +def lift_radial_noether_state( + *, + source_r: np.ndarray, + phi: np.ndarray, + chi: np.ndarray, + omega: float, + grid_size: int, + dx: float, + dt: float, + center: tuple[float, float, float] = (0.0, 0.0, 0.0), + velocity: tuple[float, float, float] = (0.0, 0.0, 0.0), + perturbation_seed: int | None = None, + perturbation_amplitude: float = 0.0, + chi0: float = CHI0, + dtype: np.dtype | type = np.float32, +) -> CartesianNoetherState: + """Lift a radial relative equilibrium into full 3D leapfrog data. + + The only supported boost is along one coordinate axis. The current and + previous layers are evaluated from the continuum Lorentz-coordinate + transform of the same scalar profile. No clipping, projection, charge + normalization, or post-processing is applied. + """ + source_r = np.asarray(source_r, dtype=np.float64) + phi = np.asarray(phi, dtype=np.float64) + chi = np.asarray(chi, dtype=np.float64) + if source_r.ndim != 1 or source_r.size < 2: + raise ValueError("source_r must be one-dimensional") + if phi.shape != source_r.shape or chi.shape != source_r.shape: + raise ValueError("radial fields must match source_r") + if not np.all(np.diff(source_r) > 0.0): + raise ValueError("source_r must be strictly increasing") + if omega <= 0.0 or dt <= 0.0: + raise ValueError("omega and dt must be positive") + if perturbation_amplitude < 0.0: + raise ValueError("perturbation_amplitude must be non-negative") + + velocity_array = np.asarray(velocity, dtype=np.float64) + speed_sq = float(np.dot(velocity_array, velocity_array)) + if speed_sq >= 1.0: + raise ValueError("boost speed must be below the LFM wave speed") + nonzero_axes = np.flatnonzero(np.abs(velocity_array) > 1.0e-15) + if nonzero_axes.size > 1: + raise ValueError("only an axis-aligned boost is supported") + + axis = _cartesian_axis(grid_size, dx) + x = axis[:, None, None] - float(center[0]) + y = axis[None, :, None] - float(center[1]) + z = axis[None, None, :] - float(center[2]) + gamma = 1.0 / np.sqrt(1.0 - speed_sq) + + current_components = [x, y, z] + previous_components = [x, y, z] + phase_current = np.zeros( + (grid_size, grid_size, grid_size), + dtype=np.float64, + ) + phase_previous = np.full_like(phase_current, -omega * gamma * dt) + + if nonzero_axes.size == 1: + boost_axis = int(nonzero_axes[0]) + speed = float(velocity_array[boost_axis]) + coordinate = current_components[boost_axis] + current_components[boost_axis] = gamma * coordinate + previous_components[boost_axis] = gamma * (coordinate + speed * dt) + phase_current = -omega * gamma * speed * coordinate + phase_previous = -omega * gamma * (dt + speed * coordinate) + + radius_current = np.sqrt( + current_components[0] ** 2 + current_components[1] ** 2 + current_components[2] ** 2 + ) + radius_previous = np.sqrt( + previous_components[0] ** 2 + previous_components[1] ** 2 + previous_components[2] ** 2 + ) + phi_current = _interpolate_radial_profile( + radius_current, + source_r, + phi, + 0.0, + ) + phi_previous = _interpolate_radial_profile( + radius_previous, + source_r, + phi, + 0.0, + ) + chi_current = _interpolate_radial_profile( + radius_current, + source_r, + chi, + chi0, + ) + chi_previous = _interpolate_radial_profile( + radius_previous, + source_r, + chi, + chi0, + ) + + if perturbation_seed is not None and perturbation_amplitude > 0.0: + if speed_sq > 0.0: + raise ValueError("seeded perturbation and boost are separate S5 cases") + rng = np.random.default_rng(perturbation_seed) + coefficients = rng.normal(size=4) + coefficients /= np.sum(np.abs(coefficients)) + half_peak_index = int(np.argmax(np.abs(phi) < 0.5 * np.max(np.abs(phi)))) + scale = max(float(source_r[half_peak_index]), dx) + radius_sq = x * x + y * y + z * z + mode = ( + coefficients[0] * np.tanh(x / scale) + + coefficients[1] * np.tanh(y / scale) + + coefficients[2] * np.tanh(z / scale) + + coefficients[3] * (x * x - y * y) / (radius_sq + scale * scale) + ) + matter_factor = 1.0 + perturbation_amplitude * mode + chi_factor = 1.0 - perturbation_amplitude * mode + phi_current *= matter_factor + phi_previous *= matter_factor + chi_current = chi0 + (chi_current - chi0) * chi_factor + chi_previous = chi0 + (chi_previous - chi0) * chi_factor + + state_dtype = np.dtype(dtype) + return CartesianNoetherState( + psi_real=(phi_current * np.cos(phase_current)).astype(state_dtype), + psi_real_prev=(phi_previous * np.cos(phase_previous)).astype(state_dtype), + psi_imag=(phi_current * np.sin(phase_current)).astype(state_dtype), + psi_imag_prev=(phi_previous * np.sin(phase_previous)).astype(state_dtype), + chi=chi_current.astype(state_dtype), + chi_prev=chi_previous.astype(state_dtype), + center=_tuple3_float(center), + velocity=_tuple3_float(velocity), + omega=float(omega), + dx=float(dx), + dt=float(dt), + ) + + +def cartesian_fixed_charge_energy_and_gradient( + variables: np.ndarray, + *, + target_charge: float, + grid_size: int, + dx: float, + chi0: float = CHI0, + kappa: float = KAPPA, + lambda_h: float = LAMBDA_H, +) -> tuple[CartesianFixedChargeEnergy, np.ndarray]: + """Evaluate the periodic 3D fixed-charge energy and analytic gradient.""" + if target_charge <= 0.0: + raise ValueError("target_charge must be positive") + if grid_size < 8: + raise ValueError("grid_size must be at least 8") + if dx <= 0.0: + raise ValueError("dx must be positive") + if chi0 <= 0.0 or kappa <= 0.0 or lambda_h <= 0.0: + raise ValueError("canonical couplings must be positive") + + sites = grid_size**3 + values = np.asarray(variables, dtype=np.float64) + if values.shape != (2 * sites,): + raise ValueError(f"variables must have shape {(2 * sites,)}") + phi = values[:sites].reshape((grid_size,) * 3) + chi = values[sites:].reshape((grid_size,) * 3) + volume = dx**3 + inv_dx2 = 1.0 / (dx * dx) + b_value = chi0 / kappa + + norm = volume * float(np.sum(phi * phi)) + if not np.isfinite(norm) or norm <= 1.0e-300: + raise ValueError("matter norm must be finite and positive") + omega = target_charge / norm + temporal = target_charge * target_charge / (2.0 * norm) + + lap_phi = laplacian_19pt(phi) + lap_chi = laplacian_19pt(chi) + matter_gradient = -0.5 * volume * inv_dx2 * float(np.sum(phi * lap_phi)) + matter_mass = 0.5 * volume * float(np.sum(chi * chi * phi * phi)) + chi_gradient = -0.5 * b_value * volume * inv_dx2 * float(np.sum(chi * lap_chi)) + chi_potential = b_value * lambda_h * volume * float(np.sum((chi * chi - chi0 * chi0) ** 2)) + total = temporal + matter_gradient + matter_mass + chi_gradient + chi_potential + + grad_phi = volume * (-inv_dx2 * lap_phi + (chi * chi - omega * omega) * phi) + grad_chi = volume * ( + chi * phi * phi + + b_value * (-inv_dx2 * lap_chi + 4.0 * lambda_h * chi * (chi * chi - chi0 * chi0)) + ) + gradient = np.concatenate((grad_phi.ravel(), grad_chi.ravel())) + ledger = CartesianFixedChargeEnergy( + total=total, + temporal=temporal, + matter_gradient=matter_gradient, + matter_mass=matter_mass, + chi_gradient=chi_gradient, + chi_potential=chi_potential, + norm=norm, + omega=omega, + energy_per_charge=total / target_charge, + ) + return ledger, gradient + + +def solve_cartesian_noether_soliton( + *, + initial_phi: np.ndarray, + initial_chi: np.ndarray, + target_charge: float, + dx: float, + chi0: float = CHI0, + kappa: float = KAPPA, + lambda_h: float = LAMBDA_H, + max_iterations: int = 1000, + history: int = 10, + gradient_tolerance: float = 1.0e-10, + function_tolerance: float = np.finfo(np.float64).eps, + progress_interval: int = 10, + progress_callback: Callable[ + [int, CartesianFixedChargeEnergy, float], + None, + ] + | None = None, +) -> CartesianNoetherSolution: + """Minimize the exact periodic 3D Hamiltonian at fixed Noether charge. + + Optimization coordinates and objective units are rescaled only for + numerical conditioning. No physical field is clipped, normalized, + projected, or reset. + """ + phi0 = np.asarray(initial_phi, dtype=np.float64) + chi_initial = np.asarray(initial_chi, dtype=np.float64) + if phi0.shape != chi_initial.shape or phi0.ndim != 3: + raise ValueError("initial_phi and initial_chi must be matching 3D arrays") + if len(set(phi0.shape)) != 1: + raise ValueError("Cartesian fixed-charge solve requires a cubic grid") + if not np.all(np.isfinite(phi0)) or not np.all(np.isfinite(chi_initial)): + raise ValueError("initial fields must be finite") + if max_iterations <= 0 or history <= 0 or progress_interval <= 0: + raise ValueError("optimizer iteration and history limits must be positive") + + grid_size = phi0.shape[0] + sites = grid_size**3 + matter_scale = max(float(np.max(np.abs(phi0))), 1.0) + chi_scale = float(chi0) + objective_scale = float(target_charge) + scaled_initial = np.concatenate( + ( + (phi0 / matter_scale).ravel(), + (chi_initial / chi_scale).ravel(), + ) + ) + + def unpack(scaled: np.ndarray) -> np.ndarray: + scaled64 = np.asarray(scaled, dtype=np.float64) + return np.concatenate( + ( + matter_scale * scaled64[:sites], + chi_scale * scaled64[sites:], + ) + ) + + def objective(scaled: np.ndarray) -> tuple[float, np.ndarray]: + ledger, raw_gradient = cartesian_fixed_charge_energy_and_gradient( + unpack(scaled), + target_charge=target_charge, + grid_size=grid_size, + dx=dx, + chi0=chi0, + kappa=kappa, + lambda_h=lambda_h, + ) + scaled_gradient = np.concatenate( + ( + matter_scale * raw_gradient[:sites], + chi_scale * raw_gradient[sites:], + ) + ) + return ( + ledger.total / objective_scale, + scaled_gradient / objective_scale, + ) + + callback_iterations = 0 + + def callback(scaled: np.ndarray) -> None: + nonlocal callback_iterations + callback_iterations += 1 + if progress_callback is None or ( + callback_iterations != 1 and callback_iterations % progress_interval != 0 + ): + return + raw = unpack(scaled) + ledger, _ = cartesian_fixed_charge_energy_and_gradient( + raw, + target_charge=target_charge, + grid_size=grid_size, + dx=dx, + chi0=chi0, + kappa=kappa, + lambda_h=lambda_h, + ) + phi = raw[:sites].reshape((grid_size,) * 3) + chi = raw[sites:].reshape((grid_size,) * 3) + residual = cartesian_stationary_residual( + phi, + chi, + omega=ledger.omega, + dx=dx, + chi0=chi0, + kappa=kappa, + lambda_h=lambda_h, + )[0] + progress_callback(callback_iterations, ledger, residual) + + result = minimize( + objective, + scaled_initial, + method="L-BFGS-B", + jac=True, + callback=callback, + options={ + "maxiter": int(max_iterations), + "maxcor": int(history), + "gtol": float(gradient_tolerance), + "ftol": float(function_tolerance), + "maxls": 40, + }, + ) + final_raw = unpack(np.asarray(result.x, dtype=np.float64)) + phi = final_raw[:sites].reshape((grid_size,) * 3) + chi = final_raw[sites:].reshape((grid_size,) * 3) + ledger, _ = cartesian_fixed_charge_energy_and_gradient( + final_raw, + target_charge=target_charge, + grid_size=grid_size, + dx=dx, + chi0=chi0, + kappa=kappa, + lambda_h=lambda_h, + ) + residual, phi_residual, chi_residual = cartesian_stationary_residual( + phi, + chi, + omega=ledger.omega, + dx=dx, + chi0=chi0, + kappa=kappa, + lambda_h=lambda_h, + ) + return CartesianNoetherSolution( + phi=phi, + chi=chi, + target_charge=float(target_charge), + charge=float(ledger.omega * ledger.norm), + dx=float(dx), + energy=ledger, + stationary_relative_residual=float(residual), + phi_relative_residual=float(phi_residual), + chi_relative_residual=float(chi_residual), + optimizer_converged=bool(result.success), + optimizer_status=int(result.status), + optimizer_iterations=int(result.nit), + function_evaluations=int(result.nfev), + message=str(result.message), + ) + + +def cartesian_stationary_residual( + phi: np.ndarray, + chi: np.ndarray, + *, + omega: float, + dx: float, + chi0: float = CHI0, + kappa: float = KAPPA, + lambda_h: float = LAMBDA_H, +) -> tuple[float, float, float]: + """Return exact 19-point residuals of a 3D scalar relative equilibrium.""" + phi64 = np.asarray(phi, dtype=np.float64) + chi64 = np.asarray(chi, dtype=np.float64) + if phi64.shape != chi64.shape or phi64.ndim != 3: + raise ValueError("phi and chi must be matching 3D arrays") + if omega <= 0.0 or dx <= 0.0: + raise ValueError("omega and dx must be positive") + inv_dx2 = 1.0 / (dx * dx) + phi_equation = inv_dx2 * laplacian_19pt(phi64) + (omega * omega - chi64 * chi64) * phi64 + chi_equation = ( + inv_dx2 * laplacian_19pt(chi64) + - 4.0 * lambda_h * chi64 * (chi64 * chi64 - chi0 * chi0) + - (kappa / chi0) * chi64 * phi64 * phi64 + ) + volume = dx**3 + phi_scale_field = np.maximum( + np.abs(omega * omega * phi64), + np.abs(chi64 * chi64 * phi64), + ) + chi_scale_field = np.abs(4.0 * lambda_h * chi64 * (chi64 * chi64 - chi0 * chi0)) + np.abs( + (kappa / chi0) * chi64 * phi64 * phi64 + ) + phi_scale = max( + float(np.sqrt(volume * np.sum(phi_scale_field * phi_scale_field))), + 1.0e-300, + ) + chi_scale = max( + float(np.sqrt(volume * np.sum(chi_scale_field * chi_scale_field))), + 1.0e-300, + ) + phi_residual = float(np.sqrt(volume * np.sum(phi_equation * phi_equation))) / phi_scale + chi_residual = float(np.sqrt(volume * np.sum(chi_equation * chi_equation))) / chi_scale + return max(phi_residual, chi_residual), phi_residual, chi_residual + + +def cartesian_noether_charge( + psi_real: np.ndarray, + psi_real_prev: np.ndarray, + psi_imag: np.ndarray, + psi_imag_prev: np.ndarray, + *, + dt: float, + dx: float, +) -> float: + """Return the exactly conserved leapfrog U(1) bilinear charge.""" + pr = np.asarray(psi_real, dtype=np.float64) + pr_prev = np.asarray(psi_real_prev, dtype=np.float64) + pi = np.asarray(psi_imag, dtype=np.float64) + pi_prev = np.asarray(psi_imag_prev, dtype=np.float64) + if not (pr.shape == pr_prev.shape == pi.shape == pi_prev.shape): + raise ValueError("all complex phase-space arrays must match") + return float(np.sum(pr_prev * pi - pi_prev * pr)) * dx**3 / dt + + +def cartesian_noether_hamiltonian( + psi_real: np.ndarray, + psi_real_prev: np.ndarray, + psi_imag: np.ndarray, + psi_imag_prev: np.ndarray, + chi: np.ndarray, + chi_prev: np.ndarray, + *, + dt: float, + dx: float, + chi0: float = CHI0, + kappa: float = KAPPA, + lambda_h: float = LAMBDA_H, +) -> dict[str, float]: + """Evaluate the continuous bare-action Hamiltonian on leapfrog layers.""" + pr = np.asarray(psi_real, dtype=np.float64) + pr_prev = np.asarray(psi_real_prev, dtype=np.float64) + pi = np.asarray(psi_imag, dtype=np.float64) + pi_prev = np.asarray(psi_imag_prev, dtype=np.float64) + chi64 = np.asarray(chi, dtype=np.float64) + chi_prev64 = np.asarray(chi_prev, dtype=np.float64) + if not ( + pr.shape == pr_prev.shape == pi.shape == pi_prev.shape == chi64.shape == chi_prev64.shape + ): + raise ValueError("all phase-space arrays must match") + if pr.ndim != 3: + raise ValueError("Hamiltonian requires 3D fields") + + volume = dx**3 + inv_dx2 = 1.0 / (dx * dx) + b_value = chi0 / kappa + dpr = (pr - pr_prev) / dt + dpi = (pi - pi_prev) / dt + dchi = (chi64 - chi_prev64) / dt + temporal = 0.5 * volume * float(np.sum(dpr * dpr + dpi * dpi)) + matter_gradient = ( + -0.5 * volume * inv_dx2 * float(np.sum(pr * laplacian_19pt(pr) + pi * laplacian_19pt(pi))) + ) + matter_mass = 0.5 * volume * float(np.sum(chi64 * chi64 * (pr * pr + pi * pi))) + chi_temporal = 0.5 * b_value * volume * float(np.sum(dchi * dchi)) + chi_gradient = -0.5 * b_value * volume * inv_dx2 * float(np.sum(chi64 * laplacian_19pt(chi64))) + chi_potential = b_value * lambda_h * volume * float(np.sum((chi64 * chi64 - chi0 * chi0) ** 2)) + total = temporal + matter_gradient + matter_mass + chi_temporal + chi_gradient + chi_potential + return { + "total": total, + "matter_temporal": temporal, + "matter_gradient": matter_gradient, + "matter_mass": matter_mass, + "chi_temporal": chi_temporal, + "chi_gradient": chi_gradient, + "chi_potential": chi_potential, + } + + +def cartesian_localization_metrics( + psi_real: np.ndarray, + psi_imag: np.ndarray, + *, + dx: float, + core_radius: float | None = None, + reference_density: np.ndarray | None = None, + reference_center: tuple[float, float, float] | None = None, +) -> dict[str, float | list[float]]: + """Measure center, radius, anisotropy, core fraction, and aligned profile.""" + pr = np.asarray(psi_real) + pi = np.asarray(psi_imag) + if pr.shape != pi.shape or pr.ndim != 3 or len(set(pr.shape)) != 1: + raise ValueError("psi arrays must be matching cubic 3D arrays") + rho = pr.astype(np.float64) ** 2 + pi.astype(np.float64) ** 2 + norm = float(np.sum(rho)) + if not np.isfinite(norm) or norm <= 0.0: + raise ValueError("density norm must be finite and positive") + axis = _cartesian_axis(pr.shape[0], dx) + wx = np.sum(rho, axis=(1, 2)) + wy = np.sum(rho, axis=(0, 2)) + wz = np.sum(rho, axis=(0, 1)) + center = np.array( + [ + float(np.dot(axis, wx) / norm), + float(np.dot(axis, wy) / norm), + float(np.dot(axis, wz) / norm), + ] + ) + offsets = [axis - center[index] for index in range(3)] + diagonal = np.array( + [ + float(np.dot(offsets[0] ** 2, wx) / norm), + float(np.dot(offsets[1] ** 2, wy) / norm), + float(np.dot(offsets[2] ** 2, wz) / norm), + ] + ) + rho_xy = np.sum(rho, axis=2) + rho_xz = np.sum(rho, axis=1) + rho_yz = np.sum(rho, axis=0) + xy = float(np.sum(rho_xy * offsets[0][:, None] * offsets[1][None, :]) / norm) + xz = float(np.sum(rho_xz * offsets[0][:, None] * offsets[2][None, :]) / norm) + yz = float(np.sum(rho_yz * offsets[1][:, None] * offsets[2][None, :]) / norm) + covariance = np.array( + [ + [diagonal[0], xy, xz], + [xy, diagonal[1], yz], + [xz, yz, diagonal[2]], + ] + ) + eigenvalues = np.linalg.eigvalsh(covariance) + min_eigenvalue = max(float(eigenvalues[0]), 1.0e-300) + anisotropy = float(eigenvalues[-1]) / min_eigenvalue + rms_radius = float(np.sqrt(np.trace(covariance))) + + result: dict[str, float | list[float]] = { + "density_norm": norm * dx**3, + "peak_density": float(np.max(rho)), + "center": center.tolist(), + "rms_radius": rms_radius, + "rms_radius_cells": rms_radius / dx, + "second_moment_eigenvalues": eigenvalues.tolist(), + "second_moment_anisotropy": anisotropy, + } + if core_radius is not None: + distance_sq = ( + offsets[0][:, None, None] ** 2 + + offsets[1][None, :, None] ** 2 + + offsets[2][None, None, :] ** 2 + ) + result["moving_core_fraction"] = float( + np.sum(rho[distance_sq <= core_radius * core_radius]) / norm + ) + if reference_density is not None: + reference = np.asarray(reference_density, dtype=np.float64) + if reference.shape != rho.shape: + raise ValueError("reference_density must match psi arrays") + if reference_center is None: + raise ValueError("reference_center is required with reference_density") + shifts = tuple( + int(round((reference_center[index] - center[index]) / dx)) for index in range(3) + ) + aligned = np.roll(rho, shift=shifts, axis=(0, 1, 2)) + reference_norm = max(float(np.sum(reference)), 1.0e-300) + result["recentered_density_l1"] = float( + np.sum(np.abs(aligned - reference)) / reference_norm + ) + return result diff --git a/lfm/particles/prepared.py b/lfm/particles/prepared.py new file mode 100644 index 0000000..a9d3d00 --- /dev/null +++ b/lfm/particles/prepared.py @@ -0,0 +1,161 @@ +"""Prepared scalar-mode composition for live LFM simulations. + +The helpers in this module create initial leapfrog layers from generic relaxed +scalar envelopes. They do not assign a particle catalog identity and do not +claim source-free formation or stability. +""" + +from __future__ import annotations + +import math +from typing import TYPE_CHECKING + +import numpy as np + +from lfm.analysis.energy import total_energy +from lfm.config import FieldLevel +from lfm.particles.solver import SolitonSolution, boost_fields + +if TYPE_CHECKING: + from lfm.simulation import Simulation + + +def _shift_mode( + solution: SolitonSolution, + position: tuple[float, float, float], + chi0: float, +) -> tuple[np.ndarray, np.ndarray]: + grid_size = solution.N + center = grid_size // 2 + envelope = np.asarray(solution.psi_r, dtype=np.float32).copy() + chi_delta = np.asarray(solution.chi, dtype=np.float32) - np.float32(chi0) + for axis in range(3): + shift = int(round(position[axis])) - center + if shift: + envelope = np.roll(envelope, shift, axis=axis) + chi_delta = np.roll(chi_delta, shift, axis=axis) + return envelope, np.float32(chi0) + chi_delta + + +def _time_harmonic_layers( + envelope: np.ndarray, + chi: np.ndarray, + velocity: tuple[float, float, float], + *, + dt: float, + omega: float, + chi0: float, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + speed_sq = float(sum(component * component for component in velocity)) + if speed_sq > 1.0e-30: + return boost_fields( + envelope, + chi, + velocity, + dt=dt, + omega=omega, + chi0=chi0, + ) + + phase = float(omega * dt) + zeros = np.zeros_like(envelope) + real_prev = (envelope * math.cos(phase)).astype(np.float32) + imag_prev = (envelope * math.sin(phase)).astype(np.float32) + return envelope.copy(), zeros, real_prev, imag_prev, chi.copy() + + +def install_prepared_scalar_pair( + sim: Simulation, + solution_a: SolitonSolution, + solution_b: SolitonSolution, + *, + position_a: tuple[float, float, float], + position_b: tuple[float, float, float], + velocity_a: tuple[float, float, float], + velocity_b: tuple[float, float, float], + phase_a: float = 0.0, + phase_b: float = 0.0, +) -> None: + """Install two generic prepared modes into an empty complex simulation.""" + if sim.step != 0: + raise ValueError("prepared modes can only be installed at step zero") + if sim.config.field_level != FieldLevel.COMPLEX: + raise ValueError("prepared moving modes require a complex field") + if sim.config.grid_size != solution_a.N or sim.config.grid_size != solution_b.N: + raise ValueError("solution grid size must match the simulation") + + chi0 = float(sim.config.chi0) + dt = float(sim.config.dt) + env_a, chi_a = _shift_mode(solution_a, position_a, chi0) + env_b, chi_b = _shift_mode(solution_b, position_b, chi0) + layers_a = _time_harmonic_layers( + env_a, + chi_a, + velocity_a, + dt=dt, + omega=float(solution_a.eigenvalue), + chi0=chi0, + ) + layers_b = _time_harmonic_layers( + env_b, + chi_b, + velocity_b, + dt=dt, + omega=float(solution_b.eigenvalue), + chi0=chi0, + ) + + def rotate( + layers: tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray], + phase: float, + ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + cosine = math.cos(phase) + sine = math.sin(phase) + real = layers[0] * cosine - layers[1] * sine + imag = layers[0] * sine + layers[1] * cosine + real_prev = layers[2] * cosine - layers[3] * sine + imag_prev = layers[2] * sine + layers[3] * cosine + return real, imag, real_prev, imag_prev, layers[4] + + layers_a = rotate(layers_a, phase_a) + layers_b = rotate(layers_b, phase_b) + + sim.set_psi_real(layers_a[0] + layers_b[0]) + sim.set_psi_imag(layers_a[1] + layers_b[1]) + sim.set_psi_real_prev(layers_a[2] + layers_b[2]) + sim.set_psi_imag_prev(layers_a[3] + layers_b[3]) + chi_current = chi0 + (chi_a - chi0) + (chi_b - chi0) + chi_previous = chi0 + (layers_a[4] - chi0) + (layers_b[4] - chi0) + sim.set_chi(chi_current) + sim.set_chi_prev(chi_previous) + + +def prepared_mode_wave_hamiltonian( + solution: SolitonSolution, + *, + dt: float, + c: float = 1.0, + chi0: float = 19.0, +) -> float: + """Return the isolated time-harmonic wave Hamiltonian of one mode.""" + envelope = np.asarray(solution.psi_r, dtype=np.float32) + real, imag, real_prev, imag_prev, _ = _time_harmonic_layers( + envelope, + np.asarray(solution.chi, dtype=np.float32), + (0.0, 0.0, 0.0), + dt=dt, + omega=float(solution.eigenvalue), + chi0=chi0, + ) + return total_energy( + real, + real_prev, + np.asarray(solution.chi, dtype=np.float32), + dt, + c, + imag, + imag_prev, + ) + + +__all__ = ["install_prepared_scalar_pair", "prepared_mode_wave_hamiltonian"] diff --git a/lfm/particles/stationary.py b/lfm/particles/stationary.py new file mode 100644 index 0000000..7da8467 --- /dev/null +++ b/lfm/particles/stationary.py @@ -0,0 +1,517 @@ +"""Stationary positive-chi branches of the bare GOV-01/GOV-02 system.""" + +from __future__ import annotations + +from dataclasses import dataclass +from functools import lru_cache + +import numpy as np +import scipy.sparse as sp +from scipy.optimize import NoConvergence, newton_krylov +from scipy.sparse.linalg import eigsh, spsolve + +from lfm.constants import CHI0, KAPPA, LAMBDA_H +from lfm.core.stencils import laplacian_19pt + + +@dataclass +class StationaryBranchPoint: + """One normalized stationary solution or failed continuation point.""" + + phi: np.ndarray + chi: np.ndarray + omega: float + norm_target: float + converged: bool + cycles: int + phi_residual: float + chi_residual_rms: float + chi_min: float + effective_sites: float + message: str + + +@dataclass +class SupportRemovalPoint: + """One point in a fixed-source to self-source stationary homotopy.""" + + phi: np.ndarray + chi: np.ndarray + source_density: np.ndarray + omega: float + norm_target: float + dynamic_fraction: float + converged: bool + cycles: int + phi_residual: float + chi_residual_rms: float + chi_min: float + effective_sites: float + message: str + + +def _interior_vector(field: np.ndarray) -> np.ndarray: + return np.asarray(field[1:-1, 1:-1, 1:-1], dtype=np.float64).ravel() + + +def _embed_interior(vector: np.ndarray, grid_size: int, boundary: float) -> np.ndarray: + field = np.full((grid_size, grid_size, grid_size), boundary, dtype=np.float64) + field[1:-1, 1:-1, 1:-1] = np.asarray(vector, dtype=np.float64).reshape( + grid_size - 2, grid_size - 2, grid_size - 2 + ) + return field + + +def _normalized_gaussian(grid_size: int, norm_target: float, sigma: float) -> np.ndarray: + x = np.arange(grid_size, dtype=np.float64) - (grid_size - 1.0) / 2.0 + xx, yy, zz = np.meshgrid(x, x, x, indexing="ij") + phi = np.exp(-(xx * xx + yy * yy + zz * zz) / (2.0 * sigma * sigma)) + phi[[0, -1], :, :] = 0.0 + phi[:, [0, -1], :] = 0.0 + phi[:, :, [0, -1]] = 0.0 + phi *= np.sqrt(norm_target / max(float(np.sum(phi * phi)), 1.0e-300)) + return phi + + +def _lowest_mode(chi: np.ndarray, initial: np.ndarray) -> tuple[np.ndarray, float]: + grid_size = chi.shape[0] + laplacian, _ = _canonical_interior_laplacian(grid_size) + operator = -laplacian + sp.diags(_interior_vector(chi * chi), format="csr") + values, vectors = eigsh( + operator, + k=1, + which="SA", + v0=_interior_vector(initial), + tol=1.0e-9, + maxiter=3000, + ) + mode = _embed_interior(vectors[:, 0], grid_size, 0.0) + if float(np.sum(mode * initial)) < 0.0: + mode *= -1.0 + return mode, float(values[0]) + + +def _solve_positive_chi_density( + source_density: np.ndarray, + initial_chi: np.ndarray, + *, + chi0: float, + kappa: float, + lambda_h: float, + tolerance: float, +) -> tuple[np.ndarray, bool, str]: + grid_size = source_density.shape[0] + initial_positive = np.clip(initial_chi[1:-1, 1:-1, 1:-1], 1.0e-8, None) + log_initial = np.log(initial_positive).ravel() + + def residual(log_vector: np.ndarray) -> np.ndarray: + chi = _embed_interior(np.exp(log_vector), grid_size, chi0) + value = ( + laplacian_19pt(chi) + - (kappa / chi0) * chi * source_density + - 4.0 * lambda_h * chi * (chi * chi - chi0 * chi0) + ) + return _interior_vector(value) + + try: + solved = newton_krylov( + residual, + log_initial, + method="lgmres", + f_tol=tolerance, + maxiter=80, + verbose=False, + ) + return _embed_interior(np.exp(np.asarray(solved)), grid_size, chi0), True, "converged" + except NoConvergence as error: + candidate = np.asarray(error.args[0], dtype=np.float64) + if candidate.shape != log_initial.shape or not np.all(np.isfinite(candidate)): + return initial_chi.copy(), False, "positive-chi Newton solve did not converge" + return ( + _embed_interior(np.exp(candidate), grid_size, chi0), + False, + "positive-chi Newton solve reached iteration limit", + ) + except (ValueError, FloatingPointError, OverflowError) as error: + return initial_chi.copy(), False, f"positive-chi solve failed: {type(error).__name__}" + + +def _solve_positive_chi( + phi: np.ndarray, + initial_chi: np.ndarray, + *, + chi0: float, + kappa: float, + lambda_h: float, + tolerance: float, +) -> tuple[np.ndarray, bool, str]: + return _solve_positive_chi_density( + phi * phi, + initial_chi, + chi0=chi0, + kappa=kappa, + lambda_h=lambda_h, + tolerance=tolerance, + ) + + +@lru_cache(maxsize=8) +def _canonical_interior_laplacian( + grid_size: int, +) -> tuple[sp.csr_matrix, np.ndarray]: + """Return the canonical 19-point interior matrix and unit boundary sum.""" + points = [ + (x, y, z) + for x in range(1, grid_size - 1) + for y in range(1, grid_size - 1) + for z in range(1, grid_size - 1) + ] + index = {point: i for i, point in enumerate(points)} + face_offsets = ( + (1, 0, 0), + (-1, 0, 0), + (0, 1, 0), + (0, -1, 0), + (0, 0, 1), + (0, 0, -1), + ) + edge_offsets = tuple( + (dx, dy, dz) + for dx in (-1, 0, 1) + for dy in (-1, 0, 1) + for dz in (-1, 0, 1) + if abs(dx) + abs(dy) + abs(dz) == 2 + ) + rows: list[int] = [] + cols: list[int] = [] + data: list[float] = [] + boundary_sum = np.zeros(len(index), dtype=np.float64) + for point, row in index.items(): + rows.append(row) + cols.append(row) + data.append(-4.0) + for offsets, weight in ((face_offsets, 1.0 / 3.0), (edge_offsets, 1.0 / 6.0)): + for dx, dy, dz in offsets: + neighbor = (point[0] + dx, point[1] + dy, point[2] + dz) + if neighbor in index: + rows.append(row) + cols.append(index[neighbor]) + data.append(weight) + else: + boundary_sum[row] += weight + matrix = sp.csr_matrix((data, (rows, cols)), shape=(len(index), len(index))) + return matrix, boundary_sum + + +def _solve_positive_chi_sparse( + source_density: np.ndarray, + initial_chi: np.ndarray, + *, + chi0: float, + kappa: float, + lambda_h: float, + tolerance: float, + max_iterations: int = 60, +) -> tuple[np.ndarray, bool, str]: + """Solve the positive stationary chi equation by sparse damped Newton.""" + grid_size = source_density.shape[0] + laplacian, unit_boundary = _canonical_interior_laplacian(grid_size) + boundary = chi0 * unit_boundary + source_coeff = (kappa / chi0) * _interior_vector(source_density) + u = _interior_vector(initial_chi).copy() + + def residual(vector: np.ndarray) -> np.ndarray: + return ( + laplacian @ vector + + boundary + - source_coeff * vector + - 4.0 * lambda_h * vector * (vector * vector - chi0 * chi0) + ) + + for _ in range(max_iterations): + value = residual(u) + if float(np.sqrt(np.mean(value * value))) < tolerance: + return _embed_interior(u, grid_size, chi0), True, "converged" + diagonal = source_coeff + 4.0 * lambda_h * (3.0 * u * u - chi0 * chi0) + jacobian = laplacian - sp.diags(diagonal, format="csr") + try: + delta = spsolve(jacobian, -value) + except (RuntimeError, ValueError): + return initial_chi.copy(), False, "sparse positive-chi solve failed" + if not np.all(np.isfinite(delta)): + return initial_chi.copy(), False, "sparse positive-chi step was non-finite" + old_norm = float(np.linalg.norm(value)) + step = 1.0 + while step > 1.0e-10: + candidate = u + step * delta + if ( + float(np.min(candidate)) > 0.0 + and float(np.linalg.norm(residual(candidate))) < old_norm + ): + u = candidate + break + step *= 0.5 + else: + return _embed_interior(u, grid_size, chi0), False, "positive-chi line search failed" + return ( + _embed_interior(u, grid_size, chi0), + False, + "positive-chi Newton solve reached iteration limit", + ) + + +def solve_stationary_branch_point( + grid_size: int, + norm_target: float, + *, + previous: StationaryBranchPoint | None = None, + chi0: float = CHI0, + kappa: float = KAPPA, + lambda_h: float = LAMBDA_H, + sigma: float = 3.5, + max_cycles: int = 30, + tolerance: float = 1.0e-7, + mixing: float = 0.6, +) -> StationaryBranchPoint: + """Solve the normalized stationary bare-LFM equations by continuation. + + The ansatz is ``Psi_a = u_a phi(x) exp(-i omega t)`` with a constant + internal unit vector. Bare GOV-02 depends only on ``sum_a |Psi_a|^2``, so + the scalar envelope ``phi`` contains the complete rank-one branch data. + Positivity is enforced by solving for ``log(chi)``; no value is clipped + after the nonlinear solve. + """ + if grid_size < 8: + raise ValueError("grid_size must be at least 8") + if norm_target <= 0.0: + raise ValueError("norm_target must be positive") + if not 0.0 < mixing <= 1.0: + raise ValueError("mixing must lie in (0, 1]") + + if previous is not None and previous.phi.shape == (grid_size,) * 3: + phi = previous.phi.copy() + phi *= np.sqrt(norm_target / max(float(np.sum(phi * phi)), 1.0e-300)) + chi = previous.chi.copy() + else: + phi = _normalized_gaussian(grid_size, norm_target, sigma) + density_scale = phi * phi / max(float(np.max(phi * phi)), 1.0e-300) + chi = chi0 - 0.05 * density_scale + chi[[0, -1], :, :] = chi0 + chi[:, [0, -1], :] = chi0 + chi[:, :, [0, -1]] = chi0 + + message = "maximum SCF cycles reached" + omega_sq = chi0 * chi0 + phi_residual = float("inf") + chi_residual_rms = float("inf") + converged = False + last_cycle = 0 + for _cycle in range(1, max_cycles + 1): + last_cycle = _cycle + mode, omega_sq = _lowest_mode(chi, phi) + mode *= np.sqrt(norm_target / max(float(np.sum(mode * mode)), 1.0e-300)) + phi = mixing * mode + (1.0 - mixing) * phi + phi *= np.sqrt(norm_target / max(float(np.sum(phi * phi)), 1.0e-300)) + + solved_chi, chi_ok, chi_message = _solve_positive_chi( + phi, + chi, + chi0=chi0, + kappa=kappa, + lambda_h=lambda_h, + tolerance=tolerance, + ) + chi = mixing * solved_chi + (1.0 - mixing) * chi + chi[[0, -1], :, :] = chi0 + chi[:, [0, -1], :] = chi0 + chi[:, :, [0, -1]] = chi0 + + h_phi = -laplacian_19pt(phi) + chi * chi * phi + phi_equation = h_phi - omega_sq * phi + phi_residual = float(np.linalg.norm(_interior_vector(phi_equation))) / max( + float(np.linalg.norm(_interior_vector(omega_sq * phi))), 1.0 + ) + chi_equation = ( + laplacian_19pt(chi) + - (kappa / chi0) * chi * phi * phi + - 4.0 * lambda_h * chi * (chi * chi - chi0 * chi0) + ) + chi_residual_rms = float(np.sqrt(np.mean(_interior_vector(chi_equation) ** 2))) + if chi_ok and phi_residual < tolerance and chi_residual_rms < tolerance: + converged = True + message = "stationary equations converged" + break + message = chi_message if not chi_ok else "SCF residual above tolerance" + + density = phi * phi + probability = density / max(float(np.sum(density)), 1.0e-300) + effective_sites = 1.0 / max(float(np.sum(probability * probability)), 1.0e-300) + return StationaryBranchPoint( + phi=phi, + chi=chi, + omega=float(np.sqrt(max(omega_sq, 0.0))), + norm_target=float(norm_target), + converged=converged, + cycles=last_cycle, + phi_residual=phi_residual, + chi_residual_rms=chi_residual_rms, + chi_min=float(np.min(chi)), + effective_sites=effective_sites, + message=message, + ) + + +def continue_stationary_branch( + grid_size: int, + norm_targets: list[float], + **kwargs, +) -> list[StationaryBranchPoint]: + """Continue stationary solutions over an ordered norm sequence.""" + points: list[StationaryBranchPoint] = [] + previous: StationaryBranchPoint | None = None + for target in norm_targets: + point = solve_stationary_branch_point( + grid_size, + target, + previous=previous, + **kwargs, + ) + points.append(point) + if point.converged: + previous = point + return points + + +def solve_support_removal_point( + fixed_source: np.ndarray, + dynamic_fraction: float, + *, + previous: SupportRemovalPoint | None = None, + chi0: float = CHI0, + kappa: float = KAPPA, + lambda_h: float = LAMBDA_H, + max_cycles: int = 60, + tolerance: float = 1.0e-7, + mixing: float = 0.5, +) -> SupportRemovalPoint: + """Remove a prescribed source while following the stationary branch. + + ``dynamic_fraction=0`` uses the supplied source density in GOV-02. + ``dynamic_fraction=1`` uses only ``phi**2`` and is therefore the bare + rank-one stationary system. The total source norm is held fixed along the + path; only its spatial support changes. + """ + fixed_source = np.asarray(fixed_source, dtype=np.float64) + if fixed_source.ndim != 3 or len(set(fixed_source.shape)) != 1: + raise ValueError("fixed_source must be a cubic 3D array") + if fixed_source.shape[0] < 8: + raise ValueError("fixed_source grid must be at least 8") + if not np.all(np.isfinite(fixed_source)) or np.any(fixed_source < 0.0): + raise ValueError("fixed_source must be finite and nonnegative") + if not 0.0 <= dynamic_fraction <= 1.0: + raise ValueError("dynamic_fraction must lie in [0, 1]") + if not 0.0 < mixing <= 1.0: + raise ValueError("mixing must lie in (0, 1]") + + grid_size = fixed_source.shape[0] + norm_target = float(np.sum(fixed_source)) + if norm_target <= 0.0: + raise ValueError("fixed_source must have positive total density") + + if previous is not None and previous.phi.shape == fixed_source.shape: + phi = previous.phi.copy() + phi *= np.sqrt(norm_target / max(float(np.sum(phi * phi)), 1.0e-300)) + chi = previous.chi.copy() + else: + sigma = max(grid_size / 8.0, 1.0) + phi = _normalized_gaussian(grid_size, norm_target, sigma) + source_scale = fixed_source / max(float(np.max(fixed_source)), 1.0e-300) + chi = chi0 - (chi0 - 1.2) * source_scale + chi[[0, -1], :, :] = chi0 + chi[:, [0, -1], :] = chi0 + chi[:, :, [0, -1]] = chi0 + + message = "maximum SCF cycles reached" + omega_sq = chi0 * chi0 + phi_residual = float("inf") + chi_residual_rms = float("inf") + converged = False + source_density = fixed_source.copy() + last_cycle = 0 + for _cycle in range(1, max_cycles + 1): + last_cycle = _cycle + mode, omega_sq = _lowest_mode(chi, phi) + mode *= np.sqrt(norm_target / max(float(np.sum(mode * mode)), 1.0e-300)) + phi = mixing * mode + (1.0 - mixing) * phi + phi *= np.sqrt(norm_target / max(float(np.sum(phi * phi)), 1.0e-300)) + + source_density = (1.0 - dynamic_fraction) * fixed_source + dynamic_fraction * phi * phi + solved_chi, chi_ok, chi_message = _solve_positive_chi_sparse( + source_density, + chi, + chi0=chi0, + kappa=kappa, + lambda_h=lambda_h, + tolerance=tolerance, + ) + chi = mixing * solved_chi + (1.0 - mixing) * chi + chi[[0, -1], :, :] = chi0 + chi[:, [0, -1], :] = chi0 + chi[:, :, [0, -1]] = chi0 + + h_phi = -laplacian_19pt(phi) + chi * chi * phi + phi_equation = h_phi - omega_sq * phi + phi_residual = float(np.linalg.norm(_interior_vector(phi_equation))) / max( + float(np.linalg.norm(_interior_vector(omega_sq * phi))), 1.0 + ) + chi_equation = ( + laplacian_19pt(chi) + - (kappa / chi0) * chi * source_density + - 4.0 * lambda_h * chi * (chi * chi - chi0 * chi0) + ) + chi_residual_rms = float(np.sqrt(np.mean(_interior_vector(chi_equation) ** 2))) + if chi_ok and phi_residual < tolerance and chi_residual_rms < tolerance: + converged = True + message = "support-removal stationary equations converged" + break + message = chi_message if not chi_ok else "SCF residual above tolerance" + + density = phi * phi + probability = density / max(float(np.sum(density)), 1.0e-300) + effective_sites = 1.0 / max(float(np.sum(probability * probability)), 1.0e-300) + return SupportRemovalPoint( + phi=phi, + chi=chi, + source_density=source_density, + omega=float(np.sqrt(max(omega_sq, 0.0))), + norm_target=norm_target, + dynamic_fraction=float(dynamic_fraction), + converged=converged, + cycles=last_cycle, + phi_residual=phi_residual, + chi_residual_rms=chi_residual_rms, + chi_min=float(np.min(chi)), + effective_sites=effective_sites, + message=message, + ) + + +def continue_support_removal( + fixed_source: np.ndarray, + dynamic_fractions: list[float], + **kwargs, +) -> list[SupportRemovalPoint]: + """Continue from a prescribed source to the bare self-source endpoint.""" + points: list[SupportRemovalPoint] = [] + previous: SupportRemovalPoint | None = None + for fraction in dynamic_fractions: + point = solve_support_removal_point( + fixed_source, + fraction, + previous=previous, + **kwargs, + ) + points.append(point) + if point.converged: + previous = point + return points diff --git a/lfm/simulation.py b/lfm/simulation.py index 7b1f17e..e3bcf9f 100644 --- a/lfm/simulation.py +++ b/lfm/simulation.py @@ -38,7 +38,13 @@ from lfm.analysis.energy import total_energy from lfm.analysis.metrics import compute_metrics from lfm.analysis.structure import interior_mask as make_interior_mask -from lfm.config import BoundaryType, FieldLevel, SimulationConfig +from lfm.config import ( + BoundaryType, + ChiPotentialModel, + FieldLevel, + Precision, + SimulationConfig, +) from lfm.core.evolver import Evolver from lfm.fields.equilibrium import equilibrate_from_fields from lfm.fields.soliton import gaussian_soliton, place_solitons @@ -66,12 +72,13 @@ def __init__( config = SimulationConfig() self.config = config self._evolver = Evolver(config, backend=backend) + self._state_dtype = self._evolver.dtype self._interior_mask: NDArray[np.bool_] | None = None self._history: list[dict[str, float]] = [] # Cache for previous-step fields (for energy calculation) - self._psi_r_prev: NDArray[np.float32] | None = None - self._psi_i_prev: NDArray[np.float32] | None = None + self._psi_r_prev: NDArray[np.floating] | None = None + self._psi_i_prev: NDArray[np.floating] | None = None # State tracking — prevents misuse like equilibrating before placing # solitons, or forgetting to equilibrate entirely. @@ -96,34 +103,44 @@ def history(self) -> list[dict[str, float]]: # ── Field access ────────────────────────────────────── @property - def chi(self) -> NDArray[np.float32]: + def chi(self) -> NDArray[np.floating]: """Current χ field, shape (N, N, N).""" return self._evolver.get_chi() @chi.setter - def chi(self, value: NDArray[np.float32]) -> None: + def chi(self, value: NDArray[np.floating]) -> None: self._evolver.set_chi(value) @property - def psi_real(self) -> NDArray[np.float32]: + def chi_previous(self) -> NDArray[np.floating]: + """Previous leapfrog chi field, shape (N, N, N).""" + + return self._evolver.get_chi_prev() + + @chi_previous.setter + def chi_previous(self, value: NDArray[np.floating]) -> None: + self._evolver.set_chi_prev(value) + + @property + def psi_real(self) -> NDArray[np.floating]: """Real part of Ψ, shape (N, N, N).""" return self._evolver.get_psi_real() @psi_real.setter - def psi_real(self, value: NDArray[np.float32]) -> None: + def psi_real(self, value: NDArray[np.floating]) -> None: self._evolver.set_psi_real(value) @property - def psi_imag(self) -> NDArray[np.float32] | None: + def psi_imag(self) -> NDArray[np.floating] | None: """Imaginary part of Ψ (None for real field level).""" return self._evolver.get_psi_imag() @psi_imag.setter - def psi_imag(self, value: NDArray[np.float32]) -> None: + def psi_imag(self, value: NDArray[np.floating]) -> None: self._evolver.set_psi_imag(value) @property - def psi_real_prev(self) -> NDArray[np.float32] | None: + def psi_real_prev(self) -> NDArray[np.floating] | None: """Real part of Ψ from the step *before* the last ``run()`` call. Automatically updated after every :meth:`run` and :meth:`run_driven` @@ -139,7 +156,7 @@ def psi_real_prev(self) -> NDArray[np.float32] | None: return self._psi_r_prev @property - def psi_imag_prev(self) -> NDArray[np.float32] | None: + def psi_imag_prev(self) -> NDArray[np.floating] | None: """Imaginary part of Ψ from the step *before* the last ``run()`` call. See :attr:`psi_real_prev` for usage. @@ -147,35 +164,87 @@ def psi_imag_prev(self) -> NDArray[np.float32] | None: return self._psi_i_prev @property - def energy_density(self) -> NDArray[np.float32]: + def energy_density(self) -> NDArray[np.floating]: """Energy density |Ψ|², shape (N, N, N).""" return self._evolver.get_energy_density() - def get_chi(self) -> NDArray[np.float32]: + def get_chi(self) -> NDArray[np.floating]: """Get current χ field, shape (N, N, N).""" return self._evolver.get_chi() - def get_psi_real(self) -> NDArray[np.float32]: + def get_psi_real(self) -> NDArray[np.floating]: """Get real part of Ψ.""" return self._evolver.get_psi_real() - def get_psi_imag(self) -> NDArray[np.float32] | None: + def get_psi_imag(self) -> NDArray[np.floating] | None: """Get imaginary part of Ψ (None for real field level).""" return self._evolver.get_psi_imag() - def get_energy_density(self) -> NDArray[np.float32]: + def get_energy_density(self) -> NDArray[np.floating]: """Get |Ψ|² energy density, shape (N, N, N).""" return self._evolver.get_energy_density() - def set_psi_real(self, value: NDArray[np.float32]) -> None: + def get_boundary_mask(self) -> NDArray[np.floating]: + """Get a detached copy of the fixed boundary mask.""" + return self._evolver.get_boundary_mask().copy() + + def set_boundary_mask(self, value: NDArray[np.floating]) -> None: + """Set fixed input-independent geometry before the first run. + + Zero-valued cells evolve normally. One-valued cells absorb ``psi`` + and restore ``chi`` to ``chi0`` on every production-kernel step. + Fractional values provide graded absorption. The mask is immutable + once evolution has started. + """ + self._evolver.set_boundary_mask(value) + + def set_local_phase_clock_map( + self, + dwell_steps: NDArray[np.floating], + unit_phase_rad: float, + enable_mask: NDArray[np.floating] | None = None, + ) -> None: + """Set a prelaunch-fixed local Noether phase-clock map. + + The map declares a local dwell class for each complex field cell. It is + stored in backend-native memory and can later be applied without a + host-side field read or modal projection. + """ + + self._evolver.set_local_phase_clock_map( + dwell_steps, + unit_phase_rad, + enable_mask, + ) + + def apply_local_phase_clock_map(self) -> None: + """Apply the stored local phase-clock map to the complex phase space.""" + + self._evolver.apply_local_phase_clock_map() + + def phase_space_snapshot(self) -> dict[str, object]: + """Return detached copies of the complete leapfrog state.""" + psi_imag = self._evolver.get_psi_imag() + psi_imag_prev = self._evolver.get_psi_imag_prev() + return { + "step": self.step, + "psi_real": self._evolver.get_psi_real().copy(), + "psi_real_prev": self._evolver.get_psi_real_prev().copy(), + "psi_imag": None if psi_imag is None else psi_imag.copy(), + "psi_imag_prev": (None if psi_imag_prev is None else psi_imag_prev.copy()), + "chi": self._evolver.get_chi().copy(), + "chi_prev": self._evolver.get_chi_prev().copy(), + } + + def set_psi_real(self, value: NDArray[np.floating]) -> None: """Set real part of Ψ.""" self._evolver.set_psi_real(value) - def set_psi_imag(self, value: NDArray[np.float32]) -> None: + def set_psi_imag(self, value: NDArray[np.floating]) -> None: """Set imaginary part of Ψ.""" self._evolver.set_psi_imag(value) - def set_psi_real_prev(self, value: NDArray[np.float32]) -> None: + def set_psi_real_prev(self, value: NDArray[np.floating]) -> None: """Override the previous-timestep Ψ_real for traveling-wave init. Call *after* :meth:`set_psi_real` to set Ψ(t=−Δt) independently, @@ -183,11 +252,11 @@ def set_psi_real_prev(self, value: NDArray[np.float32]) -> None: """ self._evolver.set_psi_real_prev(value) - def set_psi_imag_prev(self, value: NDArray[np.float32]) -> None: + def set_psi_imag_prev(self, value: NDArray[np.floating]) -> None: """Override the previous-timestep Ψ_imag for traveling-wave init.""" self._evolver.set_psi_imag_prev(value) - def set_psi_real_current(self, value: NDArray[np.float32]) -> None: + def set_psi_real_current(self, value: NDArray[np.floating]) -> None: """Set only the active current-timestep Ψ_real buffer. Safe to call from a step callback: does **not** touch the prev @@ -196,17 +265,21 @@ def set_psi_real_current(self, value: NDArray[np.float32]) -> None: """ self._evolver.set_psi_real_current(value) - def set_psi_imag_current(self, value: NDArray[np.float32]) -> None: + def set_psi_imag_current(self, value: NDArray[np.floating]) -> None: """Set only the active current-timestep Ψ_imag buffer. See :meth:`set_psi_real_current` for the intended usage pattern. """ self._evolver.set_psi_imag_current(value) - def set_chi(self, value: NDArray[np.float32]) -> None: + def set_chi(self, value: NDArray[np.floating]) -> None: """Set χ field.""" self._evolver.set_chi(value) + def set_chi_prev(self, value: NDArray[np.floating]) -> None: + """Override the previous-timestep chi layer.""" + self._evolver.set_chi_prev(value) + # ── zero-copy native buffer access (avoids GPU↔CPU roundtrips) ───── def _native_psi_real(self): @@ -253,10 +326,10 @@ def _native_chi_pair(self): def _to_device(self, arr): """Convert a numpy array to the backend's native format.""" - return self._evolver.backend.from_numpy(arr.ravel().astype(np.float32)) + return self._evolver.backend.from_numpy(arr.ravel().astype(self._state_dtype)) @property - def sa_fields(self) -> NDArray[np.float32] | None: + def sa_fields(self) -> NDArray[np.floating] | None: """S_a auxiliary confinement fields, shape (3, N, N, N). Returns ``None`` when ``config.kappa_tube == 0`` (SA disabled). @@ -266,7 +339,7 @@ def sa_fields(self) -> NDArray[np.float32] | None: return self._evolver.get_sa_fields() @sa_fields.setter - def sa_fields(self, value: NDArray[np.float32]) -> None: + def sa_fields(self, value: NDArray[np.floating]) -> None: """Set S_a auxiliary confinement fields.""" self._evolver.set_sa_fields(value) @@ -425,12 +498,13 @@ def place_particle( E = np.roll(E, shift, axis=ax) chi_local = np.full_like(E, chi0) - dchi = sol.chi - np.float32(chi0) + state_scalar = self._state_dtype.type + dchi = sol.chi.astype(self._state_dtype) - state_scalar(chi0) for ax in range(3): shift = int(position[ax]) - center if shift != 0: dchi = np.roll(dchi, shift, axis=ax) - chi_local = np.float32(chi0) + dchi + chi_local = state_scalar(chi0) + dchi # --- Step 3: Boost if moving --- if has_velocity: @@ -449,7 +523,7 @@ def place_particle( # Stationary eigenmode: ψ(−Δt) = ψ(0)·cos(ωΔt) if sol.eigenvalue and sol.eigenvalue > 0: cos_wdt = float(_math.cos(sol.eigenvalue * dt)) - pr_p = (E * cos_wdt).astype(np.float32) + pr_p = (E * cos_wdt).astype(self._state_dtype) else: pr_p = E.copy() pi_p = np.zeros_like(E) @@ -459,10 +533,10 @@ def place_particle( if abs(phase) > 1e-10: cos_p = _math.cos(phase) sin_p = _math.sin(phase) - pr_c2 = (pr_c * cos_p - pi_c * sin_p).astype(np.float32) - pi_c2 = (pr_c * sin_p + pi_c * cos_p).astype(np.float32) - pr_p2 = (pr_p * cos_p - pi_p * sin_p).astype(np.float32) - pi_p2 = (pr_p * sin_p + pi_p * cos_p).astype(np.float32) + pr_c2 = (pr_c * cos_p - pi_c * sin_p).astype(self._state_dtype) + pi_c2 = (pr_c * sin_p + pi_c * cos_p).astype(self._state_dtype) + pr_p2 = (pr_p * cos_p - pi_p * sin_p).astype(self._state_dtype) + pi_p2 = (pr_p * sin_p + pi_p * cos_p).astype(self._state_dtype) pr_c, pi_c, pr_p, pi_p = pr_c2, pi_c2, pr_p2, pi_p2 # --- Step 5: Superpose onto existing fields --- @@ -562,16 +636,18 @@ def place_soliton( ky = chi0 * vy / c kz = chi0 * vz / c - x = np.arange(N, dtype=np.float32) + x = np.arange(N, dtype=self._state_dtype) X, Y, Z = np.meshgrid(x, x, x, indexing="ij") px, py, pz = position # --- t = 0: envelope centred at r₀ --- r2 = (X - px) ** 2 + (Y - py) ** 2 + (Z - pz) ** 2 - envelope = (amp * np.exp(-r2 / (2.0 * sig**2))).astype(np.float32) - phase_grid = (phase + kx * (X - px) + ky * (Y - py) + kz * (Z - pz)).astype(np.float32) - pr = (envelope * np.cos(phase_grid)).astype(np.float32) - pi = (envelope * np.sin(phase_grid)).astype(np.float32) + envelope = (amp * np.exp(-r2 / (2.0 * sig**2))).astype(self._state_dtype) + phase_grid = (phase + kx * (X - px) + ky * (Y - py) + kz * (Z - pz)).astype( + self._state_dtype + ) + pr = (envelope * np.cos(phase_grid)).astype(self._state_dtype) + pi = (envelope * np.sin(phase_grid)).astype(self._state_dtype) # --- t = −Δt: envelope shifted back by v·Δt --- # Both the Gaussian centre AND the phase reference point move @@ -582,13 +658,13 @@ def place_soliton( py_prev = py - vy * dt pz_prev = pz - vz * dt r2_prev = (X - px_prev) ** 2 + (Y - py_prev) ** 2 + (Z - pz_prev) ** 2 - envelope_prev = (amp * np.exp(-r2_prev / (2.0 * sig**2))).astype(np.float32) + envelope_prev = (amp * np.exp(-r2_prev / (2.0 * sig**2))).astype(self._state_dtype) omega = float(np.sqrt(kx**2 + ky**2 + kz**2 + chi0**2)) phase_prev = ( phase + kx * (X - px_prev) + ky * (Y - py_prev) + kz * (Z - pz_prev) + omega * dt - ).astype(np.float32) - pr_prev = (envelope_prev * np.cos(phase_prev)).astype(np.float32) - pi_prev = (envelope_prev * np.sin(phase_prev)).astype(np.float32) + ).astype(self._state_dtype) + pr_prev = (envelope_prev * np.cos(phase_prev)).astype(self._state_dtype) + pi_prev = (envelope_prev * np.sin(phase_prev)).astype(self._state_dtype) else: pr, pi = gaussian_soliton(N, position, amp, sig, phase) pr_prev = pr @@ -758,8 +834,8 @@ def place_solitons( ) else: # Single-component: sum all solitons - pr_total = np.zeros((N, N, N), dtype=np.float32) - pi_total = np.zeros((N, N, N), dtype=np.float32) + pr_total = np.zeros((N, N, N), dtype=self._state_dtype) + pi_total = np.zeros((N, N, N), dtype=self._state_dtype) if phases is None: phases = [0.0] * len(positions) for pos, ph in zip(positions, phases, strict=False): @@ -843,22 +919,24 @@ def place_plane_wave( # ── Transverse Gaussian envelope (centred on grid) ────────────────── centre = N / 2.0 trans_axes = [i for i in range(3) if i != axis] - gauss = np.ones((N, N, N), dtype=np.float32) + gauss = np.ones((N, N, N), dtype=self._state_dtype) for ta in trans_axes: - c_t = np.arange(N, dtype=np.float32) - centre + c_t = np.arange(N, dtype=self._state_dtype) - centre shape = [1, 1, 1] shape[ta] = N gauss *= np.exp(-(c_t.reshape(shape) ** 2) / (2.0 * beam_waist**2)) # ── Propagating cosine along the propagation axis ──────────────────── - prop_coord = np.arange(N, dtype=np.float32) # 0 … N-1 - cos_cur = (amplitude * np.cos(k * prop_coord + phase)).astype(np.float32) - cos_prev = (amplitude * np.cos(k * prop_coord + phase + omega * dt)).astype(np.float32) + prop_coord = np.arange(N, dtype=self._state_dtype) # 0 ... N-1 + cos_cur = (amplitude * np.cos(k * prop_coord + phase)).astype(self._state_dtype) + cos_prev = (amplitude * np.cos(k * prop_coord + phase + omega * dt)).astype( + self._state_dtype + ) shape_p = [1, 1, 1] shape_p[axis] = N - psi_3d = (gauss * cos_cur.reshape(shape_p)).astype(np.float32) - psi_3d_prev = (gauss * cos_prev.reshape(shape_p)).astype(np.float32) + psi_3d = (gauss * cos_cur.reshape(shape_p)).astype(self._state_dtype) + psi_3d_prev = (gauss * cos_prev.reshape(shape_p)).astype(self._state_dtype) # ── Truncate at z_max (don't pre-fill past the barrier) ───────────── if z_max is not None: @@ -934,14 +1012,14 @@ def equilibrate(self) -> None: self._evolver.set_chi_current(chi_eq) # Gradient of the equilibrium chi field - grad_x = np.gradient(chi_eq, axis=0).astype(np.float32) - grad_y = np.gradient(chi_eq, axis=1).astype(np.float32) - grad_z = np.gradient(chi_eq, axis=2).astype(np.float32) + grad_x = np.gradient(chi_eq, axis=0).astype(self._state_dtype) + grad_y = np.gradient(chi_eq, axis=1).astype(self._state_dtype) + grad_z = np.gradient(chi_eq, axis=2).astype(self._state_dtype) # Build density-weighted velocity field from all boosted solitons: # v(r) = Σ_i v_i·ρ_i(r) / Σ_i ρ_i(r) N = self.config.grid_size - v_dot_grad = np.zeros((N, N, N), dtype=np.float32) + v_dot_grad = np.zeros((N, N, N), dtype=self._state_dtype) for (vx, vy, vz), rho in self._velocity_boosts: v_dot_grad += rho * (vx * grad_x + vy * grad_y + vz * grad_z) total_rho: np.ndarray = sum(rho for _, rho in self._velocity_boosts) # type: ignore[assignment] @@ -1187,10 +1265,40 @@ def _internal_callback(evolver: Evolver, step: int) -> None: freeze_chi=(not evolve_chi), ) + def run_gravity_recovery( + self, + steps: int, + potential_model: ChiPotentialModel, + *, + freeze_psi: bool = True, + relaxation_damping: float = 0.0, + dt_override: float | None = None, + callback: Callable[[Simulation, int], None] | None = None, + ) -> None: + """Run an experiment-only local GOV-02 stabilization candidate. + + Canonical :meth:`run` behavior is unchanged. This method exists only + for the frozen gravity-recovery study and accepts no target profile, + inverse solver, or external force law. + """ + + def _internal_callback(evolver: Evolver, step: int) -> None: + if callback is not None: + callback(self, step) + + self._evolver.evolve_gravity_recovery( + steps, + ChiPotentialModel(potential_model), + freeze_psi=freeze_psi, + relaxation_damping=relaxation_damping, + dt_override=dt_override, + callback=_internal_callback, + ) + def run_driven( self, steps: int, - chi_forcing: Callable[[float], NDArray[np.float32]], + chi_forcing: Callable[[float], NDArray[np.floating]], record_metrics: bool = False, ) -> None: """Run with χ forced at *every* leapfrog step by an external function. @@ -1206,15 +1314,14 @@ def run_driven( Number of leapfrog steps. chi_forcing : callable(t) -> ndarray Function of simulation time ``t`` (float) returning either a - scalar or a (N,N,N) float32 array. Use default-argument capture + scalar or a (N,N,N) floating-point array. Use default-argument capture to close over loop variables correctly in sweeps:: sim.run_driven( 1000, chi_forcing=lambda t, A=3.0, w=omega: np.full((N, N, N), - lfm.CHI0 + A * np.sin(w * t), - dtype=np.float32), + lfm.CHI0 + A * np.sin(w * t)), ) record_metrics : bool If True, append :meth:`metrics` to :attr:`history` every @@ -1236,10 +1343,10 @@ def run_driven( for s in range(steps): t = (base_step + s) * dt chi_raw = chi_forcing(t) - chi_arr = np.asarray(chi_raw, dtype=np.float32) + chi_arr = np.asarray(chi_raw, dtype=self._state_dtype) if chi_arr.ndim == 0: N = self.config.grid_size - chi_arr = np.full((N, N, N), float(chi_arr), dtype=np.float32) + chi_arr = np.full((N, N, N), float(chi_arr), dtype=self._state_dtype) evolver.set_chi(chi_arr) evolver.evolve(1) self._psi_r_prev = evolver.get_psi_real().copy() @@ -1259,7 +1366,7 @@ def run_with_snapshots( step_callback: Callable[[Simulation, int], None] | None = None, record_metrics: bool = True, evolve_chi: bool = True, - ) -> list[dict[str, NDArray[np.float32]]]: + ) -> list[dict[str, NDArray[np.floating]]]: """Run and accumulate field snapshots at regular intervals. Snapshots are stored in memory as copies of the requested fields. @@ -1474,7 +1581,8 @@ def total_energy(self) -> float: def save_checkpoint(self, path: str | Path) -> None: """Save simulation state to a .npz file for later resumption. - Saves fields, step counter, config, and metric history. + Saves the complete leapfrog phase space, fixed boundary geometry, + step counter, config, metric caches, and metric history. Parameters ---------- @@ -1484,14 +1592,24 @@ def save_checkpoint(self, path: str | Path) -> None: path = Path(path) path.parent.mkdir(parents=True, exist_ok=True) + phase = self.phase_space_snapshot() data: dict[str, object] = { + "checkpoint_version": np.int64(2), "step": np.int64(self.step), - "chi": self.get_chi(), - "psi_real": self.get_psi_real(), + "chi": phase["chi"], + "chi_leapfrog_prev": phase["chi_prev"], + "psi_real": phase["psi_real"], + "psi_real_leapfrog_prev": phase["psi_real_prev"], + "boundary_mask": self.get_boundary_mask(), } - pi = self.get_psi_imag() + pi = phase["psi_imag"] if pi is not None: data["psi_imag"] = pi + data["psi_imag_leapfrog_prev"] = phase["psi_imag_prev"] + + # These are analysis caches spanning the most recent run() call, + # distinct from the physical t-dt leapfrog layers above. Keep the + # historical key names for compatibility with existing checkpoints. if self._psi_r_prev is not None: data["psi_real_prev"] = self._psi_r_prev if self._psi_i_prev is not None: @@ -1536,17 +1654,31 @@ def load_checkpoint(cls, path: str | Path, backend: str = "auto") -> Simulation: cfg_dict = json.loads(str(data["config_json"])) cfg_dict["field_level"] = FieldLevel(cfg_dict["field_level"]) cfg_dict["boundary_type"] = BoundaryType(cfg_dict["boundary_type"]) + cfg_dict["precision"] = Precision(cfg_dict.get("precision", Precision.FLOAT32.value)) for key in ("dx", "sigma"): cfg_dict.pop(key, None) config = SimulationConfig(**cfg_dict) sim = cls(config, backend=backend) - # Restore fields + # Restore fields. Setters normalize the active state into both A/B + # buffer sets; the explicit previous-layer setters then reconstruct + # the exact physical leapfrog phase space independent of parity. sim._evolver.set_psi_real(data["psi_real"]) + if "psi_real_leapfrog_prev" in data: + sim._evolver.set_psi_real_prev(data["psi_real_leapfrog_prev"]) if "psi_imag" in data: sim._evolver.set_psi_imag(data["psi_imag"]) + if "psi_imag_leapfrog_prev" in data: + sim._evolver.set_psi_imag_prev(data["psi_imag_leapfrog_prev"]) sim._evolver.set_chi(data["chi"]) + if "chi_leapfrog_prev" in data: + sim._evolver.set_chi_prev(data["chi_leapfrog_prev"]) + + # Geometry must be restored while the new Evolver is still at step + # zero because live boundary changes are intentionally barred. + if "boundary_mask" in data: + sim._evolver.set_boundary_mask(data["boundary_mask"]) sim._evolver.step = int(data["step"]) # Restore prev fields for energy calculation diff --git a/lfm/sweep.py b/lfm/sweep.py index dc7f5b4..cabb09c 100644 --- a/lfm/sweep.py +++ b/lfm/sweep.py @@ -8,9 +8,77 @@ from lfm.simulation import Simulation if TYPE_CHECKING: + from collections.abc import Callable + from lfm.config import SimulationConfig +def sweep_cases( + config: SimulationConfig, + cases: list[dict[str, Any]], + steps: int, + *, + initializer: Callable[[Simulation, dict[str, Any]], None], + observer: Callable[[Simulation, dict[str, Any], int], dict[str, Any]], + sample_every: int, + backend: str = "auto", +) -> list[dict[str, Any]]: + """Run a reproducible sweep with caller-defined substrate initial data. + + Unlike :func:`sweep`, this runner does not place a catalog soliton and does + not equilibrate chi. It is intended for mechanism-discovery experiments + that must begin from the native causal equations without a prepared well. + + The observer is called at step zero and after every sampling block. Each + returned row includes all scalar case metadata plus ``sample_step``. + Per-case configuration overrides may be supplied in a nested + ``config_overrides`` mapping. + """ + if steps <= 0: + raise ValueError("steps must be positive") + if sample_every <= 0: + raise ValueError("sample_every must be positive") + + results: list[dict[str, Any]] = [] + for case in cases: + cfg = deepcopy(config) + overrides = case.get("config_overrides", {}) + if not isinstance(overrides, dict): + raise TypeError("config_overrides must be a mapping") + for key, value in overrides.items(): + if not hasattr(cfg, key): + raise AttributeError(f"SimulationConfig has no attribute {key!r}") + setattr(cfg, key, value) + + sim = Simulation(cfg, backend=backend) + initializer(sim, case) + scalar_case = { + key: value + for key, value in case.items() + if key != "config_overrides" and isinstance(value, (str, int, float, bool, type(None))) + } + + first = dict(scalar_case) + first["sample_step"] = 0 + first.update(observer(sim, case, 0)) + results.append(first) + + completed = 0 + while completed < steps: + block = min(sample_every, steps - completed) + sim.run( + steps=block, + record_metrics=False, + evolve_chi=bool(case.get("evolve_chi", True)), + ) + completed += block + row = dict(scalar_case) + row["sample_step"] = completed + row.update(observer(sim, case, completed)) + results.append(row) + return results + + def sweep( config: SimulationConfig, param: str, diff --git a/lfm/topology/__init__.py b/lfm/topology/__init__.py new file mode 100644 index 0000000..1e5f700 --- /dev/null +++ b/lfm/topology/__init__.py @@ -0,0 +1,25 @@ +"""Topological field sectors for explicit LFM extension studies.""" + +from lfm.topology.skyrme import ( + FRQuantization, + SkyrmeHedgehogEnergy, + SkyrmeHedgehogSolution, + hedgehog_degree, + hedgehog_energy_gradient_hessian, + hedgehog_unitarity_residual, + make_hedgehog_profile, + prolong_hedgehog_profile, + solve_skyrme_hedgehog, +) + +__all__ = [ + "FRQuantization", + "SkyrmeHedgehogEnergy", + "SkyrmeHedgehogSolution", + "hedgehog_degree", + "hedgehog_energy_gradient_hessian", + "hedgehog_unitarity_residual", + "make_hedgehog_profile", + "prolong_hedgehog_profile", + "solve_skyrme_hedgehog", +] diff --git a/lfm/topology/skyrme.py b/lfm/topology/skyrme.py new file mode 100644 index 0000000..98e543e --- /dev/null +++ b/lfm/topology/skyrme.py @@ -0,0 +1,539 @@ +"""Unit-degree SU(2) hedgehogs and explicit FR quantization certificates. + +This module implements an experimental Skyrme-type extension. It is not part +of the canonical LFM action, and no result from it is an electron observable. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np +import scipy.sparse as sp +from scipy.optimize import minimize +from scipy.sparse.linalg import MatrixRankWarning, spsolve + + +@dataclass(frozen=True) +class SkyrmeHedgehogEnergy: + """Dimensionless static energy and Derrick components.""" + + total: float + sigma: float + skyrme: float + + +@dataclass(frozen=True) +class SkyrmeHedgehogSolution: + """One source-free radial degree-one hedgehog solution.""" + + radius: float + dr: float + r: np.ndarray + profile: np.ndarray + energy: SkyrmeHedgehogEnergy + degree: float + rms_topological_radius: float + stationary_relative_residual: float + unitarity_residual: float + derrick_relative_first_derivative: float + derrick_relative_second_derivative: float + continuum_virial_mismatch: float + optimizer_converged: bool + optimizer_iterations: int + newton_converged: bool + newton_iterations: int + message: str + + +@dataclass(frozen=True) +class FRQuantization: + """Selected one-dimensional character of the Z2 universal-cover deck group. + + ``deck_character=-1`` is the fermionic Finkelstein-Rubinstein choice. + Topology permits this choice for odd degree; canonical LFM does not + currently derive it uniquely. + """ + + degree: int + deck_character: int + + def __post_init__(self) -> None: + if self.degree == 0: + raise ValueError("degree must be nonzero") + if self.deck_character not in (-1, 1): + raise ValueError("deck_character must be -1 or +1") + + @property + def rotation_loop_class(self) -> int: + """Z2 class of one physical 2*pi rotation.""" + return abs(self.degree) % 2 + + @property + def exchange_loop_class(self) -> int: + """Z2 class of one identical-soliton exchange.""" + return abs(self.degree) % 2 + + def character(self, loop_class: int) -> int: + """Evaluate the selected deck character on a Z2 loop class.""" + return self.deck_character ** (int(loop_class) % 2) + + def rotation_sign(self, full_turns: int = 1) -> int: + """Wavefunction sign after an integer number of physical full turns.""" + loop_class = self.rotation_loop_class * int(full_turns) + return self.character(loop_class) + + def exchange_sign(self, exchanges: int = 1) -> int: + """Wavefunction sign after an integer number of identical exchanges.""" + loop_class = self.exchange_loop_class * int(exchanges) + return self.character(loop_class) + + def wavefunction_on_sheet( + self, + base_amplitude: complex, + sheet: int, + ) -> complex: + """Evaluate a universal-cover wavefunction on a chosen deck sheet.""" + return complex(base_amplitude) * self.character(sheet) + + def certificate(self) -> dict[str, object]: + """Return an auditable topology/quantization statement.""" + return { + "spatial_compactification": "S3", + "target_manifold": "SU(2)~S3", + "degree": self.degree, + "configuration_space_pi1": "Z2", + "rotation_loop_class": self.rotation_loop_class, + "rotation_sign_2pi": self.rotation_sign(1), + "rotation_sign_4pi": self.rotation_sign(2), + "exchange_loop_class": self.exchange_loop_class, + "exchange_sign_once": self.exchange_sign(1), + "exchange_sign_twice": self.exchange_sign(2), + "deck_character": self.deck_character, + "deck_character_status": ("explicit_quantization_choice_not_uniquely_derived_from_LFM"), + "primary_references": [ + "doi:10.1063/1.1664510", + "arXiv:hep-th/9301101", + "arXiv:hep-th/0509094", + ], + } + + +def _radial_nodes(radius: float, dr: float) -> np.ndarray: + if radius <= 0.0 or dr <= 0.0: + raise ValueError("radius and dr must be positive") + segments_float = radius / dr + segments = int(round(segments_float)) + if segments < 8 or not np.isclose( + segments * dr, + radius, + rtol=0.0, + atol=1.0e-12, + ): + raise ValueError("radius/dr must be an integer of at least 8") + return np.arange(segments + 1, dtype=np.float64) * dr + + +def make_hedgehog_profile( + *, + radius: float, + dr: float, + scale: float = 1.0, +) -> np.ndarray: + """Return the frozen smooth degree-one analytic initial profile.""" + if scale <= 0.0: + raise ValueError("scale must be positive") + r = _radial_nodes(radius, dr) + profile = np.empty_like(r) + profile[0] = np.pi + profile[-1] = 0.0 + if profile.size > 2: + profile[1:-1] = 2.0 * np.arctan((scale / r[1:-1]) ** 2) + return profile + + +def prolong_hedgehog_profile( + *, + source_r: np.ndarray, + source_profile: np.ndarray, + radius: float, + new_dr: float, +) -> np.ndarray: + """Cell-node prolongation with exact declared boundary values.""" + old_r = np.asarray(source_r, dtype=np.float64) + old_profile = np.asarray(source_profile, dtype=np.float64) + if old_r.ndim != 1 or old_r.size < 3 or old_profile.shape != old_r.shape: + raise ValueError("source grid and profile must be matching vectors") + if not np.all(np.diff(old_r) > 0.0): + raise ValueError("source_r must be strictly increasing") + new_r = _radial_nodes(radius, new_dr) + profile = np.interp( + new_r, + old_r, + old_profile, + left=np.pi, + right=0.0, + ) + profile[0] = np.pi + profile[-1] = 0.0 + return profile + + +def _validate_profile( + profile: np.ndarray, + *, + radius: float, + dr: float, +) -> tuple[np.ndarray, np.ndarray]: + r = _radial_nodes(radius, dr) + values = np.asarray(profile, dtype=np.float64) + if values.shape != r.shape: + raise ValueError(f"profile must have shape {r.shape}") + if not np.all(np.isfinite(values)): + raise ValueError("profile must be finite") + if not np.isclose(values[0], np.pi, rtol=0.0, atol=1.0e-13): + raise ValueError("profile origin boundary must be pi") + if not np.isclose(values[-1], 0.0, rtol=0.0, atol=1.0e-13): + raise ValueError("profile outer boundary must be zero") + return r, values + + +def hedgehog_energy_gradient_hessian( + profile: np.ndarray, + *, + radius: float, + dr: float, +) -> tuple[SkyrmeHedgehogEnergy, np.ndarray, sp.csr_matrix, np.ndarray]: + """Evaluate the frozen midpoint energy, gradient, Hessian, and force scale.""" + r, values = _validate_profile(profile, radius=radius, dr=dr) + interiors = values.size - 2 + gradient_full = np.zeros_like(values) + force_scale_full = np.zeros_like(values) + main = np.zeros(interiors, dtype=np.float64) + off = np.zeros(max(interiors - 1, 0), dtype=np.float64) + sigma_sum = 0.0 + skyrme_sum = 0.0 + factor = 4.0 * np.pi * dr + + for index in range(values.size - 1): + left = values[index] + right = values[index + 1] + midpoint = 0.5 * (left + right) + derivative = (right - left) / dr + radial_midpoint = 0.5 * (r[index] + r[index + 1]) + sin_midpoint = np.sin(midpoint) + cos_midpoint = np.cos(midpoint) + sin_squared = sin_midpoint * sin_midpoint + radial_squared = radial_midpoint * radial_midpoint + + sigma_density = 0.5 * (radial_squared * derivative * derivative + 2.0 * sin_squared) + skyrme_density = sin_squared * ( + derivative * derivative + 0.5 * sin_squared / radial_squared + ) + sigma_sum += factor * sigma_density + skyrme_sum += factor * skyrme_density + + partial_derivative = derivative * (radial_squared + 2.0 * sin_squared) + partial_midpoint = ( + 2.0 + * sin_midpoint + * cos_midpoint + * (1.0 + derivative * derivative + sin_squared / radial_squared) + ) + local_left = factor * (-partial_derivative / dr + 0.5 * partial_midpoint) + local_right = factor * (partial_derivative / dr + 0.5 * partial_midpoint) + gradient_full[index] += local_left + gradient_full[index + 1] += local_right + force_scale_full[index] += abs(local_left) + force_scale_full[index + 1] += abs(local_right) + + second_derivative = radial_squared + 2.0 * sin_squared + mixed_derivative = 4.0 * derivative * sin_midpoint * cos_midpoint + second_midpoint = ( + 2.0 + * np.cos(2.0 * midpoint) + * (1.0 + derivative * derivative + sin_squared / radial_squared) + + np.sin(2.0 * midpoint) ** 2 / radial_squared + ) + hessian_left = factor * ( + second_derivative / (dr * dr) - mixed_derivative / dr + 0.25 * second_midpoint + ) + hessian_right = factor * ( + second_derivative / (dr * dr) + mixed_derivative / dr + 0.25 * second_midpoint + ) + hessian_cross = factor * (-second_derivative / (dr * dr) + 0.25 * second_midpoint) + + if 0 < index < values.size - 1: + main[index - 1] += hessian_left + if 0 < index + 1 < values.size - 1: + main[index] += hessian_right + if 0 < index < values.size - 2: + off[index - 1] += hessian_cross + + hessian = sp.diags( + diagonals=(off, main, off), + offsets=(-1, 0, 1), + shape=(interiors, interiors), + format="csr", + ) + return ( + SkyrmeHedgehogEnergy( + total=float(sigma_sum + skyrme_sum), + sigma=float(sigma_sum), + skyrme=float(skyrme_sum), + ), + gradient_full[1:-1], + hessian, + force_scale_full[1:-1], + ) + + +def hedgehog_degree( + profile: np.ndarray, + *, + radius: float, + dr: float, +) -> float: + """Integrate the degree density using the same midpoint grid.""" + _, values = _validate_profile(profile, radius=radius, dr=dr) + midpoint = 0.5 * (values[:-1] + values[1:]) + derivative = np.diff(values) / dr + return float(-(2.0 / np.pi) * np.sum(dr * derivative * np.sin(midpoint) ** 2)) + + +def hedgehog_unitarity_residual(profile: np.ndarray) -> float: + """Return max |cos(F)^2+sin(F)^2-1| for the SU(2) parameterization.""" + values = np.asarray(profile, dtype=np.float64) + return float(np.max(np.abs(np.cos(values) ** 2 + np.sin(values) ** 2 - 1.0))) + + +def _stationary_residual( + profile: np.ndarray, + *, + radius: float, + dr: float, +) -> float: + _, gradient, _, force_scale = hedgehog_energy_gradient_hessian( + profile, + radius=radius, + dr=dr, + ) + denominator = max(float(np.linalg.norm(force_scale)), 1.0e-300) + return float(np.linalg.norm(gradient)) / denominator + + +def _newton_polish( + profile: np.ndarray, + *, + radius: float, + dr: float, + tolerance: float, + max_iterations: int, +) -> tuple[np.ndarray, bool, int, str]: + current = np.asarray(profile, dtype=np.float64).copy() + for iteration in range(max_iterations + 1): + energy, gradient, hessian, force_scale = hedgehog_energy_gradient_hessian( + current, + radius=radius, + dr=dr, + ) + residual = float(np.linalg.norm(gradient)) / max( + float(np.linalg.norm(force_scale)), + 1.0e-300, + ) + if residual <= tolerance: + return current, True, iteration, "stationary residual converged" + if iteration == max_iterations: + break + + diagonal_scale = np.maximum(np.abs(hessian.diagonal()), 1.0) + accepted = False + for damping in (0.0, 1.0e-12, 1.0e-10, 1.0e-8, 1.0e-6, 1.0e-4): + system = ( + hessian + if damping == 0.0 + else hessian + sp.diags(damping * diagonal_scale, format="csr") + ) + try: + import warnings + + with warnings.catch_warnings(): + warnings.simplefilter("error", MatrixRankWarning) + delta = spsolve(system, -gradient) + except (MatrixRankWarning, RuntimeError, ValueError): + continue + if not np.all(np.isfinite(delta)): + continue + step = 1.0 + while step >= 1.0e-12: + candidate = current.copy() + candidate[1:-1] += step * delta + candidate_energy, _, _, _ = hedgehog_energy_gradient_hessian( + candidate, + radius=radius, + dr=dr, + ) + candidate_residual = _stationary_residual( + candidate, + radius=radius, + dr=dr, + ) + if ( + candidate_energy.total <= energy.total * (1.0 + 1.0e-14) + and candidate_residual < residual + ): + current = candidate + accepted = True + break + step *= 0.5 + if accepted: + break + if not accepted: + return current, False, iteration, "Newton line search failed" + return current, False, max_iterations, "Newton iteration limit reached" + + +def _topological_rms_radius( + profile: np.ndarray, + *, + radius: float, + dr: float, +) -> float: + r, values = _validate_profile(profile, radius=radius, dr=dr) + radial_midpoint = 0.5 * (r[:-1] + r[1:]) + midpoint = 0.5 * (values[:-1] + values[1:]) + derivative = np.diff(values) / dr + weights = -(2.0 / np.pi) * dr * derivative * np.sin(midpoint) ** 2 + degree = float(np.sum(weights)) + if degree <= 0.0: + raise ValueError("topological density does not have positive degree") + return float(np.sqrt(np.dot(weights, radial_midpoint**2) / degree)) + + +def _derrick_scale_variations( + profile: np.ndarray, + *, + radius: float, + dr: float, +) -> tuple[float, float]: + """Evaluate the discrete action along its infinitesimal scale mode.""" + r, values = _validate_profile(profile, radius=radius, dr=dr) + energy, gradient, hessian, _ = hedgehog_energy_gradient_hessian( + values, + radius=radius, + dr=dr, + ) + derivative = np.gradient(values, dr, edge_order=2) + scale_mode = -r[1:-1] * derivative[1:-1] + first = abs(float(np.dot(gradient, scale_mode))) / max( + energy.total, + 1.0e-300, + ) + second = float(np.dot(scale_mode, hessian @ scale_mode)) / max( + energy.total, + 1.0e-300, + ) + return first, second + + +def solve_skyrme_hedgehog( + *, + radius: float, + dr: float, + initial_profile: np.ndarray | None = None, + max_iterations: int = 4000, + gradient_tolerance: float = 1.0e-10, + newton_tolerance: float = 1.0e-12, + newton_max_iterations: int = 80, +) -> SkyrmeHedgehogSolution: + """Minimize the frozen degree-one hedgehog energy without clipping.""" + initial = ( + make_hedgehog_profile(radius=radius, dr=dr) + if initial_profile is None + else np.asarray(initial_profile, dtype=np.float64).copy() + ) + _validate_profile(initial, radius=radius, dr=dr) + + def objective(interior: np.ndarray) -> tuple[float, np.ndarray]: + profile = np.empty(interior.size + 2, dtype=np.float64) + profile[0] = np.pi + profile[-1] = 0.0 + profile[1:-1] = interior + energy, gradient, _, _ = hedgehog_energy_gradient_hessian( + profile, + radius=radius, + dr=dr, + ) + return energy.total, gradient + + result = minimize( + objective, + initial[1:-1], + method="L-BFGS-B", + jac=True, + options={ + "maxiter": int(max_iterations), + "gtol": float(gradient_tolerance), + "ftol": 1.0e-15, + "maxls": 50, + "maxcor": 30, + }, + ) + optimized = np.empty_like(initial) + optimized[0] = np.pi + optimized[-1] = 0.0 + optimized[1:-1] = np.asarray(result.x, dtype=np.float64) + ( + final_profile, + newton_converged, + newton_iterations, + newton_message, + ) = _newton_polish( + optimized, + radius=radius, + dr=dr, + tolerance=newton_tolerance, + max_iterations=newton_max_iterations, + ) + energy, _, _, _ = hedgehog_energy_gradient_hessian( + final_profile, + radius=radius, + dr=dr, + ) + total_scale = max(energy.total, 1.0e-300) + derrick_first, derrick_second = _derrick_scale_variations( + final_profile, + radius=radius, + dr=dr, + ) + return SkyrmeHedgehogSolution( + radius=float(radius), + dr=float(dr), + r=_radial_nodes(radius, dr), + profile=final_profile, + energy=energy, + degree=hedgehog_degree( + final_profile, + radius=radius, + dr=dr, + ), + rms_topological_radius=_topological_rms_radius( + final_profile, + radius=radius, + dr=dr, + ), + stationary_relative_residual=_stationary_residual( + final_profile, + radius=radius, + dr=dr, + ), + unitarity_residual=hedgehog_unitarity_residual(final_profile), + derrick_relative_first_derivative=derrick_first, + derrick_relative_second_derivative=derrick_second, + continuum_virial_mismatch=float(abs(energy.sigma - energy.skyrme) / total_scale), + optimizer_converged=bool(result.success), + optimizer_iterations=int(result.nit), + newton_converged=bool(newton_converged), + newton_iterations=int(newton_iterations), + message=(f"optimizer: {result.message}; Newton: {newton_message}"), + ) diff --git a/lfm/validation/__init__.py b/lfm/validation/__init__.py new file mode 100644 index 0000000..4f5f9a9 --- /dev/null +++ b/lfm/validation/__init__.py @@ -0,0 +1,27 @@ +"""Validation policy and evidence-ledger helpers.""" + +from lfm.validation.unified_force import ( + BenchmarkResult, + BenchmarkSpec, + BenchmarkStatus, + EvidenceClass, + ExecutionIdentity, + ForceSector, + GateTier, + HarnessReport, + UnifiedForceHarness, + stable_fingerprint, +) + +__all__ = [ + "BenchmarkResult", + "BenchmarkSpec", + "BenchmarkStatus", + "EvidenceClass", + "ExecutionIdentity", + "ForceSector", + "GateTier", + "HarnessReport", + "UnifiedForceHarness", + "stable_fingerprint", +] diff --git a/lfm/validation/unified_force.py b/lfm/validation/unified_force.py new file mode 100644 index 0000000..2a4ebfa --- /dev/null +++ b/lfm/validation/unified_force.py @@ -0,0 +1,517 @@ +"""Strict evidence policy for unified-force LFM validation. + +This module does not define force dynamics. It prevents structural identities, +reduced models, external comparators, or mixed actions from being promoted as +one live four-force result. +""" + +from __future__ import annotations + +import hashlib +import json +from dataclasses import asdict, dataclass, field, replace +from enum import Enum +from pathlib import Path +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from collections.abc import Iterable, Mapping, Sequence + +JsonScalar = str | int | float | bool | None +JsonValue = JsonScalar | list["JsonValue"] | dict[str, "JsonValue"] + + +class BenchmarkStatus(str, Enum): + """Outcome of one frozen benchmark.""" + + PASS = "PASS" + FAIL = "FAIL" + BLOCKED = "BLOCKED" + NOT_RUN = "NOT_RUN" + + +class EvidenceClass(str, Enum): + """Scientific role of a benchmark result.""" + + LIVE = "LIVE" + STRUCTURAL = "STRUCTURAL" + DIAGNOSTIC = "DIAGNOSTIC" + COMPARATOR = "COMPARATOR" + + +class ForceSector(str, Enum): + """Force or cross-cutting sector.""" + + CORE = "CORE" + GRAVITY = "GRAVITY" + EM = "EM" + WEAK = "WEAK" + STRONG = "STRONG" + UNIFIED = "UNIFIED" + + +class GateTier(str, Enum): + """Evidence maturity tier.""" + + T0 = "T0" + T1 = "T1" + T2 = "T2" + T3 = "T3" + T4 = "T4" + T5 = "T5" + + +class ReadoutFrame(str, Enum): + """Reference system used by a benchmark measurement.""" + + STRUCTURAL = "STRUCTURAL" + INTERNAL_OPERATIONAL = "INTERNAL_OPERATIONAL" + EXTERNAL_GRID = "EXTERNAL_GRID" + CONTINUUM_COMPARATOR = "CONTINUUM_COMPARATOR" + + +GLOBAL_FORBIDDEN_MECHANISMS = frozenset( + { + "external_force", + "prescribed_trajectory", + "prescribed_potential", + "frozen_source", + "negative_energy_mode", + "per_sector_tuning", + "target_law_in_evolution", + "fundamental_metric_register", + "fundamental_affine_frame", + "fundamental_u1_links", + "target_theory_field", + "external_grid_only_readout", + } +) + + +def _json_default(value: object) -> object: + if isinstance(value, Enum): + return value.value + if isinstance(value, Path): + return str(value) + raise TypeError(f"cannot serialize {type(value).__name__}") + + +def stable_fingerprint(payload: Mapping[str, Any] | Sequence[Any]) -> str: + """Return a deterministic SHA-256 fingerprint for JSON-like data.""" + encoded = json.dumps( + payload, + sort_keys=True, + separators=(",", ":"), + default=_json_default, + ).encode("utf-8") + return hashlib.sha256(encoded).hexdigest() + + +@dataclass(frozen=True) +class ExecutionIdentity: + """Identity of the live equations and numerics used by a result.""" + + action_id: str + register_id: str + parameter_fingerprint: str + gov01_stencil: str + gov02_stencil: str + boundary_policy: str + implementation_fingerprint: str + + @classmethod + def build( + cls, + *, + action_id: str, + register_id: str, + parameters: Mapping[str, Any], + gov01_stencil: str, + gov02_stencil: str, + boundary_policy: str, + implementation: Mapping[str, Any], + ) -> ExecutionIdentity: + """Construct an identity from explicit action and implementation data.""" + return cls( + action_id=action_id, + register_id=register_id, + parameter_fingerprint=stable_fingerprint(parameters), + gov01_stencil=gov01_stencil, + gov02_stencil=gov02_stencil, + boundary_policy=boundary_policy, + implementation_fingerprint=stable_fingerprint(implementation), + ) + + @property + def fingerprint(self) -> str: + """Return the full execution fingerprint.""" + return stable_fingerprint(asdict(self)) + + +@dataclass(frozen=True) +class BenchmarkSpec: + """Frozen contract for one benchmark.""" + + benchmark_id: str + sector: ForceSector + tier: GateTier + description: str + required_for_sector: bool + promotion_eligible: bool + accepted_evidence: frozenset[EvidenceClass] + dependencies: tuple[str, ...] = () + forbidden_mechanisms: frozenset[str] = frozenset() + requires_shared_identity: bool = False + readout_frame: ReadoutFrame = ReadoutFrame.STRUCTURAL + substrate_evolution: str = "" + internal_observable: str = "" + continuum_interpretation: str = "" + operational_readout_required: bool = False + + def __post_init__(self) -> None: + if not self.benchmark_id or any(char.isspace() for char in self.benchmark_id): + raise ValueError("benchmark_id must be non-empty and contain no whitespace") + if self.required_for_sector and not self.promotion_eligible: + raise ValueError("a required sector gate must be promotion eligible") + if not self.accepted_evidence: + raise ValueError("accepted_evidence cannot be empty") + if self.operational_readout_required: + if self.readout_frame is not ReadoutFrame.INTERNAL_OPERATIONAL: + raise ValueError("an operational gate must use an INTERNAL_OPERATIONAL readout") + missing = [ + name + for name, value in ( + ("substrate_evolution", self.substrate_evolution), + ("internal_observable", self.internal_observable), + ("continuum_interpretation", self.continuum_interpretation), + ) + if not value.strip() + ] + if missing: + raise ValueError("REPRESENTATION AUDIT INCOMPLETE: missing " + ", ".join(missing)) + _validate_internal_observable_text(self.internal_observable) + + +def _validate_internal_observable_text(text: str) -> None: + """Require operational gates to name an actual internal wave readout. + + The four-force harness is allowed to retain external lattice coordinates as + diagnostics, but a promoting operational gate must be phrased as something + an observer built from the evolved wave fields could measure: clocks, + rulers, phases, currents, correlations, fluxes, probes, spectra, and + similar E/chi-derived observables. This guard prevents the common false + pass where a raw simulator coordinate is relabeled as an observation. + """ + lowered = " ".join(text.lower().replace("_", " ").split()) + forbidden_fragments = ( + "external coordinate", + "external grid", + "external-grid", + "fixed grid", + "god eye", + "god's eye", + "grid coordinate", + "lattice coordinate", + "raw coordinate", + "raw simulator", + "simulator time", + ) + if any(fragment in lowered for fragment in forbidden_fragments): + raise ValueError( + "REPRESENTATION AUDIT INCOMPLETE: operational readout uses a raw " + "external coordinate diagnostic" + ) + internal_tokens = ( + "e-wave", + "wave", + "clock", + "ruler", + "phase", + "current", + "correlation", + "probe", + "charge", + "coherence", + "curvature", + "flux", + "helicity", + "chirality", + "energy", + "spectral", + "scattering", + "singlet", + "nonsinglet", + "internal", + ) + if not any(token in lowered for token in internal_tokens): + raise ValueError( + "REPRESENTATION AUDIT INCOMPLETE: operational readout must name " + "an internal E/chi wave observable" + ) + + +@dataclass(frozen=True) +class BenchmarkResult: + """Evidence returned by one benchmark.""" + + benchmark_id: str + status: BenchmarkStatus + evidence: EvidenceClass + reason: str + metrics: Mapping[str, JsonScalar] = field(default_factory=dict) + mechanisms_used: frozenset[str] = frozenset() + identity: ExecutionIdentity | None = None + artifacts: tuple[str, ...] = () + + +@dataclass(frozen=True) +class HarnessReport: + """Adjudicated immutable view of a harness run.""" + + harness_version: str + manifest_fingerprint: str + results: tuple[BenchmarkResult, ...] + sector_status: Mapping[ForceSector, BenchmarkStatus] + unified_status: BenchmarkStatus + policy_findings: tuple[str, ...] + shared_identity_fingerprint: str | None + + def to_dict(self) -> dict[str, JsonValue]: + """Return a JSON-serializable evidence ledger.""" + result_rows: list[JsonValue] = [] + for result in self.results: + identity: JsonValue = asdict(result.identity) if result.identity is not None else None + mechanisms: list[JsonValue] = [ + mechanism for mechanism in sorted(result.mechanisms_used) + ] + result_rows.append( + { + "benchmark_id": result.benchmark_id, + "status": result.status.value, + "evidence": result.evidence.value, + "reason": result.reason, + "metrics": dict(result.metrics), + "mechanisms_used": mechanisms, + "identity": identity, + "identity_fingerprint": ( + result.identity.fingerprint if result.identity is not None else None + ), + "artifacts": list(result.artifacts), + } + ) + return { + "harness_version": self.harness_version, + "manifest_fingerprint": self.manifest_fingerprint, + "unified_status": self.unified_status.value, + "sector_status": { + sector.value: status.value for sector, status in self.sector_status.items() + }, + "shared_identity_fingerprint": self.shared_identity_fingerprint, + "policy_findings": list(self.policy_findings), + "results": result_rows, + } + + +class UnifiedForceHarness: + """Adjudicate benchmark evidence under strict unified-force rules.""" + + def __init__( + self, + specs: Iterable[BenchmarkSpec], + *, + version: str = "1.0", + ) -> None: + spec_list = tuple(specs) + by_id = {spec.benchmark_id: spec for spec in spec_list} + if len(by_id) != len(spec_list): + raise ValueError("benchmark IDs must be unique") + for spec in spec_list: + missing = set(spec.dependencies) - set(by_id) + if missing: + raise ValueError(f"{spec.benchmark_id} has unknown dependencies: {sorted(missing)}") + self.specs = spec_list + self.by_id = by_id + self.version = version + self.manifest_fingerprint = stable_fingerprint( + [ + { + **asdict(spec), + "sector": spec.sector.value, + "tier": spec.tier.value, + "readout_frame": spec.readout_frame.value, + "accepted_evidence": sorted( + evidence.value for evidence in spec.accepted_evidence + ), + "forbidden_mechanisms": sorted(spec.forbidden_mechanisms), + } + for spec in spec_list + ] + ) + + def _result_map( + self, + results: Iterable[BenchmarkResult], + ) -> dict[str, BenchmarkResult]: + rows = tuple(results) + result_map = {result.benchmark_id: result for result in rows} + if len(result_map) != len(rows): + raise ValueError("result benchmark IDs must be unique") + unknown = set(result_map) - set(self.by_id) + if unknown: + raise ValueError(f"results contain unknown benchmark IDs: {sorted(unknown)}") + return result_map + + @staticmethod + def _blocked_missing(spec: BenchmarkSpec) -> BenchmarkResult: + status = BenchmarkStatus.BLOCKED if spec.required_for_sector else BenchmarkStatus.NOT_RUN + return BenchmarkResult( + benchmark_id=spec.benchmark_id, + status=status, + evidence=next(iter(spec.accepted_evidence)), + reason="No result was supplied for this frozen benchmark.", + ) + + def evaluate(self, results: Iterable[BenchmarkResult]) -> HarnessReport: + """Validate results and compute strict sector and unified statuses.""" + supplied = self._result_map(results) + adjudicated: dict[str, BenchmarkResult] = {} + findings: list[str] = [] + + for spec in self.specs: + result = supplied.get(spec.benchmark_id, self._blocked_missing(spec)) + forbidden = ( + GLOBAL_FORBIDDEN_MECHANISMS | spec.forbidden_mechanisms + ) & result.mechanisms_used + if forbidden: + message = f"{spec.benchmark_id}: forbidden mechanisms used: {sorted(forbidden)}" + findings.append(message) + result = replace( + result, + status=BenchmarkStatus.FAIL, + reason=message, + ) + elif ( + result.status is BenchmarkStatus.PASS + and result.evidence not in spec.accepted_evidence + ): + message = ( + f"{spec.benchmark_id}: {result.evidence.value} evidence cannot " + "satisfy this gate" + ) + findings.append(message) + result = replace( + result, + status=BenchmarkStatus.FAIL, + reason=message, + ) + elif ( + result.status is BenchmarkStatus.PASS + and spec.requires_shared_identity + and result.identity is None + ): + message = f"{spec.benchmark_id}: live pass lacks an execution identity" + findings.append(message) + result = replace( + result, + status=BenchmarkStatus.FAIL, + reason=message, + ) + elif ( + result.status is BenchmarkStatus.PASS + and spec.operational_readout_required + and spec.readout_frame is not ReadoutFrame.INTERNAL_OPERATIONAL + ): + message = ( + f"{spec.benchmark_id}: external-grid or comparator readout " + "cannot satisfy an internal operational gate" + ) + findings.append(message) + result = replace( + result, + status=BenchmarkStatus.FAIL, + reason=message, + ) + + dependency_statuses = { + dependency: adjudicated[dependency].status for dependency in spec.dependencies + } + unsatisfied = { + dependency: status + for dependency, status in dependency_statuses.items() + if status is not BenchmarkStatus.PASS + } + if result.status is BenchmarkStatus.PASS and unsatisfied: + message = ( + f"{spec.benchmark_id}: claimed PASS with unsatisfied dependencies " + f"{ {key: value.value for key, value in unsatisfied.items()} }" + ) + findings.append(message) + result = replace( + result, + status=BenchmarkStatus.FAIL, + reason=message, + ) + adjudicated[spec.benchmark_id] = result + + required_identities = [ + adjudicated[spec.benchmark_id].identity + for spec in self.specs + if spec.required_for_sector + and spec.requires_shared_identity + and adjudicated[spec.benchmark_id].status is BenchmarkStatus.PASS + ] + identity_fingerprints = { + identity.fingerprint for identity in required_identities if identity is not None + } + shared_identity = ( + next(iter(identity_fingerprints)) if len(identity_fingerprints) == 1 else None + ) + identity_mismatch = len(identity_fingerprints) > 1 + if identity_mismatch: + findings.append( + "Required live gates used more than one execution identity; " + "same-action promotion is forbidden." + ) + + sector_status: dict[ForceSector, BenchmarkStatus] = {} + for sector in ForceSector: + required = [ + adjudicated[spec.benchmark_id].status + for spec in self.specs + if spec.sector is sector and spec.required_for_sector + ] + if not required: + sector_status[sector] = BenchmarkStatus.BLOCKED + elif any(status is BenchmarkStatus.FAIL for status in required): + sector_status[sector] = BenchmarkStatus.FAIL + elif all(status is BenchmarkStatus.PASS for status in required): + sector_status[sector] = BenchmarkStatus.PASS + else: + sector_status[sector] = BenchmarkStatus.BLOCKED + + required_sectors = ( + ForceSector.CORE, + ForceSector.GRAVITY, + ForceSector.EM, + ForceSector.WEAK, + ForceSector.STRONG, + ForceSector.UNIFIED, + ) + if identity_mismatch or any( + sector_status[sector] is BenchmarkStatus.FAIL for sector in required_sectors + ): + unified_status = BenchmarkStatus.FAIL + elif all(sector_status[sector] is BenchmarkStatus.PASS for sector in required_sectors): + unified_status = BenchmarkStatus.PASS + else: + unified_status = BenchmarkStatus.BLOCKED + + return HarnessReport( + harness_version=self.version, + manifest_fingerprint=self.manifest_fingerprint, + results=tuple(adjudicated[spec.benchmark_id] for spec in self.specs), + sector_status=sector_status, + unified_status=unified_status, + policy_findings=tuple(findings), + shared_identity_fingerprint=shared_identity, + ) diff --git a/lfm/viz/__init__.py b/lfm/viz/__init__.py index c7b6389..93d16c2 100644 --- a/lfm/viz/__init__.py +++ b/lfm/viz/__init__.py @@ -26,6 +26,18 @@ from lfm.viz.evolution import plot_energy_components, plot_evolution from lfm.viz.fields import plot_isosurface from lfm.viz.galaxy import galaxy_summary_plot +from lfm.viz.gravity_recovery import ( + plot_energy_history, + plot_profile_comparison, + plot_stability_sweep, +) +from lfm.viz.limit_orbit import ( + animate_limit02_orbit_3d_demo, + animate_limit02_orbit_demo, + combined_limit02_chi_slice, + plot_limit02_orbit_3d_demo, + plot_limit02_orbit_demo, +) from lfm.viz.projection import plot_projection, project_field from lfm.viz.quantum import ( animate_3d_slices, @@ -73,6 +85,14 @@ "plot_isosurface", "plot_power_spectrum", "plot_trajectories", + "combined_limit02_chi_slice", + "plot_limit02_orbit_3d_demo", + "animate_limit02_orbit_3d_demo", + "plot_limit02_orbit_demo", + "animate_limit02_orbit_demo", "plot_sweep", "galaxy_summary_plot", + "plot_energy_history", + "plot_profile_comparison", + "plot_stability_sweep", ] diff --git a/lfm/viz/gravity_recovery.py b/lfm/viz/gravity_recovery.py new file mode 100644 index 0000000..886058c --- /dev/null +++ b/lfm/viz/gravity_recovery.py @@ -0,0 +1,116 @@ +"""Plots for the experiment-only GOV-02 gravity recovery study.""" + +from __future__ import annotations + +from pathlib import Path + +import matplotlib.pyplot as plt +import numpy as np + + +def _save(figure: plt.Figure, path: str | Path) -> Path: + output = Path(path) + output.parent.mkdir(parents=True, exist_ok=True) + figure.savefig(output, dpi=180, bbox_inches="tight") + plt.close(figure) + return output + + +def plot_profile_comparison( + radius: np.ndarray, + profile: np.ndarray, + acceleration_radius: np.ndarray, + acceleration: np.ndarray, + *, + title: str, + path: str | Path, +) -> Path: + """Plot radial substrate displacement and acceleration proxy.""" + + radius = np.asarray(radius, dtype=np.float64) + profile = np.asarray(profile, dtype=np.float64) + acceleration_radius = np.asarray(acceleration_radius, dtype=np.float64) + acceleration = np.asarray(acceleration, dtype=np.float64) + figure, axes = plt.subplots(1, 2, figsize=(10.5, 4.0)) + axes[0].plot(radius, profile, color="#155e75", linewidth=2.0) + axes[0].axhline(0.0, color="#64748b", linewidth=0.8) + axes[0].set_xlabel("radius") + axes[0].set_ylabel("chi - chi0") + axes[0].set_title("Exterior substrate profile") + axes[0].grid(alpha=0.25) + positive = (acceleration_radius > 0.0) & (acceleration > 0.0) + axes[1].loglog( + acceleration_radius[positive], + acceleration[positive], + color="#c2410c", + linewidth=2.0, + ) + axes[1].set_xlabel("radius") + axes[1].set_ylabel("|grad chi|") + axes[1].set_title("Acceleration proxy") + axes[1].grid(alpha=0.25, which="both") + figure.suptitle(title) + figure.tight_layout() + return _save(figure, path) + + +def plot_energy_history( + steps: np.ndarray, + total_energy: np.ndarray, + *, + title: str, + path: str | Path, +) -> Path: + """Plot relative Hamiltonian drift.""" + + steps = np.asarray(steps, dtype=np.float64) + total_energy = np.asarray(total_energy, dtype=np.float64) + scale = max(abs(float(total_energy[0])), 1.0e-30) + drift = (total_energy - total_energy[0]) / scale + figure, axis = plt.subplots(figsize=(7.0, 4.0)) + axis.plot(steps, drift, color="#7c3aed", linewidth=1.8) + axis.axhline(0.0, color="#64748b", linewidth=0.8) + axis.set_xlabel("leapfrog step") + axis.set_ylabel("relative Hamiltonian drift") + axis.set_title(title) + axis.grid(alpha=0.25) + figure.tight_layout() + return _save(figure, path) + + +def plot_stability_sweep( + timesteps: np.ndarray, + maximum_amplitudes: np.ndarray, + stable: np.ndarray, + *, + title: str, + path: str | Path, +) -> Path: + """Plot bounded and unstable points in a timestep sweep.""" + + timesteps = np.asarray(timesteps, dtype=np.float64) + maximum_amplitudes = np.asarray(maximum_amplitudes, dtype=np.float64) + stable = np.asarray(stable, dtype=bool) + figure, axis = plt.subplots(figsize=(7.0, 4.0)) + axis.semilogy( + timesteps[stable], + maximum_amplitudes[stable], + "o", + color="#15803d", + label="bounded", + ) + axis.semilogy( + timesteps[~stable], + maximum_amplitudes[~stable], + "x", + color="#b91c1c", + markersize=8, + label="unstable", + ) + axis.set_xlabel("timestep") + axis.set_ylabel("max |chi - chi0|") + axis.set_title(title) + axis.grid(alpha=0.25, which="both") + axis.legend() + figure.tight_layout() + return _save(figure, path) diff --git a/lfm/viz/limit_orbit.py b/lfm/viz/limit_orbit.py new file mode 100644 index 0000000..8bdffa9 --- /dev/null +++ b/lfm/viz/limit_orbit.py @@ -0,0 +1,925 @@ +"""Demo rendering for macroscopic LIMIT-02 two-body trajectories.""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, Any + +import numpy as np + +from lfm.constants import CHI0 +from lfm.viz._util import _require_matplotlib + +if TYPE_CHECKING: + from lfm.fields.macroscopic import Limit02BodyProfile + + +def _trajectory_arrays( + rows: list[dict[str, Any]], +) -> dict[str, np.ndarray]: + keys = ( + "time", + "heavy_x", + "heavy_y", + "light_x", + "light_y", + "separation", + "bearing_rad", + ) + return {key: np.asarray([float(row[key]) for row in rows], dtype=np.float64) for key in keys} + + +def _translated_profile_slice( + profile: Limit02BodyProfile, + position: tuple[float, float, float], +) -> np.ndarray: + from scipy.ndimage import shift + + z_index = int(round(profile.center[2])) % profile.grid_size + base = np.asarray(profile.chi_delta[:, :, z_index], dtype=np.float64) + offsets = ( + float(position[0]) - profile.center[0], + float(position[1]) - profile.center[1], + ) + return shift( + base, + shift=offsets, + order=1, + mode="wrap", + prefilter=False, + ) + + +def combined_limit02_chi_slice( + heavy: Limit02BodyProfile, + light: Limit02BodyProfile, + heavy_position: tuple[float, float, float], + light_position: tuple[float, float, float], + *, + chi0: float = CHI0, +) -> np.ndarray: + """Return the translated and superposed equatorial LIMIT-02 chi slice.""" + if heavy.grid_size != light.grid_size: + raise ValueError("body profiles must use the same grid") + return ( + float(chi0) + + _translated_profile_slice(heavy, heavy_position) + + _translated_profile_slice(light, light_position) + ) + + +def plot_limit02_orbit_demo( + rows: list[dict[str, Any]], + heavy: Limit02BodyProfile, + light: Limit02BodyProfile, + output_path: str | Path, +) -> Path: + """Render a static orbit, chi substrate, and separation summary.""" + _require_matplotlib() + import matplotlib.pyplot as plt + from matplotlib.patches import Circle + + if len(rows) < 2: + raise ValueError("at least two trajectory rows are required") + arrays = _trajectory_arrays(rows) + output = Path(output_path) + output.parent.mkdir(parents=True, exist_ok=True) + final_heavy = ( + float(rows[-1]["heavy_x"]), + float(rows[-1]["heavy_y"]), + float(rows[-1]["heavy_z"]), + ) + final_light = ( + float(rows[-1]["light_x"]), + float(rows[-1]["light_y"]), + float(rows[-1]["light_z"]), + ) + chi_slice = combined_limit02_chi_slice( + heavy, + light, + final_heavy, + final_light, + ) + angle = float( + np.degrees(np.unwrap(arrays["bearing_rad"])[-1] - np.unwrap(arrays["bearing_rad"])[0]) + ) + + figure = plt.figure(figsize=(13, 7), constrained_layout=True) + grid = figure.add_gridspec(2, 2, width_ratios=(1.15, 1.0)) + orbit_axis = figure.add_subplot(grid[:, 0]) + chi_axis = figure.add_subplot(grid[0, 1]) + separation_axis = figure.add_subplot(grid[1, 1]) + + orbit_axis.plot( + arrays["heavy_x"], + arrays["heavy_y"], + color="#2f7ed8", + linewidth=2.0, + label="Larger sphere", + ) + orbit_axis.plot( + arrays["light_x"], + arrays["light_y"], + color="#f2a93b", + linewidth=1.2, + label="Smaller sphere", + ) + orbit_axis.add_patch( + Circle( + final_heavy[:2], + heavy.radius, + facecolor="#2f7ed8", + edgecolor="#12365d", + alpha=0.75, + ) + ) + orbit_axis.add_patch( + Circle( + final_light[:2], + light.radius, + facecolor="#f2a93b", + edgecolor="#754b0c", + alpha=0.9, + ) + ) + orbit_axis.set_aspect("equal") + orbit_axis.set_xlabel("x (lattice cells)") + orbit_axis.set_ylabel("y (lattice cells)") + orbit_axis.set_title("Barycentric trajectories and true-scale bodies") + orbit_axis.grid(alpha=0.2) + orbit_axis.legend(loc="best") + + image = chi_axis.imshow( + chi_slice.T, + origin="lower", + extent=(0, heavy.grid_size, 0, heavy.grid_size), + cmap="magma", + aspect="equal", + ) + chi_axis.add_patch( + Circle( + final_heavy[:2], + heavy.radius, + facecolor="none", + edgecolor="cyan", + linewidth=1.3, + ) + ) + chi_axis.add_patch( + Circle( + final_light[:2], + light.radius, + facecolor="none", + edgecolor="white", + linewidth=1.0, + ) + ) + chi_axis.set_title("Translated LIMIT-02 chi substrate") + chi_axis.set_xlabel("x (lattice cells)") + chi_axis.set_ylabel("y (lattice cells)") + figure.colorbar(image, ax=chi_axis, label="chi") + + separation_axis.plot( + arrays["time"], + arrays["separation"], + color="#5a8f29", + linewidth=1.5, + ) + separation_axis.axhline( + heavy.radius + light.radius, + color="#a33a3a", + linewidth=1.0, + linestyle="--", + label="Surface contact", + ) + separation_axis.set_xlabel("LFM time") + separation_axis.set_ylabel("Centre separation (cells)") + separation_axis.set_title( + f"Mass ratio {heavy.mass / light.mass:.4f}:1 | net angle {angle:.1f} deg" + ) + separation_axis.grid(alpha=0.2) + separation_axis.legend(loc="best") + + figure.suptitle( + "LFM macroscopic LIMIT-02 two-sphere orbit", + fontsize=15, + ) + figure.savefig(output, dpi=180) + plt.close(figure) + return output + + +def animate_limit02_orbit_demo( + rows: list[dict[str, Any]], + heavy: Limit02BodyProfile, + light: Limit02BodyProfile, + output_path: str | Path, + *, + max_frames: int = 240, + fps: int = 30, +) -> Path: + """Render an orbit animation beside the translated live chi slice.""" + _require_matplotlib() + import matplotlib.animation as animation + import matplotlib.pyplot as plt + from matplotlib.patches import Circle + + if len(rows) < 2: + raise ValueError("at least two trajectory rows are required") + output = Path(output_path) + output.parent.mkdir(parents=True, exist_ok=True) + arrays = _trajectory_arrays(rows) + frame_count = min(max_frames, len(rows)) + frame_indices = np.unique(np.linspace(0, len(rows) - 1, frame_count).astype(int)) + + initial_heavy = ( + float(rows[0]["heavy_x"]), + float(rows[0]["heavy_y"]), + float(rows[0]["heavy_z"]), + ) + initial_light = ( + float(rows[0]["light_x"]), + float(rows[0]["light_y"]), + float(rows[0]["light_z"]), + ) + initial_chi = combined_limit02_chi_slice( + heavy, + light, + initial_heavy, + initial_light, + ) + chi_min = float(CHI0 + np.min(heavy.chi_delta) + np.min(light.chi_delta)) + chi_max = float(CHI0 + np.max(heavy.chi_delta) + np.max(light.chi_delta)) + + figure, (orbit_axis, chi_axis) = plt.subplots( + 1, + 2, + figsize=(12, 5.8), + constrained_layout=True, + ) + orbit_axis.set_aspect("equal") + orbit_axis.set_xlim(18, 110) + orbit_axis.set_ylim(18, 110) + orbit_axis.set_xlabel("x (lattice cells)") + orbit_axis.set_ylabel("y (lattice cells)") + orbit_axis.set_title("Two density spheres") + orbit_axis.grid(alpha=0.2) + (heavy_trail,) = orbit_axis.plot([], [], color="#2f7ed8", linewidth=2.0) + (light_trail,) = orbit_axis.plot([], [], color="#f2a93b", linewidth=1.2) + heavy_circle = Circle( + initial_heavy[:2], + heavy.radius, + facecolor="#2f7ed8", + edgecolor="#12365d", + alpha=0.8, + ) + light_circle = Circle( + initial_light[:2], + light.radius, + facecolor="#f2a93b", + edgecolor="#754b0c", + alpha=0.95, + ) + orbit_axis.add_patch(heavy_circle) + orbit_axis.add_patch(light_circle) + status = orbit_axis.text( + 0.02, + 0.98, + "", + transform=orbit_axis.transAxes, + va="top", + ha="left", + bbox={"facecolor": "white", "alpha": 0.8, "edgecolor": "none"}, + ) + + image = chi_axis.imshow( + initial_chi.T, + origin="lower", + extent=(0, heavy.grid_size, 0, heavy.grid_size), + cmap="magma", + vmin=chi_min, + vmax=chi_max, + aspect="equal", + ) + chi_axis.set_xlim(18, 110) + chi_axis.set_ylim(18, 110) + chi_axis.set_xlabel("x (lattice cells)") + chi_axis.set_ylabel("y (lattice cells)") + chi_axis.set_title("Live LIMIT-02 chi substrate") + chi_heavy_circle = Circle( + initial_heavy[:2], + heavy.radius, + facecolor="none", + edgecolor="cyan", + linewidth=1.3, + ) + chi_light_circle = Circle( + initial_light[:2], + light.radius, + facecolor="none", + edgecolor="white", + linewidth=1.0, + ) + chi_axis.add_patch(chi_heavy_circle) + chi_axis.add_patch(chi_light_circle) + figure.colorbar(image, ax=chi_axis, label="chi") + figure.suptitle(f"LFM LIMIT-02 orbit | mass ratio {heavy.mass / light.mass:.4f}:1") + + unwrapped = np.unwrap(arrays["bearing_rad"]) + + def update(frame_number: int): + index = int(frame_indices[frame_number]) + heavy_position = ( + float(rows[index]["heavy_x"]), + float(rows[index]["heavy_y"]), + float(rows[index]["heavy_z"]), + ) + light_position = ( + float(rows[index]["light_x"]), + float(rows[index]["light_y"]), + float(rows[index]["light_z"]), + ) + heavy_trail.set_data( + arrays["heavy_x"][: index + 1], + arrays["heavy_y"][: index + 1], + ) + light_trail.set_data( + arrays["light_x"][: index + 1], + arrays["light_y"][: index + 1], + ) + heavy_circle.center = heavy_position[:2] + light_circle.center = light_position[:2] + chi_heavy_circle.center = heavy_position[:2] + chi_light_circle.center = light_position[:2] + image.set_data( + combined_limit02_chi_slice( + heavy, + light, + heavy_position, + light_position, + ).T + ) + angle = float(np.degrees(unwrapped[index] - unwrapped[0])) + status.set_text( + f"time {arrays['time'][index]:.1f}\n" + f"separation {arrays['separation'][index]:.2f}\n" + f"angle {angle:.1f} deg" + ) + return ( + heavy_trail, + light_trail, + heavy_circle, + light_circle, + chi_heavy_circle, + chi_light_circle, + image, + status, + ) + + movie = animation.FuncAnimation( + figure, + update, + frames=len(frame_indices), + interval=1000.0 / fps, + blit=False, + ) + if output.suffix.lower() == ".mp4" and animation.writers.is_available("ffmpeg"): + movie.save( + output, + writer=animation.FFMpegWriter( + fps=fps, + bitrate=2200, + metadata={"title": "LFM LIMIT-02 two-sphere orbit"}, + ), + dpi=120, + ) + else: + output = output.with_suffix(".gif") + movie.save(output, writer=animation.PillowWriter(fps=fps), dpi=120) + plt.close(figure) + return output + + +def _limit02_surface_data( + chi_slice: np.ndarray, + *, + lower: int = 20, + upper: int = 108, + stride: int = 4, + vertical_scale: float = 2.5, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + """Return a display-sampled lattice surface and its unscaled chi values.""" + coordinates = np.arange(lower, upper + 1, stride, dtype=np.intp) + x_grid, y_grid = np.meshgrid(coordinates, coordinates, indexing="xy") + chi_values = chi_slice[np.ix_(coordinates, coordinates)].T + z_grid = (chi_values - CHI0) * float(vertical_scale) + return x_grid, y_grid, z_grid, chi_values + + +def _sphere_mesh( + center: tuple[float, float, float], + radius: float, + *, + longitude_samples: int = 24, + latitude_samples: int = 14, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + longitude = np.linspace(0.0, 2.0 * np.pi, longitude_samples) + latitude = np.linspace(0.0, np.pi, latitude_samples) + x_mesh = center[0] + radius * np.outer( + np.cos(longitude), + np.sin(latitude), + ) + y_mesh = center[1] + radius * np.outer( + np.sin(longitude), + np.sin(latitude), + ) + z_mesh = center[2] + radius * np.outer( + np.ones_like(longitude), + np.cos(latitude), + ) + return x_mesh, y_mesh, z_mesh + + +def _configure_limit02_3d_axis(axis) -> None: + axis.set_facecolor("#071019") + axis.set_xlim(20, 108) + axis.set_ylim(20, 108) + axis.set_zlim(-7, 15) + axis.set_box_aspect((88, 88, 22)) + axis.view_init(elev=31, azim=-57) + axis.set_xlabel("lattice x", color="#a6b6c8", labelpad=8) + axis.set_ylabel("lattice y", color="#a6b6c8", labelpad=8) + axis.set_zlabel("") + axis.tick_params(colors="#7890a4", labelsize=7, pad=0) + for coordinate_axis in (axis.xaxis, axis.yaxis, axis.zaxis): + coordinate_axis.pane.fill = False + coordinate_axis.pane.set_edgecolor("#25384a") + coordinate_axis._axinfo["grid"]["color"] = "#183047" + + +def _draw_limit02_3d_scene( + axis, + rows: list[dict[str, Any]], + arrays: dict[str, np.ndarray], + index: int, + heavy: Limit02BodyProfile, + light: Limit02BodyProfile, + *, + color_map, + color_norm, + audit_status: str, + audit_residual: float, + vertical_scale: float = 2.5, +) -> None: + axis.clear() + _configure_limit02_3d_axis(axis) + row = rows[index] + heavy_position = ( + float(row["heavy_x"]), + float(row["heavy_y"]), + float(row["heavy_z"]), + ) + light_position = ( + float(row["light_x"]), + float(row["light_y"]), + float(row["light_z"]), + ) + chi_slice = combined_limit02_chi_slice( + heavy, + light, + heavy_position, + light_position, + ) + x_grid, y_grid, z_grid, chi_values = _limit02_surface_data( + chi_slice, + vertical_scale=vertical_scale, + ) + chi_depth = np.maximum(CHI0 - chi_values, 0.0) + face_colors = color_map(color_norm(chi_depth)) + axis.plot_surface( + x_grid, + y_grid, + z_grid, + facecolors=face_colors, + rstride=1, + cstride=1, + linewidth=0, + antialiased=True, + shade=False, + alpha=0.94, + zorder=1, + ) + axis.plot_wireframe( + x_grid, + y_grid, + z_grid + 0.04, + rstride=1, + cstride=1, + color="#d8e5ef", + linewidth=0.35, + alpha=0.52, + zorder=2, + ) + + def local_surface_height(position: tuple[float, float, float]) -> float: + x_index = int(round(position[0])) % chi_slice.shape[0] + y_index = int(round(position[1])) % chi_slice.shape[1] + return float(vertical_scale * (chi_slice[x_index, y_index] - CHI0)) + + heavy_surface = local_surface_height(heavy_position) + light_surface = local_surface_height(light_position) + heavy_center = ( + heavy_position[0], + heavy_position[1], + heavy_surface + heavy.radius, + ) + light_center = ( + light_position[0], + light_position[1], + light_surface + light.radius, + ) + heavy_mesh = _sphere_mesh(heavy_center, heavy.radius) + light_mesh = _sphere_mesh(light_center, light.radius) + axis.plot_surface( + *heavy_mesh, + color="#38bdf8", + edgecolor="#d5f4ff", + linewidth=0.22, + antialiased=True, + shade=True, + alpha=0.98, + zorder=6, + ) + axis.plot_surface( + *light_mesh, + color="#fbbf24", + edgecolor="#fff1a8", + linewidth=0.20, + antialiased=True, + shade=True, + alpha=1.0, + zorder=7, + ) + + heavy_acceleration = np.asarray( + [ + float(row["heavy_ax"]), + float(row["heavy_ay"]), + float(row["heavy_az"]), + ], + dtype=np.float64, + ) + light_acceleration = np.asarray( + [ + float(row["light_ax"]), + float(row["light_ay"]), + float(row["light_az"]), + ], + dtype=np.float64, + ) + for center, acceleration, arrow_color in ( + (heavy_center, heavy_acceleration, "#d5f4ff"), + (light_center, light_acceleration, "#fff1a8"), + ): + magnitude = float(np.linalg.norm(acceleration)) + if magnitude > 0.0: + direction = acceleration / magnitude + axis.quiver( + center[0], + center[1], + min(center[2] + 1.0, 13.5), + direction[0], + direction[1], + 0.0, + length=7.0, + normalize=True, + color=arrow_color, + linewidth=1.8, + arrow_length_ratio=0.28, + zorder=10, + ) + + trail_height = 13.4 + axis.plot( + arrays["heavy_x"][: index + 1], + arrays["heavy_y"][: index + 1], + np.full(index + 1, trail_height), + color="#38bdf8", + linewidth=1.8, + alpha=0.9, + zorder=4, + ) + axis.plot( + arrays["light_x"][: index + 1], + arrays["light_y"][: index + 1], + np.full(index + 1, trail_height), + color="#fbbf24", + linewidth=1.2, + alpha=0.82, + zorder=5, + ) + axis.text( + heavy_center[0], + heavy_center[1], + min(14.5, heavy_center[2] + heavy.radius + 0.5), + "81.3017 mass", + color="#d5f4ff", + fontsize=8, + ha="center", + zorder=8, + ) + axis.text( + light_center[0], + light_center[1], + min(14.5, light_center[2] + light.radius + 0.7), + "1 mass", + color="#fff1a8", + fontsize=8, + ha="center", + zorder=9, + ) + + unwrapped = np.unwrap(arrays["bearing_rad"]) + angle = float(np.degrees(unwrapped[index] - unwrapped[0])) + axis.text2D( + 0.52, + 0.97, + f"RED-TEAM {audit_status} | no GM/r^2 | no Kepler | no Einstein solver", + transform=axis.transAxes, + color="#90f5c1" if audit_status == "PASS" else "#fca5a5", + fontsize=8, + va="top", + ha="center", + bbox={ + "facecolor": "#0b1722", + "edgecolor": "#315268", + "alpha": 0.88, + "boxstyle": "round,pad=0.35", + }, + ) + axis.text2D( + 0.02, + 0.925, + ( + f"time {arrays['time'][index]:.0f} | " + f"separation {arrays['separation'][index]:.2f} | " + f"angle {angle:.1f} deg | " + f"arrows = -(c^2/chi0) grad19(delta_chi)" + ), + transform=axis.transAxes, + color="#edf6ff", + fontsize=9, + va="top", + ha="left", + ) + heavy_acceleration_norm = float(np.linalg.norm(heavy_acceleration)) + light_acceleration_norm = float(np.linalg.norm(light_acceleration)) + maximum_depth = float(np.max(chi_depth)) + pipeline_boxes = ( + ( + 0.01, + f"SOURCE rho\nM_H:M_L = {heavy.mass / light.mass:.4f}:1", + "#38bdf8", + ), + ( + 0.255, + f"GOV-02 -> LIMIT-02\nD19 dchi =\nk(rho-)\ndepth max = {maximum_depth:.3f}", + "#c084fc", + ), + ( + 0.50, + "GOV-01 -> WKB\n" + "a = -(c^2/chi0)\n" + "grad19 dchi\n" + f"|aH|={heavy_acceleration_norm:.2e}\n" + f"|aL|={light_acceleration_norm:.2e}", + "#34d399", + ), + ( + 0.745, + "CENTER UPDATE\nvelocity-Verlet, dt=0.25\ntrajectory not prescribed", + "#fbbf24", + ), + ) + for x_position, text, edge_color in pipeline_boxes: + axis.text2D( + x_position, + 0.015, + text, + transform=axis.transAxes, + color="#edf6ff", + fontsize=6.8, + va="bottom", + ha="left", + family="monospace", + bbox={ + "facecolor": "#0b1722", + "edgecolor": edge_color, + "alpha": 0.90, + "boxstyle": "round,pad=0.35", + }, + ) + for x_position in (0.235, 0.48, 0.725): + axis.text2D( + x_position, + 0.065, + "->", + transform=axis.transAxes, + color="#91a9bc", + fontsize=10, + va="center", + ha="center", + ) + axis.text2D( + 0.02, + 0.885, + (f"128^3 grid | 19-point stencil | LIMIT-02 residual <= {audit_residual:.2e}"), + transform=axis.transAxes, + color="#91a9bc", + fontsize=7.5, + va="top", + ha="left", + ) + axis.set_title( + "LFM coupled-equation substrate orbit", + color="#edf6ff", + fontsize=15, + pad=12, + ) + + +def plot_limit02_orbit_3d_demo( + rows: list[dict[str, Any]], + heavy: Limit02BodyProfile, + light: Limit02BodyProfile, + output_path: str | Path, + *, + frame_index: int = -1, + audit_status: str = "NOT RUN", + audit_residual: float = float("nan"), +) -> Path: + """Render a single 3-D lattice, color-coded chi surface, and two bodies.""" + _require_matplotlib() + import matplotlib.pyplot as plt + from matplotlib.cm import ScalarMappable + from matplotlib.colors import PowerNorm + + if len(rows) < 2: + raise ValueError("at least two trajectory rows are required") + output = Path(output_path) + output.parent.mkdir(parents=True, exist_ok=True) + arrays = _trajectory_arrays(rows) + index = frame_index % len(rows) + depth_max = float(-np.min(heavy.chi_delta) - np.min(light.chi_delta)) + color_norm = PowerNorm(gamma=0.33, vmin=0.0, vmax=depth_max) + color_map = plt.get_cmap("magma") + + figure = plt.figure(figsize=(12, 7.5), facecolor="#071019") + axis = figure.add_subplot( + 111, + projection="3d", + computed_zorder=False, + ) + _draw_limit02_3d_scene( + axis, + rows, + arrays, + index, + heavy, + light, + color_map=color_map, + color_norm=color_norm, + audit_status=audit_status, + audit_residual=audit_residual, + ) + scalar_map = ScalarMappable(norm=color_norm, cmap=color_map) + scalar_map.set_array([]) + color_bar = figure.colorbar( + scalar_map, + ax=axis, + shrink=0.62, + pad=0.015, + aspect=24, + ) + color_bar.set_label("chi0 - chi (substrate depth)", color="#c8d8e8") + color_bar.ax.tick_params(colors="#91a9bc", labelsize=8) + figure.text( + 0.5, + 0.025, + ( + "Color = chi0 - chi (PowerNorm gamma=0.33) | mesh = lattice | " + "height = 2.5x display amplification of chi - chi0" + ), + color="#91a9bc", + fontsize=9, + ha="center", + ) + figure.savefig(output, dpi=150, facecolor=figure.get_facecolor()) + plt.close(figure) + return output + + +def animate_limit02_orbit_3d_demo( + rows: list[dict[str, Any]], + heavy: Limit02BodyProfile, + light: Limit02BodyProfile, + output_path: str | Path, + *, + max_frames: int = 180, + fps: int = 18, + audit_status: str = "NOT RUN", + audit_residual: float = float("nan"), +) -> Path: + """Animate one 3-D lattice scene with color-coded live chi geometry.""" + _require_matplotlib() + import matplotlib.animation as animation + import matplotlib.pyplot as plt + from matplotlib.cm import ScalarMappable + from matplotlib.colors import PowerNorm + + if len(rows) < 2: + raise ValueError("at least two trajectory rows are required") + output = Path(output_path) + output.parent.mkdir(parents=True, exist_ok=True) + arrays = _trajectory_arrays(rows) + frame_count = min(max_frames, len(rows)) + frame_indices = np.unique(np.linspace(0, len(rows) - 1, frame_count).astype(int)) + depth_max = float(-np.min(heavy.chi_delta) - np.min(light.chi_delta)) + color_norm = PowerNorm(gamma=0.33, vmin=0.0, vmax=depth_max) + color_map = plt.get_cmap("magma") + + figure = plt.figure(figsize=(12, 7.5), facecolor="#071019") + axis = figure.add_subplot( + 111, + projection="3d", + computed_zorder=False, + ) + scalar_map = ScalarMappable(norm=color_norm, cmap=color_map) + scalar_map.set_array([]) + color_bar = figure.colorbar( + scalar_map, + ax=axis, + shrink=0.62, + pad=0.015, + aspect=24, + ) + color_bar.set_label("chi0 - chi (substrate depth)", color="#c8d8e8") + color_bar.ax.tick_params(colors="#91a9bc", labelsize=8) + figure.text( + 0.5, + 0.025, + ( + "Color = chi0 - chi (PowerNorm gamma=0.33) | mesh = lattice | " + "height = 2.5x display amplification of chi - chi0" + ), + color="#91a9bc", + fontsize=9, + ha="center", + ) + + def update(frame_number: int): + _draw_limit02_3d_scene( + axis, + rows, + arrays, + int(frame_indices[frame_number]), + heavy, + light, + color_map=color_map, + color_norm=color_norm, + audit_status=audit_status, + audit_residual=audit_residual, + ) + return () + + movie = animation.FuncAnimation( + figure, + update, + frames=len(frame_indices), + interval=1000.0 / fps, + blit=False, + ) + if output.suffix.lower() == ".mp4" and animation.writers.is_available("ffmpeg"): + movie.save( + output, + writer=animation.FFMpegWriter( + fps=fps, + bitrate=3200, + metadata={"title": "LFM LIMIT-02 3-D lattice orbit"}, + ), + dpi=120, + ) + else: + output = output.with_suffix(".gif") + movie.save(output, writer=animation.PillowWriter(fps=fps), dpi=120) + plt.close(figure) + return output + + +__all__ = [ + "animate_limit02_orbit_3d_demo", + "animate_limit02_orbit_demo", + "combined_limit02_chi_slice", + "plot_limit02_orbit_3d_demo", + "plot_limit02_orbit_demo", +] diff --git a/lfm/viz/poincare.py b/lfm/viz/poincare.py new file mode 100644 index 0000000..11e73b3 --- /dev/null +++ b/lfm/viz/poincare.py @@ -0,0 +1,153 @@ +"""Publication plots for the Poincare-emergence audit.""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING + +import numpy as np + +from lfm.viz._util import _require_matplotlib + +if TYPE_CHECKING: + from collections.abc import Iterable + + +def _finish(figure, output: str | Path | None): + if output is not None: + path = Path(output) + path.parent.mkdir(parents=True, exist_ok=True) + figure.savefig(path, dpi=220, bbox_inches="tight") + return figure + + +def plot_symmetry_convergence( + rows: Iterable[dict], + *, + output: str | Path | None = None, +): + """Plot dispersion, rotational, boost, and velocity-addition convergence.""" + + _require_matplotlib() + import matplotlib.pyplot as plt + + records = list(rows) + metrics = [ + ("dispersion_error_max", "Dispersion defect"), + ("directional_anisotropy", "Rotation defect"), + ("boosted_mass_shell_residual_max", "Boost mass-shell defect"), + ("velocity_addition_error_max_over_c", "Velocity-addition defect"), + ] + figure, axes = plt.subplots(2, 2, figsize=(8.2, 6.3), sharex=True) + colors = {"19": "#1f5a99", "27": "#c25a2c"} + for axis, (metric, label) in zip(axes.flat, metrics, strict=True): + for stencil in ("19", "27"): + subset = sorted( + (row for row in records if row["stencil"] == stencil), + key=lambda row: row["spacing"], + ) + spacing = np.asarray([row["spacing"] for row in subset]) + values = np.asarray([max(row[metric], 1.0e-18) for row in subset]) + axis.loglog( + spacing, + values, + "o-", + color=colors[stencil], + label=f"{stencil}-point", + linewidth=1.6, + markersize=4, + ) + axis.set_title(label) + axis.grid(True, which="both", alpha=0.25) + axis.set_ylabel("dimensionless error") + for axis in axes[-1, :]: + axis.set_xlabel("lattice spacing h (fixed physical k)") + axes[0, 0].legend(frameon=False) + figure.suptitle("Recovery of continuum Poincare kinematics") + figure.tight_layout() + return _finish(figure, output) + + +def plot_directional_dispersion( + rows: Iterable[dict], + *, + output: str | Path | None = None, +): + """Plot group-speed defects along axis, face, and body directions.""" + + _require_matplotlib() + import matplotlib.pyplot as plt + + records = list(rows) + figure, axes = plt.subplots(1, 2, figsize=(8.2, 3.5), sharey=True) + colors = {"axis": "#1f5a99", "face": "#4f9c73", "body": "#c25a2c"} + for axis, stencil in zip(axes, ("19", "27"), strict=True): + for direction in ("axis", "face", "body"): + subset = sorted( + ( + row + for row in records + if row["stencil"] == stencil and row["direction"] == direction + ), + key=lambda row: row["kh"], + ) + axis.plot( + [row["kh"] for row in subset], + [row["group_speed_over_c"] - 1.0 for row in subset], + "o-", + color=colors[direction], + label=direction, + linewidth=1.5, + markersize=3.5, + ) + axis.axhline(0.0, color="black", linewidth=0.7) + axis.set_title(f"{stencil}-point stencil") + axis.set_xlabel("dimensionless wavenumber kh") + axis.grid(True, alpha=0.25) + axes[0].set_ylabel("radial group speed / c - 1") + axes[0].legend(frameon=False) + figure.suptitle("Finite-spacing light-cone deformation") + figure.tight_layout() + return _finish(figure, output) + + +def plot_packet_convergence( + rows: Iterable[dict], + *, + output: str | Path | None = None, +): + """Plot Gaussian-packet propagation error over grid refinements.""" + + _require_matplotlib() + import matplotlib.pyplot as plt + + records = list(rows) + figure, axes = plt.subplots(1, 2, figsize=(8.2, 3.6), sharey=True) + colors = {"axis": "#1f5a99", "face": "#4f9c73", "body": "#c25a2c"} + for axis, stencil in zip(axes, ("19", "27"), strict=True): + for direction in ("axis", "face", "body"): + subset = sorted( + ( + row + for row in records + if row["stencil"] == stencil and row["direction"] == direction + ), + key=lambda row: row["spacing"], + ) + axis.loglog( + [row["spacing"] for row in subset], + [max(row["radial_error"], 1.0e-16) for row in subset], + "o-", + color=colors[direction], + label=direction, + linewidth=1.5, + markersize=4, + ) + axis.set_title(f"{stencil}-point stencil") + axis.set_xlabel("lattice spacing h") + axis.grid(True, which="both", alpha=0.25) + axes[0].set_ylabel("packet centroid-speed error") + axes[0].legend(frameon=False) + figure.suptitle("Three-dimensional Gaussian-packet convergence") + figure.tight_layout() + return _finish(figure, output) diff --git a/pyproject.toml b/pyproject.toml index 69856fb..f96d8c4 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "lfm-physics" -version = "1.4.1" +version = "1.4.5" description = "Lattice Field Medium physics simulation library" readme = "README.md" license = "MIT" diff --git a/tests/test_analysis.py b/tests/test_analysis.py index 1eed90f..9bd4692 100644 --- a/tests/test_analysis.py +++ b/tests/test_analysis.py @@ -3,6 +3,7 @@ import numpy as np import pytest +import lfm from lfm.analysis import ( chi_statistics, compute_metrics, @@ -22,6 +23,38 @@ N = 16 # small grid for fast tests +def test_localized_weighted_centroid_tracks_extended_peak(): + field = np.zeros((24, 24, 24), dtype=np.float64) + x, y, z = np.meshgrid( + np.arange(24), + np.arange(24), + np.arange(24), + indexing="ij", + ) + field += 4.0 * np.exp(-((x - 8.25) ** 2 + (y - 11.50) ** 2 + (z - 13.75) ** 2) / (2.0 * 1.5**2)) + field += 30.0 * np.exp(-((x - 19.0) ** 2 + (y - 12.0) ** 2 + (z - 12.0) ** 2) / (2.0 * 2.0**2)) + + result = lfm.localized_weighted_centroid( + field, + center=(8.0, 12.0, 14.0), + radius=5.0, + ) + + assert result["valid"] + assert float(result["x"]) == pytest.approx(8.25, abs=0.05) + assert float(result["y"]) == pytest.approx(11.50, abs=0.05) + assert float(result["z"]) == pytest.approx(13.75, abs=0.05) + assert float(result["weight"]) > 0.0 + assert float(result["rms_radius"]) > 0.0 + + +def test_localized_weighted_centroid_rejects_bad_inputs(): + with pytest.raises(ValueError, match="3-D"): + lfm.localized_weighted_centroid(np.ones((4, 4)), (1, 1, 1), 2.0) + with pytest.raises(ValueError, match="positive"): + lfm.localized_weighted_centroid(np.ones((4, 4, 4)), (1, 1, 1), 0.0) + + def _make_fields(n=N, amplitude=1.0, with_imag=False): """Create simple test fields: Gaussian blob in uniform chi.""" x = np.arange(n, dtype=np.float32) diff --git a/tests/test_cartesian_noether_solver.py b/tests/test_cartesian_noether_solver.py new file mode 100644 index 0000000..4819e37 --- /dev/null +++ b/tests/test_cartesian_noether_solver.py @@ -0,0 +1,75 @@ +from __future__ import annotations + +import numpy as np + +from lfm.constants import CHI0 +from lfm.particles.noether import ( + cartesian_fixed_charge_energy_and_gradient, + solve_cartesian_noether_soliton, +) + + +def test_cartesian_fixed_charge_gradient_matches_directional_difference() -> None: + grid_size = 8 + dx = 0.25 + axis = (np.arange(grid_size, dtype=np.float64) - 0.5 * (grid_size - 1)) * dx + x, y, z = np.meshgrid(axis, axis, axis, indexing="ij") + radius_sq = x * x + y * y + z * z + phi = 1.7 * np.exp(-radius_sq / 0.45) + chi = CHI0 - 0.8 * np.exp(-radius_sq / 0.65) + variables = np.concatenate((phi.ravel(), chi.ravel())) + rng = np.random.default_rng(19) + direction = rng.normal(size=variables.size) + direction /= np.linalg.norm(direction) + + ledger, gradient = cartesian_fixed_charge_energy_and_gradient( + variables, + target_charge=150.0, + grid_size=grid_size, + dx=dx, + ) + epsilon = 1.0e-5 + plus, _ = cartesian_fixed_charge_energy_and_gradient( + variables + epsilon * direction, + target_charge=150.0, + grid_size=grid_size, + dx=dx, + ) + minus, _ = cartesian_fixed_charge_energy_and_gradient( + variables - epsilon * direction, + target_charge=150.0, + grid_size=grid_size, + dx=dx, + ) + finite_difference = (plus.total - minus.total) / (2.0 * epsilon) + analytic = float(np.dot(gradient, direction)) + assert np.isfinite(ledger.total) + assert np.isclose(finite_difference, analytic, rtol=2.0e-5, atol=2.0e-5) + + +def test_cartesian_solver_does_not_raise_fixed_charge_energy() -> None: + grid_size = 8 + dx = 0.25 + axis = (np.arange(grid_size, dtype=np.float64) - 0.5 * (grid_size - 1)) * dx + x, y, z = np.meshgrid(axis, axis, axis, indexing="ij") + radius_sq = x * x + y * y + z * z + phi = 2.0 * np.exp(-radius_sq / 0.5) + chi = CHI0 - 1.0 * np.exp(-radius_sq / 0.7) + initial, _ = cartesian_fixed_charge_energy_and_gradient( + np.concatenate((phi.ravel(), chi.ravel())), + target_charge=200.0, + grid_size=grid_size, + dx=dx, + ) + solution = solve_cartesian_noether_soliton( + initial_phi=phi, + initial_chi=chi, + target_charge=200.0, + dx=dx, + max_iterations=8, + history=3, + ) + assert np.all(np.isfinite(solution.phi)) + assert np.all(np.isfinite(solution.chi)) + assert np.isclose(solution.charge, 200.0) + assert solution.energy.total <= initial.total * (1.0 + 1.0e-10) diff --git a/tests/test_charge_coupling.py b/tests/test_charge_coupling.py new file mode 100644 index 0000000..8331b43 --- /dev/null +++ b/tests/test_charge_coupling.py @@ -0,0 +1,139 @@ +"""Tests for the experimental same-action charge-coupling module.""" + +from __future__ import annotations + +import numpy as np + +from lfm.analysis.energy_current import ( + BareLFMParameters, + BareLFMState, + bare_hamilton_rates, +) +from lfm.experiment.charge_coupling import ( + ChargeCouplingParameters, + canonical_charge_density, + charge_coupled_hamiltonian, + charge_coupled_rates, + step_charge_coupled_lfm, +) + + +def sample_state(seed: int = 7) -> BareLFMState: + rng = np.random.default_rng(seed) + shape = (8, 8, 8) + wave = 0.01 * rng.normal(size=(6,) + shape) + wave_p = 0.01 * rng.normal(size=(6,) + shape) + chi = 19.0 + 0.01 * rng.normal(size=shape) + chi_p = 0.01 * rng.normal(size=shape) + return BareLFMState(wave, wave_p, chi, chi_p) + + +def parameters(coupling: float) -> ChargeCouplingParameters: + return ChargeCouplingParameters( + bare=BareLFMParameters( + chi_potential="flat_octic", + spacing=0.8, + ), + coupling=coupling, + midpoint_tolerance=1.0e-12, + midpoint_max_iterations=30, + ) + + +def test_zero_coupling_rates_equal_bare_rates() -> None: + state = sample_state() + candidate = charge_coupled_rates(state, parameters(0.0)) + bare = bare_hamilton_rates( + state.wave, + state.wave_momentum, + state.chi, + state.chi_momentum, + parameters(0.0).bare, + ) + assert np.array_equal(candidate.wave, bare.wave) + assert np.array_equal(candidate.wave_momentum, bare.wave_momentum) + assert np.array_equal(candidate.chi, bare.chi) + assert np.array_equal(candidate.chi_momentum, bare.chi_momentum) + + +def test_global_charge_rate_is_zero() -> None: + state = sample_state() + rates = charge_coupled_rates(state, parameters(0.1)) + density_rate = np.zeros(state.chi.shape, dtype=np.float64) + for component in range(0, state.wave.shape[0], 2): + u = state.wave[component] + v = state.wave[component + 1] + p_u = state.wave_momentum[component] + p_v = state.wave_momentum[component + 1] + u_rate = rates.wave[component] + v_rate = rates.wave[component + 1] + p_u_rate = rates.wave_momentum[component] + p_v_rate = rates.wave_momentum[component + 1] + density_rate += u_rate * p_v + u * p_v_rate - v_rate * p_u - v * p_u_rate + assert abs(float(np.sum(density_rate))) < 1.0e-12 + + +def test_signed_coupling_reverses_interaction_rates() -> None: + state = sample_state() + bare = charge_coupled_rates(state, parameters(0.0)) + positive = charge_coupled_rates(state, parameters(0.1)) + negative = charge_coupled_rates(state, parameters(-0.1)) + for zero_rate, positive_rate, negative_rate in zip( + ( + bare.wave, + bare.wave_momentum, + bare.chi, + bare.chi_momentum, + ), + ( + positive.wave, + positive.wave_momentum, + positive.chi, + positive.chi_momentum, + ), + ( + negative.wave, + negative.wave_momentum, + negative.chi, + negative.chi_momentum, + ), + strict=False, + ): + assert np.max(np.abs((positive_rate - zero_rate) + (negative_rate - zero_rate))) < 1.0e-14 + + +def test_implicit_midpoint_is_time_reversible() -> None: + state = sample_state() + selected = parameters(0.1) + forward = step_charge_coupled_lfm(state, 2.0e-4, selected) + backward = step_charge_coupled_lfm(forward.state, -2.0e-4, selected) + for recovered, expected in zip( + ( + backward.state.wave, + backward.state.wave_momentum, + backward.state.chi, + backward.state.chi_momentum, + ), + ( + state.wave, + state.wave_momentum, + state.chi, + state.chi_momentum, + ), + strict=False, + ): + assert np.max(np.abs(recovered - expected)) < 1.0e-11 + + +def test_hamiltonian_and_charge_remain_finite() -> None: + state = sample_state() + selected = parameters(0.1) + initial_energy = charge_coupled_hamiltonian(state, selected) + initial_charge = float(np.sum(canonical_charge_density(state))) + for _index in range(10): + state = step_charge_coupled_lfm(state, 2.0e-4, selected).state + final_energy = charge_coupled_hamiltonian(state, selected) + final_charge = float(np.sum(canonical_charge_density(state))) + assert np.isfinite(final_energy) + assert abs(final_energy - initial_energy) / abs(initial_energy) < 1.0e-9 + assert abs(final_charge - initial_charge) < 1.0e-12 diff --git a/tests/test_charge_current.py b/tests/test_charge_current.py new file mode 100644 index 0000000..45d074d --- /dev/null +++ b/tests/test_charge_current.py @@ -0,0 +1,67 @@ +"""Exact lattice U(1) charge-current tests.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from lfm.analysis.phase import ( + bare_charge_continuity_residual, + canonical_charge_density, + oriented_charge_currents, +) + + +@pytest.mark.parametrize("stencil", ("19", "27")) +def test_exact_charge_continuity(stencil: str) -> None: + rng = np.random.default_rng(20260724) + shape = (7, 7, 7) + fields = [rng.normal(size=shape) for _ in range(5)] + residual = bare_charge_continuity_residual( + *fields, + wave_speed=0.73, + stencil=stencil, + ) + assert float(np.max(np.abs(residual))) < 1.0e-14 + + +@pytest.mark.parametrize("stencil", ("19", "27")) +def test_oriented_link_current_is_antisymmetric(stencil: str) -> None: + rng = np.random.default_rng(20260724) + real = rng.normal(size=(6, 6, 6)) + imag = rng.normal(size=(6, 6, 6)) + currents = oriented_charge_currents(real, imag, stencil=stencil) + for offset, current in currents.items(): + reverse = tuple(-value for value in offset) + transported_reverse = np.roll( + currents[reverse], + shift=offset, + axis=(0, 1, 2), + ) + np.testing.assert_allclose(current, -transported_reverse, atol=1.0e-15) + + +def test_global_phase_rotation_preserves_charge() -> None: + rng = np.random.default_rng(20260724) + fields = [rng.normal(size=(5, 5, 5)) for _ in range(4)] + real, imag, momentum_real, momentum_imag = fields + angle = 0.731 + cosine = np.cos(angle) + sine = np.sin(angle) + real_rotated = cosine * real - sine * imag + imag_rotated = sine * real + cosine * imag + momentum_real_rotated = cosine * momentum_real - sine * momentum_imag + momentum_imag_rotated = sine * momentum_real + cosine * momentum_imag + expected = canonical_charge_density( + real, + imag, + momentum_real, + momentum_imag, + ) + actual = canonical_charge_density( + real_rotated, + imag_rotated, + momentum_real_rotated, + momentum_imag_rotated, + ) + np.testing.assert_allclose(actual, expected, atol=2.0e-15) diff --git a/tests/test_clock_link.py b/tests/test_clock_link.py new file mode 100644 index 0000000..8ac1637 --- /dev/null +++ b/tests/test_clock_link.py @@ -0,0 +1,61 @@ +"""Tests for the unpromoted LFM clock-link candidate diagnostics.""" + +from __future__ import annotations + +import numpy as np + +from lfm.analysis.clock_link import ( + ClockLinkParameters, + clock_link_frequency_sq, + clock_link_green_residue, + clock_link_static_response, + matter_clock_sensitivity, + matter_frequency_sq, + solve_static_clock_link, + static_clock_link_residual, +) +from lfm.constants import CHI0, KAPPA + + +def test_default_inertia_reuses_substrate_normalization() -> None: + parameters = ClockLinkParameters() + assert parameters.inertia == CHI0 / KAPPA + assert clock_link_green_residue(parameters) == -KAPPA / CHI0 + + +def test_clock_link_branch_is_gapless() -> None: + stiffness = np.asarray([0.0, 0.1, 1.0]) + frequency_sq = clock_link_frequency_sq(stiffness) + assert frequency_sq[0] == 0.0 + assert np.all(frequency_sq[1:] > 0.0) + + +def test_static_response_has_nonzero_one_over_k_residue() -> None: + stiffness = np.asarray([1.0e-2, 1.0e-4, 1.0e-6]) + response = clock_link_static_response(stiffness) + expected = clock_link_green_residue() + np.testing.assert_allclose(stiffness * response, expected) + + +def test_matter_frequency_reads_clock_factor() -> None: + base = matter_frequency_sq(0.2, CHI0, 0.0) + shifted = matter_frequency_sq(0.2, CHI0, 0.1) + assert shifted > base + np.testing.assert_allclose( + matter_clock_sensitivity(0.2, CHI0), + 2.0 * base, + ) + + +def test_periodic_static_solver_closes_for_both_stencils() -> None: + source = np.zeros((20, 20, 20), dtype=np.float64) + source[10, 10, 10] = 1.0 + for stencil in ("19", "27"): + field = solve_static_clock_link(source, stencil=stencil) + residual = static_clock_link_residual( + field, + source, + stencil=stencil, + ) + assert float(np.max(np.abs(residual))) < 1.0e-12 + assert abs(float(np.mean(field))) < 1.0e-15 diff --git a/tests/test_clock_link_live.py b/tests/test_clock_link_live.py new file mode 100644 index 0000000..5623f70 --- /dev/null +++ b/tests/test_clock_link_live.py @@ -0,0 +1,126 @@ +from __future__ import annotations + +import numpy as np + +from lfm.analysis.clock_link_live import ( + LiveClockParameters, + LiveClockState, + clock_momentum_rate, + make_traveling_packet, + potential_momentum_rates, + step_live_clock, + total_hamiltonian, + weighted_gradient_force, +) +from lfm.core.stencils import laplacian_19pt, laplacian_27pt + + +def test_weighted_force_recovers_laplacian() -> None: + rng = np.random.default_rng(20260724) + field = rng.normal(size=(7, 7, 7)) + q = np.ones_like(field) + for stencil, laplacian in ( + ("19", laplacian_19pt), + ("27", laplacian_27pt), + ): + force = weighted_gradient_force( + field, + q, + coefficient=1.0, + stencil=stencil, + ) + np.testing.assert_allclose(force, laplacian(field), atol=2.0e-15) + + +def test_live_forces_are_hamiltonian_derivatives() -> None: + rng = np.random.default_rng(13) + shape = (5, 5, 5) + parameters = LiveClockParameters() + state = LiveClockState( + field=0.02 * rng.normal(size=shape), + field_momentum=0.03 * rng.normal(size=shape), + chi=parameters.chi0 + 0.01 * rng.normal(size=shape), + chi_momentum=0.02 * rng.normal(size=shape), + varphi=0.005 * rng.normal(size=shape), + clock_momentum=0.02 * rng.normal(size=shape), + ) + field_rate, chi_rate, _ = potential_momentum_rates(state, parameters) + full_clock_rate = clock_momentum_rate(state, parameters) + index = (2, 1, 3) + epsilon = 1.0e-6 + + for name, expected in ( + ("field", field_rate[index]), + ("chi", chi_rate[index]), + ("varphi", full_clock_rate[index]), + ): + plus = state.copy() + minus = state.copy() + getattr(plus, name)[index] += epsilon + getattr(minus, name)[index] -= epsilon + derivative = ( + total_hamiltonian(plus, parameters) - total_hamiltonian(minus, parameters) + ) / (2.0 * epsilon) + np.testing.assert_allclose(-derivative, expected, rtol=2.0e-6, atol=2.0e-6) + + +def test_source_free_vacuum_is_fixed_point() -> None: + parameters = LiveClockParameters() + shape = (8, 8, 8) + zeros = np.zeros(shape, dtype=np.float64) + state = LiveClockState( + field=zeros.copy(), + field_momentum=zeros.copy(), + chi=np.full(shape, parameters.chi0), + chi_momentum=zeros.copy(), + varphi=zeros.copy(), + clock_momentum=zeros.copy(), + ) + initial = state.copy() + for _ in range(20): + step_live_clock(state, 0.005, parameters) + for name in ( + "field", + "field_momentum", + "chi", + "chi_momentum", + "varphi", + "clock_momentum", + ): + np.testing.assert_array_equal(getattr(state, name), getattr(initial, name)) + + +def test_symmetric_split_is_reversible() -> None: + parameters = LiveClockParameters() + state = make_traveling_packet( + 8, + amplitude=0.01, + width=1.5, + carrier_index=1, + parameters=parameters, + ) + initial = state.copy() + for _ in range(10): + step_live_clock(state, 0.0025, parameters) + state.field_momentum *= -1.0 + state.chi_momentum *= -1.0 + state.clock_momentum *= -1.0 + for _ in range(10): + step_live_clock(state, 0.0025, parameters) + state.field_momentum *= -1.0 + state.chi_momentum *= -1.0 + state.clock_momentum *= -1.0 + for name in ( + "field", + "field_momentum", + "chi", + "chi_momentum", + "varphi", + "clock_momentum", + ): + np.testing.assert_allclose( + getattr(state, name), + getattr(initial, name), + atol=2.0e-13, + rtol=2.0e-13, + ) diff --git a/tests/test_coarse_graining.py b/tests/test_coarse_graining.py new file mode 100644 index 0000000..b791480 --- /dev/null +++ b/tests/test_coarse_graining.py @@ -0,0 +1,86 @@ +"""Tests for finite local Fourier blocking diagnostics.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from lfm.analysis.coarse_graining import ( + block_window_magnitude_sq, + blocked_static_propagator, + inverse_response_intercept, + response_log_slope, +) + + +@pytest.mark.parametrize("block_factor", [1, 2, 4, 8]) +def test_alias_windows_form_partition(block_factor: int) -> None: + coarse_k = np.asarray([0.0, 0.17, 1.1]) + weight_sum = np.zeros_like(coarse_k) + for alias in range(block_factor): + fine_k = (coarse_k + 2.0 * np.pi * alias) / block_factor + weight_sum += block_window_magnitude_sq(fine_k, block_factor) + np.testing.assert_allclose(weight_sum, 1.0, atol=2.0e-14) + + +@pytest.mark.parametrize("stencil", ["19", "27"]) +@pytest.mark.parametrize("block_factor", [1, 2, 4, 8]) +def test_gapped_blocked_response_retains_intercept( + stencil: str, + block_factor: int, +) -> None: + wave_number = np.geomspace(1.0e-6, 1.0e-2, 32) + response = blocked_static_propagator( + wave_number, + 0.0, + 0.0, + block_factor=block_factor, + mass_sq=372.64516129032256, + stencil=stencil, + ) + slope = response_log_slope(wave_number / block_factor, response, count=12) + intercept = inverse_response_intercept( + wave_number / block_factor, + response, + count=12, + ) + assert abs(slope) < 1.0e-4 + assert intercept > 300.0 + + +@pytest.mark.parametrize("stencil", ["19", "27"]) +@pytest.mark.parametrize("block_factor", [1, 2, 4, 8]) +def test_massless_control_retains_inverse_square_pole( + stencil: str, + block_factor: int, +) -> None: + wave_number = np.geomspace(1.0e-5, 1.0e-2, 32) + response = blocked_static_propagator( + wave_number, + 0.0, + 0.0, + block_factor=block_factor, + mass_sq=0.0, + stencil=stencil, + ) + slope = response_log_slope(wave_number / block_factor, response, count=12) + assert -2.01 < slope < -1.99 + + +def test_invalid_mass_and_zero_mass_uniform_mode_rejected() -> None: + with pytest.raises(ValueError): + blocked_static_propagator( + 0.1, + 0.0, + 0.0, + block_factor=2, + mass_sq=-1.0, + ) + with pytest.raises(ValueError): + blocked_static_propagator( + 0.0, + 0.0, + 0.0, + block_factor=2, + mass_sq=0.0, + ) diff --git a/tests/test_collective_geometry.py b/tests/test_collective_geometry.py new file mode 100644 index 0000000..a8af737 --- /dev/null +++ b/tests/test_collective_geometry.py @@ -0,0 +1,120 @@ +"""Tests for operational collective-substrate observables.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from lfm.analysis.collective_geometry import ( + SOURCE_CASES, + analytic_leapfrog_limit, + apply_momentum_sponge, + block_average, + collective_initial_state, + continuum_fit, + dispersion_shell_metrics, + energy_current_vector_and_tensor, + periodic_weighted_moments, + traceless, +) +from lfm.analysis.energy_current import BareLFMParameters + + +@pytest.mark.parametrize("case", SOURCE_CASES) +def test_registered_initial_states_use_only_six_plus_one_registers(case: str) -> None: + state = collective_initial_state(12, 0.96, case) + assert state.wave.shape == (6, 12, 12, 12) + assert state.chi.shape == (12, 12, 12) + assert np.all(state.chi == 19.0) + assert np.any(state.wave_momentum[4] != 0.0) + assert np.all(state.wave[2:4] == 0.0) + + +def test_moving_pair_reverses_internal_phase_gradient() -> None: + plus = collective_initial_state(16, 0.96, "moving_plus") + minus = collective_initial_state(16, 0.96, "moving_minus") + assert np.allclose(plus.wave[0], minus.wave[0]) + assert np.allclose(plus.wave[1], -minus.wave[1]) + assert np.allclose(plus.wave_momentum[0], -minus.wave_momentum[0]) + assert np.allclose(plus.wave_momentum[1], minus.wave_momentum[1]) + + +def test_diagonal_motion_preserves_wave_number_magnitude() -> None: + state = collective_initial_state( + 16, + 0.96, + "moving_plus", + motion_direction=(1.0, 1.0, 1.0), + ) + assert np.all(np.isfinite(state.wave)) + assert not np.allclose(state.wave[1], 0.0) + + +def test_sponge_leaves_interior_and_damps_boundary_momenta() -> None: + state = collective_initial_state(16, 0.96, "static_sphere") + state = type(state)( + wave=state.wave, + wave_momentum=np.ones_like(state.wave_momentum), + chi=state.chi, + chi_momentum=np.ones_like(state.chi_momentum), + ) + damped = apply_momentum_sponge(state, 0.96, 0.01) + assert damped.wave_momentum[0, 8, 8, 8] == pytest.approx(1.0) + assert damped.wave_momentum[0, 0, 0, 0] < 1.0 + assert damped.chi_momentum[0, 0, 0] < 1.0 + + +def test_periodic_weighted_moments_cross_boundary() -> None: + weights = np.zeros((8, 8, 8)) + weights[0, 4, 4] = 1.0 + weights[-1, 4, 4] = 1.0 + moments = periodic_weighted_moments(weights, 0.8) + assert moments.covariance[0, 0] < 0.02 + assert moments.covariance[1, 1] < 1.0e-14 + + +def test_block_average_preserves_prefix_axes_and_mean() -> None: + values = np.arange(2 * 8**3, dtype=float).reshape(2, 8, 8, 8) + blocked = block_average(values, 2) + assert blocked.shape == (2, 4, 4, 4) + assert float(np.mean(blocked)) == pytest.approx(float(np.mean(values))) + + +def test_current_vector_and_tensor_have_operational_shapes() -> None: + shape = (4, 4, 4) + currents = { + (1, 0, 0): np.ones(shape), + (-1, 0, 0): -np.ones(shape), + } + vector, tensor = energy_current_vector_and_tensor(currents, 0.1) + assert vector.shape == (3,) + shape + assert tensor.shape == (3, 3) + shape + assert np.allclose(vector[0], 0.1) + assert np.allclose(tensor[0, 0], 1.0) + assert np.allclose(traceless(np.eye(3)), np.zeros((3, 3))) + + +def test_continuum_fit_recovers_nonzero_intercept() -> None: + spacing = np.asarray((0.04, 0.03, 0.02)) + values = 2.5 - 7.0 * spacing**2 + fit = continuum_fit(spacing, values) + assert float(fit.intercept) == pytest.approx(2.5, abs=1.0e-12) + assert float(fit.slope) == pytest.approx(-7.0, abs=1.0e-10) + assert bool(fit.sign_consistent) + + +@pytest.mark.parametrize("stencil", ("19", "27")) +def test_dispersion_and_cfl_metrics_are_finite(stencil: str) -> None: + dispersion = dispersion_shell_metrics(stencil, 0.03, 4.0 * np.pi / 0.96) + assert dispersion["directional_anisotropy"] >= 0.0 + assert np.all(np.isfinite(list(dispersion.values()))) + limit = analytic_leapfrog_limit( + BareLFMParameters( + spacing=0.03, + gov01_stencil=stencil, + gov02_stencil=stencil, + ), + symbol_samples=17, + ) + assert limit["maximum_dt"] > 0.0 + assert limit["maximum_courant"] > 0.0 diff --git a/tests/test_collective_spectrum.py b/tests/test_collective_spectrum.py new file mode 100644 index 0000000..1c2409a --- /dev/null +++ b/tests/test_collective_spectrum.py @@ -0,0 +1,122 @@ +"""Tests for bare-LFM collective-mode diagnostics.""" + +from __future__ import annotations + +import numpy as np + +from lfm.analysis.collective_spectrum import ( + berry_action_derivative_audit, + collective_mode_eigenpairs, + collective_qep_matrices, + discrete_stiffness_19, + gov02_background_residual, + mode_polarization, + principal_symbol_audit, + rotating_background, + vacuum_spectrum_audit, + zero_chi_branch_audit, +) +from lfm.constants import CHI0, KAPPA + + +def test_principal_symbol_has_no_vacuum_vector_or_tensor_mode() -> None: + audit = principal_symbol_audit() + assert audit["real_field_count"] == 7 + assert audit["background_dependent"] is False + assert audit["trivial_vacuum_gapless_spin_1_count"] == 0 + assert audit["trivial_vacuum_gapless_spin_2_count"] == 0 + + +def test_flat_octic_makes_only_the_scalar_chi_mode_gapless() -> None: + quartic = vacuum_spectrum_audit("canonical_quartic") + octic = vacuum_spectrum_audit("flat_octic") + assert quartic["gapless_spin_0_count"] == 0 + assert octic["gapless_spin_0_count"] == 1 + assert octic["gapless_spin_1_count"] == 0 + assert octic["gapless_spin_2_count"] == 0 + + +def test_zero_chi_branch_has_massless_scalars_but_no_linear_gravity_coupling() -> None: + critical = zero_chi_branch_audit(0.0, "flat_octic")["stability_threshold_density"] + below = zero_chi_branch_audit(0.5 * critical, "flat_octic") + at = zero_chi_branch_audit(critical, "flat_octic") + above = zero_chi_branch_audit(1.5 * critical, "flat_octic") + assert below["stability"] == "TACHYONIC" + assert at["stability"] == "CRITICAL_CHI_GAPLESS_NONLINEARLY_RESTORED" + assert above["stability"] == "LINEARLY_STABLE_CHI_GAPPED" + assert at["gov01_massless_real_scalar_count"] == 6 + assert at["bare_gauss_constraint"] is False + assert at["linear_matter_to_chi_source_coefficient"] == 0.0 + assert at["linear_chi_to_matter_response_coefficient"] == 0.0 + + +def test_background_equilibrium_residuals() -> None: + q = np.diag([0.7, 0.7, 0.7]) + for model in ("canonical_quartic", "flat_octic"): + background = rotating_background(100.0, q, model=model, spacing=0.1) + assert abs(gov02_background_residual(background)) < 1.0e-10 + + +def test_q_zero_even_sideband_is_exact_discrete_stiffness() -> None: + background = rotating_background(10.0, model="canonical_quartic", spacing=0.2) + wave_vector = np.array([0.3, 0.2, 0.1]) + _, k_matrix = collective_qep_matrices(background, wave_vector) + expected = discrete_stiffness_19(wave_vector, spacing=0.2) + assert np.allclose(np.diag(k_matrix)[:6], expected) + + +def test_relative_phase_branch_is_quadratic_at_small_k() -> None: + background = rotating_background(1.0, model="canonical_quartic", spacing=0.05) + frequencies = [] + for wave_number in (0.02, 0.04, 0.08): + values, _ = collective_mode_eigenpairs(background, np.array([wave_number, 0.0, 0.0])) + expected = ( + np.sqrt( + background.carrier_frequencies[0] ** 2 + + discrete_stiffness_19(np.array([wave_number, 0.0, 0.0]), spacing=0.05) + ) + - background.carrier_frequencies[0] + ) + stable_positive = [ + value.real for value in values if value.real > 1.0e-10 and abs(value.imag) < 1.0e-8 + ] + observed = min(stable_positive, key=lambda value: abs(value - expected)) + assert np.isclose(observed, expected, rtol=1.0e-7, atol=1.0e-12) + frequencies.append(observed) + slope = np.polyfit(np.log([0.02, 0.04, 0.08]), np.log(frequencies), 1)[0] + assert 1.9 < slope < 2.1 + + +def test_phase_displacement_strain_has_zero_tt_projection() -> None: + state = np.zeros(7, dtype=np.complex128) + state[3:6] = np.array([1.0 + 0.2j, -0.3j, 0.7]) + result = mode_polarization(state, np.array([0.4, 0.2, -0.1])) + assert result["phase_strain_tt_fraction"] < 1.0e-28 + + +def test_bare_action_does_not_contain_composite_maxwell_term() -> None: + audit = berry_action_derivative_audit() + assert audit["bare_action_contains_independent_f_squared"] is False + assert audit["inhomogeneous_maxwell_equation_is_bare_euler_lagrange_equation"] is False + + +def test_common_condensate_instability_matches_small_k_derivation() -> None: + background = rotating_background(100.0, model="canonical_quartic", spacing=0.05) + attraction = ( + 4.0 * (KAPPA / CHI0) * background.chi**2 * background.total_density / background.chi_mass_sq + ) + predicted_growth_per_k = np.sqrt(attraction) / (2.0 * background.carrier_frequencies[0]) + wave_number = 0.002 + values, _ = collective_mode_eigenpairs(background, np.array([wave_number, 0.0, 0.0])) + observed_growth_per_k = max(abs(value.imag) for value in values) / wave_number + assert np.isclose(observed_growth_per_k, predicted_growth_per_k, rtol=2.0e-4) + + +def test_instability_growth_converges_with_spacing() -> None: + wave_number = 0.01 + growth_rates = [] + for spacing in (0.1, 0.05, 0.025): + background = rotating_background(100.0, model="flat_octic", spacing=spacing) + values, _ = collective_mode_eigenpairs(background, np.array([wave_number, 0.0, 0.0])) + growth_rates.append(max(abs(value.imag) for value in values)) + assert np.ptp(growth_rates) / np.mean(growth_rates) < 1.0e-6 diff --git a/tests/test_emergence.py b/tests/test_emergence.py new file mode 100644 index 0000000..250146c --- /dev/null +++ b/tests/test_emergence.py @@ -0,0 +1,69 @@ +from __future__ import annotations + +import numpy as np + +from lfm.analysis.emergence import ( + aggregate_wave_density, + localized_state_observables, + plaquette_winding_summary, + principal_internal_projection, +) + + +def test_aggregate_density_is_invariant_under_internal_unitary() -> None: + rng = np.random.default_rng(17) + psi = rng.normal(size=(3, 6, 6, 6)) + 1j * rng.normal(size=(3, 6, 6, 6)) + raw = rng.normal(size=(3, 3)) + 1j * rng.normal(size=(3, 3)) + unitary, _ = np.linalg.qr(raw) + rotated = np.einsum("ab,bxyz->axyz", unitary, psi) + before = aggregate_wave_density(psi.real, psi.imag) + after = aggregate_wave_density(rotated.real, rotated.imag) + np.testing.assert_allclose(before, after, atol=1.0e-11, rtol=1.0e-11) + + +def test_principal_projection_reports_rank_one_internal_field() -> None: + grid = np.zeros((7, 7, 7), dtype=np.complex128) + grid[3, 3, 3] = 2.0 + 1.0j + internal = np.asarray([1.0, 2.0j, -0.5], dtype=np.complex128) + psi = internal[:, None, None, None] * grid[None, ...] + projected, gap = principal_internal_projection(psi.real, psi.imag) + assert gap > 1.0 - 1.0e-12 + assert np.isclose(np.sum(np.abs(projected) ** 2), np.sum(np.abs(psi) ** 2)) + + +def test_plaquette_winding_rejects_zero_amplitude_phase() -> None: + field = np.zeros((8, 8, 8), dtype=np.complex128) + summary = plaquette_winding_summary(field) + assert summary == {"positive": 0, "negative": 0, "nonzero": 0, "valid": 0} + + +def test_localized_observables_identify_single_site_and_charge() -> None: + shape = (3, 9, 9, 9) + psi = np.zeros(shape, dtype=np.complex128) + psi[:, 4, 4, 4] = np.asarray([1.0, 1.0j, -1.0]) + dt = 0.02 + omega = 3.0 + previous = psi * np.exp(-1j * omega * dt) + chi = np.full(shape[1:], 19.0) + out = localized_state_observables( + psi.real, + psi.imag, + previous.real, + previous.imag, + chi, + dt, + 19.0, + ) + assert np.isclose(out["effective_sites"], 1.0) + assert out["top7_c7_overlap"] == 1 + assert out["noether_charge"] > 0.0 + assert np.isclose(out["chi_drop"], 0.0) + + +def test_localized_observables_classify_nonfinite_without_diagonalization() -> None: + psi = np.zeros((3, 8, 8, 8), dtype=np.float64) + psi[0, 4, 4, 4] = np.nan + chi = np.full((8, 8, 8), 19.0) + out = localized_state_observables(psi, psi, psi, psi, chi, 0.02, 19.0) + assert np.isnan(out["wave_norm"]) + assert out["winding_nonzero"] == 0 diff --git a/tests/test_energy_current.py b/tests/test_energy_current.py new file mode 100644 index 0000000..5bb80fc --- /dev/null +++ b/tests/test_energy_current.py @@ -0,0 +1,260 @@ +"""Tests for exact bare-LFM lattice energy continuity.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from lfm.analysis.energy_current import ( + BareLFMParameters, + BareLFMState, + bare_energy_continuity_residual, + bare_hamilton_rates, + bare_site_energy, + bare_site_energy_rate, + bare_total_energy, + energy_current_divergence, + oriented_energy_currents, + step_bare_lfm, + wave_component_site_energy, +) +from lfm.core.stencils import laplacian_19pt, laplacian_27pt + + +def _state(components: int, size: int = 6) -> tuple[np.ndarray, ...]: + rng = np.random.default_rng(20260724 + components) + shape = (components, size, size, size) + wave = 0.02 * rng.normal(size=shape) + wave_momentum = 0.03 * rng.normal(size=shape) + chi = 19.0 + 0.001 * rng.normal(size=shape[1:]) + chi_momentum = 0.02 * rng.normal(size=shape[1:]) + return wave, wave_momentum, chi, chi_momentum + + +@pytest.mark.parametrize( + ("gov01_stencil", "gov02_stencil"), + (("19", "19"), ("27", "19"), ("19", "27"), ("27", "27")), +) +@pytest.mark.parametrize("components", (1, 2, 6)) +def test_exact_site_continuity( + gov01_stencil: str, + gov02_stencil: str, + components: int, +) -> None: + state = _state(components) + parameters = BareLFMParameters( + gov01_stencil=gov01_stencil, + gov02_stencil=gov02_stencil, + ) + residual = bare_energy_continuity_residual(*state, parameters) + energy_rate = bare_site_energy_rate(*state, parameters) + divergence = energy_current_divergence(*state, parameters) + scale = max( + float(np.max(np.abs(energy_rate))), + float(np.max(np.abs(divergence))), + 1.0, + ) + assert float(np.max(np.abs(residual))) / scale < 1.0e-12 + assert ( + abs(float(np.sum(energy_rate))) + / max( + float(np.sum(np.abs(energy_rate))), + 1.0, + ) + < 1.0e-12 + ) + + +def test_oriented_currents_are_antisymmetric() -> None: + state = _state(6) + parameters = BareLFMParameters( + gov01_stencil="27", + gov02_stencil="19", + ) + currents = oriented_energy_currents(*state, parameters) + for offset, current in currents.items(): + reverse = tuple(-value for value in offset) + aligned_reverse = np.roll( + currents[reverse], + shift=offset, + axis=(0, 1, 2), + ) + scale = max(float(np.max(np.abs(current))), 1.0) + assert float(np.max(np.abs(current + aligned_reverse))) / scale < 1.0e-12 + + +def test_site_energy_matches_laplacian_quadratic_form() -> None: + wave, wave_p, chi, chi_p = _state(6) + for gov01_stencil, gov02_stencil in ( + ("19", "19"), + ("27", "19"), + ("19", "27"), + ("27", "27"), + ): + parameters = BareLFMParameters( + gov01_stencil=gov01_stencil, + gov02_stencil=gov02_stencil, + ) + lap01 = laplacian_19pt if gov01_stencil == "19" else laplacian_27pt + lap02 = laplacian_19pt if gov02_stencil == "19" else laplacian_27pt + norm_sq = np.sum(wave**2, axis=0) + onsite = ( + 0.5 * np.sum(wave_p**2) + + np.sum(chi_p**2) / (2.0 * parameters.chi_inertia) + + 0.5 * np.sum(chi**2 * norm_sq) + + parameters.chi_inertia + * parameters.lambda_h + * np.sum((chi**2 - parameters.chi0**2) ** 2) + ) + wave_gradient = ( + -0.5 + * parameters.wave_speed**2 + * sum(np.sum(component * lap01(component)) for component in wave) + ) + chi_displacement = chi - parameters.chi0 + chi_gradient = ( + -0.5 + * parameters.chi_inertia + * parameters.wave_speed**2 + * np.sum(chi_displacement * lap02(chi_displacement)) + ) + direct = float(onsite + wave_gradient + chi_gradient) + site_sum = float( + np.sum( + bare_site_energy( + wave, + wave_p, + chi, + chi_p, + parameters, + ) + ) + ) + assert abs(site_sum - direct) / max(abs(direct), 1.0) < 1.0e-12 + + +def test_analytic_site_rate_matches_centered_difference() -> None: + state = _state(2) + parameters = BareLFMParameters( + background_norm_sq=0.01, + gov01_stencil="19", + gov02_stencil="27", + ) + rates = bare_hamilton_rates(*state, parameters) + # A larger displacement is used for this float64 unit smoke test. + # The decision gate separately retains 1e-7 with Decimal arithmetic. + epsilon = 1.0e-4 + plus = tuple( + value + epsilon * rate + for value, rate in zip( + state, + ( + rates.wave, + rates.wave_momentum, + rates.chi, + rates.chi_momentum, + ), + strict=True, + ) + ) + minus = tuple( + value - epsilon * rate + for value, rate in zip( + state, + ( + rates.wave, + rates.wave_momentum, + rates.chi, + rates.chi_momentum, + ), + strict=True, + ) + ) + numerical = (bare_site_energy(*plus, parameters) - bare_site_energy(*minus, parameters)) / ( + 2.0 * epsilon + ) + analytic = bare_site_energy_rate(*state, parameters) + scale = max(float(np.max(np.abs(analytic))), 1.0) + assert float(np.max(np.abs(numerical - analytic))) / scale < 2.0e-7 + + +def test_internal_basis_rotation_invariance() -> None: + wave, wave_p, chi, chi_p = _state(6) + parameters = BareLFMParameters() + rng = np.random.default_rng(1197) + rotation, _ = np.linalg.qr(rng.normal(size=(6, 6))) + rotated_wave = np.einsum("ab,bxyz->axyz", rotation, wave) + rotated_p = np.einsum("ab,bxyz->axyz", rotation, wave_p) + original = ( + bare_site_energy(wave, wave_p, chi, chi_p, parameters), + bare_site_energy_rate(wave, wave_p, chi, chi_p, parameters), + energy_current_divergence(wave, wave_p, chi, chi_p, parameters), + ) + rotated = ( + bare_site_energy(rotated_wave, rotated_p, chi, chi_p, parameters), + bare_site_energy_rate( + rotated_wave, + rotated_p, + chi, + chi_p, + parameters, + ), + energy_current_divergence( + rotated_wave, + rotated_p, + chi, + chi_p, + parameters, + ), + ) + for before, after in zip(original, rotated, strict=True): + scale = max(float(np.max(np.abs(before))), 1.0) + assert float(np.max(np.abs(after - before))) / scale < 1.0e-12 + + +@pytest.mark.parametrize("chi_potential", ("quartic", "flat_octic")) +@pytest.mark.parametrize("spacing", (1.0, 0.125)) +def test_continuity_with_physical_spacing_and_potential( + chi_potential: str, + spacing: float, +) -> None: + state = _state(6) + parameters = BareLFMParameters( + spacing=spacing, + chi_potential=chi_potential, + gov01_stencil="27", + gov02_stencil="19", + ) + residual = bare_energy_continuity_residual(*state, parameters) + rate = bare_site_energy_rate(*state, parameters) + scale = max(float(np.max(np.abs(rate))), 1.0) + assert float(np.max(np.abs(residual))) / scale < 3.0e-12 + + +@pytest.mark.parametrize("chi_potential", ("quartic", "flat_octic")) +def test_velocity_verlet_preserves_bare_energy_to_second_order( + chi_potential: str, +) -> None: + raw = _state(6, size=5) + parameters = BareLFMParameters( + spacing=0.2, + chi_potential=chi_potential, + gov01_stencil="19", + gov02_stencil="27", + ) + drifts = [] + final_state = None + for dt, steps in ((0.001, 40), (0.0005, 80)): + state = BareLFMState(*(value.copy() for value in raw)) + initial = bare_total_energy(state, parameters) + for _ in range(steps): + state = step_bare_lfm(state, dt, parameters) + final = bare_total_energy(state, parameters) + drifts.append(abs(final - initial) / max(abs(initial), 1.0)) + final_state = state + assert drifts[0] < 1.0e-4 + assert drifts[1] < 0.4 * drifts[0] + assert final_state is not None + component_energy = wave_component_site_energy(final_state, 4, parameters) + assert component_energy.shape == raw[2].shape + assert float(np.min(component_energy)) >= 0.0 diff --git a/tests/test_exact_leapfrog_dispersion.py b/tests/test_exact_leapfrog_dispersion.py new file mode 100644 index 0000000..f1a9ab0 --- /dev/null +++ b/tests/test_exact_leapfrog_dispersion.py @@ -0,0 +1,42 @@ +from __future__ import annotations + +import math + +import pytest + +from lfm.experiment.dispersion import dispersion + + +def test_wavelength_path_uses_exact_leapfrog_phase() -> None: + dt = 0.02 + chi0 = 19.0 + wavelength = 4.0 + k = 2.0 * math.pi / wavelength + spatial_omega_sq = chi0 * chi0 + 2.0 * (1.0 - math.cos(k)) + expected = math.acos(1.0 - 0.5 * dt * dt * spatial_omega_sq) / dt + + observed = dispersion(wavelength=wavelength, chi0=chi0, dt=dt) + + assert observed.omega == pytest.approx(expected, rel=0.0, abs=1.0e-14) + assert abs(observed.omega - math.sqrt(spatial_omega_sq)) > 0.1 + + +def test_exact_leapfrog_dispersion_round_trip() -> None: + original = dispersion(wavelength=68.0 / 17.0, chi0=19.0, dt=0.02) + recovered = dispersion(omega=original.omega, chi0=19.0, dt=0.02) + + assert recovered.k_z == pytest.approx(original.k_z, rel=0.0, abs=1.0e-13) + assert recovered.wavelength == pytest.approx( + original.wavelength, + rel=0.0, + abs=1.0e-13, + ) + + +def test_discrete_mass_gap_is_rejected_as_nonpropagating() -> None: + dt = 0.02 + chi0 = 19.0 + mass_gap = math.acos(1.0 - 0.5 * dt * dt * chi0 * chi0) / dt + + with pytest.raises(ValueError, match="discrete mass gap"): + dispersion(omega=mass_gap, chi0=chi0, dt=dt) diff --git a/tests/test_fields.py b/tests/test_fields.py index 23edb33..19a2c4a 100644 --- a/tests/test_fields.py +++ b/tests/test_fields.py @@ -4,13 +4,20 @@ import pytest from lfm.constants import CHI0 +from lfm.core.stencils import laplacian_19pt from lfm.fields import ( equilibrate_chi, + equilibrate_chi_19pt, equilibrate_from_fields, gaussian_soliton, grid_positions, place_solitons, + planar_r1_light_packet, poisson_solve_fft, + poisson_solve_fft_19pt, + r1_light_acceleration, + r1_light_step, + r1_vacuum_subtracted_potential, seed_noise, sparse_positions, tetrahedral_positions, @@ -141,6 +148,25 @@ def test_roundtrip_spectral(self): laplacian = np.fft.irfftn(lap_hat, s=(N, N, N), axes=(0, 1, 2)) assert np.allclose(laplacian, source, atol=1e-3) + def test_roundtrip_19pt(self): + N = 16 + rng = np.random.default_rng(123) + source = rng.standard_normal((N, N, N)).astype(np.float32) + source -= np.mean(source) + phi = poisson_solve_fft_19pt(source, N) + assert phi.dtype == np.float32 + lap = laplacian_19pt(phi) + np.testing.assert_allclose(lap, source, atol=1e-4) + + def test_19pt_preserves_float64_for_small_sources(self): + N = 16 + source = np.zeros((N, N, N), dtype=np.float64) + source[N // 2, N // 2, N // 2] = 1e-9 + source -= np.mean(source) + phi = poisson_solve_fft_19pt(source, N) + assert phi.dtype == np.float64 + assert np.max(np.abs(phi)) > 0.0 + class TestEquilibrateChi: def test_zero_energy_gives_chi0(self): @@ -168,6 +194,92 @@ def test_boundary_mask(self): assert np.allclose(chi[0], CHI0) assert np.allclose(chi[-1], CHI0) + def test_19pt_energy_creates_well(self): + N = 24 + psi_sq = np.zeros((N, N, N), dtype=np.float32) + c = N // 2 + psi_sq[c - 1 : c + 2, c - 1 : c + 2, c - 1 : c + 2] = 20.0 + chi = equilibrate_chi_19pt(psi_sq) + assert chi[c, c, c] < CHI0 + + def test_19pt_preserves_float64_chi_perturbation(self): + N = 24 + psi_sq = np.zeros((N, N, N), dtype=np.float64) + c = N // 2 + psi_sq[c, c, c] = 1e-7 + chi = equilibrate_chi_19pt(psi_sq) + assert chi.dtype == np.float64 + assert np.min(chi) < CHI0 + + +class TestR1Light: + def test_vacuum_subtracted_potential_zero_in_uniform_vacuum(self): + chi = np.full((8, 8, 8), CHI0, dtype=np.float32) + pot = r1_vacuum_subtracted_potential(chi) + assert pot.dtype == np.float32 + np.testing.assert_allclose(pot, 0.0) + + def test_vacuum_subtracted_potential_preserves_float64_delta(self): + chi = np.full((8, 8, 8), CHI0, dtype=np.float64) + chi[4, 4, 4] -= 1e-9 + pot = r1_vacuum_subtracted_potential(chi) + assert pot.dtype == np.float64 + assert pot[4, 4, 4] < 0.0 + + def test_uniform_vacuum_acceleration_matches_flat_laplacian(self): + rng = np.random.default_rng(5) + psi_r = rng.standard_normal((8, 8, 8)).astype(np.float32) + psi_i = rng.standard_normal((8, 8, 8)).astype(np.float32) + chi = np.full((8, 8, 8), CHI0, dtype=np.float32) + acc_r, acc_i = r1_light_acceleration(psi_r, psi_i, chi=chi) + np.testing.assert_allclose(acc_r, laplacian_19pt(psi_r), atol=1e-6) + np.testing.assert_allclose(acc_i, laplacian_19pt(psi_i), atol=1e-6) + + def test_planar_packet_shapes_and_forward_current(self): + pr, pi, prp, pip = planar_r1_light_packet( + 16, + center=(5.0, 8.0, 8.0), + sigma=(2.0, 3.0, 3.0), + carrier_k=0.4, + ) + assert pr.shape == (16, 16, 16) + assert pi.dtype == np.float32 + assert prp.shape == pr.shape + assert pip.shape == pi.shape + dpr = 0.5 * (np.roll(pr, -1, axis=0) - np.roll(pr, 1, axis=0)) + dpi = 0.5 * (np.roll(pi, -1, axis=0) - np.roll(pi, 1, axis=0)) + current = np.sum(pr * dpi - pi * dpr) + assert current > 0.0 + + def test_planar_packet_float64_and_step_preserve_dtype(self): + pr, pi, prp, pip = planar_r1_light_packet( + 12, + center=(4.0, 6.0, 6.0), + sigma=(2.0, 2.5, 2.5), + carrier_k=0.3, + dtype=np.float64, + ) + chi = np.full((12, 12, 12), CHI0, dtype=np.float64) + nr, ni, npr, npi = r1_light_step(pr, pi, prp, pip, dt=0.1, chi=chi) + assert pr.dtype == np.float64 + assert nr.dtype == np.float64 + assert ni.dtype == np.float64 + assert npr.dtype == np.float64 + assert npi.dtype == np.float64 + + def test_r1_light_step_shapes(self): + pr, pi, prp, pip = planar_r1_light_packet( + 12, + center=(4.0, 6.0, 6.0), + sigma=(2.0, 2.5, 2.5), + carrier_k=0.3, + ) + nr, ni, npr, npi = r1_light_step(pr, pi, prp, pip, dt=0.1) + assert nr.shape == pr.shape + assert ni.shape == pi.shape + assert npr.shape == pr.shape + assert npi.shape == pi.shape + class TestEquilibrateFromFields: def test_real_field(self): @@ -295,5 +407,8 @@ def test_fields_importable_from_lfm(self): assert hasattr(lfm, "gaussian_soliton") assert hasattr(lfm, "equilibrate_chi") + assert hasattr(lfm, "equilibrate_chi_19pt") + assert hasattr(lfm, "planar_r1_light_packet") + assert hasattr(lfm, "r1_light_step") assert hasattr(lfm, "seed_noise") assert hasattr(lfm, "tetrahedral_positions") diff --git a/tests/test_frame_candidates.py b/tests/test_frame_candidates.py new file mode 100644 index 0000000..79d6489 --- /dev/null +++ b/tests/test_frame_candidates.py @@ -0,0 +1,56 @@ +"""Candidate carrier screening tests.""" + +from __future__ import annotations + +from dataclasses import replace + +from lfm.analysis.frame_candidates import ( + CandidateVerdict, + FrameCandidate, + assess_frame_candidate, + current_frame_candidate_ledger, +) + + +def _complete_candidate() -> FrameCandidate: + return FrameCandidate( + candidate_id="complete", + degrees_of_freedom="test", + source_observable="test", + gapless=True, + positive_energy=True, + source_derived_from_lfm=True, + attractive_for_positive_energy=True, + local_action_written=True, + net_source_compatible=True, + nonlinear_closure_written=True, + static_response_power=-2.0, + ) + + +def test_complete_candidate_survives_algebraic_screen() -> None: + assessment = assess_frame_candidate(_complete_candidate()) + assert assessment.verdict is CandidateVerdict.SURVIVES + + +def test_ghost_candidate_is_rejected() -> None: + candidate = replace(_complete_candidate(), positive_energy=False) + assessment = assess_frame_candidate(candidate) + assert assessment.verdict is CandidateVerdict.REJECTED + assert "positive Hamiltonian" in assessment.reasons + + +def test_unknown_action_blocks_candidate() -> None: + candidate = replace(_complete_candidate(), local_action_written=None) + assessment = assess_frame_candidate(candidate) + assert assessment.verdict is CandidateVerdict.BLOCKED + + +def test_current_ledger_has_no_survivor() -> None: + ledger = current_frame_candidate_ledger() + verdicts = {row.candidate.candidate_id: row.verdict for row in ledger} + assert verdicts["canonical-radial-chi"] is CandidateVerdict.REJECTED + assert verdicts["positive-sync-one-form"] is CandidateVerdict.REJECTED + assert verdicts["negative-sync-one-form"] is CandidateVerdict.REJECTED + assert verdicts["ordinary-displacement-strain"] is CandidateVerdict.REJECTED + assert verdicts["independent-affine-cube-frame"] is CandidateVerdict.BLOCKED diff --git a/tests/test_frame_completion.py b/tests/test_frame_completion.py new file mode 100644 index 0000000..7541616 --- /dev/null +++ b/tests/test_frame_completion.py @@ -0,0 +1,71 @@ +"""Tests for the unpromoted spacetime cube-frame candidate algebra.""" + +import numpy as np +import pytest + +from lfm.analysis.frame_completion import ( + FRAME_SHAPE_COUNT, + analytic_rest_energy_response, + frame_projectors, + frame_static_operator, + frame_static_response, + minimized_source_cross_energy, + source_projection_weights, + zero_momentum_frame_spectrum, +) + + +def test_frame_projectors_are_orthogonal_and_complete() -> None: + scale, shape = frame_projectors() + identity = np.eye(10) + assert np.allclose(scale @ scale, scale) + assert np.allclose(shape @ shape, shape) + assert np.allclose(scale @ shape, 0.0) + assert np.allclose(scale + shape, identity) + assert np.isclose(np.trace(scale), 1.0) + assert np.isclose(np.trace(shape), FRAME_SHAPE_COUNT) + + +def test_rest_energy_has_fixed_scale_and_shape_weights() -> None: + weights = source_projection_weights() + assert weights["scale"] == pytest.approx(0.25) + assert weights["shape"] == pytest.approx(0.75) + + +def test_zero_momentum_spectrum_has_nine_shape_zeros() -> None: + spectrum = zero_momentum_frame_spectrum(radial_mass_sq=12.5) + assert np.count_nonzero(np.abs(spectrum) <= 1.0e-12) == 9 + assert spectrum[-1] == pytest.approx(12.5) + + +@pytest.mark.parametrize("normalization", [1.0, 19.0, 63.0, 1197.0]) +def test_matrix_and_analytic_responses_agree(normalization: float) -> None: + measured = frame_static_response( + 0.17, + radial_mass_sq=372.0, + normalization=normalization, + ) + expected = analytic_rest_energy_response( + 0.17, + radial_mass_sq=372.0, + normalization=normalization, + ) + assert measured == pytest.approx(expected, rel=1.0e-13) + + +def test_positive_operator_and_attractive_cross_energy() -> None: + operator = frame_static_operator( + 0.2, + radial_mass_sq=372.0, + normalization=1197.0, + ) + assert np.min(np.linalg.eigvalsh(operator)) > 0.0 + assert ( + minimized_source_cross_energy( + 0.2, + 2.0, + radial_mass_sq=372.0, + normalization=1197.0, + ) + < 0.0 + ) diff --git a/tests/test_frame_links.py b/tests/test_frame_links.py new file mode 100644 index 0000000..6e17333 --- /dev/null +++ b/tests/test_frame_links.py @@ -0,0 +1,62 @@ +"""Tests for local frame-comparison provenance algebra.""" + +import numpy as np +import pytest + +from lfm.analysis.frame_links import ( + linked_frame_difference, + loop_holonomy, + loop_mismatch_energy, + reconstructed_frame_link, +) + + +def _frames() -> tuple[np.ndarray, np.ndarray, np.ndarray]: + frame_0 = np.eye(4) + frame_1 = np.diag([1.1, 0.9, 1.2, 0.8]) + frame_2 = np.asarray( + [ + [1.0, 0.1, 0.0, 0.0], + [0.0, 1.1, 0.1, 0.0], + [0.0, 0.0, 0.9, 0.1], + [0.1, 0.0, 0.0, 1.2], + ] + ) + return frame_0, frame_1, frame_2 + + +def test_reconstructed_frame_links_are_flat() -> None: + frame_0, frame_1, frame_2 = _frames() + link_01 = reconstructed_frame_link(frame_0, frame_1) + link_12 = reconstructed_frame_link(frame_1, frame_2) + link_20 = reconstructed_frame_link(frame_2, frame_0) + holonomy = loop_holonomy(link_01, link_12, link_20) + assert np.allclose(holonomy, np.eye(4), atol=1.0e-12) + assert loop_mismatch_energy(holonomy) == pytest.approx( + 0.0, + abs=1.0e-24, + ) + + +def test_linked_difference_transports_site_values() -> None: + frame_0, frame_1, _ = _frames() + global_value = np.asarray([1.0, 2.0, 3.0, 4.0]) + value_0 = np.linalg.solve(frame_0, global_value) + value_1 = np.linalg.solve(frame_1, global_value) + link_01 = reconstructed_frame_link(frame_0, frame_1) + assert np.allclose( + linked_frame_difference(value_0, value_1, link_01), + 0.0, + atol=1.0e-12, + ) + + +def test_independent_link_can_have_positive_loop_mismatch() -> None: + frame_0, frame_1, frame_2 = _frames() + link_01 = reconstructed_frame_link(frame_0, frame_1) + link_12 = reconstructed_frame_link(frame_1, frame_2) + link_20 = reconstructed_frame_link(frame_2, frame_0) + independent = link_01.copy() + independent[0, 1] += 0.05 + holonomy = loop_holonomy(independent, link_12, link_20) + assert loop_mismatch_energy(holonomy) > 0.0 diff --git a/tests/test_gradient_spectroscopy.py b/tests/test_gradient_spectroscopy.py new file mode 100644 index 0000000..3abcd2c --- /dev/null +++ b/tests/test_gradient_spectroscopy.py @@ -0,0 +1,99 @@ +from __future__ import annotations + +import numpy as np + +from lfm.analysis.gradient_spectroscopy import ( + cross_validated_mode, + expected_massless_ir_ratio, + extract_low_momentum_modes, + native_gradient_operators, + operator_completeness_audit, +) +from lfm.constants import CHI0 +from lfm.experiment.euclidean_r2 import EuclideanR2Config, EuclideanR2Sampler + + +def test_u3_basis_is_complete() -> None: + result = operator_completeness_audit() + assert result["pass"] + + +def test_zero_field_has_zero_gradient_operators() -> None: + psi = np.zeros((4, 4, 4, 4, 3), dtype=np.complex128) + chi = np.full((4, 4, 4, 4), CHI0) + operators = native_gradient_operators(psi, chi) + assert operators.shape == (4, 4, 4, 4, 19, 4) + assert np.max(np.abs(operators)) == 0.0 + + +def test_mode_shapes_and_expected_ratio() -> None: + rng = np.random.default_rng(2) + operators = rng.normal(size=(4, 4, 4, 4, 19, 4)) + modes = extract_low_momentum_modes(operators) + assert modes["transverse_k1"].shape == (6, 19) + assert modes["longitudinal_k1"].shape == (3, 19) + assert np.isclose(expected_massless_ir_ratio(4), 2.0) + + +def test_euclidean_sampler_changes_state_locally() -> None: + sampler = EuclideanR2Sampler( + EuclideanR2Config(linear_size=4, model="canonical_quartic", seed=3) + ) + result = sampler.sweep() + assert result["psi_total"] == 4**4 + assert result["phi_total"] == 4**4 + assert np.any(np.abs(sampler.psi) > 0.0) + + +def _complex_noise( + rng: np.random.Generator, + shape: tuple[int, ...], + variance: float, +) -> np.ndarray: + return np.sqrt(0.5 * variance) * (rng.normal(size=shape) + 1j * rng.normal(size=shape)) + + +def test_cross_validated_spectroscopy_recovers_synthetic_massless_channel() -> None: + rng = np.random.default_rng(12) + samples = 1200 + channels = 19 + size = 8 + expected = expected_massless_ir_ratio(size) + modes = { + "transverse_k1": _complex_noise(rng, (samples, 6, channels), 1.0), + "transverse_k2": _complex_noise(rng, (samples, 6, channels), 1.0), + "longitudinal_k1": _complex_noise(rng, (samples, 3, channels), 1.0), + "polarization_0_k1": _complex_noise(rng, (samples, 3, channels), 1.0), + "polarization_1_k1": _complex_noise(rng, (samples, 3, channels), 1.0), + "cone_p1_k1": _complex_noise(rng, (samples, 6, channels), 1.0), + "temporal_k1": _complex_noise(rng, (samples, 3, channels), 1.0), + "temporal_k2": _complex_noise(rng, (samples, 3, channels), 1.0), + } + modes["transverse_k1"][..., 0] = _complex_noise(rng, (samples, 6), expected) + modes["transverse_k2"][..., 0] = _complex_noise(rng, (samples, 6), 1.0) + modes["longitudinal_k1"][..., 0] = _complex_noise(rng, (samples, 3), 0.01 * expected) + modes["polarization_0_k1"][..., 0] = _complex_noise(rng, (samples, 3), expected) + modes["polarization_1_k1"][..., 0] = _complex_noise(rng, (samples, 3), expected) + modes["cone_p1_k1"][..., 0] = _complex_noise(rng, (samples, 6), 0.5 * expected) + result = cross_validated_mode(modes, "raw_u3", size) + assert max(abs(value / expected - 1.0) for value in result.heldout_ir_ratios) < 0.12 + assert max(abs(value / 0.5 - 1.0) for value in result.heldout_cone_ratios) < 0.12 + assert max(result.heldout_longitudinal_ratios) < 0.03 + + +def test_cross_validated_spectroscopy_rejects_white_vector_noise() -> None: + rng = np.random.default_rng(21) + samples = 1200 + channels = 19 + modes = { + "transverse_k1": _complex_noise(rng, (samples, 6, channels), 1.0), + "transverse_k2": _complex_noise(rng, (samples, 6, channels), 1.0), + "longitudinal_k1": _complex_noise(rng, (samples, 3, channels), 1.0), + "polarization_0_k1": _complex_noise(rng, (samples, 3, channels), 1.0), + "polarization_1_k1": _complex_noise(rng, (samples, 3, channels), 1.0), + "cone_p1_k1": _complex_noise(rng, (samples, 6, channels), 1.0), + "temporal_k1": _complex_noise(rng, (samples, 3, channels), 1.0), + "temporal_k2": _complex_noise(rng, (samples, 3, channels), 1.0), + } + result = cross_validated_mode(modes, "raw_u3", 8) + assert max(result.heldout_ir_ratios) < 1.25 diff --git a/tests/test_gravity_recovery.py b/tests/test_gravity_recovery.py new file mode 100644 index 0000000..23daa88 --- /dev/null +++ b/tests/test_gravity_recovery.py @@ -0,0 +1,617 @@ +"""Tests for the experiment-only local GOV-02 gravity recovery path.""" + +from __future__ import annotations + +import inspect + +import numpy as np +import pytest + +from lfm.config import ( + BoundaryType, + ChiPotentialModel, + FieldLevel, + Precision, + SimulationConfig, +) +from lfm.config_presets import full_physics +from lfm.constants import CHI0, LAMBDA_H +from lfm.core.backends import gpu_available +from lfm.core.backends.kernel_source import GRAVITY_RECOVERY_REAL_KERNEL_SRC +from lfm.core.backends.numpy_backend import NumpyBackend +from lfm.experiment.gravity_recovery import ( + gravity_recovery_candidates, + positive_frequency_previous_layers, + potential_force, + potential_second_derivative_at_vacuum, +) +from lfm.simulation import Simulation + + +def _config() -> SimulationConfig: + return SimulationConfig( + grid_size=8, + dt=0.01, + lambda_self=LAMBDA_H, + field_level=FieldLevel.REAL, + boundary_type=BoundaryType.PERIODIC, + precision=Precision.FLOAT64, + enable_chi_floor=False, + report_interval=0, + ) + + +def _complex_config() -> SimulationConfig: + return SimulationConfig( + grid_size=8, + dt=0.01, + lambda_self=LAMBDA_H, + field_level=FieldLevel.COMPLEX, + boundary_type=BoundaryType.PERIODIC, + precision=Precision.FLOAT64, + enable_chi_floor=False, + report_interval=0, + ) + + +def _color_config() -> SimulationConfig: + return SimulationConfig( + grid_size=8, + dt=0.01, + lambda_self=LAMBDA_H, + field_level=FieldLevel.COLOR, + boundary_type=BoundaryType.PERIODIC, + precision=Precision.FLOAT64, + enable_chi_floor=False, + report_interval=0, + ) + + +def _state() -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + rng = np.random.default_rng(104729) + shape = (8, 8, 8) + psi = 0.02 * rng.standard_normal(shape) + psi_prev = psi + 1.0e-4 * rng.standard_normal(shape) + chi = CHI0 + 0.01 * rng.standard_normal(shape) + chi_prev = chi + 1.0e-4 * rng.standard_normal(shape) + return psi, psi_prev, chi, chi_prev + + +def _load( + simulation: Simulation, + state: tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray], +) -> None: + psi, psi_prev, chi, chi_prev = state + simulation.psi_real = psi + simulation._evolver.set_psi_real_prev(psi_prev) + simulation.chi = chi + simulation.chi_previous = chi_prev + + +def test_candidate_catalog_covers_required_families() -> None: + families = {candidate.family for candidate in gravity_recovery_candidates()} + assert families == set("ABCDEFGHIJKL") + + +def test_only_canonical_potential_has_vacuum_curvature() -> None: + canonical = potential_second_derivative_at_vacuum(ChiPotentialModel.CANONICAL_QUARTIC) + assert np.isclose(canonical, 8.0 * LAMBDA_H * CHI0**2, rtol=2.0e-8) + for candidate in gravity_recovery_candidates(): + if candidate.model == ChiPotentialModel.CANONICAL_QUARTIC: + continue + curvature = potential_second_derivative_at_vacuum(candidate.model) + assert abs(curvature) < 2.0e-4 + + +def test_canonical_candidate_matches_production_real_step() -> None: + state = _state() + canonical = Simulation(_config(), backend="cpu") + candidate = Simulation(_config(), backend="cpu") + _load(canonical, state) + _load(candidate, state) + canonical.run(1, record_metrics=False) + candidate.run_gravity_recovery( + 1, + ChiPotentialModel.CANONICAL_QUARTIC, + freeze_psi=False, + ) + assert np.allclose(candidate.psi_real, canonical.psi_real, atol=1.0e-13) + assert np.allclose(candidate.chi, canonical.chi, atol=1.0e-13) + + +def test_frozen_source_remains_exactly_frozen() -> None: + state = _state() + simulation = Simulation(_config(), backend="cpu") + _load(simulation, state) + source = simulation.psi_real.copy() + simulation.run_gravity_recovery( + 4, + ChiPotentialModel.FLAT_OCTIC, + freeze_psi=True, + relaxation_damping=0.5, + ) + assert np.array_equal(simulation.psi_real, source) + + +def test_complex_flat_octic_matches_real_scalar_subspace() -> None: + state = _state() + real = Simulation(_config(), backend="cpu") + complex_simulation = Simulation(_complex_config(), backend="cpu") + _load(real, state) + _load(complex_simulation, state) + complex_simulation.psi_imag = np.zeros_like(state[0]) + complex_simulation.set_psi_imag_prev(np.zeros_like(state[0])) + real.run_gravity_recovery( + 2, + ChiPotentialModel.FLAT_OCTIC, + freeze_psi=False, + ) + complex_simulation.run_gravity_recovery( + 2, + ChiPotentialModel.FLAT_OCTIC, + freeze_psi=False, + ) + assert np.allclose( + complex_simulation.psi_real, + real.psi_real, + atol=1.0e-13, + ) + assert np.allclose(complex_simulation.chi, real.chi, atol=1.0e-13) + + +def test_complex_flat_octic_is_quadrature_invariant() -> None: + state = _state() + real_quadrature = Simulation(_complex_config(), backend="cpu") + imag_quadrature = Simulation(_complex_config(), backend="cpu") + _load(real_quadrature, state) + imag_state = ( + np.zeros_like(state[0]), + np.zeros_like(state[1]), + state[2], + state[3], + ) + _load(imag_quadrature, imag_state) + real_quadrature.psi_imag = np.zeros_like(state[0]) + real_quadrature.set_psi_imag_prev(np.zeros_like(state[0])) + imag_quadrature.psi_imag = state[0] + imag_quadrature.set_psi_imag_prev(state[1]) + real_quadrature.run_gravity_recovery( + 3, + ChiPotentialModel.FLAT_OCTIC, + freeze_psi=False, + ) + imag_quadrature.run_gravity_recovery( + 3, + ChiPotentialModel.FLAT_OCTIC, + freeze_psi=False, + ) + assert np.allclose( + imag_quadrature.psi_imag, + real_quadrature.psi_real, + atol=1.0e-13, + ) + assert np.allclose( + imag_quadrature.chi, + real_quadrature.chi, + atol=1.0e-13, + ) + + +def test_color_flat_octic_matches_complex_one_channel_subspace() -> None: + state = _state() + complex_simulation = Simulation(_complex_config(), backend="cpu") + color_simulation = Simulation(_color_config(), backend="cpu") + _load(complex_simulation, state) + complex_simulation.psi_imag = np.zeros_like(state[0]) + complex_simulation.set_psi_imag_prev(np.zeros_like(state[0])) + color_real = np.zeros((3,) + state[0].shape) + color_real_prev = np.zeros_like(color_real) + color_real[0] = state[0] + color_real_prev[0] = state[1] + color_simulation.psi_real = color_real + color_simulation.set_psi_real_prev(color_real_prev) + color_simulation.psi_imag = np.zeros_like(color_real) + color_simulation.set_psi_imag_prev(np.zeros_like(color_real)) + color_simulation.chi = state[2] + color_simulation.chi_previous = state[3] + complex_simulation.run_gravity_recovery( + 2, + ChiPotentialModel.FLAT_OCTIC, + freeze_psi=False, + ) + color_simulation.run_gravity_recovery( + 2, + ChiPotentialModel.FLAT_OCTIC, + freeze_psi=False, + ) + assert np.allclose( + color_simulation.psi_real[0], + complex_simulation.psi_real, + atol=1.0e-13, + ) + assert np.allclose( + color_simulation.chi, + complex_simulation.chi, + atol=1.0e-13, + ) + + +def test_positive_frequency_previous_layer_matches_uniform_mode() -> None: + shape = (3, 8, 8, 8) + real = np.zeros(shape) + imag = np.zeros(shape) + real[0] = 0.25 + chi = np.full(shape[-3:], CHI0) + dt = 0.005 + previous_real, previous_imag, metadata = positive_frequency_previous_layers( + real, + imag, + chi, + dt=dt, + polynomial_degree=12, + ) + cosine = 1.0 - 0.5 * dt**2 * CHI0**2 + sine = np.sqrt(1.0 - cosine**2) + assert np.allclose(previous_real[0], cosine * real[0], atol=1.0e-13) + assert np.allclose(previous_imag[0], sine * real[0], atol=1.0e-13) + assert np.array_equal(previous_real[1:], np.zeros_like(real[1:])) + assert np.array_equal(previous_imag[1:], np.zeros_like(imag[1:])) + assert metadata["local_stencil_radius_upper_bound"] == 12 + + +def test_positive_frequency_initializer_contains_no_inverse_solver() -> None: + source = inspect.getsource(positive_frequency_previous_layers).lower() + for forbidden in ( + "np.fft", + "rfftn", + "irfftn", + "green", + "poisson", + "inverse_square", + "np.linalg.inv", + "np.linalg.solve", + ): + assert forbidden not in source + + +@pytest.mark.skipif(not gpu_available(), reason="CuPy GPU backend unavailable") +def test_complex_flat_octic_cpu_gpu_parity() -> None: + state = _state() + cpu = Simulation(_complex_config(), backend="cpu") + gpu = Simulation(_complex_config(), backend="gpu") + _load(cpu, state) + _load(gpu, state) + imag = 0.75 * state[0] + imag_prev = 0.75 * state[1] + for simulation in (cpu, gpu): + simulation.psi_imag = imag + simulation.set_psi_imag_prev(imag_prev) + simulation.run_gravity_recovery( + 2, + ChiPotentialModel.FLAT_OCTIC, + freeze_psi=False, + relaxation_damping=0.25, + ) + assert np.allclose(gpu.psi_real, cpu.psi_real, atol=2.0e-11) + assert np.allclose(gpu.psi_imag, cpu.psi_imag, atol=2.0e-11) + assert np.allclose(gpu.chi, cpu.chi, atol=2.0e-11) + + +@pytest.mark.skipif(not gpu_available(), reason="CuPy GPU backend unavailable") +def test_color_flat_octic_cpu_gpu_parity() -> None: + state = _state() + cpu = Simulation(_color_config(), backend="cpu") + gpu = Simulation(_color_config(), backend="gpu") + rng = np.random.default_rng(15485863) + color_real = 0.02 * rng.standard_normal((3,) + state[0].shape) + color_imag = 0.02 * rng.standard_normal((3,) + state[0].shape) + color_real_prev = color_real + 1.0e-4 * rng.standard_normal(color_real.shape) + color_imag_prev = color_imag + 1.0e-4 * rng.standard_normal(color_imag.shape) + for simulation in (cpu, gpu): + simulation.psi_real = color_real + simulation.set_psi_real_prev(color_real_prev) + simulation.psi_imag = color_imag + simulation.set_psi_imag_prev(color_imag_prev) + simulation.chi = state[2] + simulation.chi_previous = state[3] + simulation.run_gravity_recovery( + 2, + ChiPotentialModel.FLAT_OCTIC, + freeze_psi=False, + relaxation_damping=0.25, + ) + assert np.allclose(gpu.psi_real, cpu.psi_real, atol=2.0e-11) + assert np.allclose(gpu.psi_imag, cpu.psi_imag, atol=2.0e-11) + assert np.allclose(gpu.chi, cpu.chi, atol=2.0e-11) + + +@pytest.mark.skipif(not gpu_available(), reason="CuPy GPU backend unavailable") +@pytest.mark.parametrize( + "model", + [ + ChiPotentialModel.CANONICAL_QUARTIC, + ChiPotentialModel.SMOOTH_EXPONENTIAL, + ChiPotentialModel.NONLINEAR_GRADIENT, + ChiPotentialModel.VARIABLE_INERTIA, + ], +) +def test_cpu_gpu_candidate_step_parity(model: ChiPotentialModel) -> None: + state = _state() + cpu = Simulation(_config(), backend="cpu") + gpu = Simulation(_config(), backend="gpu") + _load(cpu, state) + _load(gpu, state) + cpu.run_gravity_recovery( + 2, + model, + freeze_psi=True, + relaxation_damping=0.25, + ) + gpu.run_gravity_recovery( + 2, + model, + freeze_psi=True, + relaxation_damping=0.25, + ) + assert np.allclose(gpu.chi, cpu.chi, atol=2.0e-11, rtol=2.0e-11) + + +def test_analysis_and_backend_force_laws_are_finite() -> None: + chi = np.linspace(0.1 * CHI0, 1.5 * CHI0, 51) + source = np.linspace(0.0, CHI0**2, 51) + for candidate in gravity_recovery_candidates(): + force = potential_force( + chi, + candidate.model, + source_density=source, + ) + assert np.all(np.isfinite(force)) + + +def test_gravity_recovery_evolution_contains_no_nonlocal_solver() -> None: + cpu_source = inspect.getsource(NumpyBackend.step_real_gravity_recovery) + combined = cpu_source.lower() + GRAVITY_RECOVERY_REAL_KERNEL_SRC.lower() + for forbidden in ( + "np.fft", + "rfftn", + "irfftn", + "green", + "poisson", + "inverse_square", + "target_profile", + ): + assert forbidden not in combined + + +def test_flat_octic_force_matches_declared_equation() -> None: + chi = np.linspace(0.5 * CHI0, 1.5 * CHI0, 41) + measured = potential_force( + chi, + ChiPotentialModel.FLAT_OCTIC, + ) + expected = -8.0 * LAMBDA_H * chi * (chi**2 - CHI0**2) ** 3 / CHI0**4 + assert np.allclose(measured, expected, atol=1.0e-10, rtol=1.0e-13) + + +def test_invalid_timestep_override_is_rejected() -> None: + simulation = Simulation(_config(), backend="cpu") + with pytest.raises(ValueError, match="dt_override"): + simulation.run_gravity_recovery( + 1, + ChiPotentialModel.FLAT_OCTIC, + dt_override=0.0, + ) + + +@pytest.mark.parametrize("lambda_self", [0.0, LAMBDA_H]) +def test_bare_color_candidate_is_charge_conjugation_blind( + lambda_self: float, +) -> None: + rng = np.random.default_rng(260725) + shape = (3, 8, 8, 8) + psi = 0.002 * (rng.standard_normal(shape) + 1j * rng.standard_normal(shape)) + psi_prev = psi + 0.0001 * (rng.standard_normal(shape) + 1j * rng.standard_normal(shape)) + chi = CHI0 + 0.002 * rng.standard_normal(shape[-3:]) + chi_prev = chi + 0.0001 * rng.standard_normal(chi.shape) + simulations = [] + for field, previous in ((psi, psi_prev), (np.conj(psi), np.conj(psi_prev))): + config = _color_config() + config.lambda_self = lambda_self + config.epsilon_w = 0.0 + simulation = Simulation(config, backend="cpu") + simulation.psi_real = field.real + simulation.psi_imag = field.imag + simulation.set_psi_real_prev(previous.real) + simulation.set_psi_imag_prev(previous.imag) + simulation.chi = chi + simulation.chi_previous = chi_prev + simulation.run_gravity_recovery( + 5, + ChiPotentialModel.FLAT_OCTIC, + freeze_psi=False, + ) + simulations.append(simulation) + positive, negative = simulations + assert np.array_equal(negative.chi, positive.chi) + assert np.allclose(negative.psi_real, positive.psi_real, atol=1.0e-14) + assert np.allclose(negative.psi_imag, -positive.psi_imag, atol=1.0e-14) + + +@pytest.mark.parametrize("lambda_self", [0.0, LAMBDA_H]) +def test_bare_color_candidate_remains_parity_equivariant( + lambda_self: float, +) -> None: + rng = np.random.default_rng(32452843) + shape = (3, 8, 8, 8) + psi = 0.002 * (rng.standard_normal(shape) + 1j * rng.standard_normal(shape)) + psi_prev = psi + 0.0001 * (rng.standard_normal(shape) + 1j * rng.standard_normal(shape)) + chi = CHI0 + 0.002 * rng.standard_normal(shape[-3:]) + chi_prev = chi + 0.0001 * rng.standard_normal(chi.shape) + simulations = [] + for field, previous, substrate, substrate_prev in ( + (psi, psi_prev, chi, chi_prev), + ( + psi[:, ::-1, :, :], + psi_prev[:, ::-1, :, :], + chi[::-1, :, :], + chi_prev[::-1, :, :], + ), + ): + config = _color_config() + config.lambda_self = lambda_self + config.epsilon_w = 0.0 + simulation = Simulation(config, backend="cpu") + simulation.psi_real = field.real + simulation.psi_imag = field.imag + simulation.set_psi_real_prev(previous.real) + simulation.set_psi_imag_prev(previous.imag) + simulation.chi = substrate + simulation.chi_previous = substrate_prev + simulation.run_gravity_recovery( + 5, + ChiPotentialModel.FLAT_OCTIC, + freeze_psi=False, + ) + simulations.append(simulation) + original, mirrored = simulations + assert np.allclose( + mirrored.psi_real, + original.psi_real[:, ::-1, :, :], + atol=1.0e-14, + ) + assert np.allclose( + mirrored.psi_imag, + original.psi_imag[:, ::-1, :, :], + atol=1.0e-14, + ) + assert np.allclose(mirrored.chi, original.chi[::-1, :, :], atol=1.0e-13) + + +@pytest.mark.parametrize("lambda_self", [0.0, LAMBDA_H]) +def test_bare_color_candidate_is_globally_su3_covariant( + lambda_self: float, +) -> None: + rng = np.random.default_rng(49979687) + shape = (3, 8, 8, 8) + psi = 0.001 * (rng.standard_normal(shape) + 1j * rng.standard_normal(shape)) + psi_prev = psi + 0.00005 * (rng.standard_normal(shape) + 1j * rng.standard_normal(shape)) + random_matrix = rng.standard_normal((3, 3)) + 1j * rng.standard_normal((3, 3)) + unitary, _ = np.linalg.qr(random_matrix) + unitary = unitary / np.linalg.det(unitary) ** (1.0 / 3.0) + rotated = np.einsum("ab,bijk->aijk", unitary, psi) + rotated_prev = np.einsum("ab,bijk->aijk", unitary, psi_prev) + chi = CHI0 + 0.001 * rng.standard_normal(shape[-3:]) + chi_prev = chi + 0.00005 * rng.standard_normal(chi.shape) + simulations = [] + for field, previous in ((psi, psi_prev), (rotated, rotated_prev)): + config = _color_config() + config.lambda_self = lambda_self + simulation = Simulation(config, backend="cpu") + simulation.psi_real = field.real + simulation.psi_imag = field.imag + simulation.set_psi_real_prev(previous.real) + simulation.set_psi_imag_prev(previous.imag) + simulation.chi = chi + simulation.chi_previous = chi_prev + simulation.run_gravity_recovery( + 5, + ChiPotentialModel.FLAT_OCTIC, + freeze_psi=False, + ) + simulations.append(simulation) + original, transformed = simulations + original_field = original.psi_real + 1j * original.psi_imag + transformed_field = transformed.psi_real + 1j * transformed.psi_imag + expected = np.einsum("ab,bijk->aijk", unitary, original_field) + assert np.array_equal(transformed.chi, original.chi) + assert np.allclose(transformed_field, expected, atol=1.0e-14) + + +def _full_color_config(lambda_self: float) -> SimulationConfig: + return full_physics( + grid_size=8, + boundary_type=BoundaryType.PERIODIC, + lambda_self=lambda_self, + precision=Precision.FLOAT64, + enable_chi_floor=False, + use_stencil19_noether_current=True, + report_interval=0, + ) + + +def _load_full_color_state( + simulation: Simulation, + *, + seed: int = 67867967, +) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: + rng = np.random.default_rng(seed) + shape = (3, 8, 8, 8) + psi = 0.003 * (rng.standard_normal(shape) + 1j * rng.standard_normal(shape)) + psi_prev = psi + 0.0001 * (rng.standard_normal(shape) + 1j * rng.standard_normal(shape)) + chi = CHI0 + 0.003 * rng.standard_normal(shape[-3:]) + chi_prev = chi + 0.0001 * rng.standard_normal(chi.shape) + simulation.psi_real = psi.real + simulation.psi_imag = psi.imag + simulation.set_psi_real_prev(psi_prev.real) + simulation.set_psi_imag_prev(psi_prev.imag) + simulation.chi = chi + simulation.chi_previous = chi_prev + return psi, psi_prev, chi, chi_prev + + +def test_full_color_zero_restoring_candidate_matches_production_step() -> None: + production = Simulation(_full_color_config(0.0), backend="cpu") + candidate = Simulation(_full_color_config(0.0), backend="cpu") + _load_full_color_state(production) + _load_full_color_state(candidate) + production.run(1) + candidate.run_gravity_recovery( + 1, + ChiPotentialModel.FLAT_OCTIC, + freeze_psi=False, + ) + assert np.allclose(candidate.psi_real, production.psi_real, atol=1.0e-14) + assert np.allclose(candidate.psi_imag, production.psi_imag, atol=1.0e-14) + assert np.allclose(candidate.chi, production.chi, atol=1.0e-13) + + +def test_full_color_flat_octic_changes_only_restoring_force() -> None: + production = Simulation(_full_color_config(0.0), backend="cpu") + candidate = Simulation(_full_color_config(LAMBDA_H), backend="cpu") + _, _, chi, _ = _load_full_color_state(production) + _load_full_color_state(candidate) + production.run(1) + candidate.run_gravity_recovery( + 1, + ChiPotentialModel.FLAT_OCTIC, + freeze_psi=False, + ) + expected_delta = production.config.dt**2 * potential_force( + chi, + ChiPotentialModel.FLAT_OCTIC, + ) + assert np.allclose(candidate.psi_real, production.psi_real, atol=1.0e-14) + assert np.allclose(candidate.psi_imag, production.psi_imag, atol=1.0e-14) + assert np.allclose( + candidate.chi - production.chi, + expected_delta, + atol=2.0e-13, + rtol=1.0e-10, + ) + + +@pytest.mark.skipif(not gpu_available(), reason="CuPy GPU backend unavailable") +def test_full_color_flat_octic_cpu_gpu_parity() -> None: + cpu = Simulation(_full_color_config(LAMBDA_H), backend="cpu") + gpu = Simulation(_full_color_config(LAMBDA_H), backend="gpu") + _load_full_color_state(cpu, seed=86028121) + _load_full_color_state(gpu, seed=86028121) + for simulation in (cpu, gpu): + simulation.run_gravity_recovery( + 2, + ChiPotentialModel.FLAT_OCTIC, + freeze_psi=False, + ) + assert np.allclose(gpu.psi_real, cpu.psi_real, atol=2.0e-10) + assert np.allclose(gpu.psi_imag, cpu.psi_imag, atol=2.0e-10) + assert np.allclose(gpu.chi, cpu.chi, atol=2.0e-10) diff --git a/tests/test_limit02_orbit.py b/tests/test_limit02_orbit.py new file mode 100644 index 0000000..77494ae --- /dev/null +++ b/tests/test_limit02_orbit.py @@ -0,0 +1,76 @@ +"""Tests for the macroscopic LIMIT-02 two-body reduction.""" + +import numpy as np + +import lfm + + +def test_smooth_spherical_density_normalizes_mass(): + density = lfm.smooth_spherical_density( + 32, + center=(16.0, 16.0, 16.0), + radius=4.5, + mass=123.0, + ) + assert density.shape == (32, 32, 32) + assert np.all(density >= 0.0) + np.testing.assert_allclose( + np.sum(density), + 123.0, + rtol=1e-13, + ) + + +def test_periodic_trilinear_sample_affine_interior(): + x, y, z = np.meshgrid( + np.arange(8, dtype=np.float64), + np.arange(8, dtype=np.float64), + np.arange(8, dtype=np.float64), + indexing="ij", + ) + field = 2.0 * x - 3.0 * y + 0.5 * z + point = (2.25, 3.5, 4.75) + measured = lfm.periodic_trilinear_sample(field, point) + expected = 2.0 * point[0] - 3.0 * point[1] + 0.5 * point[2] + np.testing.assert_allclose(measured, expected, atol=1e-12) + + +def test_limit02_profile_accelerates_inward(): + source = lfm.build_limit02_body_profile( + 32, + radius=4.0, + mass=100.0, + ) + acceleration = lfm.limit02_acceleration_from_profile( + source, + (10.0, 0.0, 0.0), + ) + assert acceleration[0] < 0.0 + assert abs(acceleration[1]) < 1e-12 + assert abs(acceleration[2]) < 1e-12 + + +def test_rest_release_reduces_separation(): + heavy = lfm.build_limit02_body_profile( + 32, + radius=4.0, + mass=100.0, + ) + light = lfm.build_limit02_body_profile( + 32, + radius=1.5, + mass=2.0, + ) + rows = lfm.integrate_limit02_two_body( + heavy, + light, + initial_separation=10.0, + light_tangential_speed=0.0, + dt=1.0, + steps=100, + sample_every=10, + ) + summary = lfm.summarize_limit02_orbit(rows) + assert summary["initial_heavy_inward_acceleration"] > 0.0 + assert summary["initial_light_inward_acceleration"] > 0.0 + assert summary["final_separation"] < summary["initial_separation"] diff --git a/tests/test_local_phase_clock.py b/tests/test_local_phase_clock.py new file mode 100644 index 0000000..1673070 --- /dev/null +++ b/tests/test_local_phase_clock.py @@ -0,0 +1,84 @@ +"""Tests for local Noether phase-clock maps.""" + +import numpy as np +import pytest + +from lfm.config import FieldLevel, SimulationConfig +from lfm.core.evolver import Evolver + + +def test_local_phase_clock_rotates_complex_state_and_prev_buffers(): + cfg = SimulationConfig( + grid_size=8, + field_level=FieldLevel.COMPLEX, + e0_sq=0.0, + lambda_self=0.0, + kappa=0.0, + ) + ev = Evolver(cfg, backend="cpu") + real = np.ones((8, 8, 8), dtype=np.float64) + imag = np.zeros((8, 8, 8), dtype=np.float64) + dwell = np.zeros((8, 8, 8), dtype=np.float64) + dwell[1, 2, 3] = 1.0 + dwell[2, 3, 4] = 2.0 + dwell[3, 4, 5] = 3.0 + + ev.set_psi_real(real) + ev.set_psi_imag(imag) + ev.set_local_phase_clock_map(dwell, np.pi / 2.0) + ev.apply_local_phase_clock_map() + + expected = real.astype(np.complex128) * np.exp(1j * dwell * np.pi / 2.0) + np.testing.assert_allclose(ev.get_psi_real(), expected.real, atol=1e-12) + np.testing.assert_allclose(ev.get_psi_imag(), expected.imag, atol=1e-12) + np.testing.assert_allclose(ev.get_psi_real_prev(), expected.real, atol=1e-12) + np.testing.assert_allclose(ev.get_psi_imag_prev(), expected.imag, atol=1e-12) + + +def test_local_phase_clock_can_address_color_components_separately(): + cfg = SimulationConfig( + grid_size=8, + field_level=FieldLevel.COLOR, + n_colors=3, + e0_sq=0.0, + lambda_self=0.0, + kappa=0.0, + ) + ev = Evolver(cfg, backend="cpu") + real = np.ones((3, 8, 8, 8), dtype=np.float64) + imag = np.zeros((3, 8, 8, 8), dtype=np.float64) + dwell = np.zeros((3, 8, 8, 8), dtype=np.float64) + dwell[1, 1, 2, 3] = 1.0 + + ev.set_psi_real(real) + ev.set_psi_imag(imag) + ev.set_local_phase_clock_map(dwell, np.pi) + ev.apply_local_phase_clock_map() + + expected = real.astype(np.complex128) + expected[1, 1, 2, 3] *= -1.0 + np.testing.assert_allclose(ev.get_psi_real(), expected.real, atol=1e-12) + np.testing.assert_allclose(ev.get_psi_imag(), expected.imag, atol=1e-12) + + +def test_local_phase_clock_rejects_real_field_level(): + cfg = SimulationConfig(grid_size=8, field_level=FieldLevel.REAL) + ev = Evolver(cfg, backend="cpu") + + with pytest.raises(ValueError, match="complex field level"): + ev.set_local_phase_clock_map(np.zeros((8, 8, 8)), np.pi) + + +def test_local_phase_clock_map_must_be_declared_before_evolution(): + cfg = SimulationConfig( + grid_size=8, + field_level=FieldLevel.COMPLEX, + e0_sq=0.0, + lambda_self=0.0, + kappa=0.0, + ) + ev = Evolver(cfg, backend="cpu") + ev.evolve(1) + + with pytest.raises(RuntimeError, match="before evolution"): + ev.set_local_phase_clock_map(np.zeros((8, 8, 8)), np.pi) diff --git a/tests/test_mode_analysis.py b/tests/test_mode_analysis.py new file mode 100644 index 0000000..c7c1e2d --- /dev/null +++ b/tests/test_mode_analysis.py @@ -0,0 +1,55 @@ +"""Tests for periodic spatial and leapfrog temporal mode observables.""" + +from __future__ import annotations + +import numpy as np +import pytest + +import lfm + + +def test_periodic_mode_coefficient_handles_a_bank_of_lines() -> None: + n = 17 + z = np.arange(n, dtype=np.float64) + amplitudes = np.asarray([1.0 + 0.5j, -0.2 + 0.3j]) + field = amplitudes[:, None] * np.exp(2j * np.pi * 5 * z / n) + observed = lfm.periodic_mode_coefficient(field, 5, axis=1) + assert np.allclose(observed, amplitudes, rtol=0.0, atol=1.0e-15) + + +def test_periodic_mode_coefficient_subtracts_background() -> None: + n = 16 + z = np.arange(n, dtype=np.float64) + field = 19.0 + 0.4 * np.exp(2j * np.pi * 3 * z / n) + observed = lfm.periodic_mode_coefficient(field, 3, background=19.0) + assert np.isclose(observed, 0.4, rtol=0.0, atol=1.0e-15) + + +def test_leapfrog_branch_projection_recovers_both_branches() -> None: + theta = 0.37 + forward = np.asarray([0.7 - 0.2j, -0.1 + 0.4j]) + backward = np.asarray([-0.03 + 0.04j, 0.2 - 0.1j]) + current = forward + backward + previous = forward * np.exp(1j * theta) + backward * np.exp(-1j * theta) + observed_forward, observed_backward = lfm.leapfrog_branch_projection(current, previous, theta) + assert np.allclose(observed_forward, forward, rtol=0.0, atol=1.0e-15) + assert np.allclose(observed_backward, backward, rtol=0.0, atol=1.0e-15) + + +def test_leapfrog_branch_projection_rejects_degenerate_frequency() -> None: + with pytest.raises(ValueError): + lfm.leapfrog_branch_projection(1.0, 1.0, 0.0) + + +def test_project_leapfrog_mode_locks_spatial_and_buffer_conventions() -> None: + n = 20 + z = np.arange(n, dtype=np.float64) + theta = 0.41 + forward = 0.8 - 0.3j + backward = -0.04 + 0.06j + spatial = np.exp(2j * np.pi * 7 * z / n) + current = (forward + backward) * spatial + previous = (forward * np.exp(1j * theta) + backward * np.exp(-1j * theta)) * spatial + observed_forward, observed_backward = lfm.project_leapfrog_mode(current, previous, 7, theta) + assert abs(observed_forward - forward) <= 1.0e-15 + assert abs(observed_backward - backward) <= 1.0e-15 diff --git a/tests/test_noether_current_19pt.py b/tests/test_noether_current_19pt.py new file mode 100644 index 0000000..329f62d --- /dev/null +++ b/tests/test_noether_current_19pt.py @@ -0,0 +1,230 @@ +"""Tests for the opt-in 19-point current-feedback observable.""" + +from __future__ import annotations + +import numpy as np +import pytest + +import lfm +from lfm.core.backends import gpu_available +from lfm.core.stencils import noether_current_19pt_raw + + +def _plane_wave( + n: int, + modes: tuple[int, int, int], + amplitude: float, + phase0: float = 0.0, +) -> tuple[np.ndarray, np.ndarray]: + coords = np.arange(n, dtype=np.float64) + x, y, z = np.meshgrid(coords, coords, coords, indexing="ij") + kx, ky, kz = (2.0 * np.pi * mode / n for mode in modes) + phase = kx * x + ky * y + kz * z + phase0 + return amplitude * np.cos(phase), amplitude * np.sin(phase) + + +def _nyquist_strip( + n: int, + amplitude: float, + delta: float, +) -> tuple[np.ndarray, np.ndarray, tuple[int, int, int]]: + psi = np.zeros((n, n, n), dtype=np.complex128) + x0 = n // 2 + y0 = n // 2 - 1 + carrier = np.where(np.arange(n) % 2 == 0, 1.0, -1.0) + outer = amplitude * np.exp(-1j * delta) * carrier + center = amplitude * carrier + psi[x0, y0, :] = outer + psi[x0, y0 + 1, :] = center + psi[x0, y0 + 2, :] = outer + return psi.real, psi.imag, (x0, y0, n // 2) + + +class TestNoetherCurrent19ptSymbol: + def test_plane_wave_matches_stencil_symbol(self): + n = 24 + modes = (2, 3, 5) + amplitude = 1.7 + real, imag = _plane_wave(n, modes, amplitude, phase0=0.31) + + jx_raw, jy_raw, jz_raw = noether_current_19pt_raw(real, imag) + + kx, ky, kz = (2.0 * np.pi * mode / n for mode in modes) + expected = ( + (2.0 * amplitude**2 / 3.0) * np.sin(kx) * (1.0 + np.cos(ky) + np.cos(kz)), + (2.0 * amplitude**2 / 3.0) * np.sin(ky) * (1.0 + np.cos(kx) + np.cos(kz)), + (2.0 * amplitude**2 / 3.0) * np.sin(kz) * (1.0 + np.cos(kx) + np.cos(ky)), + ) + for actual, target in zip((jx_raw, jy_raw, jz_raw), expected, strict=True): + np.testing.assert_allclose(actual, target, rtol=0.0, atol=2e-14) + + def test_global_phase_invariant_and_wave_reversal_changes_sign(self): + n = 20 + modes = (2, 1, 3) + real_a, imag_a = _plane_wave(n, modes, 0.8, phase0=0.0) + real_b, imag_b = _plane_wave(n, modes, 0.8, phase0=1.23) + real_r, imag_r = _plane_wave(n, tuple(-mode for mode in modes), 0.8) + + current_a = noether_current_19pt_raw(real_a, imag_a) + current_b = noether_current_19pt_raw(real_b, imag_b) + current_r = noether_current_19pt_raw(real_r, imag_r) + for base, shifted, reversed_wave in zip(current_a, current_b, current_r, strict=True): + np.testing.assert_allclose(shifted, base, rtol=0.0, atol=1e-14) + np.testing.assert_allclose(reversed_wave, -base, rtol=0.0, atol=1e-14) + + def test_transverse_nyquist_strip_cancels_face_with_edges(self): + real, imag, (x0, y0, z0) = _nyquist_strip(12, 1.4, 0.37) + _, jy_raw, _ = noether_current_19pt_raw(real, imag) + + np.testing.assert_allclose( + jy_raw[x0, y0 : y0 + 3, :], + 0.0, + rtol=0.0, + atol=1e-15, + ) + + d_real_y = np.roll(real, -1, axis=1) - np.roll(real, 1, axis=1) + d_imag_y = np.roll(imag, -1, axis=1) - np.roll(imag, 1, axis=1) + legacy_raw = real * d_imag_y - imag * d_real_y + expected_top = 1.4**2 * np.sin(0.37) + np.testing.assert_allclose(legacy_raw[x0, y0, z0], expected_top, rtol=0.0, atol=1e-14) + np.testing.assert_allclose(legacy_raw[x0, y0 + 2, z0], -expected_top, rtol=0.0, atol=1e-14) + + +def test_cpu_complex_one_step_source_sign_and_normalization(): + n = 12 + amplitude = 0.4 + epsilon_w = 0.2 + modes = (1, 2, 3) + real, imag = _plane_wave(n, modes, amplitude, phase0=0.17) + cfg = lfm.SimulationConfig( + grid_size=n, + precision=lfm.Precision.FLOAT64, + field_level=lfm.FieldLevel.COMPLEX, + boundary_type=lfm.BoundaryType.PERIODIC, + lambda_self=0.0, + e0_sq=amplitude**2, + epsilon_w=epsilon_w, + use_stencil19_noether_current=True, + report_interval=10**9, + ) + sim = lfm.Simulation(cfg, backend="cpu") + sim.set_psi_real(real) + sim.set_psi_imag(imag) + sim.run(1, record_metrics=False) + + raw_components = noether_current_19pt_raw(real, imag) + physical_scalar = 0.5 * sum(raw_components) + expected_chi = cfg.chi0 - cfg.dt**2 * cfg.kappa * epsilon_w * physical_scalar + np.testing.assert_allclose(sim.get_chi(), expected_chi, rtol=0.0, atol=2e-14) + + +def _run_nyquist_color( + epsilon_w: float, + use_stencil19: bool, + backend: str, +) -> np.ndarray: + n = 12 + real, imag, _ = _nyquist_strip(n, 1.4, 0.37) + real_color = np.zeros((3, n, n, n), dtype=np.float64) + imag_color = np.zeros_like(real_color) + real_color[0] = real + imag_color[0] = imag + cfg = lfm.SimulationConfig( + grid_size=n, + precision=lfm.Precision.FLOAT64, + field_level=lfm.FieldLevel.COLOR, + boundary_type=lfm.BoundaryType.PERIODIC, + lambda_self=0.0, + epsilon_w=epsilon_w, + use_stencil19_noether_current=use_stencil19, + report_interval=10**9, + ) + sim = lfm.Simulation(cfg, backend=backend) + sim.set_psi_real(real_color) + sim.set_psi_imag(imag_color) + sim.run(1, record_metrics=False) + return sim.get_chi() + + +def test_cpu_color_default_retains_legacy_and_opt_in_removes_artifact(): + legacy_zero = _run_nyquist_color(0.0, False, "cpu") + legacy_active = _run_nyquist_color(1.0, False, "cpu") + stencil_zero = _run_nyquist_color(0.0, True, "cpu") + stencil_active = _run_nyquist_color(1.0, True, "cpu") + + n = legacy_zero.shape[0] + x0 = n // 2 + y0 = n // 2 - 1 + expected = -0.5 * (0.02**2) * lfm.KAPPA * 1.4**2 * np.sin(0.37) + np.testing.assert_allclose( + legacy_active[x0, y0, :] - legacy_zero[x0, y0, :], + expected, + rtol=0.0, + atol=2e-14, + ) + np.testing.assert_allclose( + legacy_active[x0, y0 + 2, :] - legacy_zero[x0, y0 + 2, :], + -expected, + rtol=0.0, + atol=2e-14, + ) + np.testing.assert_array_equal(stencil_active, stencil_zero) + + +@pytest.mark.gpu +@pytest.mark.skipif(not gpu_available(), reason="CuPy GPU backend unavailable") +@pytest.mark.parametrize( + "field_level", + (lfm.FieldLevel.COMPLEX, lfm.FieldLevel.COLOR), +) +def test_float64_gpu_matches_cpu_with_stencil19_current(field_level): + n = 8 + cfg = lfm.SimulationConfig( + grid_size=n, + precision=lfm.Precision.FLOAT64, + field_level=field_level, + boundary_type=lfm.BoundaryType.PERIODIC, + lambda_self=0.0, + epsilon_w=0.1, + e0_sq=0.01, + use_stencil19_noether_current=True, + report_interval=10**9, + ) + cpu = lfm.Simulation(cfg, backend="cpu") + gpu = lfm.Simulation(cfg, backend="gpu") + + rng = np.random.default_rng(20260716) + prefix = (3,) if field_level == lfm.FieldLevel.COLOR else () + shape = prefix + (n, n, n) + real = rng.normal(0.0, 0.05, shape) + imag = rng.normal(0.0, 0.05, shape) + real_prev = real + rng.normal(0.0, 1e-4, shape) + imag_prev = imag + rng.normal(0.0, 1e-4, shape) + chi = 19.0 + rng.normal(0.0, 1e-3, (n, n, n)) + chi_prev = chi + rng.normal(0.0, 1e-5, (n, n, n)) + for sim in (cpu, gpu): + sim.set_psi_real(real) + sim.set_psi_imag(imag) + sim.set_psi_real_prev(real_prev) + sim.set_psi_imag_prev(imag_prev) + sim.set_chi(chi) + sim.set_chi_prev(chi_prev) + sim.run(3, record_metrics=False) + + cpu_state = cpu.phase_space_snapshot() + gpu_state = gpu.phase_space_snapshot() + for key in ( + "psi_real", + "psi_real_prev", + "psi_imag", + "psi_imag_prev", + "chi", + "chi_prev", + ): + np.testing.assert_allclose( + gpu_state[key], + cpu_state[key], + rtol=5e-12, + atol=5e-12, + ) diff --git a/tests/test_noether_soliton.py b/tests/test_noether_soliton.py new file mode 100644 index 0000000..397b19f --- /dev/null +++ b/tests/test_noether_soliton.py @@ -0,0 +1,232 @@ +from __future__ import annotations + +import numpy as np + +from lfm import BoundaryType, FieldLevel, Simulation, SimulationConfig +from lfm.particles.noether import ( + cartesian_localization_metrics, + cartesian_noether_charge, + lift_radial_noether_state, + make_radial_bag_guess, + prolong_radial_fields, + radial_fixed_charge_energy_and_gradient, + radial_fixed_charge_hessian, + radial_shell_geometry, +) + + +def test_radial_geometry_exactly_fills_domain() -> None: + r, volumes, areas = radial_shell_geometry(2.0, 0.1) + assert r.shape == (20,) + assert volumes.shape == (20,) + assert areas.shape == (21,) + assert np.isclose(np.sum(volumes), 4.0 * np.pi * 2.0**3 / 3.0) + assert areas[0] == 0.0 + + +def test_bag_guess_has_requested_charge_at_guess_frequency() -> None: + target_charge = 1200.0 + omega_guess = 10.0 + variables = make_radial_bag_guess( + target_charge=target_charge, + radius=3.0, + dx=0.1, + core_radius=0.7, + omega_guess=omega_guess, + ) + _, volumes, _ = radial_shell_geometry(3.0, 0.1) + cells = volumes.size + norm = float(np.dot(volumes, variables[:cells] ** 2)) + assert np.isclose(omega_guess * norm, target_charge, rtol=1.0e-13) + + +def test_cell_centered_prolongation_matches_declared_linear_interpolation() -> None: + source_r, _, _ = radial_shell_geometry(2.0, 0.1) + phi = np.exp(-source_r) + chi = 19.0 - np.exp(-source_r) + prolonged = prolong_radial_fields( + radius=2.0, + source_r=source_r, + phi=phi, + chi=chi, + new_dx=0.05, + ) + new_r, _, _ = radial_shell_geometry(2.0, 0.05) + cells = new_r.size + assert prolonged.shape == (2 * cells,) + assert np.all(np.isfinite(prolonged)) + assert np.allclose( + prolonged[:cells], + np.interp(new_r, source_r, phi, left=phi[0], right=0.0), + ) + assert np.allclose( + prolonged[cells:], + np.interp(new_r, source_r, chi, left=chi[0], right=19.0), + ) + + +def test_fixed_charge_analytic_gradient_matches_finite_difference() -> None: + target_charge = 800.0 + radius = 1.2 + dx = 0.1 + variables = make_radial_bag_guess( + target_charge=target_charge, + radius=radius, + dx=dx, + core_radius=0.45, + omega_guess=12.0, + chi_depth_fraction=0.7, + ) + ledger, gradient = radial_fixed_charge_energy_and_gradient( + variables, + target_charge=target_charge, + radius=radius, + dx=dx, + ) + assert np.isfinite(ledger.total) + probe_indices = [0, 3, 11, 12, 17, 23] + # The total energy is O(1e7), so a 1e-6 step loses several digits to + # subtraction. This step is inside the observed centered-difference + # convergence window for both field blocks. + epsilon = 3.0e-5 + for index in probe_indices: + plus = variables.copy() + minus = variables.copy() + plus[index] += epsilon + minus[index] -= epsilon + plus_energy, _ = radial_fixed_charge_energy_and_gradient( + plus, + target_charge=target_charge, + radius=radius, + dx=dx, + ) + minus_energy, _ = radial_fixed_charge_energy_and_gradient( + minus, + target_charge=target_charge, + radius=radius, + dx=dx, + ) + finite_difference = (plus_energy.total - minus_energy.total) / (2.0 * epsilon) + assert np.isclose( + gradient[index], + finite_difference, + rtol=5.0e-6, + atol=3.0e-5, + ) + + +def test_fixed_charge_analytic_hessian_matches_gradient_difference() -> None: + target_charge = 1200.0 + radius = 1.2 + dx = 0.1 + variables = make_radial_bag_guess( + target_charge=target_charge, + radius=radius, + dx=dx, + core_radius=0.45, + omega_guess=10.0, + chi_depth_fraction=0.8, + ) + hessian = radial_fixed_charge_hessian( + variables, + target_charge=target_charge, + radius=radius, + dx=dx, + ) + dense = hessian.toarray() + assert np.allclose(dense, dense.T, rtol=0.0, atol=1.0e-12) + + epsilon = 1.0e-5 + for index in [0, 5, 12, 19, 23]: + plus = variables.copy() + minus = variables.copy() + plus[index] += epsilon + minus[index] -= epsilon + _, plus_gradient = radial_fixed_charge_energy_and_gradient( + plus, + target_charge=target_charge, + radius=radius, + dx=dx, + ) + _, minus_gradient = radial_fixed_charge_energy_and_gradient( + minus, + target_charge=target_charge, + radius=radius, + dx=dx, + ) + finite_difference = (plus_gradient - minus_gradient) / (2.0 * epsilon) + assert np.allclose( + dense[:, index], + finite_difference, + rtol=2.0e-5, + atol=3.0e-4, + ) + + +def test_radial_lift_initializes_the_declared_noether_rotation() -> None: + source_r = (np.arange(40, dtype=np.float64) + 0.5) * 0.1 + phi = 12.0 * np.exp(-0.5 * (source_r / 0.7) ** 2) + chi = 19.0 - 6.0 * np.exp(-0.5 * (source_r / 0.8) ** 2) + omega = 1.7 + dt = 0.002 + state = lift_radial_noether_state( + source_r=source_r, + phi=phi, + chi=chi, + omega=omega, + grid_size=32, + dx=0.1, + dt=dt, + dtype=np.float64, + ) + charge = cartesian_noether_charge( + state.psi_real, + state.psi_real_prev, + state.psi_imag, + state.psi_imag_prev, + dt=dt, + dx=0.1, + ) + norm = float(np.sum(state.psi_real**2 + state.psi_imag**2)) * 0.1**3 + expected = np.sin(omega * dt) * norm / dt + assert np.isclose(charge, expected, rtol=1.0e-13) + metrics = cartesian_localization_metrics( + state.psi_real, + state.psi_imag, + dx=0.1, + core_radius=1.4, + ) + assert metrics["rms_radius_cells"] > 5.0 + assert metrics["second_moment_anisotropy"] < 1.01 + + +def test_conservative_mode_disables_historical_chi_floor() -> None: + common = dict( + grid_size=8, + field_level=FieldLevel.COMPLEX, + boundary_type=BoundaryType.PERIODIC, + kappa=0.0, + lambda_self=0.0, + epsilon_w=0.0, + dt=0.001, + dx=0.5, + report_interval=0, + ) + unclipped = Simulation( + SimulationConfig(**common, enable_chi_floor=False), + backend="cpu", + ) + below_floor = np.full((8, 8, 8), -20.0, dtype=np.float32) + unclipped.set_chi(below_floor) + unclipped.set_chi_prev(below_floor) + unclipped.run(steps=1, record_metrics=False) + assert np.allclose(unclipped.chi, -20.0) + + clipped = Simulation( + SimulationConfig(**common, enable_chi_floor=True), + backend="cpu", + ) + clipped.set_chi(below_floor) + clipped.set_chi_prev(below_floor) + clipped.run(steps=1, record_metrics=False) + assert np.allclose(clipped.chi, -19.0) diff --git a/tests/test_operational_force_harness_entrypoints.py b/tests/test_operational_force_harness_entrypoints.py new file mode 100644 index 0000000..3e7e55c --- /dev/null +++ b/tests/test_operational_force_harness_entrypoints.py @@ -0,0 +1,104 @@ +"""Regression tests for active versus legacy four-force entry points.""" + +from __future__ import annotations + +import importlib.util +import json +import sys +from pathlib import Path + +import pytest + +from lfm.validation.unified_force import ( + EvidenceClass, + ForceSector, + ReadoutFrame, +) + +REPO = Path(__file__).resolve().parents[2] +HARNESS_DIR = REPO / "paper_experiments" / "lfm_unified_force_harness_2026" +if str(HARNESS_DIR) not in sys.path: + sys.path.insert(0, str(HARNESS_DIR)) + + +def _require_harness_file(path: Path) -> Path: + if not path.exists(): + pytest.skip(f"external parent-repo force harness file is unavailable: {path}") + return path + + +def _load_module(name: str, path: Path): + path = _require_harness_file(path) + spec = importlib.util.spec_from_file_location(name, path) + if spec is None or spec.loader is None: + raise RuntimeError(f"could not load {path}") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def test_active_manifest_enforces_internal_operational_readouts() -> None: + runner = _load_module( + "operational_force_harness_runner", + HARNESS_DIR / "run_unified_force_harness.py", + ) + version, specs, payload = runner._load_manifest(runner.MANIFEST_PATH) + assert version == "2.0.0-operational" + assert payload["schema_version"] == "2.0" + assert runner.MANIFEST_PATH.name == "operational_emergence_manifest.json" + operational = [ + spec for spec in specs if spec.required_for_sector and spec.operational_readout_required + ] + assert operational + assert all( + spec.readout_frame is ReadoutFrame.INTERNAL_OPERATIONAL + and spec.substrate_evolution + and spec.internal_observable + and spec.continuum_interpretation + for spec in operational + ) + forbidden_readout_fragments = ( + "external coordinate", + "external grid", + "fixed grid", + "god eye", + "grid coordinate", + "lattice coordinate", + "raw coordinate", + "raw simulator", + "simulator time", + ) + assert all( + not any( + fragment in spec.internal_observable.lower() for fragment in forbidden_readout_fragments + ) + for spec in operational + ) + em_maxwell = next(spec for spec in specs if spec.benchmark_id == "EM-MAXWELL-CONTINUUM-CLOSURE") + assert "E-wave observers" in em_maxwell.internal_observable + assert "chi-clock compensated" in em_maxwell.internal_observable + required_ids = {spec.benchmark_id for spec in specs if spec.required_for_sector} + assert "GR-FRAME-PROVENANCE" not in required_ids + assert "STRONG-GAUGE-INVARIANT-OBSERVABLE" not in required_ids + + +def test_v1_manifest_is_marked_legacy() -> None: + manifest_path = _require_harness_file(HARNESS_DIR / "benchmark_manifest.json") + payload = json.loads(manifest_path.read_text(encoding="utf-8")) + assert payload["status"].startswith("LEGACY_V1") + assert payload["superseded_by"] == "operational_emergence_manifest.json" + + +def test_pure_ab_entrypoint_cannot_promote_force_diagnostics() -> None: + runner = _load_module( + "pure_gov02_diagnostic_runner", + HARNESS_DIR / "run_pure_gov02_ab_force_harness.py", + ) + force_specs = [spec for spec in runner._specs() if spec.sector is not ForceSector.CORE] + assert force_specs + assert all(not spec.required_for_sector for spec in force_specs) + assert all(not spec.promotion_eligible for spec in force_specs) + assert all( + spec.accepted_evidence == frozenset({EvidenceClass.DIAGNOSTIC}) for spec in force_specs + ) diff --git a/tests/test_p4f_dw_force_harness_adapter.py b/tests/test_p4f_dw_force_harness_adapter.py new file mode 100644 index 0000000..4319bc8 --- /dev/null +++ b/tests/test_p4f_dw_force_harness_adapter.py @@ -0,0 +1,66 @@ +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path + +import pytest + +from lfm.validation.unified_force import ( + BenchmarkStatus, + ForceSector, + UnifiedForceHarness, +) + +REPO = Path(__file__).resolve().parents[2] +HARNESS_DIR = REPO / "paper_experiments" / "lfm_unified_force_harness_2026" +RUNNER = HARNESS_DIR / "run_p4f_dw_force_harness.py" + + +def _runner_module(): + if not RUNNER.exists(): + pytest.skip(f"external parent-repo force harness file is unavailable: {RUNNER}") + spec = importlib.util.spec_from_file_location("p4f_dw_harness_adapter", RUNNER) + if spec is None or spec.loader is None: + raise RuntimeError("could not load P4F-DW harness adapter") + module = importlib.util.module_from_spec(spec) + sys.modules[spec.name] = module + spec.loader.exec_module(module) + return module + + +def test_candidate_manifest_replaces_only_frame_specific_provenance() -> None: + runner = _runner_module() + assert "gravity_attraction_t2" in runner.ARTIFACTS + _version, specs, payload = runner._candidate_specs() + identifiers = {spec.benchmark_id for spec in specs} + assert "GR-FRAME-PROVENANCE" not in identifiers + assert "GR-CARRIER-PROVENANCE" in identifiers + assert payload["candidate_changes"] == {"GR-FRAME-PROVENANCE": "GR-CARRIER-PROVENANCE"} + strong_live = next(spec for spec in specs if spec.benchmark_id == "STRONG-CONFINEMENT-LIVE") + assert strong_live.required_for_sector + assert "STRONG-ACTION-CLOSURE" in strong_live.dependencies + + +def test_candidate_ledger_cannot_promote_scoped_four_channel_pass() -> None: + runner = _runner_module() + version, specs, _payload = runner._candidate_specs() + report = UnifiedForceHarness(specs, version=version).evaluate( + runner._candidate_results(20260725) + ) + assert report.unified_status is not BenchmarkStatus.PASS + assert report.sector_status[ForceSector.STRONG] is BenchmarkStatus.BLOCKED + passed_live_identities = { + result.identity.fingerprint + for result in report.results + if result.status is BenchmarkStatus.PASS and result.identity is not None + } + assert len(passed_live_identities) <= 1 + + +def test_unresolved_parameter_provenance_blocks_action_promotion() -> None: + runner = _runner_module() + results = {result.benchmark_id: result for result in runner._candidate_results(20260725)} + assert results["WEAK-ACTION-CLOSURE"].status is BenchmarkStatus.BLOCKED + assert results["WEAK-CHIRAL-LIVE-INTERACTION"].status is BenchmarkStatus.BLOCKED + assert results["STRONG-ACTION-CLOSURE"].status is BenchmarkStatus.BLOCKED diff --git a/tests/test_parsimonious_domain_wall.py b/tests/test_parsimonious_domain_wall.py new file mode 100644 index 0000000..11e0bb1 --- /dev/null +++ b/tests/test_parsimonious_domain_wall.py @@ -0,0 +1,257 @@ +"""Tests for the experimental local domain-wall weak completion.""" + +from __future__ import annotations + +import numpy as np +import pytest +from scipy.linalg import expm + +from lfm.foundations.parsimonious_domain_wall import ( + P4FDWParameters, + apply_domain_wall, + apply_domain_wall_adjoint, + domain_wall_potential_and_rates, + project_domain_wall_register, + reverse_momenta, + state_distance, + step_p4f_domain_wall, + total_hamiltonian, + vacuum_state, +) +from lfm.foundations.r3_link_frame_live import ( + _dagger, + _link_table, + _neighbor, +) +from lfm.foundations.r4_unified_live import ( + group_constraint_errors, + su2_generators, +) + + +def _parameters(depth: int = 4) -> P4FDWParameters: + return P4FDWParameters(internal_depth=depth) + + +def _random_field( + shape: tuple[int, ...], + seed: int, + scale: float = 0.02, +) -> np.ndarray: + rng = np.random.default_rng(seed) + values = scale * (rng.standard_normal(shape) + 1.0j * rng.standard_normal(shape)) + return project_domain_wall_register(values) + + +def _random_site_gauge( + size: int, + seed: int, + scale: float = 0.3, +) -> np.ndarray: + rng = np.random.default_rng(seed) + generators = su2_generators() + gauge = np.empty((size, size, size, 2, 2), dtype=np.complex128) + for site in np.ndindex((size, size, size)): + coefficients = scale * rng.standard_normal(3) + gauge[site] = expm( + 1.0j + * np.einsum( + "a,aij->ij", + coefficients, + generators, + optimize=True, + ) + ) + return gauge + + +def _transform_field( + gauge: np.ndarray, + field: np.ndarray, +) -> np.ndarray: + return np.einsum( + "...ij,...sajc->...saic", + gauge, + field, + optimize=True, + ) + + +def _transform_links( + gauge: np.ndarray, + links: np.ndarray, +) -> np.ndarray: + transformed = np.empty_like(links) + unique, _ = _link_table("19") + for index, (offset, _) in enumerate(unique): + transformed[..., index, :, :] = ( + gauge @ links[..., index, :, :] @ _dagger(_neighbor(gauge, offset)) + ) + return transformed + + +def test_domain_wall_operator_adjointness() -> None: + parameters = _parameters() + state = vacuum_state(2, parameters) + state.domain_wall_field = _random_field( + state.domain_wall_field.shape, + seed=4101, + ) + probe = _random_field( + state.domain_wall_field.shape, + seed=4102, + ) + gauge = _random_site_gauge(2, seed=4103) + state.base.weak_links = _transform_links( + gauge, + state.base.weak_links, + ) + applied = apply_domain_wall( + state.domain_wall_field, + state.base.weak_links, + parameters, + ) + adjoint_applied = apply_domain_wall_adjoint( + probe, + state.base.weak_links, + parameters, + ) + left = np.vdot(applied, probe) + right = np.vdot(state.domain_wall_field, adjoint_applied) + scale = max(abs(left), abs(right), 1.0) + assert abs(left - right) / scale < 1.0e-12 + + +def test_domain_wall_operator_is_locally_su2_covariant() -> None: + parameters = _parameters() + state = vacuum_state(2, parameters) + field = _random_field( + state.domain_wall_field.shape, + seed=4201, + ) + gauge = _random_site_gauge(2, seed=4202) + transformed_field = _transform_field(gauge, field) + transformed_links = _transform_links( + gauge, + state.base.weak_links, + ) + original_output = apply_domain_wall( + field, + state.base.weak_links, + parameters, + ) + transformed_output = apply_domain_wall( + transformed_field, + transformed_links, + parameters, + ) + expected = _transform_field(gauge, original_output) + residual = np.linalg.norm(transformed_output - expected) / max( + np.linalg.norm(expected), + 1.0, + ) + assert residual < 1.0e-12 + + +def test_domain_wall_weak_link_rate_is_action_gradient() -> None: + parameters = _parameters() + state = vacuum_state(2, parameters) + state.domain_wall_field = _random_field( + state.domain_wall_field.shape, + seed=4301, + ) + _, _, electric_rate = domain_wall_potential_and_rates( + state, + parameters, + ) + site = (0, 0, 0) + link_index = 0 + generator_index = 1 + generator = su2_generators()[generator_index] + epsilon = 1.0e-7 + + energies = [] + for sign in (-1.0, 1.0): + varied = state.copy() + varied.base.weak_links[site + (link_index,)] = ( + expm(1.0j * sign * epsilon * generator) @ varied.base.weak_links[site + (link_index,)] + ) + energy, _, _ = domain_wall_potential_and_rates( + varied, + parameters, + ) + energies.append(energy) + numerical_derivative = (energies[1] - energies[0]) / (2.0 * epsilon) + analytic_derivative = -electric_rate[site + (link_index, generator_index)] + assert np.isclose( + analytic_derivative, + numerical_derivative, + rtol=2.0e-6, + atol=2.0e-9, + ) + + +def test_domain_wall_step_is_reversible_and_energy_bounded() -> None: + parameters = _parameters() + initial = vacuum_state(2, parameters) + initial.domain_wall_field = _random_field( + initial.domain_wall_field.shape, + seed=4401, + scale=0.002, + ) + initial.domain_wall_momentum = _random_field( + initial.domain_wall_momentum.shape, + seed=4402, + scale=0.001, + ) + evolved = initial.copy() + energy_initial, _ = total_hamiltonian(evolved, parameters) + dt = 1.0e-4 + steps = 5 + for _ in range(steps): + step_p4f_domain_wall(evolved, dt, parameters) + energy_final, _ = total_hamiltonian(evolved, parameters) + relative_drift = abs(energy_final - energy_initial) / max( + abs(energy_initial), + 1.0, + ) + assert relative_drift < 1.0e-8 + assert max(group_constraint_errors(evolved.base).values()) < 1.0e-12 + + returned = reverse_momenta(evolved) + for _ in range(steps): + step_p4f_domain_wall(returned, dt, parameters) + returned = reverse_momenta(returned) + assert state_distance(initial, returned) < 1.0e-10 + + +def test_single_wall_register_has_one_weyl_pair_and_no_mirror() -> None: + parameters = _parameters() + state = vacuum_state(2, parameters) + reduced_shape = (2, 2, 2, parameters.internal_depth, 4) + basis = [ + index + for index in np.ndindex(reduced_shape) + if not (index[3] == parameters.internal_depth - 1 and index[4] < 2) + ] + matrix = np.zeros( + (int(np.prod(reduced_shape)), len(basis)), + dtype=np.complex128, + ) + for column, index in enumerate(basis): + field = np.zeros_like(state.domain_wall_field) + field[index + (0, 0)] = 1.0 + output = apply_domain_wall( + field, + state.base.weak_links, + parameters, + ) + matrix[:, column] = output[..., 0, 0].reshape(-1) + singular_values = np.linalg.svd(matrix, compute_uv=False) + assert np.sum(singular_values < 1.0e-10) == 2 + assert singular_values[-3] > 0.9 + + forbidden = state.copy() + forbidden.domain_wall_field[..., -1, 0, 0, 0] = 1.0 + with pytest.raises(ValueError, match="excluded right-wall mirror"): + total_hamiltonian(forbidden, parameters) diff --git a/tests/test_parsimonious_four_force.py b/tests/test_parsimonious_four_force.py new file mode 100644 index 0000000..2948420 --- /dev/null +++ b/tests/test_parsimonious_four_force.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +import numpy as np +import pytest + +from lfm.foundations.parsimonious_four_force import ( + P4FParameters, + flat_octic_minimality_ledger, + inactive_frame_error, + p4f_action_declaration, + p4f_action_fingerprint, + potential_energy_and_rates, + step_p4f, + total_hamiltonian, + vacuum_state, +) +from lfm.foundations.r3_link_frame_live import ( + R3LiveParameters, + R3LiveState, +) +from lfm.foundations.r3_link_frame_live import ( + potential_energy_and_rates as r3_potential_energy_and_rates, +) +from lfm.foundations.r6_unified_live import R6Parameters, R6R4Parameters + + +def test_p4f_freezes_required_action_choices() -> None: + parameters = P4FParameters() + r3 = parameters.r6.r4.r3 + assert r3.stencil == "19" + assert r3.chi_potential == "flat_octic" + assert not r3.frame_enabled + + +def test_p4f_rejects_frame_or_quartic_variants() -> None: + with pytest.raises(ValueError): + P4FParameters( + r6=R6Parameters( + r4=R6R4Parameters( + r3=R3LiveParameters( + frame_enabled=True, + chi_potential="flat_octic", + ) + ) + ) + ) + with pytest.raises(ValueError): + P4FParameters( + r6=R6Parameters( + r4=R6R4Parameters( + r3=R3LiveParameters( + frame_enabled=False, + chi_potential="quartic", + ) + ) + ) + ) + + +def test_flat_octic_force_matches_analytic_derivative() -> None: + parameters = R3LiveParameters( + frame_enabled=False, + chi_potential="flat_octic", + ) + state = R3LiveState.vacuum(3, parameters) + state.chi[...] = parameters.chi0 + 0.125 + _, rates, parts = r3_potential_energy_and_rates(state, parameters) + delta = state.chi**2 - parameters.chi0**2 + expected = ( + -8.0 + * parameters.frame_inertia + * parameters.lambda_h + * state.chi + * delta**3 + / parameters.chi0**4 + ) + np.testing.assert_allclose(rates.chi, expected, rtol=1e-13, atol=1e-13) + assert parts["shape_gradient"] == 0.0 + assert parts["frame_loop"] == 0.0 + + +def test_inactive_frame_has_no_energy_or_rate() -> None: + state = vacuum_state(3) + base = state.r3 + base.shape_momentum[...] = 0.25 + base.frame_electric[...] = 0.5 + energy, rates, parts = potential_energy_and_rates(state) + kinetic, kinetic_parts = total_hamiltonian(state) + assert np.isfinite(energy) + assert np.isfinite(kinetic) + assert np.max(np.abs(rates.r3.shape)) == 0.0 + assert np.max(np.abs(rates.r3.frame_electric)) == 0.0 + assert parts["shape_gradient"] == 0.0 + assert parts["frame_loop"] == 0.0 + assert kinetic_parts["shape_kinetic"] == 0.0 + assert kinetic_parts["frame_electric"] == 0.0 + assert kinetic_parts["frame_electric_weighted"] == 0.0 + + +def test_p4f_step_preserves_dormant_frame_exactly() -> None: + state = vacuum_state(3) + state.r3.matter[1, 1, 1, 0] = 1.0e-3 + before, _ = total_hamiltonian(state) + assert inactive_frame_error(state) == 0.0 + for _ in range(4): + step_p4f(state, 1.0e-4) + after, _ = total_hamiltonian(state) + assert inactive_frame_error(state) == 0.0 + assert np.isfinite(after) + assert abs(after - before) / max(abs(before), 1.0e-30) < 1.0e-4 + + +def test_action_declaration_and_fingerprint_are_stable() -> None: + declaration = p4f_action_declaration() + assert declaration["canonical_status"] == "EXPERIMENT_ONLY_UNPROMOTED" + assert declaration["forbidden_mechanisms_used"] == [] + assert declaration["paper_45_update_authorized"] is False + fingerprint = p4f_action_fingerprint() + assert len(fingerprint) == 64 + assert fingerprint == p4f_action_fingerprint() + + +def test_flat_octic_is_minimal_only_in_declared_monomial_class() -> None: + ledger = flat_octic_minimality_ledger() + assert ledger["minimal_admissible_z_power"] == 4 + assert ledger["minimal_admissible_field_degree"] == 8 + by_power = {row["z_power"]: row for row in ledger["rows"]} + assert not by_power[2]["vacuum_hessian_vanishes"] + assert not by_power[3]["nonnegative_for_both_signs_of_z"] + assert by_power[4]["admissible"] + assert "monomial" in ledger["uniqueness_boundary"] diff --git a/tests/test_particle_kinematics.py b/tests/test_particle_kinematics.py new file mode 100644 index 0000000..b0f0978 --- /dev/null +++ b/tests/test_particle_kinematics.py @@ -0,0 +1,63 @@ +"""Tests for external collective particle-kinematics readouts.""" + +from __future__ import annotations + +import numpy as np + +from lfm.analysis.particle_kinematics import ( + component_noether_charges, + fit_offset_power_convergence, + flat_octic_hamiltonian_19pt, + time_centered_momentum_19pt, +) + + +def _packet(size: int = 12) -> tuple[np.ndarray, ...]: + dx = 0.1 + dt = 0.002 + axis = (np.arange(size, dtype=np.float32) - 0.5 * (size - 1)) * dx + x = axis[:, None, None] + profile = np.exp(-(x * x) / np.float32(0.12)).astype(np.float32) + profile = np.broadcast_to(profile, (size, size, size)).copy() + components = np.zeros((3, size, size, size), dtype=np.float32) + components[0] = profile + real = components.copy() + previous_real = real - np.float32(dt * 0.2) * np.roll(real, 1, axis=1) / np.float32(dx) + imag = np.zeros_like(real) + previous_imag = np.zeros_like(real) + chi = np.full((size, size, size), 19.0, dtype=np.float32) + return real, previous_real, imag, previous_imag, chi, chi.copy() + + +def test_time_centered_momentum_direction() -> None: + state = _packet() + momentum = time_centered_momentum_19pt(*state, dt=0.002, dx=0.1) + assert np.all(np.isfinite(momentum)) + assert abs(momentum[0]) > 0.0 + transverse = float(np.linalg.norm(momentum[1:])) + assert transverse / abs(float(momentum[0])) < 1.0e-5 + + +def test_component_noether_charge_and_hamiltonian_are_finite() -> None: + state = list(_packet()) + phase = np.float32(0.01) + state[2][0] = phase * state[0][0] + state[3][0] = state[2][0] - np.float32(0.002) * state[0][0] + charges = component_noether_charges(*state[:4], dt=0.002, dx=0.1) + energy = flat_octic_hamiltonian_19pt(*state, dt=0.002, dx=0.1) + assert charges.shape == (3,) + assert np.all(np.isfinite(charges)) + assert np.isfinite(energy["total"]) + assert energy["total"] > 0.0 + + +def test_offset_power_convergence_recovers_synthetic_parameters() -> None: + h = np.asarray((0.125, 0.1, 1.0 / 12.0, 0.0625)) + expected_offset = 7.0e-4 + expected_order = 1.5 + errors = expected_offset + 0.4 * h**expected_order + fit = fit_offset_power_convergence(h, errors) + assert fit["converged"] + assert abs(fit["offset"] - expected_offset) < 1.0e-8 + assert abs(fit["order"] - expected_order) < 1.0e-6 + assert fit["r_squared"] > 0.999999 diff --git a/tests/test_physics_modules.py b/tests/test_physics_modules.py index 656eb7c..cfbd2e9 100644 --- a/tests/test_physics_modules.py +++ b/tests/test_physics_modules.py @@ -12,16 +12,23 @@ effective_metric_00, gravitational_potential, metric_perturbation, + metric_refractive_index, + op05_spherical_chi_deflection, schwarzschild_chi, + schwarzschild_radius_si, time_dilation_factor, ) from lfm.analysis.phase import ( charge_density, coulomb_interaction_energy, + noether_spatial_current, phase_coherence, + phase_current_energy_density, phase_field, + positive_noether_current, ) from lfm.config import SimulationConfig +from lfm.constants import SOLAR_MASS_KG, SOLAR_RADIUS_M from lfm.fields.boosted import boosted_soliton from lfm.sweep import sweep_2d @@ -77,6 +84,42 @@ def test_schwarzschild_chi_boundary(self): # At center → 0 (inside horizon) assert chi[16, 16, 16] == pytest.approx(0.0, abs=0.1) + def test_metric_refractive_index_increases_in_chi_well(self): + chi = np.array([19.0, 18.99], dtype=np.float64) + n_eff = metric_refractive_index(chi) + assert n_eff[0] == pytest.approx(1.0) + assert n_eff[1] > 1.0 + + def test_op05_solar_limb_deflection_recovers_arcsecond_scale(self): + result = op05_spherical_chi_deflection( + SOLAR_MASS_KG, + SOLAR_RADIUS_M, + x_extent_multiplier=500.0, + sample_count=20001, + ) + assert result["schwarzschild_radius_m"] == pytest.approx( + schwarzschild_radius_si(SOLAR_MASS_KG), + rel=1e-14, + ) + assert result["recovered_angle_arcsec"] == pytest.approx(1.751243281, rel=1e-3) + assert abs(result["comparator_relative_error"]) < 1e-4 + + def test_op05_deflection_is_linear_in_weak_lens_mass(self): + half = op05_spherical_chi_deflection( + 0.5 * SOLAR_MASS_KG, + SOLAR_RADIUS_M, + x_extent_multiplier=250.0, + sample_count=5001, + ) + full = op05_spherical_chi_deflection( + SOLAR_MASS_KG, + SOLAR_RADIUS_M, + x_extent_multiplier=250.0, + sample_count=5001, + ) + ratio = half["recovered_angle_arcsec"] / full["recovered_angle_arcsec"] + assert ratio == pytest.approx(0.5, rel=1e-5) + # ── Phase ──────────────────────────────────────────────────────────── @@ -146,6 +189,124 @@ def test_coulomb_opposite_phase(self): e_int = coulomb_interaction_energy(psi, zero, -psi, zero) assert e_int < 0 + def test_noether_spatial_current_plane_wave(self): + N = 16 + k = 2.0 * np.pi / N + x = np.arange(N, dtype=np.float32) + phase = k * x[:, None, None] + psi_r = np.broadcast_to(np.cos(phase), (N, N, N)).astype(np.float32) + psi_i = np.broadcast_to(np.sin(phase), (N, N, N)).astype(np.float32) + jx = noether_spatial_current(psi_r, psi_i, axis=0) + np.testing.assert_allclose(jx, np.sin(k), rtol=1e-5, atol=1e-6) + + def test_noether_spatial_current_preserves_float64(self): + N = 16 + k = 2.0 * np.pi / N + x = np.arange(N, dtype=np.float64) + phase = k * x[:, None, None] + psi_r = np.broadcast_to(np.cos(phase), (N, N, N)).astype(np.float64) + psi_i = np.broadcast_to(np.sin(phase), (N, N, N)).astype(np.float64) + jx = positive_noether_current(psi_r, psi_i, axis=0) + assert jx.dtype == np.float64 + np.testing.assert_allclose(jx, np.sin(k), rtol=1e-12, atol=1e-12) + + def test_positive_noether_current_clips_reverse_wave(self): + N = 16 + k = 2.0 * np.pi / N + x = np.arange(N, dtype=np.float32) + phase = -k * x[:, None, None] + psi_r = np.broadcast_to(np.cos(phase), (N, N, N)).astype(np.float32) + psi_i = np.broadcast_to(np.sin(phase), (N, N, N)).astype(np.float32) + jx = positive_noether_current(psi_r, psi_i, axis=0) + np.testing.assert_allclose(jx, 0.0, atol=1e-6) + + def test_phase_current_energy_static_uniform_zero(self): + psi_r = np.ones((8, 8, 8), dtype=np.float64) + psi_i = np.zeros((8, 8, 8), dtype=np.float64) + rho = phase_current_energy_density(psi_r, psi_i, psi_r, psi_i, dt=0.02) + assert rho.dtype == np.float64 + np.testing.assert_allclose(rho, 0.0, atol=1e-14) + + def test_phase_current_energy_global_phase_invariant(self): + N = 16 + dt = 0.02 + k = 2.0 * np.pi / N + omega = 0.35 + x = np.arange(N, dtype=np.float64) + phase = k * x[:, None, None] + psi_r = np.broadcast_to(np.cos(phase), (N, N, N)).astype(np.float64) + psi_i = np.broadcast_to(np.sin(phase), (N, N, N)).astype(np.float64) + prev_phase = phase - omega * dt + psi_r_prev = np.broadcast_to(np.cos(prev_phase), (N, N, N)).astype(np.float64) + psi_i_prev = np.broadcast_to(np.sin(prev_phase), (N, N, N)).astype(np.float64) + + rho = phase_current_energy_density(psi_r, psi_i, psi_r_prev, psi_i_prev, dt=dt) + + phi0 = 1.234 + psi_r_rot = np.cos(phi0) * psi_r - np.sin(phi0) * psi_i + psi_i_rot = np.sin(phi0) * psi_r + np.cos(phi0) * psi_i + psi_r_prev_rot = np.cos(phi0) * psi_r_prev - np.sin(phi0) * psi_i_prev + psi_i_prev_rot = np.sin(phi0) * psi_r_prev + np.cos(phi0) * psi_i_prev + rho_rot = phase_current_energy_density( + psi_r_rot, + psi_i_rot, + psi_r_prev_rot, + psi_i_prev_rot, + dt=dt, + ) + np.testing.assert_allclose(rho_rot, rho, rtol=1e-12, atol=1e-12) + + def test_phase_current_energy_sees_constant_amplitude_plane_wave(self): + N = 32 + dt = 0.01 + amp = 0.4 + k = 2.0 * np.pi / N + omega = k + x = np.arange(N, dtype=np.float64) + phase = k * x[:, None, None] + prev_phase = phase - omega * dt + psi_r = np.broadcast_to(amp * np.cos(phase), (N, N, N)).astype(np.float64) + psi_i = np.broadcast_to(amp * np.sin(phase), (N, N, N)).astype(np.float64) + psi_r_prev = np.broadcast_to(amp * np.cos(prev_phase), (N, N, N)).astype(np.float64) + psi_i_prev = np.broadcast_to(amp * np.sin(prev_phase), (N, N, N)).astype(np.float64) + + amp_sq = psi_r * psi_r + psi_i * psi_i + rho = phase_current_energy_density(psi_r, psi_i, psi_r_prev, psi_i_prev, dt=dt) + + np.testing.assert_allclose(amp_sq, amp * amp, rtol=1e-14, atol=1e-14) + assert float(np.mean(rho)) > 0.0 + assert float(np.std(rho)) < 1e-12 + + def test_phase_current_energy_scales_with_carrier(self): + N = 64 + dt = 0.005 + amp = 0.25 + x = np.arange(N, dtype=np.float64) + + def mean_rho(mode: int) -> float: + k = 2.0 * np.pi * mode / N + phase = k * x[:, None, None] + prev_phase = phase - k * dt + psi_r = np.broadcast_to(amp * np.cos(phase), (N, N, N)).astype(np.float64) + psi_i = np.broadcast_to(amp * np.sin(phase), (N, N, N)).astype(np.float64) + psi_r_prev = np.broadcast_to(amp * np.cos(prev_phase), (N, N, N)).astype(np.float64) + psi_i_prev = np.broadcast_to(amp * np.sin(prev_phase), (N, N, N)).astype(np.float64) + return float( + np.mean( + phase_current_energy_density( + psi_r, + psi_i, + psi_r_prev, + psi_i_prev, + dt=dt, + ) + ) + ) + + rho_1 = mean_rho(1) + rho_2 = mean_rho(2) + assert rho_2 / rho_1 == pytest.approx(4.0, rel=0.08) + # ── Angular Momentum ───────────────────────────────────────────────── diff --git a/tests/test_poincare.py b/tests/test_poincare.py new file mode 100644 index 0000000..bb79827 --- /dev/null +++ b/tests/test_poincare.py @@ -0,0 +1,93 @@ +"""Tests for exact lattice Poincare-emergence diagnostics.""" + +from __future__ import annotations + +import math + +import numpy as np + +from lfm.analysis.poincare import ( + STENCIL_19, + STENCIL_27, + cubic_rotation_residual, + discrete_omega, + fibonacci_sphere, + gaussian_packet, + group_velocity, + max_spatial_eigenvalue, + poincare_algebra_matrix_residual, + stencil_symbol, + symanzik_coefficients, + time_composition_residual, + translation_equivariance_residual, +) + + +def test_stencil_symbols_have_correct_axis_limit(): + k = np.asarray([0.01, 0.0, 0.0]) + for stencil in (STENCIL_19, STENCIL_27): + symbol = float(stencil_symbol(k, stencil=stencil)) + assert math.isclose(symbol, -(0.01**2), rel_tol=1.0e-5) + + +def test_exact_cubic_rotation_invariance(): + k = np.asarray([0.37, -0.51, 0.83]) + assert cubic_rotation_residual(k, stencil="19") < 1.0e-14 + assert cubic_rotation_residual(k, stencil="27") < 1.0e-14 + + +def test_low_k_dispersion_and_group_velocity(): + directions = fibonacci_sphere(32) + k = 1.0e-3 * directions + for stencil in ("19", "27"): + omega = discrete_omega(k, dt=1.0e-4, spacing=1.0, stencil=stencil) + velocity = group_velocity(k, dt=1.0e-4, spacing=1.0, stencil=stencil) + radial = np.sum(velocity * directions, axis=-1) + assert np.max(np.abs(omega / 1.0e-3 - 1.0)) < 1.0e-6 + assert np.max(np.abs(radial - 1.0)) < 1.0e-6 + + +def test_stability_extrema(): + assert math.isclose(max_spatial_eigenvalue(stencil="19"), 16.0 / 3.0) + assert math.isclose(max_spatial_eigenvalue(stencil="27"), 52.0 / 9.0) + + +def test_symanzik_coefficients_share_isotropic_quartic_term(): + for stencil in ("19", "27"): + coefficients = symanzik_coefficients(stencil) + assert math.isclose(coefficients["quartic_pure"], 1.0 / 12.0) + assert math.isclose(coefficients["quartic_mixed"], 1.0 / 6.0) + + +def test_continuum_poincare_matrix_algebra_closes(): + residuals = poincare_algebra_matrix_residual() + assert residuals["maximum"] == 0.0 + + +def test_integer_translation_and_time_composition(): + field = gaussian_packet( + 12, + length=12.0, + center=(3.0, 4.0, 5.0), + direction=(1.0, 1.0, 0.5), + k_magnitude=0.5, + sigma=1.2, + ) + translation = translation_equivariance_residual( + field, + shift=(2, -1, 3), + time=0.4, + length=12.0, + dt=0.05, + stencil="19", + ) + composition = time_composition_residual( + field, + time_1=0.2, + time_2=0.3, + length=12.0, + dt=0.05, + stencil="19", + ) + assert translation < 1.0e-12 + assert composition < 1.0e-12 diff --git a/tests/test_precision.py b/tests/test_precision.py new file mode 100644 index 0000000..23b4b4a --- /dev/null +++ b/tests/test_precision.py @@ -0,0 +1,277 @@ +"""Focused tests for configurable persistent simulation precision.""" + +from __future__ import annotations + +import re + +import numpy as np +import pytest + +import lfm +from lfm.core.backends import get_backend, gpu_available +from lfm.core.backends.kernel_source import ( + EVOLUTION_COMPLEX_KERNEL_SRC, + EVOLUTION_KERNEL_SRC, + EVOLUTION_REAL_KERNEL_SRC, + PHASE1_KERNEL_SRC, + SA_DIFFUSION_KERNEL_SRC, + kernel_source_for_precision, +) +from lfm.fields.equilibrium import equilibrate_from_fields, equilibrate_from_fields_19pt + +CUDA_SOURCES = ( + EVOLUTION_REAL_KERNEL_SRC, + EVOLUTION_COMPLEX_KERNEL_SRC, + EVOLUTION_KERNEL_SRC, + PHASE1_KERNEL_SRC, + SA_DIFFUSION_KERNEL_SRC, +) + + +def _config(precision: lfm.Precision, field_level: lfm.FieldLevel) -> lfm.SimulationConfig: + return lfm.SimulationConfig( + grid_size=8, + precision=precision, + field_level=field_level, + boundary_type=lfm.BoundaryType.PERIODIC, + lambda_self=lfm.LAMBDA_H, + epsilon_w=0.1, + e0_sq=1.0, + report_interval=10**9, + ) + + +def _seed_phase_space(sim: lfm.Simulation, dtype: np.dtype) -> None: + rng = np.random.default_rng(20260715) + n = sim.config.grid_size + prefix = (3,) if sim.config.field_level == lfm.FieldLevel.COLOR else () + shape = prefix + (n, n, n) + real = rng.normal(0.0, 0.01, shape).astype(dtype) + real_prev = (real + rng.normal(0.0, 1e-4, shape)).astype(dtype) + sim.set_psi_real(real) + sim.set_psi_real_prev(real_prev) + if sim.config.field_level != lfm.FieldLevel.REAL: + imag = rng.normal(0.0, 0.01, shape).astype(dtype) + imag_prev = (imag + rng.normal(0.0, 1e-4, shape)).astype(dtype) + sim.set_psi_imag(imag) + sim.set_psi_imag_prev(imag_prev) + chi = (19.0 + rng.normal(0.0, 1e-3, (n, n, n))).astype(dtype) + chi_prev = (chi + rng.normal(0.0, 1e-5, (n, n, n))).astype(dtype) + sim.set_chi(chi) + sim.set_chi_prev(chi_prev) + + +class TestPrecisionConfig: + def test_default_is_float32(self): + assert lfm.SimulationConfig().precision == lfm.Precision.FLOAT32 + + def test_string_is_normalized(self): + cfg = lfm.SimulationConfig(precision="float64") + assert cfg.precision == lfm.Precision.FLOAT64 + + def test_invalid_precision_rejected(self): + with pytest.raises(ValueError, match="precision"): + lfm.SimulationConfig(precision="float16") + + +class TestKernelPrecisionSource: + @pytest.mark.parametrize("source", CUDA_SOURCES) + def test_float32_source_is_identical_object(self, source): + assert kernel_source_for_precision(source, "float32") is source + + @pytest.mark.parametrize("source", CUDA_SOURCES) + def test_float64_source_promotes_types_and_literals(self, source): + promoted = kernel_source_for_precision(source, "float64") + assert not re.search(r"\bfloat\b", promoted) + assert not re.search(r"(?<=[0-9.])f\b", promoted) + assert "double" in promoted + + +class TestCpuPrecision: + @pytest.mark.parametrize( + "field_level", + (lfm.FieldLevel.REAL, lfm.FieldLevel.COMPLEX, lfm.FieldLevel.COLOR), + ) + @pytest.mark.parametrize( + ("precision", "dtype"), + ( + (lfm.Precision.FLOAT32, np.dtype(np.float32)), + (lfm.Precision.FLOAT64, np.dtype(np.float64)), + ), + ) + def test_complete_phase_space_uses_configured_dtype(self, field_level, precision, dtype): + sim = lfm.Simulation(_config(precision, field_level), backend="cpu") + _seed_phase_space(sim, np.dtype(np.float64)) + sim.run(2, record_metrics=False) + snapshot = sim.phase_space_snapshot() + for key in ("psi_real", "psi_real_prev", "chi", "chi_prev"): + assert snapshot[key].dtype == dtype + if field_level == lfm.FieldLevel.REAL: + assert snapshot["psi_imag"] is None + assert snapshot["psi_imag_prev"] is None + else: + assert snapshot["psi_imag"].dtype == dtype + assert snapshot["psi_imag_prev"].dtype == dtype + assert sim.get_boundary_mask().dtype == dtype + + def test_float64_setter_retains_sub_float32_increment(self): + sim = lfm.Simulation(_config(lfm.Precision.FLOAT64, lfm.FieldLevel.REAL), backend="cpu") + value = 1.0 + 2.0**-40 + field = np.full((8, 8, 8), value, dtype=np.float64) + sim.set_psi_real(field) + assert sim.get_psi_real()[0, 0, 0] == value + assert sim.get_psi_real()[0, 0, 0] != np.float64(np.float32(value)) + + def test_backend_conversion_uses_requested_dtype(self): + source = np.array([1.0, 2.0], dtype=np.float32) + backend = get_backend("cpu", lfm.Precision.FLOAT64) + converted = backend.from_numpy(source) + assert backend.dtype == np.dtype(np.float64) + assert converted.dtype == np.dtype(np.float64) + + def test_float64_color_sa_path_preserves_dtype(self): + cfg = _config(lfm.Precision.FLOAT64, lfm.FieldLevel.COLOR) + cfg.kappa_tube = 0.1 + sim = lfm.Simulation(cfg, backend="cpu") + _seed_phase_space(sim, np.dtype(np.float64)) + sim.run(1, record_metrics=False) + assert sim.sa_fields is not None + assert sim.sa_fields.dtype == np.dtype(np.float64) + assert np.isfinite(sim.sa_fields).all() + + @pytest.mark.parametrize( + "equilibrate", + (equilibrate_from_fields, equilibrate_from_fields_19pt), + ) + @pytest.mark.parametrize("color", (False, True)) + def test_equilibrium_helpers_preserve_float64(self, equilibrate, color): + rng = np.random.default_rng(41) + shape = (3, 8, 8, 8) if color else (8, 8, 8) + real = rng.normal(0.0, 0.2, shape).astype(np.float64) + imag = rng.normal(0.0, 0.2, shape).astype(np.float64) + + chi = equilibrate(real, imag) + + assert chi.dtype == np.dtype(np.float64) + assert np.any(chi != chi.astype(np.float32).astype(np.float64)) + + def test_simulation_equilibrate_preserves_float64(self): + sim = lfm.Simulation( + _config(lfm.Precision.FLOAT64, lfm.FieldLevel.COMPLEX), + backend="cpu", + ) + _seed_phase_space(sim, np.dtype(np.float64)) + with pytest.warns(UserWarning, match="before any solitons"): + sim.equilibrate() + assert sim.get_chi().dtype == np.dtype(np.float64) + + def test_remote_float32_direct_job_backend_is_available(self): + backend = get_backend("remote", lfm.Precision.FLOAT32) + assert backend.__class__.__name__ == "RemoteBackend" + + def test_remote_float64_is_rejected_before_submission(self): + with pytest.raises(NotImplementedError, match="float32"): + get_backend("remote", lfm.Precision.FLOAT64) + + def test_remote_simulation_contract_is_rejected_intentionally(self): + with pytest.raises(NotImplementedError, match="direct float32 remote jobs"): + lfm.Simulation( + _config(lfm.Precision.FLOAT32, lfm.FieldLevel.REAL), + backend="remote", + ) + + def test_float64_checkpoint_round_trip(self, tmp_path): + cfg = _config(lfm.Precision.FLOAT64, lfm.FieldLevel.COMPLEX) + sim = lfm.Simulation(cfg, backend="cpu") + _seed_phase_space(sim, np.dtype(np.float64)) + sim.run(3, record_metrics=False) + expected = sim.phase_space_snapshot() + path = tmp_path / "float64_checkpoint.npz" + sim.save_checkpoint(path) + + restored = lfm.Simulation.load_checkpoint(path, backend="cpu") + assert restored.config.precision == lfm.Precision.FLOAT64 + assert restored.get_psi_real().dtype == np.dtype(np.float64) + assert restored.get_chi().dtype == np.dtype(np.float64) + np.testing.assert_array_equal(restored.get_psi_real(), expected["psi_real"]) + np.testing.assert_array_equal(restored.get_psi_imag(), expected["psi_imag"]) + np.testing.assert_array_equal(restored.get_chi(), expected["chi"]) + restored_phase = restored.phase_space_snapshot() + for key in ( + "psi_real_prev", + "psi_imag_prev", + "chi_prev", + ): + np.testing.assert_array_equal(restored_phase[key], expected[key]) + + sim.run(1, record_metrics=False) + restored.run(1, record_metrics=False) + continued = sim.phase_space_snapshot() + restored_continued = restored.phase_space_snapshot() + for key in ( + "psi_real", + "psi_real_prev", + "psi_imag", + "psi_imag_prev", + "chi", + "chi_prev", + ): + np.testing.assert_array_equal(restored_continued[key], continued[key]) + + +@pytest.mark.gpu +@pytest.mark.skipif(not gpu_available(), reason="CuPy GPU backend unavailable") +class TestGpuPrecision: + def test_all_float64_kernels_compile(self): + backend = get_backend("gpu", lfm.Precision.FLOAT64) + for name in ( + "_kernel_real", + "_kernel_complex", + "_kernel_color", + "_kernel_phase1", + "_kernel_sa_diffusion", + ): + getattr(backend, name).compile() + + @pytest.mark.parametrize( + "field_level", + (lfm.FieldLevel.REAL, lfm.FieldLevel.COMPLEX, lfm.FieldLevel.COLOR), + ) + def test_float64_gpu_state_and_evolution(self, field_level): + sim = lfm.Simulation(_config(lfm.Precision.FLOAT64, field_level), backend="gpu") + _seed_phase_space(sim, np.dtype(np.float64)) + sim.run(2, record_metrics=False) + snapshot = sim.phase_space_snapshot() + for value in snapshot.values(): + if isinstance(value, np.ndarray): + assert value.dtype == np.dtype(np.float64) + assert np.isfinite(value).all() + + def test_float64_color_gpu_matches_cpu(self): + cpu = lfm.Simulation(_config(lfm.Precision.FLOAT64, lfm.FieldLevel.COLOR), backend="cpu") + gpu = lfm.Simulation(_config(lfm.Precision.FLOAT64, lfm.FieldLevel.COLOR), backend="gpu") + _seed_phase_space(cpu, np.dtype(np.float64)) + _seed_phase_space(gpu, np.dtype(np.float64)) + cpu.run(3, record_metrics=False) + gpu.run(3, record_metrics=False) + cpu_state = cpu.phase_space_snapshot() + gpu_state = gpu.phase_space_snapshot() + for key in ( + "psi_real", + "psi_real_prev", + "psi_imag", + "psi_imag_prev", + "chi", + "chi_prev", + ): + np.testing.assert_allclose(gpu_state[key], cpu_state[key], rtol=5e-12, atol=5e-12) + + def test_float64_color_sa_gpu_path_preserves_dtype(self): + cfg = _config(lfm.Precision.FLOAT64, lfm.FieldLevel.COLOR) + cfg.kappa_tube = 0.1 + sim = lfm.Simulation(cfg, backend="gpu") + _seed_phase_space(sim, np.dtype(np.float64)) + sim.run(1, record_metrics=False) + assert sim.sa_fields is not None + assert sim.sa_fields.dtype == np.dtype(np.float64) + assert np.isfinite(sim.sa_fields).all() diff --git a/tests/test_prepared_scalar_pair.py b/tests/test_prepared_scalar_pair.py new file mode 100644 index 0000000..fa13e0f --- /dev/null +++ b/tests/test_prepared_scalar_pair.py @@ -0,0 +1,95 @@ +from __future__ import annotations + +import numpy as np + +import lfm +from lfm.particles.prepared import ( + install_prepared_scalar_pair, + prepared_mode_wave_hamiltonian, +) +from lfm.particles.solver import SolitonSolution + + +def _solution(grid_size: int, amplitude: float) -> SolitonSolution: + coords = np.arange(grid_size, dtype=np.float32) - grid_size // 2 + x, y, z = np.meshgrid(coords, coords, coords, indexing="ij") + envelope = amplitude * np.exp(-(x * x + y * y + z * z) / 8.0) + chi = lfm.CHI0 - 0.1 * envelope * envelope + return SolitonSolution( + psi_r=envelope.astype(np.float32), + psi_i=None, + chi=chi.astype(np.float32), + chi_min=float(np.min(chi)), + energy=float(np.sum(envelope * envelope)), + eigenvalue=18.9, + converged=True, + cycles=1, + particle=None, + N=grid_size, + ) + + +def test_install_prepared_pair_sets_distinct_live_layers() -> None: + config = lfm.SimulationConfig( + grid_size=16, + field_level=lfm.FieldLevel.COMPLEX, + dt=0.02, + ) + sim = lfm.Simulation(config, backend="cpu") + solution_a = _solution(16, 1.0) + solution_b = _solution(16, 0.5) + install_prepared_scalar_pair( + sim, + solution_a, + solution_b, + position_a=(5.0, 8.0, 8.0), + position_b=(11.0, 8.0, 8.0), + velocity_a=(0.0, -0.01, 0.0), + velocity_b=(0.0, 0.03, 0.0), + ) + state = sim.phase_space_snapshot() + assert state["psi_imag"] is not None + assert not np.allclose(state["psi_real"], state["psi_real_prev"]) + assert not np.array_equal(state["chi"], state["chi_prev"]) + assert len(lfm.find_peaks(sim.energy_density, n=2, min_separation=3)) == 2 + + +def test_prepared_mode_energy_increases_with_amplitude() -> None: + low = _solution(16, 0.5) + high = _solution(16, 1.0) + low_energy = prepared_mode_wave_hamiltonian(low, dt=0.02) + high_energy = prepared_mode_wave_hamiltonian(high, dt=0.02) + assert high_energy > low_energy + + +def test_relative_pi_phase_reverses_second_mode_interference() -> None: + config = lfm.SimulationConfig( + grid_size=16, + field_level=lfm.FieldLevel.COMPLEX, + dt=0.02, + ) + solution_a = _solution(16, 1.0) + solution_b = _solution(16, 0.5) + sim_same = lfm.Simulation(config, backend="cpu") + install_prepared_scalar_pair( + sim_same, + solution_a, + solution_b, + position_a=(6.0, 8.0, 8.0), + position_b=(10.0, 8.0, 8.0), + velocity_a=(0.0, 0.0, 0.0), + velocity_b=(0.0, 0.0, 0.0), + ) + sim_opposite = lfm.Simulation(config, backend="cpu") + install_prepared_scalar_pair( + sim_opposite, + solution_a, + solution_b, + position_a=(6.0, 8.0, 8.0), + position_b=(10.0, 8.0, 8.0), + velocity_a=(0.0, 0.0, 0.0), + velocity_b=(0.0, 0.0, 0.0), + phase_b=np.pi, + ) + midpoint = (8, 8, 8) + assert sim_same.energy_density[midpoint] > sim_opposite.energy_density[midpoint] diff --git a/tests/test_r3_link_frame.py b/tests/test_r3_link_frame.py new file mode 100644 index 0000000..1dda7c0 --- /dev/null +++ b/tests/test_r3_link_frame.py @@ -0,0 +1,140 @@ +"""Tests for the unpromoted R3 link-frame action prototype.""" + +import numpy as np +import pytest + +from lfm.foundations.r3_link_frame import ( + R3LinkFrameParameters, + R3ProductLink, + chiral_frame_curvature, + frame_shape_acceleration, + internal_covariant_difference, + product_plaquette_energy, + r3_action_declaration, + r3_action_fingerprint, + transform_internal_matter, + transform_product_link, +) + + +def _rotation(size: int, left: int, right: int, angle: float) -> np.ndarray: + matrix = np.eye(size) + matrix[left, left] = np.cos(angle) + matrix[right, right] = np.cos(angle) + matrix[left, right] = -np.sin(angle) + matrix[right, left] = np.sin(angle) + return matrix + + +def _su3_rotation(left: int, right: int, angle: float) -> np.ndarray: + return _rotation(3, left, right, angle).astype(np.complex128) + + +def test_internal_covariant_difference_is_locally_covariant() -> None: + link = R3ProductLink( + frame=_rotation(4, 0, 1, 0.13), + phase=np.exp(0.21j), + color=_su3_rotation(0, 1, 0.17), + ) + matter_i = np.asarray([1.0 + 0.2j, -0.3j, 0.5]) + matter_j = np.asarray([0.2, 0.7 - 0.1j, -0.4j]) + phase_i = np.exp(0.31j) + phase_j = np.exp(-0.27j) + color_i = _su3_rotation(1, 2, 0.23) + color_j = _su3_rotation(0, 2, -0.19) + frame_i = _rotation(4, 1, 2, 0.11) + frame_j = _rotation(4, 2, 3, -0.09) + difference = internal_covariant_difference(matter_i, matter_j, link) + transformed_link = transform_product_link( + link, + frame_i=frame_i, + frame_j=frame_j, + phase_i=phase_i, + phase_j=phase_j, + color_i=color_i, + color_j=color_j, + ) + transformed = internal_covariant_difference( + transform_internal_matter( + matter_i, + phase=phase_i, + color=color_i, + ), + transform_internal_matter( + matter_j, + phase=phase_j, + color=color_j, + ), + transformed_link, + ) + expected = transform_internal_matter( + difference, + phase=phase_i, + color=color_i, + ) + assert np.allclose(transformed, expected, atol=1.0e-12) + + +def test_reverse_link_is_exact_inverse() -> None: + link = R3ProductLink( + frame=_rotation(4, 0, 3, 0.4), + phase=np.exp(0.5j), + color=_su3_rotation(1, 2, -0.3), + ) + identity = link.compose(link.reverse()) + assert np.allclose(identity.frame, np.eye(4), atol=1.0e-12) + assert identity.phase == pytest.approx(1.0 + 0.0j) + assert np.allclose(identity.color, np.eye(3), atol=1.0e-12) + + +def test_product_plaquette_energy_is_positive_and_zero_at_identity() -> None: + parameters = R3LinkFrameParameters() + zero = product_plaquette_energy(R3ProductLink.identity(), parameters) + assert zero["total"] == pytest.approx(0.0, abs=1.0e-14) + holonomy = R3ProductLink( + frame=_rotation(4, 0, 1, 0.14), + phase=np.exp(0.18j), + color=_su3_rotation(0, 2, 0.16), + ) + energy = product_plaquette_energy(holonomy, parameters) + assert energy["frame_even"] >= 0.0 + assert energy["frame_chiral"] > 0.0 + assert energy["phase"] > 0.0 + assert energy["color"] > 0.0 + assert energy["total"] > 0.0 + + +def test_chiral_frame_weights_remain_positive() -> None: + holonomy = _rotation(4, 0, 1, 0.2) @ _rotation(4, 2, 3, 0.1) + plus, minus, omega = chiral_frame_curvature(holonomy) + assert plus.shape == minus.shape == (3,) + assert np.allclose(omega, -omega.T) + for epsilon in (-0.9, 0.0, 0.1, 0.9): + energy = product_plaquette_energy( + R3ProductLink( + frame=holonomy, + phase=1.0, + color=np.eye(3), + ), + R3LinkFrameParameters(epsilon_w=epsilon), + ) + assert energy["frame_chiral"] >= 0.0 + + +def test_frame_source_preserves_traceless_shape_sector() -> None: + shape = np.zeros((2, 2, 2, 10)) + laplacian = np.zeros_like(shape) + density = np.ones((2, 2, 2)) + acceleration = frame_shape_acceleration(laplacian, density, shape) + trace = np.sum(acceleration[..., :4], axis=-1) + assert np.max(np.abs(trace)) <= 1.0e-14 + assert np.all(acceleration[..., 0] < 0.0) + assert np.all(acceleration[..., 1:4] > 0.0) + + +def test_action_declaration_is_stable_and_retains_radial_chi() -> None: + declaration = r3_action_declaration() + assert declaration["canonical_status"] == ("UNPROMOTED_FOUNDATIONAL_CANDIDATE") + assert "full_mexican_hat" in declaration["retained_sectors"] + assert len(r3_action_fingerprint()) == 64 + assert r3_action_fingerprint() == r3_action_fingerprint() diff --git a/tests/test_r3_link_frame_live.py b/tests/test_r3_link_frame_live.py new file mode 100644 index 0000000..4dea379 --- /dev/null +++ b/tests/test_r3_link_frame_live.py @@ -0,0 +1,222 @@ +from __future__ import annotations + +import numpy as np +from scipy.linalg import expm + +from lfm.foundations.r3_link_frame_live import ( + R3LiveParameters, + R3LiveState, + _tracefree_symmetric, + group_constraint_errors, + potential_energy_and_rates, + reverse_momenta, + so4_generators, + state_distance, + step_r3_live, + su3_generators, + total_hamiltonian, + triangle_loops, +) + + +def _seeded_state(seed: int = 7) -> tuple[R3LiveState, R3LiveParameters]: + rng = np.random.default_rng(seed) + parameters = R3LiveParameters(stencil="19") + state = R3LiveState.vacuum(2, parameters) + state.matter = 0.01 * ( + rng.normal(size=state.matter.shape) + 1.0j * rng.normal(size=state.matter.shape) + ) + state.matter_momentum = 0.01 * ( + rng.normal(size=state.matter.shape) + 1.0j * rng.normal(size=state.matter.shape) + ) + state.chi += 1.0e-4 * rng.normal(size=state.chi.shape) + state.chi_momentum = 0.01 * rng.normal(size=state.chi.shape) + state.shape = _tracefree_symmetric(1.0e-4 * rng.normal(size=state.shape.shape)) + state.shape_momentum = _tracefree_symmetric(0.01 * rng.normal(size=state.shape_momentum.shape)) + state.phase_electric = 0.01 * rng.normal(size=state.phase_electric.shape) + state.color_electric = 0.01 * rng.normal(size=state.color_electric.shape) + state.frame_electric = 0.01 * rng.normal(size=state.frame_electric.shape) + state.phase_links *= np.exp(1.0j * 1.0e-3 * rng.normal(size=state.phase_links.shape)) + color_generators = su3_generators() + frame_generators = so4_generators() + for site in np.ndindex(state.chi.shape): + for link_index in range(state.phase_links.shape[3]): + color_coordinates = 1.0e-3 * rng.normal(size=8) + state.color_links[site + (link_index,)] = expm( + 1.0j + * np.einsum( + "a,aij->ij", + color_coordinates, + color_generators, + ) + ) + frame_coordinates = 1.0e-3 * rng.normal(size=6) + state.frame_links[site + (link_index,)] = expm( + np.einsum( + "a,aij->ij", + frame_coordinates, + frame_generators, + ) + ) + return state, parameters + + +def _potential(state: R3LiveState, parameters: R3LiveParameters) -> float: + return potential_energy_and_rates(state, parameters)[0] + + +def test_live_loop_inventory_and_vacuum() -> None: + assert len(triangle_loops("19")) == 10 + assert len(triangle_loops("27")) == 22 + parameters = R3LiveParameters() + state = R3LiveState.vacuum(2, parameters) + energy, parts = total_hamiltonian(state, parameters) + assert energy == 0.0 + assert all(value == 0.0 for value in parts.values()) + step_r3_live(state, 1.0e-3, parameters) + assert total_hamiltonian(state, parameters)[0] == 0.0 + + +def test_live_rates_are_hamiltonian_gradients() -> None: + state, parameters = _seeded_state() + _, rates, _ = potential_energy_and_rates(state, parameters) + epsilon = 1.0e-7 + site = (0, 0, 0) + link = 0 + + def directional( + plus_update, + minus_update, + ) -> float: + plus = state.copy() + minus = state.copy() + plus_update(plus) + minus_update(minus) + return (_potential(plus, parameters) - _potential(minus, parameters)) / (2.0 * epsilon) + + matter_derivative = directional( + lambda value: value.matter.__setitem__( + site + (0,), + value.matter[site + (0,)] + epsilon, + ), + lambda value: value.matter.__setitem__( + site + (0,), + value.matter[site + (0,)] - epsilon, + ), + ) + assert np.isclose( + matter_derivative, + -rates.matter[site + (0,)].real, + rtol=2.0e-5, + atol=2.0e-7, + ) + + chi_derivative = directional( + lambda value: value.chi.__setitem__( + site, + value.chi[site] + epsilon, + ), + lambda value: value.chi.__setitem__( + site, + value.chi[site] - epsilon, + ), + ) + assert np.isclose( + chi_derivative, + -rates.chi[site], + rtol=2.0e-5, + atol=2.0e-7, + ) + + phase_derivative = directional( + lambda value: value.phase_links.__setitem__( + site + (link,), + np.exp(1.0j * epsilon) * value.phase_links[site + (link,)], + ), + lambda value: value.phase_links.__setitem__( + site + (link,), + np.exp(-1.0j * epsilon) * value.phase_links[site + (link,)], + ), + ) + assert np.isclose( + phase_derivative, + -rates.phase_electric[site + (link,)], + rtol=2.0e-5, + atol=2.0e-7, + ) + + color_generator = su3_generators()[2] + color_derivative = directional( + lambda value: value.color_links.__setitem__( + site + (link,), + expm(1.0j * epsilon * color_generator) @ value.color_links[site + (link,)], + ), + lambda value: value.color_links.__setitem__( + site + (link,), + expm(-1.0j * epsilon * color_generator) @ value.color_links[site + (link,)], + ), + ) + assert np.isclose( + color_derivative, + -rates.color_electric[site + (link, 2)], + rtol=2.0e-5, + atol=2.0e-7, + ) + + frame_generator = so4_generators()[1] + frame_derivative = directional( + lambda value: value.frame_links.__setitem__( + site + (link,), + expm(epsilon * frame_generator) @ value.frame_links[site + (link,)], + ), + lambda value: value.frame_links.__setitem__( + site + (link,), + expm(-epsilon * frame_generator) @ value.frame_links[site + (link,)], + ), + ) + assert np.isclose( + frame_derivative, + -rates.frame_electric[site + (link, 1)], + rtol=2.0e-5, + atol=2.0e-7, + ) + + +def test_live_step_preserves_groups_and_reverses() -> None: + state, parameters = _seeded_state(11) + initial = state.copy() + for _ in range(4): + step_r3_live(state, 2.0e-4, parameters) + constraints = group_constraint_errors(state) + assert max(constraints.values()) < 2.0e-13 + reverse_momenta(state) + for _ in range(4): + step_r3_live(state, 2.0e-4, parameters) + reverse_momenta(state) + assert state_distance(initial, state) < 2.0e-11 + + +def test_live_energy_error_is_second_order_bounded() -> None: + initial, parameters = _seeded_state(19) + + def run(dt: float, steps: int) -> tuple[R3LiveState, float]: + state = initial.copy() + energies = [total_hamiltonian(state, parameters)[0]] + for _ in range(steps): + step_r3_live(state, dt, parameters) + energies.append(total_hamiltonian(state, parameters)[0]) + span = (max(energies) - min(energies)) / max( + abs(energies[0]), + 1.0, + ) + return state, span + + coarse, coarse_span = run(4.0e-4, 4) + medium, medium_span = run(2.0e-4, 8) + fine, fine_span = run(1.0e-4, 16) + assert coarse_span < 2.0e-7 + assert medium_span < coarse_span + assert fine_span < medium_span + coarse_medium = state_distance(coarse, medium) + medium_fine = state_distance(medium, fine) + assert coarse_medium / medium_fine > 3.0 diff --git a/tests/test_r4_color_static.py b/tests/test_r4_color_static.py new file mode 100644 index 0000000..2d9757a --- /dev/null +++ b/tests/test_r4_color_static.py @@ -0,0 +1,101 @@ +from __future__ import annotations + +import numpy as np + +from lfm.foundations.r4_color_static import ( + _chi_energy_gradient, + r4_color_static_energy, + relax_r4_chi_at_fixed_color_electric, + relax_r4_color_static, + solve_color_gauss_minimum, +) +from lfm.foundations.r4_unified_live import R4Parameters + + +def _point_pair(size: int, separation: int, charge: float) -> np.ndarray: + values = np.zeros((size, size, size), dtype=np.float64) + center = size // 2 + left = center - separation // 2 + right = left + separation + values[left, center, center] = charge + values[right, center, center] = -charge + return values + + +def test_r4_color_gauss_minimum_and_chi_gradient() -> None: + parameters = R4Parameters() + rng = np.random.default_rng(81) + chi = parameters.r3.chi0 + 1.0e-3 * rng.normal(size=(5, 5, 5)) + charge = _point_pair(5, 2, 2.0) + potential, electric, residual = solve_color_gauss_minimum( + chi, + charge, + parameters, + tolerance=1.0e-12, + ) + assert residual < 1.0e-10 + gradient = _chi_energy_gradient(chi, electric, parameters) + site = (2, 2, 2) + epsilon = 1.0e-6 + + def minimized_energy(delta: float) -> float: + varied = chi.copy() + varied[site] += delta + _, varied_electric, varied_residual = solve_color_gauss_minimum( + varied, + charge, + parameters, + tolerance=1.0e-12, + initial_potential=potential, + ) + assert varied_residual < 1.0e-10 + return r4_color_static_energy( + varied, + varied_electric, + parameters, + )[0] + + derivative = (minimized_energy(epsilon) - minimized_energy(-epsilon)) / (2.0 * epsilon) + assert np.isclose( + derivative, + gradient[site], + rtol=2.0e-5, + atol=2.0e-4, + ) + + +def test_r4_color_relaxation_preserves_gauss_and_is_finite() -> None: + parameters = R4Parameters() + charge = _point_pair(5, 2, 20.0) + result = relax_r4_color_static( + charge, + parameters, + seed=83, + initial_chi_noise=1.0e-3, + chi_iterations=20, + chi_step=1.0e-6, + gauss_tolerance=1.0e-11, + gauss_block=5, + ) + assert result.gauss_residual < 1.0e-9 + assert np.isfinite(result.energy) + assert np.all(np.isfinite(result.chi)) + assert result.energy_parts["color_electric"] > 0.0 + + +def test_fixed_divergence_free_flux_relaxation() -> None: + parameters = R4Parameters() + size = 7 + electric = np.zeros((size, size, size, 9), dtype=np.float64) + electric[..., 0] = 1.0 + relaxed = relax_r4_chi_at_fixed_color_electric( + electric, + parameters, + seed=11, + initial_chi_noise=0.0, + chi_iterations=10, + chi_step=1.0e-6, + ) + assert relaxed.gauss_residual < 1.0e-14 + assert np.isfinite(relaxed.energy) + assert 1.0 <= relaxed.effective_g_squared <= 63.0 diff --git a/tests/test_r4_gauge_spectrum.py b/tests/test_r4_gauge_spectrum.py new file mode 100644 index 0000000..81aa2e3 --- /dev/null +++ b/tests/test_r4_gauge_spectrum.py @@ -0,0 +1,54 @@ +"""Spectral tests for the R4 compact-link loop complex.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from lfm.foundations.r4_gauge_spectrum import ( + directional_link_inertia, + gauge_link_spectrum, + transverse_mode_speeds, +) + + +@pytest.mark.parametrize( + ("stencil", "inertia"), + [("19", 5.0), ("27", 9.0)], +) +def test_directional_link_inertia_is_geometric( + stencil: str, + inertia: float, +) -> None: + assert directional_link_inertia(stencil) == inertia + + +@pytest.mark.parametrize("stencil", ["19", "27"]) +def test_triangle_only_r4_has_two_spurious_flat_modes(stencil: str) -> None: + momentum = 2.0 * np.pi / 64.0 + eigenvalues = gauge_link_spectrum( + stencil, + (momentum, 0.0, 0.0), + include_face_squares=False, + ) + assert np.count_nonzero(np.abs(eigenvalues) < 1.0e-12) == 3 + + +@pytest.mark.parametrize("stencil", ["19", "27"]) +def test_geometry_normalized_squares_restore_two_unit_speed_modes( + stencil: str, +) -> None: + momentum = 2.0 * np.pi / 256.0 + eigenvalues = gauge_link_spectrum( + stencil, + (momentum, 0.0, 0.0), + include_face_squares=True, + ) + assert np.count_nonzero(np.abs(eigenvalues) < 1.0e-12) == 1 + speeds = transverse_mode_speeds( + stencil, + (momentum, 0.0, 0.0), + include_face_squares=True, + ) + assert speeds[0] == pytest.approx(1.0, abs=2.0e-3) + assert speeds[1] == pytest.approx(1.0, abs=2.0e-3) diff --git a/tests/test_r4_quantum_color.py b/tests/test_r4_quantum_color.py new file mode 100644 index 0000000..45782c2 --- /dev/null +++ b/tests/test_r4_quantum_color.py @@ -0,0 +1,100 @@ +"""Tests for the compact-link R4 quantum color diagnostics.""" + +from __future__ import annotations + +import numpy as np +import pytest +from scipy.linalg import expm + +from lfm.foundations.r3_link_frame_live import ( + R3LiveParameters, + su3_generators, +) +from lfm.foundations.r4_quantum_color import ( + creutz_ratio_from_log_transfer, + fundamental_flux_energy, + local_su3_gauge_transform, + minimum_link_distance, + r4_magnetic_competition_bound, + r4_quantum_color_coefficients, + su3_fundamental_algebra_audit, +) +from lfm.foundations.r4_unified_live import R4Parameters, R4State, total_hamiltonian + + +def _parameters(stencil: str) -> R4Parameters: + return R4Parameters(r3=R3LiveParameters(stencil=stencil)) + + +def test_su3_fundamental_algebra_is_derived() -> None: + audit = su3_fundamental_algebra_audit() + assert audit["generator_count"] == 8 + assert audit["normalization_error"] < 1.0e-14 + assert audit["casimir_spread"] < 1.0e-14 + assert float(audit["casimir"]) == pytest.approx(4.0 / 3.0) + + +@pytest.mark.parametrize("stencil", ["19", "27"]) +def test_vacuum_coefficients_give_positive_flux_slope(stencil: str) -> None: + coefficients = r4_quantum_color_coefficients(_parameters(stencil)) + assert coefficients.epsilon_vacuum == pytest.approx(1.0 / 63.0) + assert coefficients.g_squared_from_electric == pytest.approx(63.0) + assert coefficients.inverse_g_squared_from_magnetic == pytest.approx(1.0 / 63.0) + assert coefficients.fundamental_flux_slope == pytest.approx(42.0) + bound = r4_magnetic_competition_bound(_parameters(stencil)) + assert bound.magnetic_bound_per_link > 0.0 + assert bound.residual_positive_slope > 0.99 * 42.0 + + +@pytest.mark.parametrize("stencil", ["19", "27"]) +def test_gauss_flux_energy_and_creutz_area_law(stencil: str) -> None: + parameters = _parameters(stencil) + for distance in range(1, 9): + assert minimum_link_distance((distance, 0, 0), stencil) == distance + assert fundamental_flux_energy( + (distance, 0, 0), + parameters, + ) == pytest.approx(42.0 * distance) + values = [ + creutz_ratio_from_log_transfer( + distance, + euclidean_time=0.05, + time_increment=0.025, + parameters=parameters, + ) + for distance in range(1, 5) + ] + assert np.asarray(values) == pytest.approx(np.full(4, 42.0)) + + +@pytest.mark.parametrize("stencil", ["19", "27"]) +def test_full_r4_hamiltonian_is_locally_su3_gauge_invariant( + stencil: str, +) -> None: + parameters = _parameters(stencil) + state = R4State.vacuum(2, parameters) + rng = np.random.default_rng(541) + state.r3.matter = 0.02 * ( + rng.normal(size=state.r3.matter.shape) + 1.0j * rng.normal(size=state.r3.matter.shape) + ) + state.r3.matter_momentum = 0.03 * ( + rng.normal(size=state.r3.matter.shape) + 1.0j * rng.normal(size=state.r3.matter.shape) + ) + state.r3.color_electric = 0.01 * rng.normal(size=state.r3.color_electric.shape) + generators = su3_generators() + gauge = np.empty(state.r3.chi.shape + (3, 3), dtype=np.complex128) + for site in np.ndindex(state.r3.chi.shape): + algebra = np.einsum( + "a,aij->ij", + 0.2 * rng.normal(size=8), + generators, + ) + gauge[site] = expm(1.0j * algebra) + transformed = local_su3_gauge_transform( + state, + gauge, + parameters, + ) + before = total_hamiltonian(state, parameters)[0] + after = total_hamiltonian(transformed, parameters)[0] + assert after == pytest.approx(before, rel=1.0e-12, abs=1.0e-10) diff --git a/tests/test_r4_unified_live.py b/tests/test_r4_unified_live.py new file mode 100644 index 0000000..5c3c64e --- /dev/null +++ b/tests/test_r4_unified_live.py @@ -0,0 +1,404 @@ +from __future__ import annotations + +import numpy as np +import pytest +from scipy.linalg import expm + +from lfm.foundations.r3_link_frame_live import ( + R3LiveParameters, + _neighbor, + _temporal_shape_projector, + _tracefree_symmetric, + so4_generators, + su3_generators, +) +from lfm.foundations.r4_unified_live import ( + R4FrameScalarState, + R4Parameters, + R4State, + color_dielectric, + group_constraint_errors, + potential_energy_and_rates, + r4_frame_scalar_energy, + reverse_momenta, + state_distance, + step_r4, + step_r4_frame_scalar, + su2_generators, + total_hamiltonian, +) + + +def _seeded_state( + seed: int = 41, + stencil: str = "19", +) -> tuple[R4State, R4Parameters]: + rng = np.random.default_rng(seed) + parameters = R4Parameters(r3=R3LiveParameters(stencil=stencil)) + state = R4State.vacuum(2, parameters) + base = state.r3 + base.matter = 0.005 * ( + rng.normal(size=base.matter.shape) + 1.0j * rng.normal(size=base.matter.shape) + ) + base.matter_momentum = 0.005 * ( + rng.normal(size=base.matter.shape) + 1.0j * rng.normal(size=base.matter.shape) + ) + base.chi += 1.0e-4 * rng.normal(size=base.chi.shape) + base.chi_momentum = 0.005 * rng.normal(size=base.chi.shape) + base.shape = _tracefree_symmetric(1.0e-4 * rng.normal(size=base.shape.shape)) + base.shape_momentum = _tracefree_symmetric(0.005 * rng.normal(size=base.shape.shape)) + base.phase_links *= np.exp(1.0j * 1.0e-3 * rng.normal(size=base.phase_links.shape)) + base.phase_electric = 0.005 * rng.normal(size=base.phase_electric.shape) + base.color_electric = 0.005 * rng.normal(size=base.color_electric.shape) + base.frame_electric = 0.005 * rng.normal(size=base.frame_electric.shape) + state.weak_matter = 0.005 * ( + rng.normal(size=state.weak_matter.shape) + 1.0j * rng.normal(size=state.weak_matter.shape) + ) + state.weak_momentum = 0.005 * ( + rng.normal(size=state.weak_momentum.shape) + + 1.0j * rng.normal(size=state.weak_momentum.shape) + ) + state.weak_electric = 0.005 * rng.normal(size=state.weak_electric.shape) + state.higgs_electric = 0.005 * rng.normal(size=state.higgs_electric.shape) + color_generators = su3_generators() + frame_generators = so4_generators() + weak_generators = su2_generators() + for site in np.ndindex(base.chi.shape): + state.higgs_orientation[site] = expm( + 1.0j + * np.einsum( + "a,aij->ij", + 1.0e-3 * rng.normal(size=3), + weak_generators, + ) + ) + for link in range(base.phase_links.shape[3]): + base.color_links[site + (link,)] = expm( + 1.0j + * np.einsum( + "a,aij->ij", + 1.0e-3 * rng.normal(size=8), + color_generators, + ) + ) + base.frame_links[site + (link,)] = expm( + np.einsum( + "a,aij->ij", + 1.0e-3 * rng.normal(size=6), + frame_generators, + ) + ) + state.weak_links[site + (link,)] = expm( + 1.0j + * np.einsum( + "a,aij->ij", + 1.0e-3 * rng.normal(size=3), + weak_generators, + ) + ) + return state, parameters + + +def _potential(state: R4State, parameters: R4Parameters) -> float: + return potential_energy_and_rates(state, parameters)[0] + + +@pytest.mark.parametrize("stencil", ["19", "27"]) +def test_r4_vacuum_dielectric_and_generated_weak_gap( + stencil: str, +) -> None: + parameters = R4Parameters(r3=R3LiveParameters(stencil=stencil)) + state = R4State.vacuum(2, parameters) + epsilon, derivative = color_dielectric(state.r3.chi, parameters) + assert np.max(np.abs(epsilon - parameters.r3.kappa)) < 1.0e-15 + assert np.max(np.abs(derivative)) == 0.0 + assert total_hamiltonian(state, parameters)[0] == 0.0 + + generator = su2_generators()[0] + amplitude = 1.0e-4 + for index, (offset, _) in enumerate( + __import__( + "lfm.analysis.energy_current", + fromlist=["stencil_links"], + ).stencil_links(parameters.stencil, oriented=False) + ): + state.weak_links[..., index, :, :] = expm(1.0j * amplitude * offset[0] * generator) + energy = total_hamiltonian(state, parameters)[0] + assert energy > 0.0 + + +@pytest.mark.parametrize("stencil", ["19", "27"]) +def test_r4_added_rates_are_hamiltonian_gradients( + stencil: str, +) -> None: + state, parameters = _seeded_state(stencil=stencil) + _, rates, _ = potential_energy_and_rates(state, parameters) + epsilon = 1.0e-7 + site = (0, 0, 0) + link = 0 + + def derivative(plus_update, minus_update) -> float: + plus = state.copy() + minus = state.copy() + plus_update(plus) + minus_update(minus) + return (_potential(plus, parameters) - _potential(minus, parameters)) / (2.0 * epsilon) + + weak_matter_derivative = derivative( + lambda value: value.weak_matter.__setitem__( + site + (0,), + value.weak_matter[site + (0,)] + epsilon, + ), + lambda value: value.weak_matter.__setitem__( + site + (0,), + value.weak_matter[site + (0,)] - epsilon, + ), + ) + assert np.isclose( + weak_matter_derivative, + -rates.weak_matter[site + (0,)].real, + rtol=3.0e-5, + atol=3.0e-7, + ) + + weak_generator = su2_generators()[1] + weak_link_derivative = derivative( + lambda value: value.weak_links.__setitem__( + site + (link,), + expm(1.0j * epsilon * weak_generator) @ value.weak_links[site + (link,)], + ), + lambda value: value.weak_links.__setitem__( + site + (link,), + expm(-1.0j * epsilon * weak_generator) @ value.weak_links[site + (link,)], + ), + ) + assert np.isclose( + weak_link_derivative, + -rates.weak_electric[site + (link, 1)], + rtol=4.0e-5, + atol=4.0e-7, + ) + + higgs_derivative = derivative( + lambda value: value.higgs_orientation.__setitem__( + site, + expm(1.0j * epsilon * weak_generator) @ value.higgs_orientation[site], + ), + lambda value: value.higgs_orientation.__setitem__( + site, + expm(-1.0j * epsilon * weak_generator) @ value.higgs_orientation[site], + ), + ) + assert np.isclose( + higgs_derivative, + -rates.higgs_electric[site + (1,)], + rtol=4.0e-5, + atol=4.0e-7, + ) + + chi_derivative = derivative( + lambda value: value.r3.chi.__setitem__( + site, + value.r3.chi[site] + epsilon, + ), + lambda value: value.r3.chi.__setitem__( + site, + value.r3.chi[site] - epsilon, + ), + ) + assert np.isclose( + chi_derivative, + -rates.r3.chi[site], + rtol=4.0e-5, + atol=4.0e-7, + ) + + +@pytest.mark.parametrize("stencil", ["19", "27"]) +def test_r4_weak_local_covariance_of_potential( + stencil: str, +) -> None: + state, parameters = _seeded_state(43, stencil) + state.r3.matter_momentum.fill(0.0) + state.weak_momentum.fill(0.0) + state.weak_electric.fill(0.0) + state.higgs_electric.fill(0.0) + before = _potential(state, parameters) + rng = np.random.default_rng(47) + generators = su2_generators() + transformations = np.empty( + state.r3.chi.shape + (2, 2), + dtype=np.complex128, + ) + for site in np.ndindex(state.r3.chi.shape): + transformations[site] = expm( + 1.0j + * np.einsum( + "a,aij->ij", + 0.2 * rng.normal(size=3), + generators, + ) + ) + state.weak_matter = np.einsum( + "...ab,...b->...a", + transformations, + state.weak_matter, + ) + state.higgs_orientation = transformations @ state.higgs_orientation + unique = __import__( + "lfm.analysis.energy_current", + fromlist=["stencil_links"], + ).stencil_links(parameters.stencil, oriented=False) + for index, (offset, _) in enumerate(unique): + neighbor_transformation = _neighbor(transformations, offset) + state.weak_links[..., index, :, :] = ( + transformations + @ state.weak_links[..., index, :, :] + @ np.swapaxes( + neighbor_transformation.conj(), + -1, + -2, + ) + ) + after = _potential(state, parameters) + assert abs(after - before) / max(abs(before), 1.0) < 2.0e-13 + + +@pytest.mark.parametrize("stencil", ["19", "27"]) +def test_r4_u1_su3_local_covariance_of_potential( + stencil: str, +) -> None: + state, parameters = _seeded_state(49, stencil) + before = _potential(state, parameters) + rng = np.random.default_rng(51) + phases = 0.2 * rng.normal(size=state.r3.chi.shape) + phase_transform = np.exp(1.0j * phases) + generators = su3_generators() + color_transform = np.empty( + state.r3.chi.shape + (3, 3), + dtype=np.complex128, + ) + for site in np.ndindex(state.r3.chi.shape): + color_transform[site] = expm( + 1.0j + * np.einsum( + "a,aij->ij", + 0.1 * rng.normal(size=8), + generators, + ) + ) + state.r3.matter = phase_transform[..., np.newaxis] * np.einsum( + "...ab,...b->...a", + color_transform, + state.r3.matter, + ) + state.weak_matter *= phase_transform[..., np.newaxis] + unique = __import__( + "lfm.analysis.energy_current", + fromlist=["stencil_links"], + ).stencil_links(parameters.stencil, oriented=False) + for index, (offset, _) in enumerate(unique): + neighbor_phase = _neighbor(phase_transform, offset) + neighbor_color = _neighbor(color_transform, offset) + state.r3.phase_links[..., index] *= phase_transform * np.conj(neighbor_phase) + state.r3.color_links[..., index, :, :] = ( + color_transform + @ state.r3.color_links[..., index, :, :] + @ np.swapaxes(neighbor_color.conj(), -1, -2) + ) + after = _potential(state, parameters) + assert abs(after - before) / max(abs(before), 1.0) < 3.0e-13 + + +@pytest.mark.parametrize("stencil", ["19", "27"]) +def test_r4_live_reversal_groups_and_energy_order( + stencil: str, +) -> None: + initial, parameters = _seeded_state(53, stencil) + + reverse = initial.copy() + for _ in range(3): + step_r4(reverse, 1.0e-4, parameters) + reverse_momenta(reverse) + for _ in range(3): + step_r4(reverse, 1.0e-4, parameters) + reverse_momenta(reverse) + assert state_distance(initial, reverse) < 5.0e-10 + assert max(group_constraint_errors(reverse).values()) < 2.0e-12 + + def endpoint(dt: float, duration: float) -> tuple[R4State, float]: + state = initial.copy() + energies = [total_hamiltonian(state, parameters)[0]] + for _ in range(int(round(duration / dt))): + step_r4(state, dt, parameters) + energies.append(total_hamiltonian(state, parameters)[0]) + span = (max(energies) - min(energies)) / max( + abs(energies[0]), + 1.0, + ) + return state, span + + coarse, coarse_span = endpoint(2.0e-4, 8.0e-4) + medium, medium_span = endpoint(1.0e-4, 8.0e-4) + fine, fine_span = endpoint(5.0e-5, 8.0e-4) + assert coarse_span < 2.0e-6 + assert medium_span < coarse_span + assert fine_span < medium_span + assert state_distance(coarse, medium) / state_distance(medium, fine) > 3.0 + + +@pytest.mark.parametrize("stencil", ["19", "27"]) +def test_r4_scalar_frame_sector_matches_full_r4(stencil: str) -> None: + parameters = R4Parameters(r3=R3LiveParameters(stencil=stencil)) + rng = np.random.default_rng(71) + scalar = R4FrameScalarState.vacuum(3) + scalar.shape_amplitude = 1.0e-4 * rng.normal(size=scalar.shape_amplitude.shape) + scalar.shape_momentum = 1.0e-4 * rng.normal(size=scalar.shape_momentum.shape) + full = R4State.vacuum(3, parameters) + projector = _temporal_shape_projector() + full.r3.shape = scalar.shape_amplitude[..., np.newaxis, np.newaxis] * projector + full.r3.shape_momentum = scalar.shape_momentum[..., np.newaxis, np.newaxis] * projector + full_energy = total_hamiltonian(full, parameters)[0] + scalar_energy = r4_frame_scalar_energy( + scalar, + parameters, + )[0] + assert abs(full_energy - scalar_energy) < 1.0e-13 + step = 1.0e-4 + step_r4(full, step, parameters) + step_r4_frame_scalar(scalar, step, parameters) + recovered_amplitude = np.sum(full.r3.shape * projector, axis=(-2, -1)) / np.sum(projector**2) + recovered_momentum = np.sum( + full.r3.shape_momentum * projector, + axis=(-2, -1), + ) / np.sum(projector**2) + assert np.max(np.abs(recovered_amplitude - scalar.shape_amplitude)) < 2.0e-15 + assert np.max(np.abs(recovered_momentum - scalar.shape_momentum)) < 2.0e-14 + assert np.max(np.abs(full.r3.frame_electric)) < 2.0e-18 + + +@pytest.mark.parametrize("stencil", ["19", "27"]) +def test_r4_scalar_frame_fixed_source_reverses(stencil: str) -> None: + parameters = R4Parameters(r3=R3LiveParameters(stencil=stencil)) + initial = R4FrameScalarState.vacuum(5) + source = np.zeros(initial.shape_amplitude.shape) + source[2, 2, 2] = 1.0 + state = initial.copy() + for _ in range(10): + step_r4_frame_scalar( + state, + 1.0e-2, + parameters, + source_density=source, + ) + state.shape_momentum *= -1.0 + for _ in range(10): + step_r4_frame_scalar( + state, + 1.0e-2, + parameters, + source_density=source, + ) + state.shape_momentum *= -1.0 + assert np.max(np.abs(state.shape_amplitude)) < 2.0e-16 + assert np.max(np.abs(state.shape_momentum)) < 2.0e-16 diff --git a/tests/test_r5_u1_static.py b/tests/test_r5_u1_static.py new file mode 100644 index 0000000..768105b --- /dev/null +++ b/tests/test_r5_u1_static.py @@ -0,0 +1,32 @@ +"""Tests for Gauss-constrained R5 U(1) probes.""" + +from __future__ import annotations + +import numpy as np +import pytest + +from lfm.foundations.r3_link_frame_live import R3LiveParameters +from lfm.foundations.r4_unified_live import R4Parameters +from lfm.foundations.r5_u1_static import ( + periodic_point_pair_charge, + solve_u1_gauss_minimum, +) +from lfm.foundations.r5_unified_live import R5Parameters + + +@pytest.mark.parametrize("stencil", ["19", "27"]) +@pytest.mark.parametrize("relative_sign", [-1, 1]) +def test_periodic_u1_point_pair_satisfies_gauss( + stencil: str, + relative_sign: int, +) -> None: + parameters = R5Parameters(r4=R4Parameters(r3=R3LiveParameters(stencil=stencil))) + charge = periodic_point_pair_charge(9, 2, relative_sign) + state = solve_u1_gauss_minimum( + charge, + parameters, + tolerance=1.0e-12, + ) + assert abs(float(np.sum(charge))) < 1.0e-12 + assert state.gauss_residual < 1.0e-10 + assert state.electric_energy > 0.0 diff --git a/tests/test_r5_unified_live.py b/tests/test_r5_unified_live.py new file mode 100644 index 0000000..6d16fd4 --- /dev/null +++ b/tests/test_r5_unified_live.py @@ -0,0 +1,104 @@ +"""Tests for the experiment-only R5 curvature completion.""" + +from __future__ import annotations + +import numpy as np +import pytest +from scipy.linalg import expm + +from lfm.foundations.r3_link_frame_live import ( + R3LiveParameters, + su3_generators, +) +from lfm.foundations.r4_quantum_color import ( + local_su3_gauge_transform, +) +from lfm.foundations.r4_unified_live import R4Parameters +from lfm.foundations.r5_unified_live import ( + R5Parameters, + potential_energy_and_rates, + r5_action_declaration, + total_hamiltonian, + vacuum_state, +) + + +def _parameters(stencil: str) -> R5Parameters: + return R5Parameters(r4=R4Parameters(r3=R3LiveParameters(stencil=stencil))) + + +@pytest.mark.parametrize( + ("stencil", "coefficient"), + [("19", 5.0), ("27", 9.0)], +) +def test_r5_vacuum_and_derived_square_coefficient( + stencil: str, + coefficient: float, +) -> None: + parameters = _parameters(stencil) + state = vacuum_state(2, parameters) + energy, parts = total_hamiltonian(state, parameters) + assert energy == pytest.approx(0.0, abs=1.0e-12) + assert parameters.square_coefficient == coefficient + assert r5_action_declaration(parameters)["new_registers"] == [] + assert parts["phase_face_square"] == pytest.approx(0.0) + assert parts["color_face_square"] == pytest.approx(0.0) + assert parts["frame_face_square"] == pytest.approx(0.0) + assert parts["weak_face_square"] == pytest.approx(0.0) + + +@pytest.mark.parametrize("stencil", ["19", "27"]) +def test_r5_face_square_force_is_variational(stencil: str) -> None: + parameters = _parameters(stencil) + state = vacuum_state(3, parameters) + rng = np.random.default_rng(719) + state.r3.phase_links *= np.exp(1.0j * 0.02 * rng.normal(size=state.r3.phase_links.shape)) + _, rates, _ = potential_energy_and_rates(state, parameters) + site = (1, 1, 1, 0) + epsilon = 1.0e-6 + + def energy(delta: float) -> float: + varied = state.copy() + varied.r3.phase_links[site] *= np.exp(1.0j * delta) + return total_hamiltonian(varied, parameters)[0] + + derivative = (energy(epsilon) - energy(-epsilon)) / (2.0 * epsilon) + assert derivative == pytest.approx( + -rates.r3.phase_electric[site], + rel=2.0e-6, + abs=2.0e-7, + ) + + +@pytest.mark.parametrize("stencil", ["19", "27"]) +def test_r5_full_hamiltonian_retains_local_su3_invariance( + stencil: str, +) -> None: + parameters = _parameters(stencil) + state = vacuum_state(2, parameters) + rng = np.random.default_rng(727) + state.r3.matter = 0.02 * ( + rng.normal(size=state.r3.matter.shape) + 1.0j * rng.normal(size=state.r3.matter.shape) + ) + gauge = np.empty(state.r3.chi.shape + (3, 3), dtype=np.complex128) + generators = su3_generators() + for site in np.ndindex(state.r3.chi.shape): + algebra = np.einsum( + "a,aij->ij", + 0.2 * rng.normal(size=8), + generators, + ) + gauge[site] = expm(1.0j * algebra) + transformed = local_su3_gauge_transform( + state, + gauge, + parameters.r4, + ) + assert total_hamiltonian( + transformed, + parameters, + )[0] == pytest.approx( + total_hamiltonian(state, parameters)[0], + rel=1.0e-12, + abs=1.0e-10, + ) diff --git a/tests/test_r6_unified_live.py b/tests/test_r6_unified_live.py new file mode 100644 index 0000000..4b79b36 --- /dev/null +++ b/tests/test_r6_unified_live.py @@ -0,0 +1,30 @@ +"""Tests for the experiment-only R6 weak normalization.""" + +from __future__ import annotations + +import pytest + +from lfm.foundations.r3_link_frame_live import R3LiveParameters +from lfm.foundations.r6_unified_live import ( + R6Parameters, + R6R4Parameters, + r6_action_declaration, + total_hamiltonian, + vacuum_state, +) + + +@pytest.mark.parametrize("stencil", ["19", "27"]) +def test_r6_has_no_new_register_and_unit_weak_speed(stencil: str) -> None: + parameters = R6Parameters(r4=R6R4Parameters(r3=R3LiveParameters(stencil=stencil))) + declaration = r6_action_declaration(parameters) + assert parameters.r4.weak_stiffness == pytest.approx(10.0) + assert parameters.r4.weak_inertia == pytest.approx(10.0) + assert declaration["derived_parameters"]["weak_speed_squared"] == 1.0 + assert declaration["new_registers"] == [] + assert declaration["new_potential_terms"] == [] + energy = total_hamiltonian( + vacuum_state(2, parameters), + parameters, + )[0] + assert energy == pytest.approx(0.0, abs=1.0e-12) diff --git a/tests/test_simulation.py b/tests/test_simulation.py index e8e94bc..acdae8a 100644 --- a/tests/test_simulation.py +++ b/tests/test_simulation.py @@ -90,6 +90,90 @@ def test_energy_density_nonnegative(self): ed = sim.get_energy_density() assert np.all(ed >= 0) + def test_periodic_boundary_has_zero_absorption_mask(self): + sim = Simulation(_small_config(boundary_type=BoundaryType.PERIODIC), backend="cpu") + mask = sim.get_boundary_mask() + assert mask.shape == (N, N, N) + assert np.count_nonzero(mask) == 0 + + def test_frozen_boundary_retains_nonzero_absorption_mask(self): + sim = Simulation(_small_config(boundary_type=BoundaryType.FROZEN), backend="cpu") + mask = sim.get_boundary_mask() + assert mask.shape == (N, N, N) + assert np.count_nonzero(mask) > 0 + + def test_custom_boundary_mask_is_copied_and_round_trips(self): + sim = Simulation(_small_config(boundary_type=BoundaryType.PERIODIC), backend="cpu") + mask = np.ones((N, N, N), dtype=np.float32) + mask[2:4, 5:7, :] = 0.0 + sim.set_boundary_mask(mask) + observed = sim.get_boundary_mask() + np.testing.assert_array_equal(observed, mask) + observed[0, 0, 0] = 0.0 + assert sim.get_boundary_mask()[0, 0, 0] == pytest.approx(1.0) + + @pytest.mark.parametrize( + "mask", + [ + np.zeros((N, N), dtype=np.float32), + np.full((N, N, N), -0.01, dtype=np.float32), + np.full((N, N, N), 1.01, dtype=np.float32), + np.full((N, N, N), np.nan, dtype=np.float32), + ], + ) + def test_custom_boundary_mask_rejects_invalid_values(self, mask): + sim = Simulation(_small_config(boundary_type=BoundaryType.PERIODIC), backend="cpu") + with pytest.raises(ValueError): + sim.set_boundary_mask(mask) + + def test_custom_boundary_mask_is_frozen_after_evolution(self): + sim = Simulation(_small_config(boundary_type=BoundaryType.PERIODIC), backend="cpu") + sim.run(1, record_metrics=False) + with pytest.raises(RuntimeError, match="before evolution"): + sim.set_boundary_mask(np.zeros((N, N, N), dtype=np.float32)) + + def test_custom_boundary_mask_enforces_wall_in_production_step(self): + sim = Simulation(_small_config(boundary_type=BoundaryType.PERIODIC), backend="cpu") + mask = np.ones((N, N, N), dtype=np.float32) + mask[2:4, 5:7, :] = 0.0 + sim.set_boundary_mask(mask) + sim.set_psi_real(np.ones((N, N, N), dtype=np.float32)) + sim.run(1, record_metrics=False) + field = sim.get_psi_real() + assert np.count_nonzero(field[mask == 1.0]) == 0 + assert np.any(np.abs(field[mask == 0.0]) > 0.0) + + def test_phase_space_snapshot_color_inventory_and_copy_isolation(self): + sim = Simulation( + _small_config(field_level=FieldLevel.COLOR, n_colors=3), + backend="cpu", + ) + shape = (3, N, N, N) + current_r = np.full(shape, 0.25, dtype=np.float32) + previous_r = np.full(shape, -0.5, dtype=np.float32) + current_i = np.full(shape, 0.75, dtype=np.float32) + previous_i = np.full(shape, -1.0, dtype=np.float32) + current_chi = np.full((N, N, N), CHI0 + 0.25, dtype=np.float32) + previous_chi = np.full((N, N, N), CHI0 - 0.5, dtype=np.float32) + sim.set_psi_real(current_r) + sim.set_psi_real_prev(previous_r) + sim.set_psi_imag(current_i) + sim.set_psi_imag_prev(previous_i) + sim.set_chi(current_chi) + sim.set_chi_prev(previous_chi) + + snapshot = sim.phase_space_snapshot() + assert snapshot["step"] == 0 + np.testing.assert_array_equal(snapshot["psi_real"], current_r) + np.testing.assert_array_equal(snapshot["psi_real_prev"], previous_r) + np.testing.assert_array_equal(snapshot["psi_imag"], current_i) + np.testing.assert_array_equal(snapshot["psi_imag_prev"], previous_i) + np.testing.assert_array_equal(snapshot["chi"], current_chi) + np.testing.assert_array_equal(snapshot["chi_prev"], previous_chi) + + snapshot["chi"][0, 0, 0] = -999.0 + assert sim.get_chi()[0, 0, 0] == pytest.approx(CHI0 + 0.25) + # ──── Soliton placement ──── @@ -366,6 +450,39 @@ def test_round_trip_preserves_history(self, tmp_path): loaded = Simulation.load_checkpoint(path) assert len(loaded.history) == len(sim.history) + def test_round_trip_restores_phase_space_boundary_and_continuation(self, tmp_path): + cfg = _small_config(boundary_type=BoundaryType.PERIODIC) + sim = Simulation(cfg, backend="cpu") + rng = np.random.default_rng(20260715) + real = rng.normal(0.0, 0.01, (N, N, N)).astype(np.float32) + chi = (CHI0 + rng.normal(0.0, 1e-3, (N, N, N))).astype(np.float32) + mask = np.zeros((N, N, N), dtype=np.float32) + mask[0, :, :] = 1.0 + mask[:, 5, 2:7] = 1.0 + sim.set_psi_real(real) + sim.set_psi_real_prev(real + np.float32(1e-4)) + sim.set_chi(chi) + sim.set_chi_prev(chi + np.float32(1e-5)) + sim.set_boundary_mask(mask) + sim.run(2, record_metrics=False) + + path = tmp_path / "phase_space.npz" + sim.save_checkpoint(path) + loaded = Simulation.load_checkpoint(path, backend="cpu") + + np.testing.assert_array_equal(loaded.get_boundary_mask(), mask) + expected = sim.phase_space_snapshot() + observed = loaded.phase_space_snapshot() + for key in ("psi_real", "psi_real_prev", "chi", "chi_prev"): + np.testing.assert_array_equal(observed[key], expected[key]) + + sim.run(1, record_metrics=False) + loaded.run(1, record_metrics=False) + expected = sim.phase_space_snapshot() + observed = loaded.phase_space_snapshot() + for key in ("psi_real", "psi_real_prev", "chi", "chi_prev"): + np.testing.assert_array_equal(observed[key], expected[key]) + def test_complex_field_round_trip(self, tmp_path): cfg = _small_config(field_level=FieldLevel.COMPLEX) sim = Simulation(cfg) diff --git a/tests/test_skyrme_topology.py b/tests/test_skyrme_topology.py new file mode 100644 index 0000000..3966057 --- /dev/null +++ b/tests/test_skyrme_topology.py @@ -0,0 +1,92 @@ +from __future__ import annotations + +import numpy as np + +from lfm.topology import ( + FRQuantization, + hedgehog_degree, + hedgehog_energy_gradient_hessian, + make_hedgehog_profile, + solve_skyrme_hedgehog, +) + + +def test_hedgehog_gradient_and_hessian_match_finite_differences() -> None: + radius = 2.0 + dr = 0.1 + profile = make_hedgehog_profile(radius=radius, dr=dr) + _, gradient, hessian, _ = hedgehog_energy_gradient_hessian( + profile, + radius=radius, + dr=dr, + ) + dense = hessian.toarray() + assert np.allclose(dense, dense.T, rtol=0.0, atol=1.0e-12) + + epsilon = 1.0e-6 + for interior_index in (0, 4, 9, 17): + profile_index = interior_index + 1 + plus = profile.copy() + minus = profile.copy() + plus[profile_index] += epsilon + minus[profile_index] -= epsilon + plus_energy, plus_gradient, _, _ = hedgehog_energy_gradient_hessian( + plus, + radius=radius, + dr=dr, + ) + minus_energy, minus_gradient, _, _ = hedgehog_energy_gradient_hessian( + minus, + radius=radius, + dr=dr, + ) + energy_difference = (plus_energy.total - minus_energy.total) / (2.0 * epsilon) + gradient_difference = (plus_gradient - minus_gradient) / (2.0 * epsilon) + assert np.isclose( + gradient[interior_index], + energy_difference, + rtol=2.0e-7, + atol=2.0e-7, + ) + assert np.allclose( + dense[:, interior_index], + gradient_difference, + rtol=2.0e-6, + atol=2.0e-5, + ) + + +def test_hedgehog_solver_produces_stationary_unit_degree_state() -> None: + solution = solve_skyrme_hedgehog(radius=6.0, dr=0.1) + assert solution.stationary_relative_residual < 1.0e-9 + assert abs(solution.degree - 1.0) < 2.0e-3 + assert solution.unitarity_residual < 1.0e-14 + assert solution.derrick_relative_first_derivative < 1.0e-9 + assert solution.derrick_relative_second_derivative > 0.01 + assert solution.continuum_virial_mismatch < 0.03 + assert np.isclose( + hedgehog_degree( + solution.profile, + radius=solution.radius, + dr=solution.dr, + ), + solution.degree, + ) + + +def test_fr_nontrivial_character_gives_odd_degree_fermionic_signs() -> None: + quantization = FRQuantization(degree=1, deck_character=-1) + assert quantization.rotation_loop_class == 1 + assert quantization.exchange_loop_class == 1 + assert quantization.rotation_sign(1) == -1 + assert quantization.rotation_sign(2) == 1 + assert quantization.exchange_sign(1) == -1 + assert quantization.exchange_sign(2) == 1 + assert quantization.wavefunction_on_sheet(2.0 + 3.0j, 1) == -2.0 - 3.0j + + +def test_fr_even_degree_rotation_loop_is_contractible() -> None: + quantization = FRQuantization(degree=2, deck_character=-1) + assert quantization.rotation_loop_class == 0 + assert quantization.rotation_sign(1) == 1 + assert quantization.exchange_sign(1) == 1 diff --git a/tests/test_stationary_branch.py b/tests/test_stationary_branch.py new file mode 100644 index 0000000..a18200a --- /dev/null +++ b/tests/test_stationary_branch.py @@ -0,0 +1,22 @@ +from __future__ import annotations + +import numpy as np + +from lfm.particles.stationary import solve_stationary_branch_point + + +def test_stationary_branch_point_satisfies_norm_and_boundaries() -> None: + point = solve_stationary_branch_point( + 10, + 1000.0, + max_cycles=40, + tolerance=1.0e-5, + mixing=0.5, + ) + assert point.converged + assert np.isclose(np.sum(point.phi * point.phi), 1000.0, rtol=1.0e-10) + assert point.chi_min > 0.0 + assert np.all(point.phi[0] == 0.0) + assert np.all(point.chi[0] == 19.0) + assert point.phi_residual < 1.0e-5 + assert point.chi_residual_rms < 1.0e-5 diff --git a/tests/test_stencils.py b/tests/test_stencils.py index 21b430e..7e1dbc3 100644 --- a/tests/test_stencils.py +++ b/tests/test_stencils.py @@ -2,7 +2,14 @@ import numpy as np -from lfm.core.stencils import laplacian_7pt, laplacian_19pt +from lfm.core.stencils import ( + eigenvalue_19pt, + eigenvalue_27pt, + gradient_19pt, + laplacian_7pt, + laplacian_19pt, + laplacian_27pt, +) class TestLaplacian19pt: @@ -55,6 +62,63 @@ def test_output_shape(self): lap = laplacian_19pt(field) assert lap.shape == field.shape + def test_eigenvalue_matches_single_fourier_mode(self): + N = 16 + coords = np.arange(N, dtype=np.float64) + X, Y, Z = np.meshgrid(coords, coords, coords, indexing="ij") + kx_i, ky_i, kz_i = 2, 1, 0 + kx = 2.0 * np.pi * kx_i / N + ky = 2.0 * np.pi * ky_i / N + kz = 2.0 * np.pi * kz_i / N + field = np.cos(kx * X + ky * Y + kz * Z) + lap = laplacian_19pt(field) + lam = float(eigenvalue_19pt(np.array(kx), np.array(ky), np.array(kz))) + np.testing.assert_allclose(lap, lam * field, atol=1e-12) + + +class TestGradient19pt: + def test_constant_field_zero(self): + gradient = gradient_19pt(np.ones((12, 12, 12))) + for component in gradient: + np.testing.assert_allclose(component, 0.0, atol=1e-12) + + def test_axis_fourier_mode(self): + size = 24 + k = 2.0 * np.pi / size + x = np.arange(size, dtype=np.float64) + field = np.broadcast_to( + np.sin(k * x)[:, None, None], + (size, size, size), + ).copy() + gx, gy, gz = gradient_19pt(field) + expected = np.broadcast_to( + (np.sin(k) * np.cos(k * x))[:, None, None], + field.shape, + ) + np.testing.assert_allclose(gx, expected, atol=1e-12) + np.testing.assert_allclose(gy, 0.0, atol=1e-12) + np.testing.assert_allclose(gz, 0.0, atol=1e-12) + + +class TestLaplacian27pt: + def test_constant_field_zero(self): + field = np.ones((16, 16, 16)) + lap = laplacian_27pt(field) + np.testing.assert_allclose(lap, 0.0, atol=1e-12) + + def test_eigenvalue_matches_single_fourier_mode(self): + size = 16 + coords = np.arange(size, dtype=np.float64) + x, y, z = np.meshgrid(coords, coords, coords, indexing="ij") + kx_i, ky_i, kz_i = 2, 1, 3 + kx = 2.0 * np.pi * kx_i / size + ky = 2.0 * np.pi * ky_i / size + kz = 2.0 * np.pi * kz_i / size + field = np.cos(kx * x + ky * y + kz * z) + lap = laplacian_27pt(field) + eigenvalue = float(eigenvalue_27pt(np.array(kx), np.array(ky), np.array(kz))) + np.testing.assert_allclose(lap, eigenvalue * field, atol=1e-12) + class TestLaplacian7pt: def test_constant_field_zero(self): diff --git a/tests/test_substrate_emergence.py b/tests/test_substrate_emergence.py new file mode 100644 index 0000000..c4d9774 --- /dev/null +++ b/tests/test_substrate_emergence.py @@ -0,0 +1,62 @@ +"""Tests for discrete-to-continuum substrate observables.""" + +from __future__ import annotations + +import numpy as np + +from lfm.analysis.substrate_emergence import ( + composite_curvature_19pt, + curvature_rms, + normalize_internal_field, + relational_wave_scaling_scan, +) + + +def _texture(size: int, epsilon: float = 0.1) -> np.ndarray: + length = 2.0 * np.pi + axis = np.arange(size, dtype=np.float64) * length / size + x, y, _z = np.meshgrid(axis, axis, axis, indexing="ij") + singlet = np.ones(3, dtype=np.complex128) / np.sqrt(3.0) + relative = np.asarray((1.0, -1.0, 0.0), dtype=np.complex128) / np.sqrt(2.0) + eta = np.sin(x) + 1j * np.sin(y) + raw = singlet[:, None, None, None] + (epsilon * relative[:, None, None, None] * eta[None, ...]) + return normalize_internal_field(raw) + + +def test_relational_wave_scaling_converges() -> None: + result = relational_wave_scaling_scan( + (9.5, 19.0, 28.5), + (0.25, 0.5, 1.0), + (0.02, 0.01, 0.005, 0.0025), + mass_factors=(1.0, np.sqrt(1.0 + 2.0 / 17.0)), + ) + assert result["pass"] is True + assert result["convergence"]["max_dispersion_error_slope"] > 1.8 + + +def test_composite_curvature_is_global_unitary_invariant() -> None: + z = _texture(20) + rng = np.random.default_rng(20260725) + matrix = rng.standard_normal((3, 3)) + 1j * rng.standard_normal((3, 3)) + unitary, _r = np.linalg.qr(matrix) + rotated = np.einsum("ab,bxyz->axyz", unitary, z) + dx = 2.0 * np.pi / z.shape[1] + reference = composite_curvature_19pt(z, dx=dx) + transformed = composite_curvature_19pt(rotated, dx=dx) + error = max( + float(np.max(np.abs(left - right))) + for left, right in zip(reference, transformed, strict=True) + ) + assert curvature_rms(reference) > 1.0e-6 + assert error < 1.0e-12 + + +def test_composite_curvature_changes_sign_under_conjugation() -> None: + z = _texture(20) + dx = 2.0 * np.pi / z.shape[1] + positive = composite_curvature_19pt(z, dx=dx) + negative = composite_curvature_19pt(np.conj(z), dx=dx) + residual = max( + float(np.max(np.abs(left + right))) for left, right in zip(positive, negative, strict=True) + ) + assert residual < 1.0e-12 diff --git a/tests/test_support_removal.py b/tests/test_support_removal.py new file mode 100644 index 0000000..48ae952 --- /dev/null +++ b/tests/test_support_removal.py @@ -0,0 +1,40 @@ +from __future__ import annotations + +import numpy as np + +from lfm.particles.stationary import continue_support_removal + +C7_OFFSETS = ( + (0, 0, 0), + (1, 0, 0), + (-1, 0, 0), + (0, 1, 0), + (0, -1, 0), + (0, 0, 1), + (0, 0, -1), +) + + +def test_support_removal_preserves_norm_and_responds_to_dynamic_source() -> None: + grid = 9 + source_total = 1_847_183.1602203925 + fixed_source = np.zeros((grid, grid, grid), dtype=np.float64) + center = grid // 2 + for dx, dy, dz in C7_OFFSETS: + fixed_source[center + dx, center + dy, center + dz] = source_total / 7.0 + + fixed, partly_dynamic = continue_support_removal( + fixed_source, + [0.0, 0.01], + max_cycles=80, + tolerance=1.0e-5, + mixing=0.4, + ) + + assert fixed.converged + assert partly_dynamic.converged + assert np.isclose(np.sum(fixed.source_density), source_total) + assert np.isclose(np.sum(partly_dynamic.source_density), source_total) + assert np.allclose(fixed.source_density, fixed_source) + assert partly_dynamic.effective_sites < fixed.effective_sites + assert partly_dynamic.chi_min < fixed.chi_min diff --git a/tests/test_unified_force_harness.py b/tests/test_unified_force_harness.py new file mode 100644 index 0000000..f0f325a --- /dev/null +++ b/tests/test_unified_force_harness.py @@ -0,0 +1,218 @@ +"""Mutation tests for the unified-force evidence policy.""" + +from __future__ import annotations + +import pytest + +from lfm.validation.unified_force import ( + BenchmarkResult, + BenchmarkSpec, + BenchmarkStatus, + EvidenceClass, + ExecutionIdentity, + ForceSector, + GateTier, + ReadoutFrame, + UnifiedForceHarness, +) + + +def _identity(action: str = "action-a") -> ExecutionIdentity: + return ExecutionIdentity.build( + action_id=action, + register_id="R2", + parameters={"chi0": 19.0}, + gov01_stencil="19", + gov02_stencil="19", + boundary_policy="periodic", + implementation={"module": "test"}, + ) + + +def _spec( + benchmark_id: str, + sector: ForceSector, + *, + evidence: EvidenceClass = EvidenceClass.LIVE, + dependencies: tuple[str, ...] = (), +) -> BenchmarkSpec: + return BenchmarkSpec( + benchmark_id=benchmark_id, + sector=sector, + tier=GateTier.T2, + description="test gate", + required_for_sector=True, + promotion_eligible=True, + accepted_evidence=frozenset({evidence}), + dependencies=dependencies, + requires_shared_identity=evidence is EvidenceClass.LIVE, + ) + + +def _pass( + benchmark_id: str, + *, + evidence: EvidenceClass = EvidenceClass.LIVE, + identity: ExecutionIdentity | None = None, + mechanisms: frozenset[str] = frozenset(), +) -> BenchmarkResult: + return BenchmarkResult( + benchmark_id=benchmark_id, + status=BenchmarkStatus.PASS, + evidence=evidence, + reason="synthetic pass", + mechanisms_used=mechanisms, + identity=identity, + ) + + +def test_missing_required_gate_blocks() -> None: + harness = UnifiedForceHarness([_spec("GR-LIVE", ForceSector.GRAVITY)]) + report = harness.evaluate([]) + assert report.sector_status[ForceSector.GRAVITY] is BenchmarkStatus.BLOCKED + assert report.unified_status is BenchmarkStatus.BLOCKED + + +def test_diagnostic_cannot_substitute_for_live() -> None: + harness = UnifiedForceHarness([_spec("EM-LIVE", ForceSector.EM)]) + report = harness.evaluate( + [_pass("EM-LIVE", evidence=EvidenceClass.DIAGNOSTIC, identity=_identity())] + ) + assert report.results[0].status is BenchmarkStatus.FAIL + + +def test_forbidden_mechanism_forces_failure() -> None: + harness = UnifiedForceHarness([_spec("GR-LIVE", ForceSector.GRAVITY)]) + report = harness.evaluate( + [ + _pass( + "GR-LIVE", + identity=_identity(), + mechanisms=frozenset({"prescribed_potential"}), + ) + ] + ) + assert report.results[0].status is BenchmarkStatus.FAIL + + +def test_unsatisfied_dependency_rejects_claimed_pass() -> None: + specs = [ + _spec("CORE", ForceSector.CORE, evidence=EvidenceClass.STRUCTURAL), + _spec("EM-LIVE", ForceSector.EM, dependencies=("CORE",)), + ] + harness = UnifiedForceHarness(specs) + report = harness.evaluate([_pass("EM-LIVE", identity=_identity())]) + assert report.results[1].status is BenchmarkStatus.FAIL + + +def test_mixed_actions_forbid_unified_promotion() -> None: + sectors = ( + ForceSector.CORE, + ForceSector.GRAVITY, + ForceSector.EM, + ForceSector.WEAK, + ForceSector.STRONG, + ForceSector.UNIFIED, + ) + specs = [_spec(f"{sector.value}-LIVE", sector) for sector in sectors] + results = [ + _pass( + spec.benchmark_id, + identity=_identity("action-b" if index == 4 else "action-a"), + ) + for index, spec in enumerate(specs) + ] + report = UnifiedForceHarness(specs).evaluate(results) + assert report.unified_status is BenchmarkStatus.FAIL + assert report.shared_identity_fingerprint is None + + +def test_one_shared_action_can_pass_policy() -> None: + sectors = ( + ForceSector.CORE, + ForceSector.GRAVITY, + ForceSector.EM, + ForceSector.WEAK, + ForceSector.STRONG, + ForceSector.UNIFIED, + ) + specs = [_spec(f"{sector.value}-LIVE", sector) for sector in sectors] + identity = _identity() + results = [_pass(spec.benchmark_id, identity=identity) for spec in specs] + report = UnifiedForceHarness(specs).evaluate(results) + assert report.unified_status is BenchmarkStatus.PASS + assert report.shared_identity_fingerprint == identity.fingerprint + + +def test_external_grid_cannot_define_required_operational_gate() -> None: + with pytest.raises(ValueError, match="INTERNAL_OPERATIONAL"): + BenchmarkSpec( + benchmark_id="GR-EXTERNAL", + sector=ForceSector.GRAVITY, + tier=GateTier.T2, + description="invalid external readout", + required_for_sector=True, + promotion_eligible=True, + accepted_evidence=frozenset({EvidenceClass.LIVE}), + readout_frame=ReadoutFrame.EXTERNAL_GRID, + substrate_evolution="GOV-01/GOV-02", + internal_observable="grid coordinate position", + continuum_interpretation="gravity", + operational_readout_required=True, + ) + + +def test_operational_gate_requires_three_layer_mapping() -> None: + with pytest.raises(ValueError, match="REPRESENTATION AUDIT INCOMPLETE"): + BenchmarkSpec( + benchmark_id="EM-INCOMPLETE", + sector=ForceSector.EM, + tier=GateTier.T2, + description="missing internal measurement", + required_for_sector=True, + promotion_eligible=True, + accepted_evidence=frozenset({EvidenceClass.LIVE}), + readout_frame=ReadoutFrame.INTERNAL_OPERATIONAL, + substrate_evolution="multicomponent GOV-01/GOV-02", + internal_observable="", + continuum_interpretation="electromagnetism", + operational_readout_required=True, + ) + + +def test_internal_operational_gate_rejects_raw_coordinate_readout() -> None: + with pytest.raises(ValueError, match="raw external coordinate"): + BenchmarkSpec( + benchmark_id="EM-RAW-READOUT", + sector=ForceSector.EM, + tier=GateTier.T3, + description="invalid raw coordinate readout", + required_for_sector=True, + promotion_eligible=True, + accepted_evidence=frozenset({EvidenceClass.LIVE}), + readout_frame=ReadoutFrame.INTERNAL_OPERATIONAL, + substrate_evolution="multicomponent GOV-01/GOV-02", + internal_observable="raw simulator time and grid coordinate phase", + continuum_interpretation="electromagnetism", + operational_readout_required=True, + ) + + +def test_valid_internal_operational_gate_can_pass() -> None: + spec = BenchmarkSpec( + benchmark_id="GR-INTERNAL", + sector=ForceSector.GRAVITY, + tier=GateTier.T2, + description="valid relational readout", + required_for_sector=True, + promotion_eligible=True, + accepted_evidence=frozenset({EvidenceClass.LIVE}), + requires_shared_identity=True, + readout_frame=ReadoutFrame.INTERNAL_OPERATIONAL, + substrate_evolution="local GOV-01/GOV-02", + internal_observable="E-wave clock and ruler ratios", + continuum_interpretation="effective free fall", + operational_readout_required=True, + ) + report = UnifiedForceHarness([spec]).evaluate([_pass("GR-INTERNAL", identity=_identity())]) + assert report.results[0].status is BenchmarkStatus.PASS diff --git a/tests/validation/test_string_tension.py b/tests/validation/test_string_tension.py index 9f022c7..a3f43c9 100644 --- a/tests/validation/test_string_tension.py +++ b/tests/validation/test_string_tension.py @@ -12,6 +12,7 @@ from __future__ import annotations import numpy as np +import pytest from lfm import Simulation from lfm.config_presets import full_physics @@ -64,6 +65,7 @@ def _make_pair(sep: int) -> Simulation: class TestStringTension: """Tube energy between colored quarks should grow with separation.""" + @pytest.mark.timeout(300) def test_string_tension(self) -> None: """Tube energy should increase monotonically and yield positive σ.