diff --git a/core/physics/identity_action.py b/core/physics/identity_action.py index 8956bff1..9102a43d 100644 --- a/core/physics/identity_action.py +++ b/core/physics/identity_action.py @@ -25,14 +25,19 @@ lawful action: ``d_stab`` is a pass/fail distance, and callers must NOT soft-pro Enlarging ``H_id`` is a future, explicit, reviewed pack/policy change — never an implicit convenience here. -Pure (numpy + identity_manifold only), deterministic, float64, off-serving. The -lawful-only path composition and hard-break ledger (§3.4/§3.5) are a separate, -later unit; this module provides only the per-turn stabilizer defect. +Pure (numpy + identity_manifold only), deterministic, float64, off-serving. This +module owns both the per-turn stabilizer defect (``d_stab``) and the lawful-only +path ledger (§3.4/§3.5): the identity path composes ONLY the induced actions of +turns certified lawful, refused turns insert break markers (never a soft-projected +``I``), and a scope change forces a hard break onto a new chain. """ from __future__ import annotations +import hashlib +import json from dataclasses import dataclass +from typing import Any, Sequence import numpy as np @@ -129,3 +134,199 @@ def stabilizer_defect_for_versor( if stabilizer is None: stabilizer = IdentityStabilizer.singleton(action.shape[0]) return stabilizer_defect(action, geometry.gram, stabilizer) + + +# -- ADR-0246 §3.4/§3.5 lawful-only identity-path ledger ----------------------- + + +@dataclass(frozen=True) +class PathBudget: + """Two-level lawfulness budget (ADR-0246 §3.4). + + ``epsilon_turn`` bounds a single turn's ``d_stab`` (a large one-turn departure + refuses immediately); ``epsilon_session`` bounds the composed lawful path's + ``d_stab`` (slow accumulation of individually-lawful turns eventually refuses). + """ + + epsilon_turn: float + epsilon_session: float + + +@dataclass(frozen=True) +class IdentityChainScope: + """The scope that keys an identity-action chain (ADR-0246 §3.5). + + Any change to these forces a **hard break** — a new chain that does NOT + continue the previous composed path. The frame, its geometry, the lawfulness + policy, the session, and (when explicit) the biography epoch each redefine + what "the path" means, so a path may only compose within a single scope. + """ + + pack_content_digest: str + geometry_version: str + policy_version: str + session_id: str + biography_epoch: str | None = None + + def as_dict(self) -> dict[str, Any]: + return { + "pack_content_digest": self.pack_content_digest, + "geometry_version": self.geometry_version, + "policy_version": self.policy_version, + "session_id": self.session_id, + "biography_epoch": self.biography_epoch, + } + + +def _chain_id(scope: IdentityChainScope, chain_index: int) -> str: + """Deterministic full-SHA-256 chain id (ADR-0245 §2.3 — no truncation). + + Includes ``chain_index`` so a scope that recurs later in a session (e.g. a + pack A → B → A cycle) still yields a distinct chain id. + """ + payload = json.dumps( + {**scope.as_dict(), "chain_index": int(chain_index)}, + sort_keys=True, + separators=(",", ":"), + ) + return hashlib.sha256(payload.encode("utf-8")).hexdigest() + + +@dataclass(frozen=True) +class IdentityPathLedger: + """Immutable snapshot of the lawful-only identity path (ADR-0246 §4.2). + + ``a_path_lawful`` is the time-forward product of the induced actions of the + turns certified lawful within this chain (``I`` for an empty chain). Refused + turns are counted in ``break_count`` and excluded from the product — never + composed as ``I`` (that would be the soft-projection §3.4 forbids). + """ + + chain_id: str + scope: IdentityChainScope + chain_index: int + dimension: int + a_path_lawful: np.ndarray + d_stab_path: float + composed_turn_count: int + break_count: int + session_admit: bool + + def ledger_digest(self) -> str: + """Full-SHA-256 content id over the path state (LE f64 byte-order).""" + digest = hashlib.sha256() + digest.update(self.chain_id.encode("utf-8")) + digest.update( + np.ascontiguousarray(self.a_path_lawful, dtype=np.dtype(" dict[str, Any]: + return { + "schema_version": "identity_path_v1", + "chain_id": self.chain_id, + "scope": self.scope.as_dict(), + "chain_index": self.chain_index, + "dimension": self.dimension, + "a_path_lawful": [ + [float(x) for x in row] for row in self.a_path_lawful + ], + "d_stab_path": float(self.d_stab_path), + "composed_turn_count": self.composed_turn_count, + "break_count": self.break_count, + "session_admit": self.session_admit, + "ledger_digest": self.ledger_digest(), + } + + +def advance_identity_path( + ledger: IdentityPathLedger | None, + scope: IdentityChainScope, + action: np.ndarray, + gram: np.ndarray, + budget: PathBudget, +) -> tuple[IdentityPathLedger, dict[str, Any]]: + """Fold one turn's raw induced action into the lawful-only path (§3.4/§3.5). + + Returns ``(new_ledger, turn_record)``. ``turn_record`` reports this turn's + ``lawful`` / ``d_stab_turn`` / ``path_break`` / ``hard_break``. A turn is + lawful iff ``d_stab(action) ≤ epsilon_turn`` under the locked singleton + ``H_id={I}``; only lawful turns compose. A scope change (or an absent prior + ledger) is a hard break that starts a fresh chain — the previous path is NOT + continued. Immutable: ``ledger`` is never mutated. + """ + action = np.asarray(action, dtype=np.float64) + if action.ndim != 2 or action.shape[0] != action.shape[1]: + raise ValueError( + f"induced action must be a square matrix, got shape {action.shape}" + ) + dimension = action.shape[0] + stabilizer = IdentityStabilizer.singleton(dimension) + d_stab_turn = stabilizer_defect(action, gram, stabilizer) + lawful = d_stab_turn <= budget.epsilon_turn + + hard_break = ledger is None or ledger.scope != scope + if hard_break: + chain_index = 0 if ledger is None else ledger.chain_index + 1 + a_path = np.eye(dimension, dtype=np.float64) + composed = 0 + breaks = 0 + else: + assert ledger is not None # not a hard break ⇒ prior ledger exists + chain_index = ledger.chain_index + a_path = ledger.a_path_lawful + composed = ledger.composed_turn_count + breaks = ledger.break_count + + if lawful: + # time-forward: later turns act on the left of the accumulated frame action + a_path = action @ a_path + composed += 1 + path_break = False + else: + # break marker — excluded from the product; NOT composed as identity + breaks += 1 + path_break = True + + d_stab_path = stabilizer_defect(a_path, gram, stabilizer) + new_ledger = IdentityPathLedger( + chain_id=_chain_id(scope, chain_index), + scope=scope, + chain_index=chain_index, + dimension=dimension, + a_path_lawful=a_path, + d_stab_path=d_stab_path, + composed_turn_count=composed, + break_count=breaks, + session_admit=(d_stab_path <= budget.epsilon_session), + ) + turn_record = { + "lawful": lawful, + "d_stab_turn": d_stab_turn, + "path_break": path_break, + "hard_break": hard_break, + } + return new_ledger, turn_record + + +def raw_path_product(actions: Sequence[np.ndarray]) -> np.ndarray: + """Time-forward product of ALL actions, lawful or not — **forensic only**. + + This is the category error §3.4 forbids for the live path (it mixes refused, + ill-conditioned, and leaked actions into a fake "holonomy"). It exists solely + so tests and forensics can demonstrate that the lawful-only product differs + from the naive raw product. Never use it to admit a turn. + """ + if not actions: + raise ValueError("raw_path_product requires at least one action") + result = np.eye(np.asarray(actions[0]).shape[0], dtype=np.float64) + for action in actions: + result = np.asarray(action, dtype=np.float64) @ result + return result diff --git a/docs/briefs/ADR-0246-induced-identity-action-and-path-integrity-preflight.md b/docs/briefs/ADR-0246-induced-identity-action-and-path-integrity-preflight.md index 26f26c67..64be36a5 100644 --- a/docs/briefs/ADR-0246-induced-identity-action-and-path-integrity-preflight.md +++ b/docs/briefs/ADR-0246-induced-identity-action-and-path-integrity-preflight.md @@ -53,8 +53,8 @@ structure is stabilized would instrument lawfulness on a frame the dynamics igno | Unit | Scope | Status | |------|-------|--------| | **Slice 0** — mismatch diagnostic | evidence-only classification of the benign mismatch (foreign leakage vs in-span-unlawful vs numerical vs path vs semantic-coupling-absent) | **merged** `main` (quarantined diagnostic artifact); found: structural e4/e5 foreign leakage, declared frame dynamically unspecial | -| **§3 primitives** (this unit) | pure `A(F)`, `d_orth`, `d_stab` vs locked `H_id={I}`, typed residual channels in `core/physics/identity_manifold.py` + `identity_action.py`; slice-0 eval rewired to consume them (single source of truth) | in progress — RED→GREEN, off-serving, flag untouched | -| §3.4/§3.5 path ledger | lawful-only composition + hard breaks | next unit | +| **§3 primitives** | pure `A(F)`, `d_orth`, `d_stab` vs locked `H_id={I}`, typed residual channels in `core/physics/identity_manifold.py` + `identity_action.py`; slice-0 eval rewired to consume them (single source of truth) | **committed** `feat/adr-0246-induced-action-primitives` (RED→GREEN, off-serving, flag untouched) — awaiting review | +| **§3.4/§3.5 path ledger** (this unit) | lawful-only composition + hard breaks: `PathBudget`, `IdentityChainScope`, `IdentityPathLedger`, `advance_identity_path` in `identity_action.py`; refused turns = break markers (never soft-projected `I`); scope change = hard break onto new chain | in progress — RED→GREEN, off-serving; stacked on the primitives branch | | §3.7 gate admit surface | wire d_orth/d_stab/typed channels into `IdentityScore` (flag default-off) | after ledger | | §6 eval matrix + §11 feasibility | synthetic + path suites + discrimination report; the invariant/semantic-grounding feasibility study runs here as the **first consumer** of the §3 primitives (fixed cohort splits, synthetic recovery controls, typed e4/e5 generator analysis, precision pairs, adversarial discrimination) | after gate surface | | ADR-0246 body + acceptance packet | §10 criteria; no self-Accept | last | diff --git a/tests/test_adr_0246_path_ledger.py b/tests/test_adr_0246_path_ledger.py new file mode 100644 index 00000000..21308177 --- /dev/null +++ b/tests/test_adr_0246_path_ledger.py @@ -0,0 +1,229 @@ +"""ADR-0246 §3.4/§3.5 — lawful-only identity-path ledger pins (§6.2 path suite). + +The path ledger composes ONLY the induced actions of turns that were individually +certified lawful (``d_stab ≤ ε_turn`` under the locked singleton ``H_id={I}``). +Refused turns insert a break marker and are excluded from the product — never a +soft-projected identity matrix masquerading as a pass (brief §3.4 / non-goal #11). +A scope change (pack digest / geometry / policy / session / biography epoch) forces +a hard break: a new ``chain_id`` and a fresh path (§3.5). Pure, off-serving. +""" + +from __future__ import annotations + +import numpy as np +import pytest + +from algebra.cl41 import N_COMPONENTS +from core.physics.identity_manifold import IdentityManifoldGeometry +from core.physics.identity_action import ( + IdentityChainScope, + PathBudget, + advance_identity_path, + raw_path_product, +) + +_E12, _E13 = 6, 7 + + +def _rotor(biv: int, theta: float) -> np.ndarray: + r = np.zeros(N_COMPONENTS, dtype=np.float64) + r[0] = np.cos(theta / 2.0) + r[biv] = np.sin(theta / 2.0) + return r + + +def _identity_versor() -> np.ndarray: + v = np.zeros(N_COMPONENTS, dtype=np.float64) + v[0] = 1.0 + return v + + +@pytest.fixture(scope="module") +def geometry() -> IdentityManifoldGeometry: + return IdentityManifoldGeometry.from_directions( + ((1.0, 0.0, 0.0), (0.0, 1.0, 0.0), (0.0, 0.0, 1.0)) + ) + + +def _scope(pack="packA", geom="geomV1", policy="polV1", session="sess1", bio=None): + return IdentityChainScope( + pack_content_digest=pack, + geometry_version=geom, + policy_version=policy, + session_id=session, + biography_epoch=bio, + ) + + +_BUDGET = PathBudget(epsilon_turn=0.1, epsilon_session=0.3) + + +def _action(geometry, versor): + return geometry.induced_action(versor) + + +def test_first_lawful_turn_starts_chain_near_identity(geometry): + ledger, rec = advance_identity_path( + None, _scope(), _action(geometry, _identity_versor()), geometry.gram, _BUDGET + ) + assert rec["hard_break"] is True + assert rec["lawful"] is True and rec["path_break"] is False + assert ledger.composed_turn_count == 1 and ledger.break_count == 0 + assert np.allclose(ledger.a_path_lawful, np.eye(3), atol=1e-12) + assert ledger.d_stab_path < 1e-12 + assert ledger.session_admit is True + + +def test_lawful_identity_sequence_stays_admitted(geometry): + ledger = None + for _ in range(20): + ledger, _ = advance_identity_path( + ledger, _scope(), _action(geometry, _identity_versor()), geometry.gram, _BUDGET + ) + assert ledger.composed_turn_count == 20 and ledger.break_count == 0 + assert np.allclose(ledger.a_path_lawful, np.eye(3), atol=1e-12) + assert ledger.session_admit is True + + +def test_small_rotations_accumulate_to_session_refusal(geometry): + small = _action(geometry, _rotor(_E12, 0.05)) # each turn is lawful (small d_stab) + ledger = None + admitted_turns = 0 + for _ in range(40): + ledger, rec = advance_identity_path(ledger, _scope(), small, geometry.gram, _BUDGET) + assert rec["lawful"] is True # each small step passes ε_turn + admitted_turns += 1 + if not ledger.session_admit: + break + # per-turn always lawful, but the composed path eventually breaches ε_session + assert ledger.session_admit is False + assert ledger.d_stab_path > _BUDGET.epsilon_session + assert ledger.composed_turn_count == admitted_turns + + +def test_refused_turn_is_break_and_excluded(geometry): + ledger, _ = advance_identity_path( + None, _scope(), _action(geometry, _identity_versor()), geometry.gram, _BUDGET + ) + before = ledger.a_path_lawful.copy() + big = _action(geometry, _rotor(_E12, np.pi / 2.0)) # 90° rotation: d_stab huge + ledger, rec = advance_identity_path(ledger, _scope(), big, geometry.gram, _BUDGET) + assert rec["lawful"] is False and rec["path_break"] is True + assert ledger.break_count == 1 and ledger.composed_turn_count == 1 + # the refused action does NOT compose (no soft-projected I either): path unchanged + assert np.allclose(ledger.a_path_lawful, before, atol=1e-12) + + +def test_interleaved_refuse_admit_records_raw(geometry): + ident = _action(geometry, _identity_versor()) + big = _action(geometry, _rotor(_E12, np.pi / 2.0)) + seq = [ident, big, ident, big, ident] + ledger = None + records = [] + for a in seq: + ledger, rec = advance_identity_path(ledger, _scope(), a, geometry.gram, _BUDGET) + records.append(rec) + assert ledger.composed_turn_count == 3 and ledger.break_count == 2 + assert [r["path_break"] for r in records] == [False, True, False, True, False] + + +def test_hard_break_on_pack_digest_change(geometry): + ident = _action(geometry, _identity_versor()) + ledger, _ = advance_identity_path(None, _scope(pack="packA"), ident, geometry.gram, _BUDGET) + id_a = ledger.chain_id + # drift the path a little so "not continued" is observable + small = _action(geometry, _rotor(_E12, 0.05)) + ledger, _ = advance_identity_path(ledger, _scope(pack="packA"), small, geometry.gram, _BUDGET) + drifted = ledger.a_path_lawful.copy() + # pack change → hard break + ledger, rec = advance_identity_path(ledger, _scope(pack="packB"), ident, geometry.gram, _BUDGET) + assert rec["hard_break"] is True + assert ledger.chain_id != id_a + assert ledger.composed_turn_count == 1 and ledger.break_count == 0 # fresh chain + assert not np.allclose(ledger.a_path_lawful, drifted) # old path not continued + assert np.allclose(ledger.a_path_lawful, np.eye(3), atol=1e-12) + + +@pytest.mark.parametrize( + "changed", + [ + {"geom": "geomV2"}, + {"policy": "polV2"}, + {"session": "sess2"}, + {"bio": "epoch2"}, + ], +) +def test_hard_break_on_each_scope_dimension(geometry, changed): + ident = _action(geometry, _identity_versor()) + ledger, _ = advance_identity_path(None, _scope(), ident, geometry.gram, _BUDGET) + base_id = ledger.chain_id + ledger, rec = advance_identity_path(ledger, _scope(**changed), ident, geometry.gram, _BUDGET) + assert rec["hard_break"] is True + assert ledger.chain_id != base_id + + +def test_raw_product_differs_from_lawful_product(geometry): + ident = _action(geometry, _identity_versor()) + big = _action(geometry, _rotor(_E12, np.pi / 2.0)) + seq = [ident, big, ident] + ledger = None + for a in seq: + ledger, _ = advance_identity_path(ledger, _scope(), a, geometry.gram, _BUDGET) + # forensic pin: composing ALL raw actions (incl. the refused 90°) gives a very + # different result than the lawful-only product — the category error §3.4 forbids. + raw = raw_path_product(seq) + assert not np.allclose(raw, ledger.a_path_lawful) + assert np.allclose(ledger.a_path_lawful, np.eye(3), atol=1e-12) # lawful excludes the big turn + + +def test_chain_id_is_deterministic_full_sha256(geometry): + ident = _action(geometry, _identity_versor()) + l1, _ = advance_identity_path(None, _scope(), ident, geometry.gram, _BUDGET) + l2, _ = advance_identity_path(None, _scope(), ident, geometry.gram, _BUDGET) + assert l1.chain_id == l2.chain_id + assert len(l1.chain_id) == 64 + int(l1.chain_id, 16) # valid hex + + +def test_ledger_digest_deterministic_and_path_sensitive(geometry): + ident = _action(geometry, _identity_versor()) + small = _action(geometry, _rotor(_E13, 0.05)) + l1, _ = advance_identity_path(None, _scope(), ident, geometry.gram, _BUDGET) + l1b, _ = advance_identity_path(None, _scope(), ident, geometry.gram, _BUDGET) + assert l1.ledger_digest() == l1b.ledger_digest() + assert len(l1.ledger_digest()) == 64 + l2, _ = advance_identity_path(l1, _scope(), small, geometry.gram, _BUDGET) + assert l2.ledger_digest() != l1.ledger_digest() + + +def test_advance_is_immutable(geometry): + ident = _action(geometry, _identity_versor()) + ledger, _ = advance_identity_path(None, _scope(), ident, geometry.gram, _BUDGET) + snapshot = ledger.a_path_lawful.copy() + count_before = ledger.composed_turn_count + _new, _ = advance_identity_path( + ledger, _scope(), _action(geometry, _rotor(_E12, 0.05)), geometry.gram, _BUDGET + ) + assert ledger.composed_turn_count == count_before # original untouched + assert np.allclose(ledger.a_path_lawful, snapshot) + + +def test_as_dict_shape(geometry): + ledger, _ = advance_identity_path( + None, _scope(), _action(geometry, _identity_versor()), geometry.gram, _BUDGET + ) + d = ledger.as_dict() + assert d["schema_version"] == "identity_path_v1" + assert d["chain_id"] == ledger.chain_id + assert d["composed_turn_count"] == 1 + assert d["break_count"] == 0 + assert d["session_admit"] is True + assert "a_path_lawful" in d and "d_stab_path" in d and "ledger_digest" in d + + +def test_module_is_pure_offserving(): + import core.physics.identity_action as a + + with open(a.__file__, encoding="utf-8") as fh: + src = fh.read() + assert "chat.runtime" not in src and "import chat" not in src