diff --git a/core/adr/__init__.py b/core/adr/__init__.py new file mode 100644 index 00000000..08096fc6 --- /dev/null +++ b/core/adr/__init__.py @@ -0,0 +1,23 @@ +"""ADR-DAG conformal embedding (R&D-Revised §2.4 / issue #21). + +Deterministic embedding of ADR markdown into Cl(4,1) bivector space and +master-blade drift checks for proposal coherence. +""" + +from core.adr.validator import ( + AdrDagValidationError, + embed_adr_markdown, + master_architecture_blade, + proposal_drift, + simple_bivector_project, + validate_proposal_against_master, +) + +__all__ = [ + "AdrDagValidationError", + "embed_adr_markdown", + "master_architecture_blade", + "proposal_drift", + "simple_bivector_project", + "validate_proposal_against_master", +] diff --git a/core/adr/validator.py b/core/adr/validator.py new file mode 100644 index 00000000..2f36b513 --- /dev/null +++ b/core/adr/validator.py @@ -0,0 +1,168 @@ +""" +core/adr/validator.py + +ADR-DAG conformal embedding Ψ(M) (R&D-Revised §2.4 / #21). + + SHA-256(M) → 10×3-byte segments → c_k ∈ [−1, 1] + → 10 basis bivectors (planes 6..15) → simple-bivector projection + → master blade = successive wedge of load-bearing ADR embeddings + → proposal drift = ‖B_p ∧ A_master‖ + +Cross-check: does **not** reimplement GeometricDelta ABI validation +(``core/abi/geometric_delta_validator.py``). This module embeds ADR text into +geometry; that module validates GeometricDelta envelopes. +""" + +from __future__ import annotations + +import hashlib +from typing import Sequence + +import numpy as np + +from algebra.cl41 import N_COMPONENTS, geometric_product, grade_project + +_BIVECTOR_PLANES = tuple(range(6, 16)) # 10 planes +_NEAR_ZERO = 1e-12 +_SIMPLE_G4_TOL = 1e-9 + + +class AdrDagValidationError(ValueError): + """Fail-closed refusal from ADR-DAG embedding / drift checks.""" + + def __init__(self, reason: str, **disclosure) -> None: + self.reason = reason + self.disclosure = dict(disclosure) + super().__init__(f"adr_dag refused [{reason}]: {self.disclosure}") + + +def _grade_mass(v: np.ndarray) -> int: + for g in range(5, -1, -1): + if float(np.linalg.norm(grade_project(v, g))) > _NEAR_ZERO: + return g + return 0 + + +def multivector_wedge(A: np.ndarray, B: np.ndarray) -> np.ndarray: + """Grade-raising wedge approximation: grade-project of geometric product. + + For pure blades this matches the outer product grade. Used for master-blade + assembly and drift (B_p ∧ A_master). + """ + a = np.asarray(A, dtype=np.float64) + b = np.asarray(B, dtype=np.float64) + ga, gb = _grade_mass(a), _grade_mass(b) + target = min(5, ga + gb) + if target == 0: + return grade_project(geometric_product(a, b), 0) + return grade_project(geometric_product(a, b), target).astype(np.float64) + + +def simple_bivector_project(B: np.ndarray) -> np.ndarray: + """Project a multivector generator onto a simple bivector. + + Spec intent: pure grade-2 support that is *simple* (single plane). Pure + multiplane grade-2 has nontrivial ``⟨B B⟩₄``; we always collapse to the + dominant plane when more than one plane is occupied (deterministic). + """ + arr = np.asarray(B, dtype=np.float64) + if arr.shape != (N_COMPONENTS,): + raise AdrDagValidationError("bad_shape", shape=tuple(arr.shape)) + B2 = grade_project(arr, 2).astype(np.float64) + occupied = [i for i in _BIVECTOR_PLANES if abs(float(B2[i])) > _NEAR_ZERO] + if len(occupied) <= 1: + return B2 + # Multiplane → dominant-plane collapse (simple by construction). + best_i = max(occupied, key=lambda i: abs(float(B2[i]))) + out = np.zeros(N_COMPONENTS, dtype=np.float64) + out[best_i] = float(B2[best_i]) + return out + + +def embed_adr_markdown(markdown: str) -> np.ndarray: + """Ψ(M): deterministic SHA-256 → 10 bivector coefficients → simple project. + + Identical markdown ⇒ identical 32-vector (replay pin). + """ + if not isinstance(markdown, str): + raise AdrDagValidationError("not_str", type=type(markdown).__name__) + # Empty markdown is a valid document identity (still deterministic). + digest = hashlib.sha256(markdown.encode("utf-8")).digest() # 32 bytes + B = np.zeros(N_COMPONENTS, dtype=np.float64) + for k, plane in enumerate(_BIVECTOR_PLANES): + # 3-byte segments; last 2 hash bytes unused (spec: 10×3=30). + chunk = digest[k * 3 : k * 3 + 3] + u = int.from_bytes(chunk, "big") # 0 .. 2^24-1 + c = (u / float(0xFFFFFF)) * 2.0 - 1.0 # [-1, 1] + B[plane] = c + return simple_bivector_project(B) + + +def master_architecture_blade( + embeddings: Sequence[np.ndarray], +) -> np.ndarray: + """Assemble load-bearing ADR embeddings into a master architecture blade. + + Prefer successive wedge when non-degenerate; if a wedge step vanishes + (parallel simple planes after projection), fall back to algebraic sum of + simple bivectors so the master never fabricates a zero blade. + """ + if not embeddings: + raise AdrDagValidationError("empty_master_set") + simples = [ + simple_bivector_project(np.asarray(e, dtype=np.float64)) + for e in embeddings + ] + wedge = simples[0].copy() + for i, e in enumerate(simples[1:], start=1): + w = multivector_wedge(wedge, e) + if float(np.linalg.norm(w)) > _NEAR_ZERO: + wedge = w + # else: parallel/collinear under wedge — keep prior wedge, continue + if float(np.linalg.norm(wedge)) > _NEAR_ZERO: + return wedge.astype(np.float64) + # Full wedge chain degenerate: superposition master (still deterministic). + acc = np.zeros(N_COMPONENTS, dtype=np.float64) + for e in simples: + acc = acc + e + if float(np.linalg.norm(acc)) < _NEAR_ZERO: + raise AdrDagValidationError("degenerate_master_blade", at_index=0) + return simple_bivector_project(acc) + + +def proposal_drift(B_proposal: np.ndarray, A_master: np.ndarray) -> float: + """Drift = ‖B_p ∧ A_master‖ (Euclidean coeff norm of the wedge).""" + Bp = simple_bivector_project(np.asarray(B_proposal, dtype=np.float64)) + Am = np.asarray(A_master, dtype=np.float64) + if Am.shape != (N_COMPONENTS,): + raise AdrDagValidationError("bad_master_shape", shape=tuple(Am.shape)) + w = multivector_wedge(Bp, Am) + return float(np.linalg.norm(w)) + + +def validate_proposal_against_master( + proposal_markdown: str, + master_markdowns: Sequence[str], + *, + max_drift: float = 1.0, +) -> tuple[bool, float, np.ndarray, np.ndarray]: + """Embed proposal + masters; return (ok, drift, B_p, A_master).""" + if not master_markdowns: + raise AdrDagValidationError("empty_master_set") + masters = [embed_adr_markdown(m) for m in master_markdowns] + A = master_architecture_blade(masters) + Bp = embed_adr_markdown(proposal_markdown) + d = proposal_drift(Bp, A) + ok = bool(d <= float(max_drift) + _NEAR_ZERO) + return ok, d, Bp, A + + +__all__ = [ + "AdrDagValidationError", + "embed_adr_markdown", + "master_architecture_blade", + "multivector_wedge", + "proposal_drift", + "simple_bivector_project", + "validate_proposal_against_master", +] diff --git a/core/physics/__init__.py b/core/physics/__init__.py index 56e42ae0..cad95bc6 100644 --- a/core/physics/__init__.py +++ b/core/physics/__init__.py @@ -69,6 +69,14 @@ from core.physics.temporal_gate import ( ) from core.physics.self_authorship import AuthorshipProposal, SelfAuthorshipMiner from core.physics.wave_manifold import WaveManifold +from core.physics.trajectory_invariants import ( + TrajectoryAssessment, + TrajectoryInvariantError, + assess_trajectory, + energy_boundary_ok, + relative_holonomy, + trajectory_divergence, +) __all__ = [ "SalienceOperator", "SalienceMap", "FieldRegion", @@ -99,4 +107,7 @@ __all__ = [ "TemporalDecision", "TemporalVerdict", "AuthorshipProposal", "SelfAuthorshipMiner", "WaveManifold", + "TrajectoryAssessment", "TrajectoryInvariantError", + "assess_trajectory", "energy_boundary_ok", + "relative_holonomy", "trajectory_divergence", ] diff --git a/core/physics/trajectory_invariants.py b/core/physics/trajectory_invariants.py new file mode 100644 index 00000000..b53f6bcf --- /dev/null +++ b/core/physics/trajectory_invariants.py @@ -0,0 +1,172 @@ +""" +core/physics/trajectory_invariants.py + +Continuous-space trajectory invariants + zero-fabrication (R&D-Revised §2.2 / #21). + +Python geometry-first surface (algebra/*). A Ring-1 Rust port may later mirror +this contract under ``core-rs``; this module is the behavioral source of truth. + +Invariants: + * Relative holonomy H(t) = V_i · reverse(V_{i+1}) + * Divergence D = Σ log(1 + ‖H·reverse(H) − 1‖_F) · Δt (discrete integral) + * Replay bound D < ε_trajectory + * Hamiltonian energy boundary E_exertion ≤ κ · E_sensory + * Zero-fabrication: refuse empty / non-closed trajectory steps +""" + +from __future__ import annotations + +import math +from dataclasses import dataclass +from typing import Sequence + +import numpy as np + +from algebra.cl41 import N_COMPONENTS, geometric_product, reverse +from algebra.versor import versor_condition, versor_unit_residual + +_CLOSURE_TOL = 1e-6 +_DEFAULT_EPS_TRAJECTORY = 1e-5 +_NEAR_ZERO = 1e-15 + + +class TrajectoryInvariantError(ValueError): + """Fail-closed refusal from trajectory invariant checks.""" + + def __init__(self, reason: str, **disclosure) -> None: + self.reason = reason + self.disclosure = dict(disclosure) + super().__init__(f"trajectory_invariant refused [{reason}]: {self.disclosure}") + + +def _as_versor(V: np.ndarray, name: str) -> np.ndarray: + arr = np.asarray(V, dtype=np.float64) + if arr.shape != (N_COMPONENTS,): + raise TrajectoryInvariantError( + "bad_shape", name=name, shape=tuple(arr.shape) + ) + cond = float(versor_condition(arr)) + if cond >= _CLOSURE_TOL: + raise TrajectoryInvariantError( + "not_closed", name=name, versor_condition=cond + ) + return arr + + +def relative_holonomy(V1: np.ndarray, V2: np.ndarray) -> np.ndarray: + """H = V1 · reverse(V2) — relative transport between consecutive steps.""" + a = _as_versor(V1, "V1") + b = _as_versor(V2, "V2") + return geometric_product(a, reverse(b)).astype(np.float64) + + +def holonomy_unit_residual(H: np.ndarray) -> float: + """‖H · reverse(H) − 1‖_F (dual-checked via versor_unit_residual).""" + H_arr = np.asarray(H, dtype=np.float64) + if H_arr.shape != (N_COMPONENTS,): + raise TrajectoryInvariantError("bad_shape", name="H", shape=tuple(H_arr.shape)) + r = float(versor_unit_residual(H_arr)) + r_rev = float(versor_unit_residual(reverse(H_arr))) + return max(r, r_rev) + + +def trajectory_divergence( + versors: Sequence[np.ndarray], + *, + dt: float = 1.0, +) -> float: + """Discrete divergence integral D = Σ log(1 + residual(H_i)) · Δt. + + Zero-fabrication: empty or single-step trajectories refused (no confabulated + path). Each step must be a closed unit versor. + """ + if not versors: + raise TrajectoryInvariantError("empty_trajectory") + if len(versors) < 2: + raise TrajectoryInvariantError( + "trajectory_too_short", n=len(versors) + ) + if float(dt) <= 0.0: + raise TrajectoryInvariantError("non_positive_dt", dt=float(dt)) + + closed = [_as_versor(v, f"versors[{i}]") for i, v in enumerate(versors)] + D = 0.0 + for i in range(len(closed) - 1): + H = relative_holonomy(closed[i], closed[i + 1]) + r = holonomy_unit_residual(H) + D += math.log1p(max(r, 0.0)) * float(dt) + return float(D) + + +def energy_boundary_ok( + E_exertion: float, + E_sensory: float, + *, + kappa: float = 1.0, +) -> bool: + """Hamiltonian energy boundary: E_exertion ≤ κ · E_sensory. + + Refuse negative energies (zero-fabrication of free action). + """ + ee = float(E_exertion) + es = float(E_sensory) + k = float(kappa) + if ee < -_NEAR_ZERO or es < -_NEAR_ZERO: + raise TrajectoryInvariantError( + "negative_energy", E_exertion=ee, E_sensory=es + ) + if k < 0.0: + raise TrajectoryInvariantError("negative_kappa", kappa=k) + return ee <= k * es + _NEAR_ZERO + + +@dataclass(frozen=True, slots=True) +class TrajectoryAssessment: + """Result of assessing a finite trajectory against #21 invariants.""" + + divergence: float + within_replay_bound: bool + energy_ok: bool + n_steps: int + eps_trajectory: float + kappa: float + E_exertion: float + E_sensory: float + + +def assess_trajectory( + versors: Sequence[np.ndarray], + *, + E_exertion: float, + E_sensory: float, + eps_trajectory: float = _DEFAULT_EPS_TRAJECTORY, + kappa: float = 1.0, + dt: float = 1.0, +) -> TrajectoryAssessment: + """Full trajectory gate: divergence bound + energy boundary.""" + D = trajectory_divergence(versors, dt=dt) + eps = float(eps_trajectory) + if eps <= 0.0: + raise TrajectoryInvariantError("non_positive_eps", eps_trajectory=eps) + e_ok = energy_boundary_ok(E_exertion, E_sensory, kappa=kappa) + return TrajectoryAssessment( + divergence=float(D), + within_replay_bound=bool(D < eps), + energy_ok=bool(e_ok), + n_steps=len(versors), + eps_trajectory=eps, + kappa=float(kappa), + E_exertion=float(E_exertion), + E_sensory=float(E_sensory), + ) + + +__all__ = [ + "TrajectoryAssessment", + "TrajectoryInvariantError", + "assess_trajectory", + "energy_boundary_ok", + "holonomy_unit_residual", + "relative_holonomy", + "trajectory_divergence", +] diff --git a/docs/research/third-door-blueprint-fidelity.md b/docs/research/third-door-blueprint-fidelity.md index 85e0d56b..85583069 100644 --- a/docs/research/third-door-blueprint-fidelity.md +++ b/docs/research/third-door-blueprint-fidelity.md @@ -35,8 +35,8 @@ | 4 | GoldTether residual + α law | Super §2.3, R&D §2.3/§5 | 🟢 residual+α + bootstrap gates + principal-axis prune (#24+#18) | #18 | | 5 | Grade-5 pseudoscalar invariant | Super §3.3 | ⚪ RETIRED — vacuous in odd-dim Cl(4,1) | #19 (closed) | | 6 | Surprise residual operator | Super §3.2 | 🟢 math + DiscoveryCandidate wiring landed (#26 + #31) | #20 | -| 7 | Trajectory invariants + zero-fabrication | R&D §2.2 | ⚫ absent | #21 | -| 8 | ADR-DAG conformal embedding | R&D §2.4 | ⚫ absent | #21 | +| 7 | Trajectory invariants + zero-fabrication | R&D §2.2 | 🟢 Python geometry surface (`trajectory_invariants.py`) | #21 | +| 8 | ADR-DAG conformal embedding | R&D §2.4 | 🟢 Python surface (`core/adr/validator.py`) | #21 | | W1 | WaveManifold unitary / sandwich step | ADR-0241 §2 | 🟢 | ADR-0241 | | W2 | Spectral leakage surprise | ADR-0241 §2.4B | 🟢 subsumed into `surprise_residual` | ADR-0241 | | W3 | Wave polar + multi-pair conjugacy | ADR-0241 §2.4A | 🟢 single polar + multi-pair thin wrap | ADR-0241 | @@ -206,10 +206,23 @@ The null add-on is **untested**: `test_signature_aware_pca_keeps_nulls` classifi --- -## 9. Absent whole proposals — ⚫ (#21) +## 9. Trajectory invariants + ADR-DAG — 🟢 Python surfaces (#21) -- **Trajectory invariants + zero-fabrication (R&D §2.2 → `core-rs/src/sensorimotor.rs`):** relative holonomy `H(t)=V₁Ṽ₂`, divergence integral `D < ε_trajectory`, Hamiltonian energy boundary `E_exertion ≤ κ·E_sensory`. Not landed. Gated by the Zig/Rust substrate doctrine (Ring-1 only). -- **ADR-DAG conformal embedding (R&D §2.4 → `core/adr/validator.py`):** `Ψ(M)` = SHA-256 → 10 bivector coeffs → simple-bivector projection → master-blade wedge → drift check. Not landed. Cross-check `core/abi/geometric_delta_validator.py` before adding a parallel validator. +> **Landed (2026-07-14):** geometry-first Python authorities. Rust Ring-1 port remains optional acceleration, not a second truth. + +### Trajectory invariants (R&D §2.2 → `core/physics/trajectory_invariants.py`) +- Relative holonomy `H = V_i · reverse(V_{i+1})` +- Divergence `D = Σ log(1 + ‖H reverse(H) − 1‖) · Δt`; bound `D < ε_trajectory` +- Energy boundary `E_exertion ≤ κ · E_sensory` +- Zero-fabrication: empty / short / non-closed steps refused +- Tests: `tests/test_third_door_trajectory_invariants.py` + +### ADR-DAG conformal embedding (R&D §2.4 → `core/adr/validator.py`) +- `Ψ(M)`: SHA-256 → 10×3-byte → `c_k ∈ [−1,1]` → planes 6..15 → simple-bivector project +- Master blade = successive wedge of load-bearing ADR embeddings +- Proposal drift = `‖B_p ∧ A_master‖` +- Does **not** parallel `core/abi/geometric_delta_validator.py` (that validates GeometricDelta ABI envelopes) +- Tests: `tests/test_third_door_adr_dag_embedding.py` --- @@ -297,7 +310,7 @@ PY - Durable holographic memory **vault store** (CRDT-backed standing-wave spectrum) — session registry only today. - Rust/MLX acceleration of exp-map / cross-spectral (ADR-0235 later). - Full ADR-0092 reviewer-service integration (promote remains caller-gated). -- R&D #21 trajectory invariants + ADR-DAG embedding. +- Optional Rust Ring-1 port of trajectory invariants (Python is authority today). --- @@ -310,7 +323,7 @@ PY | GoldTether gold-set + harmonized residual + α=Φ(R) + bootstrap/prune — 🟢 | #18 | | Grade-5 pseudoscalar preservation gate — ⚪ RETIRED (vacuous; see §5) | #19 (closed) | | Surprise: metric projection + productivity polarity + DiscoveryCandidate wiring — 🟢 done | #20 (math #26; wiring #31) | -| Absent proposals: sensorimotor + ADR-DAG | #21 | +| Trajectory invariants + ADR-DAG embedding — 🟢 Python surfaces | #21 | | Wave-field substrate + operator subsumption (W1–W6) — 🟢 on branch | ADR-0241 | | `core_ha` deprecation — 🟢 no live tree + hygiene pin | ADR-0241 / deprecation plan | | Durable holographic vault spectrum — deferred | ADR-0241 follow-on | diff --git a/tests/test_third_door_adr_dag_embedding.py b/tests/test_third_door_adr_dag_embedding.py new file mode 100644 index 00000000..a67aed54 --- /dev/null +++ b/tests/test_third_door_adr_dag_embedding.py @@ -0,0 +1,98 @@ +"""#21 B — ADR-DAG conformal embedding Ψ(M) (R&D §2.4).""" + +from __future__ import annotations + +import numpy as np +import pytest + +from algebra.cl41 import grade_project +from core.adr.validator import ( + AdrDagValidationError, + embed_adr_markdown, + master_architecture_blade, + proposal_drift, + simple_bivector_project, + validate_proposal_against_master, +) + + +def test_embed_deterministic_replay(): + m = "# ADR-TEST\n\nBody of the decision.\n" + a = embed_adr_markdown(m) + b = embed_adr_markdown(m) + assert np.array_equal(a, b) + assert a.shape == (32,) + + +def test_embed_differs_for_different_markdown(): + a = embed_adr_markdown("# ADR-A\nfoo") + b = embed_adr_markdown("# ADR-B\nbar") + assert not np.allclose(a, b) + + +def test_embed_is_grade2_supported(): + B = embed_adr_markdown("# ADR-G2\ncontent") + # After simple project: only grade-2 (and zeros elsewhere) + for g in (0, 1, 3, 4, 5): + assert float(np.linalg.norm(grade_project(B, g))) < 1e-12 + assert float(np.linalg.norm(grade_project(B, 2))) > 0.0 + + +def test_simple_bivector_project_collapses_multiplane(): + B = np.zeros(32, dtype=np.float64) + B[6] = 0.9 + B[7] = 0.8 + B[8] = 0.1 + S = simple_bivector_project(B) + # Dominant plane kept + assert abs(S[6] - 0.9) < 1e-12 or abs(S[7] - 0.8) < 1e-12 + # At most one plane nonzero after collapse when multiplane non-simple + n_planes = sum(1 for i in range(6, 16) if abs(S[i]) > 1e-12) + assert n_planes == 1 + + +def test_master_blade_refuses_empty(): + with pytest.raises(AdrDagValidationError, match="empty"): + master_architecture_blade([]) + + +def test_master_blade_from_two_adrs(): + e1 = embed_adr_markdown("# ADR-0003\nCoordinate dissolution.") + e2 = embed_adr_markdown("# ADR-0006\nField energy.") + A = master_architecture_blade([e1, e2]) + assert A.shape == (32,) + assert float(np.linalg.norm(A)) > 0.0 + + +def test_proposal_drift_nonneg_and_deterministic(): + masters = [ + embed_adr_markdown("# M1\none"), + embed_adr_markdown("# M2\ntwo"), + ] + A = master_architecture_blade(masters) + Bp = embed_adr_markdown("# Proposal\nnew idea") + d1 = proposal_drift(Bp, A) + d2 = proposal_drift(Bp, A) + assert d1 == d2 + assert d1 >= 0.0 + + +def test_validate_proposal_returns_ok_flag(): + masters_md = ["# ADR-A\nalpha", "# ADR-B\nbeta"] + ok, drift, Bp, A = validate_proposal_against_master( + "# Proposal\ngamma", masters_md, max_drift=1e9 + ) + assert ok is True + assert drift >= 0.0 + assert Bp.shape == (32,) + assert A.shape == (32,) + + +def test_validate_proposal_tight_max_drift_can_fail(): + masters_md = ["# ADR-A\nalpha", "# ADR-B\nbeta"] + ok, drift, _Bp, _A = validate_proposal_against_master( + "# Proposal\nentirely different body xyz", masters_md, max_drift=-1.0 + ) + # max_drift < 0 forces fail unless drift is negative (impossible) + assert ok is False + assert drift >= 0.0 diff --git a/tests/test_third_door_trajectory_invariants.py b/tests/test_third_door_trajectory_invariants.py new file mode 100644 index 00000000..49d5cf87 --- /dev/null +++ b/tests/test_third_door_trajectory_invariants.py @@ -0,0 +1,92 @@ +"""#21 A — trajectory invariants + zero-fabrication (R&D §2.2).""" + +from __future__ import annotations + +import numpy as np +import pytest + +from algebra.rotor import make_rotor_from_angle +from algebra.versor import versor_condition +from core.physics.trajectory_invariants import ( + TrajectoryInvariantError, + assess_trajectory, + energy_boundary_ok, + relative_holonomy, + trajectory_divergence, +) + + +def _id() -> np.ndarray: + v = np.zeros(32, dtype=np.float64) + v[0] = 1.0 + return v + + +def test_relative_holonomy_identity_pair_is_identity(): + H = relative_holonomy(_id(), _id()) + assert versor_condition(H) < 1e-6 + assert abs(H[0] - 1.0) < 1e-9 + + +def test_relative_holonomy_order_sensitive(): + A = make_rotor_from_angle(0.3, bivector_idx=6) + B = make_rotor_from_angle(0.9, bivector_idx=7) + H_ab = relative_holonomy(A, B) + H_ba = relative_holonomy(B, A) + assert not np.allclose(H_ab, H_ba, atol=1e-9) + + +def test_trajectory_divergence_zero_on_constant_path(): + path = [_id(), _id(), _id()] + assert trajectory_divergence(path) == 0.0 + + +def test_trajectory_divergence_positive_on_moving_path(): + path = [ + make_rotor_from_angle(0.1 * i, bivector_idx=6) for i in range(1, 6) + ] + D = trajectory_divergence(path) + assert D > 0.0 + + +def test_trajectory_divergence_deterministic(): + path = [make_rotor_from_angle(0.2 * i) for i in range(1, 4)] + assert trajectory_divergence(path) == trajectory_divergence(path) + + +def test_trajectory_divergence_refuses_empty_and_short(): + with pytest.raises(TrajectoryInvariantError, match="empty"): + trajectory_divergence([]) + with pytest.raises(TrajectoryInvariantError, match="too_short"): + trajectory_divergence([_id()]) + + +def test_trajectory_divergence_refuses_non_closed(): + dirty = np.zeros(32, dtype=np.float64) + dirty[0] = 0.5 + dirty[1] = 0.5 + with pytest.raises(TrajectoryInvariantError, match="not_closed"): + trajectory_divergence([_id(), dirty]) + + +def test_energy_boundary_ok_and_refuse_negative(): + assert energy_boundary_ok(1.0, 2.0, kappa=1.0) is True + assert energy_boundary_ok(3.0, 2.0, kappa=1.0) is False + assert energy_boundary_ok(3.0, 2.0, kappa=2.0) is True + with pytest.raises(TrajectoryInvariantError, match="negative_energy"): + energy_boundary_ok(-0.1, 1.0) + + +def test_assess_trajectory_replay_bound(): + path = [make_rotor_from_angle(0.05 * i) for i in range(1, 4)] + a = assess_trajectory( + path, E_exertion=0.5, E_sensory=1.0, eps_trajectory=1.0, kappa=1.0 + ) + assert a.energy_ok is True + assert a.n_steps == 3 + assert a.divergence >= 0.0 + # Tight eps → may fail replay bound on moving path + tight = assess_trajectory( + path, E_exertion=0.5, E_sensory=1.0, eps_trajectory=1e-30, kappa=1.0 + ) + assert tight.within_replay_bound is False