diff --git a/core/physics/goldtether.py b/core/physics/goldtether.py index 00e0aa54..8409c484 100644 --- a/core/physics/goldtether.py +++ b/core/physics/goldtether.py @@ -27,6 +27,25 @@ _CLOSURE_TOL = 1e-6 _NEAR_ZERO = 1e-12 _PSEUDOSCALAR_IDX = 31 _TELEMETRY_SCHEMA = "goldtether_coherence_v1" +_E4_IDX = 4 +_E5_IDX = 5 + + +def _primal_gold_invariants() -> list: + """R&D-Revised §5 bootstrapping seeds: the identity versor and the two + conformal null directions ``n_o = 0.5(e5-e4)`` and ``n_inf = e4+e5``. + Coordinate-free algebraic anchors so the geometric distance term never + degenerates to drift-only at cold start. + """ + ident = np.zeros(N_COMPONENTS, dtype=np.float64) + ident[0] = 1.0 + n_o = np.zeros(N_COMPONENTS, dtype=np.float64) + n_o[_E5_IDX] = 0.5 + n_o[_E4_IDX] = -0.5 + n_inf = np.zeros(N_COMPONENTS, dtype=np.float64) + n_inf[_E4_IDX] = 1.0 + n_inf[_E5_IDX] = 1.0 + return [ident, n_o, n_inf] class OperatingMode(str, Enum): @@ -102,6 +121,11 @@ class GoldTetherMonitor: autonomy_step: float = 0.01 hitl_floor_threshold: float = 0.7 hitl_autonomy_threshold: float = 0.5 + # Harmonized residual + alpha control law (ADR-0238 §2.3 / R&D-Revised §2.3). + w_drift: float = 0.5 + r_floor: float = 0.1 + r_critical: float = 1.0 + gold_invariants: list = field(default_factory=_primal_gold_invariants, compare=False) @property def supervised_autonomy_level(self) -> float: @@ -158,6 +182,101 @@ class GoldTetherMonitor: self.floor = 0.0 self.history.clear() + # --- Harmonized residual + alpha control law (ADR-0238 §2.3) --------------- + + def goldtether_residual(self, F: np.ndarray) -> float: + """Scale-harmonized coherence residual (ADR-0238 §2.3): + + R = w·(drift / ε_drift) + (1−w)·(min_{I∈𝓘_gold} ‖F−I‖_F / ‖F‖_F) + + The algebraic drift term (normalized by the numerical floor ε_drift) and + the geometric distance-to-gold term (normalized by ‖F‖) are each scaled to + ``[0, O(1)]`` so neither masks the other — the exact defect §2.3 exists to + fix. This is the ALIGNMENT signal that drives the constraint weight α; the + raw :func:`coherence_residual` stays the fail-closed *closure* gate. + """ + F_arr = _as_mv(F) + drift = coherence_residual(F_arr) + drift_term = drift / self.epsilon_drift if self.epsilon_drift > 0.0 else drift + scale = float(np.linalg.norm(F_arr)) + if self.gold_invariants and scale > _NEAR_ZERO: + min_dist = min( + float(np.linalg.norm(F_arr - np.asarray(inv, dtype=np.float64))) + for inv in self.gold_invariants + ) + geo_term = min_dist / scale + else: + geo_term = 0.0 + w = float(self.w_drift) + return float(w * drift_term + (1.0 - w) * geo_term) + + def alpha_constraint( + self, + F: np.ndarray, + *, + mode: OperatingMode | str = OperatingMode.PRACTICE, + ) -> float: + """Human-constraint weight ``α ∈ [0,1]`` for the supervised transition + surface (R&D-Revised §2.3): ``α = Φ(R_gt; r_floor, r_critical)`` — a smooth + step of the *instantaneous* harmonized residual — composed with the + earned-autonomy ceiling and the serve-never-autonomous rule. + + ``α = 0`` fully autonomous (trust self); ``α = 1`` full human override. + Earned autonomy sets the FLOOR on α: the engine may never act more + autonomously than it has earned over its trajectory, and SERVE is pinned + to full override. + """ + op = OperatingMode(mode) + if op is OperatingMode.SERVE: + return 1.0 + r = self.goldtether_residual(F) + lo, hi = float(self.r_floor), float(self.r_critical) + if r <= lo: + phi = 0.0 + elif r >= hi or hi <= lo: + phi = 1.0 + else: + phi = (r - lo) / (hi - lo) + alpha_floor = 1.0 - float(self.autonomy) + return float(min(1.0, max(phi, alpha_floor))) + + def supervised_transition( + self, + v_self: np.ndarray, + v_constraint: np.ndarray, + F: np.ndarray, + *, + mode: OperatingMode | str = OperatingMode.PRACTICE, + ) -> np.ndarray: + """Blend the engine's own transition ``v_self`` toward the human/gold + ``v_constraint`` by the residual-driven constraint weight α. + ``α=0 → v_self`` (autonomous), ``α=1 → v_constraint`` (override). + Rides the exact geodesic (`supervised_blend`), so closure is preserved. + """ + alpha = self.alpha_constraint(F, mode=mode) + return self.supervised_blend(v_self, v_constraint, alpha) + + def promote_gold_invariant(self, F: np.ndarray, *, authorized: bool = False) -> None: + """Add a state versor to 𝓘_gold. CALLER-GATED: the ADR-0092 signed / + replay-verified promotion happens in the caller; this refuses to + self-authorize (one-mutation-path discipline). The full replay-verified + promotion pipeline + principal-axis decay are deferred (issue #18 follow-up). + """ + if not authorized: + raise ValueError( + "promote_gold_invariant requires explicit authorization (ADR-0092 gate)" + ) + self.gold_invariants.append(_as_mv(F).copy()) + + def prune_gold_invariants(self, max_size: int = 64) -> None: + """Bound 𝓘_gold (decay hook), always retaining the three primal seeds. + Full principal-axis pruning (R&D-Revised §5) is deferred.""" + max_size = max(3, int(max_size)) + if len(self.gold_invariants) > max_size: + primal = self.gold_invariants[:3] + recent = self.gold_invariants[3:][-(max_size - 3):] + self.gold_invariants = primal + recent + def measure(self, F: np.ndarray, reference: Optional[np.ndarray] = None) -> CoherenceResidual: """Structured residual (primary + optional geometric distance to reference).""" F_arr = _as_mv(F) diff --git a/tests/test_adr_0238_goldtether_alpha.py b/tests/test_adr_0238_goldtether_alpha.py new file mode 100644 index 00000000..31640b37 --- /dev/null +++ b/tests/test_adr_0238_goldtether_alpha.py @@ -0,0 +1,153 @@ +"""ADR-0238 §2.3 / R&D-Revised §2.3 — harmonized GoldTether residual + α=Φ(R). + +The prior GoldTether shipped only the drift term and an "earned-autonomy" ramp +found in neither blueprint. This adds the scale-harmonized residual (drift + +distance-to-gold-set), the gold-invariant set (primal seeds), and the +α=Φ(R_gt) constraint-weight control law — composed with the earned-autonomy +ceiling and the serve-never-autonomous rule, so the two mechanisms operate at +their two timescales (lifetime ceiling × per-transition blend) rather than +compete. See `docs/research/third-door-blueprint-fidelity.md` finding #4 (#18). +""" + +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.goldtether import GoldTetherMonitor, OperatingMode, coherence_residual + + +def _id() -> np.ndarray: + v = np.zeros(32, dtype=np.float64) + v[0] = 1.0 + return v + + +# --- gold-invariant set (R&D §5 bootstrapping seeds) ----------------------- + +def test_primal_gold_seeds(): + m = GoldTetherMonitor() + assert len(m.gold_invariants) == 3 + ident, n_o, n_inf = m.gold_invariants + assert ident[0] == 1.0 + assert n_o[5] == 0.5 and n_o[4] == -0.5 # n_o = 0.5(e5 - e4) + assert n_inf[4] == 1.0 and n_inf[5] == 1.0 # n_inf = e4 + e5 + + +# --- harmonized residual (§2.3) -------------------------------------------- + +def test_harmonized_residual_zero_on_identity(): + # identity is both closed (drift 0) and a seed (distance 0) + assert GoldTetherMonitor().goldtether_residual(_id()) == 0.0 + + +def test_harmonized_residual_nonneg_and_geo_driven_on_closed_versor(): + m = GoldTetherMonitor() + F = make_rotor_from_angle(1.0) + r = m.goldtether_residual(F) + assert r >= 0.0 + # on a closed distant versor the geo term dominates → strictly above drift-only + assert r > coherence_residual(F) + + +def test_harmonized_residual_explodes_on_drift_fail_closed(): + m = GoldTetherMonitor() + dirty = np.zeros(32, dtype=np.float64) + dirty[0] = 0.5 + dirty[1] = 0.5 + assert m.goldtether_residual(dirty) > 1.0 # drift/ε makes non-closure loud + m.autonomy = 1.0 + assert m.alpha_constraint(dirty) == 1.0 # → full override + + +# --- α = Φ(R) control law (§2.3) ------------------------------------------- + +def test_alpha_serve_is_always_full_override(): + m = GoldTetherMonitor() + m.autonomy = 1.0 + assert m.alpha_constraint(_id(), mode=OperatingMode.SERVE) == 1.0 + + +def test_alpha_earned_autonomy_is_the_floor(): + m = GoldTetherMonitor() # fresh: autonomy 0 + assert m.alpha_constraint(_id()) == 1.0 # cannot go autonomous yet + m.autonomy = 0.4 + for th in (0.0, 0.5, 1.0, 2.0): + a = m.alpha_constraint(make_rotor_from_angle(th)) + assert a >= 1.0 - m.autonomy - 1e-12 # α floored by (1 − earned) + + +def test_alpha_autonomous_when_earned_and_coherent(): + m = GoldTetherMonitor() + m.autonomy = 1.0 + assert m.alpha_constraint(_id()) == 0.0 # earned + on-seed + closed + + +def test_alpha_smooth_step_thresholds(): + m = GoldTetherMonitor() + m.autonomy = 1.0 + F = make_rotor_from_angle(1.0) + r = m.goldtether_residual(F) + m.r_floor, m.r_critical = r + 0.1, r + 0.2 + assert m.alpha_constraint(F) == 0.0 # below floor → autonomous + m.r_floor, m.r_critical = r - 0.2, r - 0.1 + assert m.alpha_constraint(F) == 1.0 # above critical → override + m.r_floor, m.r_critical = r - 0.1, r + 0.1 + assert 0.0 < m.alpha_constraint(F) < 1.0 # in the ramp + + +def test_alpha_monotone_non_decreasing_in_distance(): + m = GoldTetherMonitor() + m.autonomy = 1.0 + m.r_floor, m.r_critical = 0.0, 1.0 + prev = -1.0 + for th in np.linspace(0.05, 3.0, 25): + a = m.alpha_constraint(make_rotor_from_angle(float(th))) + assert 0.0 <= a <= 1.0 + assert a >= prev - 1e-9 + prev = a + + +def test_alpha_determinism_replay(): + F = make_rotor_from_angle(0.7) + m1 = GoldTetherMonitor() + m2 = GoldTetherMonitor() + m1.autonomy = m2.autonomy = 0.5 + assert m1.alpha_constraint(F) == m2.alpha_constraint(F) + + +# --- supervised transition surface ----------------------------------------- + +def test_supervised_transition_endpoints_and_closure(): + m = GoldTetherMonitor() + src, tgt = _id(), make_rotor_from_angle(0.6) + # fresh (autonomy 0) → α = 1 → transition lands on the constraint + out = m.supervised_transition(src, tgt, _id()) + assert np.allclose(out, tgt, atol=1e-6) + assert versor_condition(out) < 1e-6 + # earned + coherent → α = 0 → transition stays on self + m.autonomy = 1.0 + out = m.supervised_transition(src, tgt, _id()) + assert np.allclose(out, src) + + +# --- gold-set mutation discipline ------------------------------------------ + +def test_promote_requires_explicit_authorization(): + m = GoldTetherMonitor() + with pytest.raises(ValueError): + m.promote_gold_invariant(make_rotor_from_angle(0.3)) + m.promote_gold_invariant(make_rotor_from_angle(0.3), authorized=True) + assert len(m.gold_invariants) == 4 + + +def test_prune_retains_primal_seeds(): + m = GoldTetherMonitor() + for i in range(50): + m.promote_gold_invariant(make_rotor_from_angle(0.01 * i + 0.01), authorized=True) + m.prune_gold_invariants(max_size=10) + assert len(m.gold_invariants) == 10 + assert m.gold_invariants[0][0] == 1.0 # identity seed retained + assert m.gold_invariants[1][5] == 0.5 # n_o seed retained