From 07f49eb2157edcd87ba516bc801ca9c0cee66249 Mon Sep 17 00:00:00 2001 From: Shay Date: Sat, 16 May 2026 11:44:45 -0700 Subject: [PATCH] fix(drift): proper rotor-manifold scaling; restore respond contract MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three issues in the drift-fix landing (922bddc) addressed: 1. algebra/rotor.py: add rotor_power(R, alpha) — slerp on the rotor manifold via the rotor's exp/log decomposition. Handles both rotation planes (cos/sin) and boost planes (cosh/sinh); falls back to identity for non-simple bivectors or null cases. 2. generate/stream.py: the score-weighted vault recall previously did `weight*V + (1-weight)*np.eye(V.shape[0])`. Two bugs: - np.eye produced a 32x32 matrix for a 1D multivector, crashing versor_apply with a broadcasting error (2 cognition tests failing on main). - The linear blend produced multivectors with versor_condition up to 2.2e-2, violating the non-negotiable 1e-6 invariant declared in CLAUDE.md. Now uses rotor_power(V, weight) which stays on the manifold by construction (versor_condition <= 1.1e-16). 3. session/context.py: respond() now re-binds result.final_state to self.state after finalize_turn's anchor pull, restoring the "respond returns the same object that was vaulted" contract (test_engine_loop_proof regression). Verification: - 41 new tests in tests/test_rotor_power.py covering closure preservation, alpha=0/1 boundaries, half-angle composition, and word-transition rotors. - Empirical multi-turn versor_condition stays at machine epsilon with anchor pull, max 9.4e-7 without (under threshold either way after fix). - Full suite: 609 passed, 4 skipped, 0 failed. --- algebra/rotor.py | 72 ++++++++++++++++++++++++++++++++++ generate/stream.py | 11 +++--- session/context.py | 7 +++- tests/test_rotor_power.py | 82 +++++++++++++++++++++++++++++++++++++++ 4 files changed, 166 insertions(+), 6 deletions(-) create mode 100644 tests/test_rotor_power.py diff --git a/algebra/rotor.py b/algebra/rotor.py index 226ef11d..208e0932 100644 --- a/algebra/rotor.py +++ b/algebra/rotor.py @@ -72,6 +72,78 @@ def make_rotor_from_angle(angle: float, bivector_idx: int = 6) -> np.ndarray: return unitize_versor(rotor) +def rotor_power(R: np.ndarray, alpha: float) -> np.ndarray: + """Return R^alpha — the rotor on the manifold path from identity to R by alpha. + + For a simple unit rotor decomposed as ``R = a + B`` (scalar + bivector): + + - rotation plane (``B² < 0``): ``R^α = cos(α·θ/2) + (sin(α·θ/2)/|B|) · B`` + where ``θ/2 = atan2(|B|, a)``. + - boost plane (``B² > 0``): ``R^α = cosh(α·η/2) + (sinh(α·η/2)/|B|) · B`` + where ``η/2 = atanh(|B|/a)``. + + This is the proper slerp on the rotor manifold: it stays on the manifold + by construction, so ``versor_condition(rotor_power(R, α)) < 1e-6`` for any + α whenever ``R`` is itself a closed unit rotor. + + Falls back to the identity rotor when ``R`` is not a closed scalar+bivector + rotor (e.g. carries higher-grade components or a non-simple bivector) so + callers never receive a manifold-violating output. + """ + R_arr = np.asarray(R, dtype=np.float64) + if R_arr.shape != (N_COMPONENTS,): + raise ValueError( + f"rotor_power expects a {N_COMPONENTS}-component rotor; got {R_arr.shape}." + ) + + dtype = _result_dtype(R_arr) + a = float(R_arr[0]) + B = R_arr.copy() + B[0] = 0.0 + + # Quick guard: bivector must be a simple bivector (B² is grade-0 only). + B_sq_full = geometric_product(B, B).astype(np.float64) + bsq_scalar = float(B_sq_full[0]) + B_sq_higher = B_sq_full.copy() + B_sq_higher[0] = 0.0 + if float(np.linalg.norm(B_sq_higher)) > 1e-6: + # Non-simple bivector — return identity to avoid drift. + return _identity(dtype) + + # Near-identity: nothing to scale. + bivector_norm = float(np.linalg.norm(B)) + if bivector_norm < _NEAR_ZERO_TOL: + return _identity(dtype) + + if bsq_scalar < 0.0: + # Rotation plane. B² = -|B|² under signature, so the effective + # magnitude is the Euclidean norm of the bivector coefficients. + b_mag = float(np.sqrt(-bsq_scalar)) + theta_half = float(np.arctan2(b_mag, a)) + new_a = float(np.cos(alpha * theta_half)) + new_b_mag = float(np.sin(alpha * theta_half)) + elif bsq_scalar > 0.0: + # Boost plane. + b_mag = float(np.sqrt(bsq_scalar)) + # atanh requires |b_mag/a| < 1; for closed rotors a² - B² = 1 means + # |b_mag| < |a|, so this is safe when a > 0. + if a == 0.0: + return _identity(dtype) + eta_half = float(np.arctanh(b_mag / a)) + new_a = float(np.cosh(alpha * eta_half)) + new_b_mag = float(np.sinh(alpha * eta_half)) + else: + # B² = 0: null bivector. Cannot interpolate on the manifold; + # return identity to fail safely. + return _identity(dtype) + + result = np.zeros(N_COMPONENTS, dtype=np.float64) + result[0] = new_a + if b_mag > _NEAR_ZERO_TOL: + result += (new_b_mag / b_mag) * B + return result.astype(dtype, copy=False) + + def word_transition_rotor(A: np.ndarray, B: np.ndarray) -> np.ndarray: """ Compute the closed transition operator from source versor A to target B. diff --git a/generate/stream.py b/generate/stream.py index 52f42ce0..2b418fde 100644 --- a/generate/stream.py +++ b/generate/stream.py @@ -17,7 +17,7 @@ import numpy as np from field.state import FieldState from field.propagate import propagate_step -from algebra.rotor import word_transition_rotor +from algebra.rotor import rotor_power, word_transition_rotor from algebra.versor import unitize_versor from generate.attention import AttentionOperator from generate.result import GenerationResult @@ -191,10 +191,11 @@ def _recall_state(state: FieldState, vault, top_k: int) -> tuple[FieldState, int V = word_transition_rotor(current.F, recalled_F) except ValueError: continue - # Scale the rotor toward identity by (1 - weight) so a weight of - # ~0.0 leaves the field nearly unchanged and weight ~1.0 applies - # the full transition. - V_scaled = weight * V + (1.0 - weight) * np.eye(V.shape[0], dtype=V.dtype) + # Scale the rotor toward identity by raising it to the (weight) + # power on the rotor manifold. ``rotor_power`` stays on the manifold + # by construction (versor_condition stays < 1e-6), unlike a linear + # blend ``weight·V + (1-weight)·identity`` which violates closure. + V_scaled = rotor_power(V, float(weight)) current = propagate_step(current, V_scaled) current = FieldState( F=current.F, diff --git a/session/context.py b/session/context.py index bea90ca8..6843cf0f 100644 --- a/session/context.py +++ b/session/context.py @@ -325,7 +325,12 @@ class SessionContext: ) result = generate(pivot, self.vocab, self.persona, max_tokens, vault=self.vault) self.finalize_turn(result, input_versor=input_versor, dialogue_role="assert") - return result + # Drift fix 3 may have rotated/pulled the state inside finalize_turn; + # re-bind result.final_state so the returned result mirrors the actual + # post-turn session state (preserves the "respond returns the same + # state object that was vaulted" contract). + from dataclasses import replace as _replace + return _replace(result, final_state=self.state) def recall(self, query_tokens: list, top_k: int = 5) -> list: query_state = inject(query_tokens, self.vocab) diff --git a/tests/test_rotor_power.py b/tests/test_rotor_power.py new file mode 100644 index 00000000..638485aa --- /dev/null +++ b/tests/test_rotor_power.py @@ -0,0 +1,82 @@ +"""Tests for algebra.rotor.rotor_power — manifold-preserving rotor scaling. + +The drift-fix #2 originally used linear interpolation between a rotor and +identity, which produced multivectors with versor_condition ≈ 10⁻², violating +the non-negotiable 1e-6 invariant. ``rotor_power`` replaces that with a proper +slerp on the rotor manifold: identity -> R^α stays on the manifold for any α. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from algebra.rotor import make_rotor_from_angle, rotor_power, word_transition_rotor +from algebra.versor import versor_condition + +_TOL = 1e-6 + + +@pytest.mark.parametrize("angle", [0.05, 0.3, 0.7, 1.2, np.pi / 2]) +@pytest.mark.parametrize("alpha", [0.0, 0.1, 0.3, 0.5, 0.7, 0.9, 1.0]) +def test_rotor_power_preserves_versor_closure(angle: float, alpha: float) -> None: + """For any rotation rotor and any fractional power, output is a closed unit rotor.""" + R = make_rotor_from_angle(angle, bivector_idx=7) + R_alpha = rotor_power(R, alpha) + assert versor_condition(R_alpha) < _TOL, ( + f"rotor_power(R(angle={angle}), {alpha}) violates closure: " + f"versor_condition = {versor_condition(R_alpha):.3e}" + ) + + +def test_rotor_power_alpha_zero_returns_identity() -> None: + R = make_rotor_from_angle(0.7, bivector_idx=7) + R_zero = rotor_power(R, 0.0) + expected = np.zeros(32, dtype=R_zero.dtype) + expected[0] = 1.0 + np.testing.assert_allclose(R_zero, expected, atol=1e-9) + + +def test_rotor_power_alpha_one_returns_input() -> None: + R = make_rotor_from_angle(0.4, bivector_idx=7) + R_one = rotor_power(R, 1.0) + np.testing.assert_allclose(R_one, R, atol=1e-9) + + +def test_rotor_power_half_angle_halves_rotation() -> None: + """R^0.5 applied twice equals R.""" + from algebra.cl41 import geometric_product + + R = make_rotor_from_angle(0.8, bivector_idx=7) + R_half = rotor_power(R, 0.5) + R_half_squared = geometric_product(R_half, R_half) + np.testing.assert_allclose(R_half_squared, R, atol=1e-6) + + +def test_rotor_power_handles_identity_input() -> None: + """Identity rotor under any power stays identity.""" + identity = np.zeros(32, dtype=np.float64) + identity[0] = 1.0 + for alpha in [0.0, 0.3, 1.0, 1.5]: + result = rotor_power(identity, alpha) + np.testing.assert_allclose(result, identity, atol=1e-9) + + +def test_rotor_power_on_word_transition_preserves_closure() -> None: + """The real-world case: rotors produced by word_transition_rotor.""" + A = np.zeros(32, dtype=np.float64) + A[0] = 1.0 + B = np.zeros(32, dtype=np.float64) + B[0] = np.cos(0.4) + B[7] = np.sin(0.4) + + R = word_transition_rotor(A, B) + for alpha in [0.05, 0.2, 0.5, 0.8, 0.95]: + R_alpha = rotor_power(R, alpha) + cond = versor_condition(R_alpha) + assert cond < _TOL, f"alpha={alpha}: versor_condition = {cond:.3e}" + + +def test_rotor_power_rejects_wrong_shape() -> None: + with pytest.raises(ValueError): + rotor_power(np.zeros(16, dtype=np.float64), 0.5)