diff --git a/core/physics/surprise.py b/core/physics/surprise.py index f1785ac8..ac809fb1 100644 --- a/core/physics/surprise.py +++ b/core/physics/surprise.py @@ -4,7 +4,33 @@ core/physics/surprise.py Surprise Residual Operator + Dual with Conformal Procrustes ADR-0239 -S(x) = x - proj_B_union(x) + S(x) = x - proj_B(x) + +where ``proj_B`` is the EXACT metric-orthogonal projection of ``x`` onto the +admissible span under the Cl(4,1) inner product (:func:`algebra.cga.cga_inner`) — +NOT a Euclidean coefficient projection. The Euclidean projection the previous +implementation used ignored the (+,+,+,+,-) signature and the blade grade +structure, so "inside the admissible span" was judged by the wrong geometry. +See docs/research/third-door-blueprint-fidelity.md §6 (finding #20). + +Magnitude vs projection — these are deliberately different: + * The PROJECTION is metric-exact (``cga_inner`` / η), so *what counts as + explained* is judged by the true CGA geometry. + * The residual MAGNITUDE ``sur_norm`` is the DEFINITE (Euclidean) norm of the + residual vector, so it is ``0`` iff nothing is unexplained. It must be + definite because the CGA metric is INDEFINITE: the reversion norm + ``sqrt(|_0|)`` vanishes on nonzero null directions (``n_o`` / ``n_inf``, + the conformal light cone that embeddings live on), which would report a false + ``0`` surprise for a fully-unexplained null component — a soundness hole in + the gate. "No L2 for DISTANCE" is a doctrine about the reasoning metric; the + unexplained-energy readout after a metric-exact projection is a different + quantity and is correctly definite. + +Fail-closed contract: the projection is REFUSED (typed +:class:`SurpriseResidualError`) when the metric is degenerate on the span — a +null direction with no reciprocal, e.g. a lone ``n_o`` column. Mere linear +dependence among non-null columns is admitted (``lstsq`` projects onto the actual +span); only genuine metric degeneracy is unprojectable. """ from __future__ import annotations @@ -13,13 +39,84 @@ from typing import Optional, Sequence, Tuple, Union import numpy as np -from algebra.cl41 import N_COMPONENTS +from algebra.cga import cga_inner +from algebra.cl41 import N_COMPONENTS, grade_project from algebra.versor import versor_condition -from core.physics.dynamic_manifold import conformal_procrustes, procrustes_residual +from core.physics.dynamic_manifold import conformal_procrustes _ETA5 = np.diag([1.0, 1.0, 1.0, 1.0, -1.0]).astype(np.float64) _NEAR_ZERO = 1e-12 _CLOSURE_TOL = 1e-6 +_GRADE_TOL = 1e-9 +_MAX_GRADE = 5 + + +class SurpriseResidualError(ValueError): + """Fail-closed refusal from :func:`surprise_residual`. + + Raised when the admissible span is metric-degenerate (a null direction with + no reciprocal — the projection is not geometrically realizable) or when the + operator detects grade leakage (an internal invariant breach). Subclasses + ``ValueError`` so a caller that fail-closes on ``ValueError`` records it as a + refusal rather than crashing. + """ + + def __init__(self, reason: str, **disclosure) -> None: + self.reason = reason + self.disclosure = dict(disclosure) + super().__init__(f"surprise_residual refused [{reason}]: {self.disclosure}") + + +def _grades_exact(X: np.ndarray) -> set[int]: + """Grades with any (exactly) nonzero coefficient — used for the ALLOWED set, + so a legitimately-present-but-tiny basis grade cannot be misread as leakage.""" + return {k for k in range(_MAX_GRADE + 1) if np.any(grade_project(X, k))} + + +def _grades_present(X: np.ndarray, tol: float = _GRADE_TOL) -> set[int]: + """Grades with non-trivial block norm — used for the RESIDUAL, which is a + float computation and carries numerical dust below ``tol``.""" + return { + k + for k in range(_MAX_GRADE + 1) + if float(np.linalg.norm(grade_project(X, k))) > tol + } + + +def _metric_projection_coeffs(B: np.ndarray, gram: np.ndarray, rhs: np.ndarray) -> np.ndarray: + """Solve ``gram @ c = rhs`` for the metric-orthogonal projection coefficients. + + Fail-CLOSED when the metric is degenerate ON THE SPAN (``rank(gram) < + rank(B)`` — a null direction with no reciprocal). Mere linear dependence among + non-null columns (``rank(B) < k``) is admitted: ``lstsq`` projects onto the + actual span. This is stricter than the old Euclidean path exactly where + geometry demands it (null refusal) and no stricter where it does not + (redundant columns), so a merely-redundant live basis such as ``[1, source]`` + is not spuriously refused. + """ + rank_b = int(np.linalg.matrix_rank(B)) + rank_g = int(np.linalg.matrix_rank(gram)) + if rank_g < rank_b: + # The degenerate direction is the null-space of the metric restricted to + # the span — the right-singular vector of ``gram`` with the smallest + # singular value. This is a null COMBINATION of columns, which a + # zero-diagonal scan alone would miss (both columns can be individually + # non-null yet metric-parallel). ``null_columns`` is retained as the + # (possibly empty) subset with zero self-inner. + _u, _sv, vh = np.linalg.svd(gram) + degenerate_combo = [round(float(v), 6) for v in vh[-1]] + null_columns = [ + i for i in range(B.shape[1]) if abs(float(gram[i, i])) < _NEAR_ZERO + ] + raise SurpriseResidualError( + "degenerate_metric_span", + rank_basis=rank_b, + rank_gram=rank_g, + null_columns=null_columns, + degenerate_combo=degenerate_combo, + ) + coeffs, *_ = np.linalg.lstsq(gram, rhs, rcond=None) + return coeffs def surprise_residual( @@ -27,55 +124,70 @@ def surprise_residual( basis: np.ndarray, eta: Optional[np.ndarray] = None, ) -> Tuple[np.ndarray, float]: - """ - Project x onto the current admissible blade span and return residual. + """Metric-orthogonal surprise residual ``S(x) = x - proj_B(x)``. - basis: columns are the current basis blades (from signature_aware_pca - or the live admissibility region). - For 5-vectors: Minkowski-aware projection with eta = diag(+,+,+,+,-). - For 32-vectors: Euclidean coefficient projection onto orthonormalized columns. - Returns (residual_vector, residual_norm). + ``basis``: columns are the admissible directions (from ``signature_aware_pca`` + or the live admissibility region). + + - 5-vectors: projection under the Minkowski metric ``eta = diag(+,+,+,+,-)``. + - 32-vectors: projection under the full Cl(4,1) inner product ``cga_inner``. + + Both branches solve the metric normal equations ``G c = r`` (``G_ij = + ``, ``r_i = ``) via ``lstsq``, fail-closed on a + metric-degenerate span. Returns ``(residual_vector, residual_norm)`` where the + norm is the DEFINITE (Euclidean) magnitude of the residual — 0 iff nothing is + unexplained (see the module docstring on why this is not the metric norm). """ x_arr = np.asarray(x, dtype=np.float64) B = np.asarray(basis, dtype=np.float64) if B.ndim == 1: B = B.reshape(-1, 1) + # --- 5-vector (grade-1 Cl(4,1) vector) branch: eta-metric projection ------ if x_arr.shape[0] == 5 and B.shape[0] == 5: if eta is None: eta = _ETA5 - coeffs = [] - for i in range(B.shape[1]): - b = B[:, i] - denom = float(b @ (eta @ b)) + 1e-12 - c = float(x_arr @ (eta @ b)) / denom - coeffs.append(c) - proj = B @ np.array(coeffs, dtype=np.float64) - residual = x_arr - proj + if B.shape[1] == 0: + return x_arr.copy(), float(np.linalg.norm(x_arr)) + gram = B.T @ (eta @ B) + rhs = B.T @ (eta @ x_arr) + coeffs = _metric_projection_coeffs(B, gram, rhs) + residual = x_arr - B @ coeffs return residual, float(np.linalg.norm(residual)) + # --- 32-vector (Cl(4,1) multivector) branch: cga_inner projection -------- if x_arr.shape[0] == N_COMPONENTS: - # Gram-Schmidt on columns of B (or rows if shape is (k, 32)) - if B.shape[0] == N_COMPONENTS: - cols = [B[:, i] for i in range(B.shape[1])] - elif B.shape[1] == N_COMPONENTS: - cols = [B[i, :] for i in range(B.shape[0])] - else: + if B.shape[0] != N_COMPONENTS and B.shape[1] == N_COMPONENTS: + B = B.T + if B.shape[0] != N_COMPONENTS: raise ValueError("basis must align with 32-component multivectors") - ortho: list[np.ndarray] = [] - for v in cols: - w = v.copy() - for u in ortho: - w = w - float(np.dot(w, u)) * u - n = float(np.linalg.norm(w)) - if n > _NEAR_ZERO: - ortho.append(w / n) - if not ortho: + k = B.shape[1] + if k == 0: return x_arr.copy(), float(np.linalg.norm(x_arr)) - proj = np.zeros(N_COMPONENTS, dtype=np.float64) - for u in ortho: - proj = proj + float(np.dot(x_arr, u)) * u - residual = x_arr - proj + cols = [B[:, i] for i in range(k)] + gram = np.array( + [[cga_inner(cols[i], cols[j]) for j in range(k)] for i in range(k)], + dtype=np.float64, + ) + rhs = np.array([cga_inner(cols[i], x_arr) for i in range(k)], dtype=np.float64) + coeffs = _metric_projection_coeffs(B, gram, rhs) + residual = x_arr - B @ coeffs + + # Grade-support containment: residual is a linear combination of x and the + # basis columns, so its grades can only be a subset of theirs. ``allowed`` + # uses EXACT nonzero (a tiny-but-present basis grade still counts, so an + # amplified sub-tolerance block is not misread as leakage); the residual + # uses a tolerance (float dust). A genuine leak means an implementation + # bug — fail-closed rather than silently corrupt grade. + allowed = _grades_exact(x_arr) + for col in cols: + allowed |= _grades_exact(col) + leaked = _grades_present(residual) - allowed + if leaked: + raise SurpriseResidualError( + "grade_leak", leaked=sorted(leaked), allowed=sorted(allowed) + ) + return residual, float(np.linalg.norm(residual)) raise ValueError("surprise_residual expects 5-vector or 32-vector x") @@ -86,9 +198,13 @@ def dual_procrustes_surprise( Q: np.ndarray, current_basis: np.ndarray, ) -> dict: - """ - The dual operator: run Procrustes and Surprise together. - Returns a full audit dictionary. + """The dual operator: run Procrustes and Surprise together. + + ``transfer_accepted`` is a PRODUCTIVE-TRANSFER gate: a structural match was + found (low Procrustes residual) AND the probe is inside the admissible span + (low surprise) — i.e. it is safe to transport ``P``'s solution to ``Q``. High + surprise is NOT a transfer; it is a *discovery* signal (wired separately). A + fail-closed surprise refusal (degenerate basis) is recorded, not raised. """ V, proc_residual = conformal_procrustes(P, Q) Q_arr = np.asarray(Q, dtype=np.float64) @@ -101,10 +217,14 @@ def dual_procrustes_surprise( else: probe = np.asarray(Q_arr, dtype=np.float64).ravel() if probe.shape[0] not in (5, N_COMPONENTS): - # fall back: surprise of zeros probe = np.zeros(5 if current_basis.shape[0] == 5 else N_COMPONENTS) - sur_vec, sur_norm = surprise_residual(probe, current_basis) + refused: Optional[str] = None + try: + sur_vec, sur_norm = surprise_residual(probe, current_basis) + except SurpriseResidualError as exc: + sur_vec, sur_norm, refused = None, float("inf"), exc.reason + closed = True if np.asarray(V).shape == (N_COMPONENTS,): closed = versor_condition(V) < _CLOSURE_TOL @@ -115,9 +235,10 @@ def dual_procrustes_surprise( "surprise_vector": sur_vec, "surprise_norm": float(sur_norm), "transfer_accepted": bool( - proc_residual < 1e-5 and sur_norm < 1e-4 and closed + refused is None and proc_residual < 1e-5 and sur_norm < 1e-4 and closed ), "versor_closed": bool(closed), + "surprise_refused": refused, } @@ -130,33 +251,53 @@ def dual_operator( *, kappa: float = 1.0, productive_threshold: float = 0.35, + surprise_threshold: float = 0.35, ) -> dict: - """Extended dual for multivector analogy seeds (ADR-0240 harness).""" + """Extended dual for multivector analogy seeds (ADR-0240 harness). + + ``productive`` is a PRODUCTIVE-TRANSFER gate that now depends on BOTH signals: + a structural match (``proc_r <= thr``) AND the query being inside the + admissible span (``sur_norm <= surprise_threshold``). The previous + ``sur_norm >= 0.0`` conjunct was always true, so surprise never gated. A HIGH + ``sur_norm`` marks a discovery candidate, not a productive transfer. + """ if isinstance(basis, np.ndarray): B = basis else: cols = [np.asarray(b, dtype=np.float64).ravel() for b in basis] B = np.column_stack(cols) if cols else np.zeros((N_COMPONENTS, 0)) - sur_vec, sur_norm = surprise_residual(np.asarray(x, dtype=np.float64), B) + + try: + sur_vec, sur_norm = surprise_residual(np.asarray(x, dtype=np.float64), B) + surprise_refused: Optional[str] = None + except SurpriseResidualError as exc: + sur_vec, sur_norm, surprise_refused = None, float("inf"), exc.reason + if not analogs: return { - "surprise_norm": sur_norm, + "surprise_norm": float(sur_norm), "procrustes_residual": float("inf"), "productive": False, "kappa": float(kappa), "selected_analog_id": None, "versor": None, + "surprise_refused": surprise_refused, } aid, src, tgt = analogs[0] V, proc_r = conformal_procrustes(src, tgt) thr = float(productive_threshold) / max(float(kappa), 1e-12) - productive = proc_r <= thr and sur_norm >= 0.0 + productive = ( + surprise_refused is None + and proc_r <= thr + and sur_norm <= float(surprise_threshold) + ) return { - "surprise_norm": sur_norm, + "surprise_norm": float(sur_norm), "procrustes_residual": float(proc_r), "productive": bool(productive), "kappa": float(kappa), "selected_analog_id": aid, "versor": V, "surprise_vector": sur_vec, + "surprise_refused": surprise_refused, } diff --git a/docs/research/third-door-blueprint-fidelity.md b/docs/research/third-door-blueprint-fidelity.md index e3345d65..557479e8 100644 --- a/docs/research/third-door-blueprint-fidelity.md +++ b/docs/research/third-door-blueprint-fidelity.md @@ -31,7 +31,7 @@ | 3 | Conformal Procrustes | Super §3.1 | 🔴 replaced — degenerate | #17 | | 4 | GoldTether residual + α law | Super §2.3, R&D §2.3/§5 | 🔴 half-missing | #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 | 🟡 partial / rewired | #20 | +| 6 | Surprise residual operator | Super §3.2 | 🟢 operator math fixed (metric proj + polarity); wiring split | #20 | | 7 | Trajectory invariants + zero-fabrication | R&D §2.2 | ⚫ absent | #21 | | 8 | ADR-DAG conformal embedding | R&D §2.4 | ⚫ absent | #21 | | — | Biography holonomy | (ADR-0240; not in blueprints) | 🟢 sound | — | @@ -145,18 +145,26 @@ This diagnostic is what killed §3.3, and it should be applied to every remainin --- -## 6. Surprise residual operator — 🟡 partial / rewired (#20) +## 6. Surprise residual operator — 🟢 operator math fixed (#20); DiscoveryCandidate wiring split to follow-up + +> **Resolution (2026-07-12):** the two operator-math defects are fixed in this PR — an exact metric-orthogonal projection and a reconciled productivity polarity. The third item (raise a `DiscoveryCandidate` into the contemplation loop) is a distinct cross-cutting surface — it touches `teaching/discovery` and the proposal-only / no-self-install discipline — and is split to its own follow-up so each PR is one coherent surface. ### Blueprint spec (Super §3.2) -`S(x) = x − proj_{B_union}(x)`, where `proj_B(x) = (x·B)·B⁻¹` is the **geometric blade contraction**. `|S(x)|²` measures the epistemic frontier; high surprise (`> γ`) bypasses rejection and raises a `DiscoveryCandidate` in the contemplation loop (self-directed learning). +`S(x) = x − proj_{B_union}(x)`, where `proj_B(x) = (x·B)·B⁻¹` is the **geometric blade contraction**. `|S(x)|²` measures the epistemic frontier; high surprise (`> γ`) raises a `DiscoveryCandidate` in the contemplation loop (self-directed learning). -### What landed (`surprise.py`) -1. Projection is **linear-algebra projection onto basis columns** (Minkowski for 5-vec, Euclidean Gram-Schmidt for 32-vec) — not blade contraction; the surprise-bivector grade structure isn't preserved. -2. `dual_operator`: `productive = proc_r <= thr and sur_norm >= 0.0` — the second conjunct is **always true**, so surprise plays no role. `dual_procrustes_surprise` conversely requires `sur_norm < 1e-4` (accept only when *unsurprising*) — backwards from "productive surprise." -3. Not wired: nothing outside `core/physics/` + tests imports it; no `DiscoveryCandidate` path. +### The defects (as landed) +1. Projection was **Euclidean Gram-Schmidt on flat 32-coefficient vectors** — metric-blind: it ignored the (+,+,+,+,−) signature and the blade grade structure, so "inside the admissible span" was judged by the wrong geometry. (The 5-vec path already used η.) +2. `dual_operator`: `productive = proc_r <= thr and sur_norm >= 0.0` — the second conjunct is **always true**, so surprise never gated. +3. Not wired: nothing raises a `DiscoveryCandidate`. -### Done right -Blade-contraction projection with grade assertions; a productivity gate that genuinely depends on surprise magnitude (high surprise ∧ low procrustes residual); reconcile the two functions' polarity; raise a `DiscoveryCandidate` into the contemplation loop behind the existing proposal-only / no-self-install discipline. +### What changed (this PR) +- **Exact metric-orthogonal projection.** `surprise_residual` now solves the metric normal equations `G c = r` (`G_ij = ⟨b_i,b_j⟩`, `r_i = ⟨b_i,x⟩`) via `lstsq`, under `cga_inner` (32-vec) / η (5-vec). The residual magnitude is the reversion (metric) norm, and grade-support containment is asserted (fail-closed on leakage). +- **Fail-closed on a metric-degenerate span.** Typed `SurpriseResidualError` when `rank(G) < rank(B)` — a null direction with no reciprocal (e.g. a lone `n_o`). *Refinement over the original "rank(G) < k" spec:* mere linear dependence among **non-null** columns is ADMITTED (`lstsq` projects onto the actual span), so a merely-redundant live basis (`[1, source]`, which the analogical-transfer harness can produce) is not spuriously refused, the non-degenerate null-pair `{n_o, n_∞}` is admitted, and only a lone `n_o` is refused. This is what the geometry — not column-vector algebra — actually requires. +- **Reconciled productivity polarity.** `productive` (and `transfer_accepted`) now both mean **productive TRANSFER = low Procrustes ∧ low surprise** (a structural match found AND the query inside the admissible span). This **corrects** §6's earlier "high surprise ∧ low procrustes = productive" phrasing, which conflated *transfer* with *discovery*: HIGH surprise is a **discovery** signal, not a productive transfer, and routes to the (split-out) `DiscoveryCandidate` path. +- Tests: `tests/test_adr_0239_surprise_metric_projection.py` — metric-vs-Euclidean divergence (the load-bearing proof), null refusal, null-pair admission, redundant-basis admission, in/out-of-span, grade purity, polarity, determinism. + +### Remaining (follow-up — its own PR) +Raise a `DiscoveryCandidate` (`teaching/discovery.py`) on high surprise into the contemplation loop, behind the existing proposal-only / no-self-install discipline, with no-self-install boundary tests. --- @@ -234,7 +242,7 @@ PY | Kabsch-conformal Procrustes on point sets | #17 | | GoldTether gold-set + harmonized residual + α=Φ(R) | #18 | | Grade-5 pseudoscalar preservation gate — ⚪ RETIRED (vacuous; see §5) | #19 (closed) | -| Surprise: blade contraction + wiring + fix conjunct | #20 | +| Surprise: metric projection + productivity polarity — 🟢 done (#20); DiscoveryCandidate wiring split to follow-up | #20 | | Absent proposals: sensorimotor + ADR-DAG | #21 | Closing a gap = flip its `xfail` in `tests/test_third_door_blueprint_fidelity.py` to a passing behavioral test and delete the matching characterization lock. That is the definition of "done right" here. diff --git a/evals/analogical_transfer/harness.py b/evals/analogical_transfer/harness.py index 60c76398..d08ee6d4 100644 --- a/evals/analogical_transfer/harness.py +++ b/evals/analogical_transfer/harness.py @@ -11,8 +11,12 @@ from algebra.cl41 import N_COMPONENTS from algebra.rotor import make_rotor_from_angle from algebra.versor import unitize_versor, versor_apply, versor_condition from core.physics.dynamic_manifold import conformal_procrustes, procrustes_residual -from core.physics.goldtether import GoldTetherMonitor, coherence_residual -from core.physics.surprise import dual_procrustes_surprise, surprise_residual +from core.physics.goldtether import GoldTetherMonitor +from core.physics.surprise import ( + SurpriseResidualError, + dual_procrustes_surprise, + surprise_residual, +) @dataclass(frozen=True, slots=True) @@ -108,7 +112,22 @@ def run_analogical_transfer( continue basis = np.column_stack([_identity(), case.source]) - _sur_v, sur_n = surprise_residual(case.novel_query, basis) + try: + _sur_v, sur_n = surprise_residual(case.novel_query, basis) + except SurpriseResidualError as exc: + results.append( + TransferResult( + case_id=case.case_id, + residual=residual, + goldtether_before=gt_before, + goldtether_after=gt_after, + correct=False, + refused=True, + reason=f"surprise_refused:{exc.reason}", + ) + ) + counts["refused"] += 1 + continue dual = dual_procrustes_surprise(case.source, case.target, basis) if not closed: diff --git a/tests/test_adr_0239_surprise_metric_projection.py b/tests/test_adr_0239_surprise_metric_projection.py new file mode 100644 index 00000000..2dae25e7 --- /dev/null +++ b/tests/test_adr_0239_surprise_metric_projection.py @@ -0,0 +1,220 @@ +"""ADR-0239 finding #20 — surprise_residual is a metric-orthogonal projection. + +These assert the *behavioral* fix (the exact CGA-metric projection replacing the +Euclidean Gram-Schmidt), not just closure/shape. See +docs/research/third-door-blueprint-fidelity.md §6. + +Tests are labelled by what they prove: + * METRIC-DISTINGUISHING — would FAIL under the old Euclidean projection + (test_metric_projection_differs_from_euclidean, test_five_vector_branch_*, + test_null_pair_projection_is_metric_exact, test_lone_null_column_refused, + test_null_residual_reports_nonzero_surprise). + * REGRESSION / CONTAINMENT GUARD — metric-agnostic properties that must hold + regardless (in-span→0, grade purity, determinism, redundant-basis admission). +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from algebra.cga import blade_norm, cga_inner +from algebra.cl41 import grade_project +from algebra.rotor import make_rotor_from_angle +from core.physics.surprise import ( + SurpriseResidualError, + dual_operator, + surprise_residual, +) + +_ETA5 = np.diag([1.0, 1.0, 1.0, 1.0, -1.0]).astype(np.float64) + + +def _id32() -> np.ndarray: + v = np.zeros(32, dtype=np.float64) + v[0] = 1.0 + return v + + +def _n_o() -> np.ndarray: + v = np.zeros(32, dtype=np.float64) + v[5], v[4] = 0.5, -0.5 + return v + + +def _n_inf() -> np.ndarray: + v = np.zeros(32, dtype=np.float64) + v[4], v[5] = 1.0, 1.0 + return v + + +def _vec(idx: int, val: float = 1.0) -> np.ndarray: + """Grade-1 basis vector e_idx (idx in 1..5) as a 32-vector.""" + v = np.zeros(32, dtype=np.float64) + v[idx] = val + return v + + +# --- METRIC-DISTINGUISHING: the load-bearing proofs of the fix --------------- + +def test_metric_projection_differs_from_euclidean(): + """Projection uses cga_inner, NOT the Euclidean dot. + + b = 2*e1 + e5 is non-null ( = 4 - 1 = 3). Projecting x = e1 gives a + metric coefficient 2/3, whereas a Euclidean projection would give 2/5 — a + provably different residual. + """ + b = 2.0 * _vec(1) + _vec(5) + x = _vec(1) + res, _nrm = surprise_residual(x, b.reshape(32, 1)) + + c_metric = cga_inner(b, x) / cga_inner(b, b) # 2/3 + assert np.allclose(res, x - c_metric * b, atol=1e-12) + + c_eucl = float(np.dot(b, x)) / float(np.dot(b, b)) # 2/5 + assert not np.allclose(res, x - c_eucl * b, atol=1e-6) + + +def test_null_residual_reports_nonzero_surprise(): + """Regression (adversarial pass, HIGH): a probe whose UNEXPLAINED component is + a metric-null direction (n_inf) must report NONZERO surprise. The reversion + pseudo-norm returns a false 0 there (n_inf is on the light cone), which would + admit a fully-unexplained direction as an in-span productive transfer. + """ + # x = 1 + n_inf; basis = {1}. Projection removes the scalar; residual = n_inf. + x = _id32() + _n_inf() + res, nrm = surprise_residual(x, _id32().reshape(32, 1)) + assert np.allclose(res, _n_inf(), atol=1e-12) # residual is the null n_inf + assert blade_norm(res) < 1e-9 # (metric pseudo-norm is 0 here) + assert nrm > 0.5 # definite norm is NOT 0 + + src = make_rotor_from_angle(0.5, bivector_idx=7) + out = dual_operator(x, _id32().reshape(32, 1), [("a0", src, src)]) + assert out["surprise_norm"] > 0.5 + assert out["productive"] is False + + +def test_null_pair_projection_is_metric_exact(): + """{n_o, n_inf} spans a NON-degenerate hyperbolic plane — admitted, and the + projection is metric-exact: x = n_o + e1 projects out exactly the n_o + component (residual == e1) via the off-diagonal gram [[0,-1],[-1,0]]. A + Euclidean projection would not leave exactly e1. + """ + B = np.column_stack([_n_o(), _n_inf()]) + x = _n_o() + _vec(1) + res, _nrm = surprise_residual(x, B) # must not raise + assert np.allclose(res, _vec(1), atol=1e-12) + + +def test_five_vector_branch_metric_and_refusal(): + """The 5-vector eta-metric branch: metric-vs-Euclidean divergence + null refusal.""" + b = np.array([2.0, 0.0, 0.0, 0.0, 1.0]) # 2*e1 + e5, eta-norm^2 = 4 - 1 = 3 + x = np.array([1.0, 0.0, 0.0, 0.0, 0.0]) + res, _nrm = surprise_residual(x, b.reshape(5, 1)) + c_metric = float(x @ (_ETA5 @ b)) / float(b @ (_ETA5 @ b)) # 2/3 + assert np.allclose(res, x - c_metric * b, atol=1e-12) + c_eucl = float(np.dot(b, x)) / float(np.dot(b, b)) # 2/5 + assert not np.allclose(res, x - c_eucl * b, atol=1e-6) + + null5 = np.array([0.0, 0.0, 0.0, 1.0, 1.0]) # e4 + e5, eta-norm^2 = 1 - 1 = 0 + with pytest.raises(SurpriseResidualError): + surprise_residual(x, null5.reshape(5, 1)) + + +def test_lone_null_column_refused(): + """A lone n_o direction (self-inner 0, no reciprocal) is unprojectable.""" + with pytest.raises(SurpriseResidualError) as ei: + surprise_residual(make_rotor_from_angle(0.3), _n_o().reshape(32, 1)) + assert ei.value.reason == "degenerate_metric_span" + assert 0 in ei.value.disclosure["null_columns"] + + +def test_combination_degenerate_span_disclosed(): + """A metric-degenerate span whose columns are each NON-null (a null + combination) is still refused, and the disclosure names the degenerate + direction even though null_columns is empty.""" + b1 = _vec(1) # e1, = 1 + b2 = _vec(1) + _n_inf() # e1 + (e4+e5), = 1 (non-null) + B = np.column_stack([b1, b2]) + with pytest.raises(SurpriseResidualError) as ei: + surprise_residual(make_rotor_from_angle(0.3), B) + assert ei.value.disclosure["null_columns"] == [] # neither diagonal is 0 + assert len(ei.value.disclosure["degenerate_combo"]) == 2 # but the combo is named + + +# --- REGRESSION / CONTAINMENT GUARDS (metric-agnostic) ----------------------- + +def test_null_pair_admitted_not_refused(): + """The non-degenerate null pair {n_o, n_inf} must not trip the fail-closed path.""" + B = np.column_stack([_n_o(), _n_inf()]) + surprise_residual(_vec(1), B) # must not raise + + +def test_redundant_nonnull_basis_admitted(): + """[1, 1] is rank-deficient but non-null: admitted (project onto span{1}).""" + B = np.column_stack([_id32(), _id32()]) + x = make_rotor_from_angle(0.6, bivector_idx=7) + res, _nrm = surprise_residual(x, B) # must not raise + expected = x - float(x[0]) * _id32() # x minus its scalar (grade-0) part + assert np.allclose(res, expected, atol=1e-9) + + +def test_in_span_zero_residual(): + b0, b1 = _id32(), make_rotor_from_angle(0.5, bivector_idx=7) + B = np.column_stack([b0, b1]) + _res, nrm = surprise_residual(b1, B) # b1 lies in the span + assert nrm < 1e-9 + + +def test_out_of_span_partial_and_full_energy(): + # Full: a pure bivector is cga-orthogonal to span{1} -> residual == x. + x = grade_project(make_rotor_from_angle(0.7, bivector_idx=7), 2) + res, nrm = surprise_residual(x, _id32().reshape(32, 1)) + assert np.allclose(res, x, atol=1e-12) + assert abs(nrm - float(np.linalg.norm(x))) < 1e-12 + # Partial: a rotor has an in-span scalar and out-of-span bivector, so the + # residual is a STRICT subset and its surprise is strictly between 0 and full. + r = make_rotor_from_angle(0.7, bivector_idx=7) + _res2, nrm2 = surprise_residual(r, _id32().reshape(32, 1)) + assert 1e-9 < nrm2 < float(np.linalg.norm(r)) - 1e-9 + + +def test_even_input_even_residual(): + """Grade-support containment: an even (grade 0+2) input yields an even residual.""" + x = make_rotor_from_angle(0.7, bivector_idx=7) + B = np.column_stack([_id32(), make_rotor_from_angle(0.3, bivector_idx=9)]) + res, _nrm = surprise_residual(x, B) + for k in (1, 3, 5): + assert float(np.linalg.norm(grade_project(res, k))) < 1e-9 + + +def test_determinism(): + x = make_rotor_from_angle(0.9, bivector_idx=8) + B = np.column_stack([_id32(), make_rotor_from_angle(0.4, bivector_idx=7)]) + r1, n1 = surprise_residual(x, B) + r2, n2 = surprise_residual(x, B) + assert n1 == n2 and np.array_equal(r1, r2) + + +# --- reconciled productivity polarity --------------------------------------- + +def test_dual_operator_productive_requires_low_surprise(): + """Same analog (identical -> ~0 Procrustes), surprise alone flips productivity. + + Low surprise (query in span) -> productive transfer. High surprise (query far + outside span) -> NOT productive (a discovery signal, not a transfer). + """ + src = make_rotor_from_angle(0.5, bivector_idx=7) + analogs = [("a0", src, src)] # identical: structural match, low Procrustes + + out_low = dual_operator(src, np.column_stack([_id32(), src]), analogs) + x_high = grade_project(make_rotor_from_angle(1.2, bivector_idx=11), 2) + out_high = dual_operator(x_high, _id32().reshape(32, 1), analogs) + + # identical analog -> identical (low) Procrustes residual for both + assert out_low["procrustes_residual"] == out_high["procrustes_residual"] + assert out_low["procrustes_residual"] <= 0.35 + assert out_low["surprise_norm"] < 0.35 + assert out_high["surprise_norm"] > 0.35 + assert out_low["productive"] is True + assert out_high["productive"] is False