core/tests/test_rotor_admissibility.py
Shay 542e13d2f3 feat(adr-0025): Phase 4 — rotor / frame admissibility at the seam
Promote ADR-0025 from Draft (design note) to Accepted with the
architectural home decision reversed: rotor admissibility lives at
the same generation/propagation seam as ADR-0024's destination
check — in a sibling-but-separate module
`generate/rotor_admissibility.py` — NOT in `algebra/versor.py` or
`field/propagate.py`.

Algebra rejected because admissibility is a pack-semantic test, not
a closure invariant; placing it there couples algebra to pack state
and creates structural temptation toward grade-projection repair
(CLAUDE.md §Normalization Rules forbids). field/propagate rejected
as a forbidden normalization site even when framed as precondition
guard. The clean answer is generation-side, in its own file:
endpoint admissibility (token-side, blade) and rotor admissibility
(rotor-side, frame) compose at the same seam while remaining
conceptually separable.

New module generate/rotor_admissibility.py:
  RotorVerdict — admit/reject + score + region_label + reason
  check_rotor_admissibility(region, *, field_current, rotor)
    -> RotorVerdict
  Pure semantic check:
    F'    = versor_apply(V, F_current)
    score = cga_inner(F', region.frame_versor)
    admit iff score > 0   (basic positivity in frame half-space)
  No state mutation, no closure enforcement (algebra's job).
  region.frame_versor is None → trivial admit (back-compat).

RefusalReason extended:
  INNER_LOOP_EXHAUSTION — destination-side (ADR-0024 / ADR-0026)
  ROTOR_REJECTION       — rotor-side (this ADR)
The two reasons let the trace name the axis that ran out without a
parallel exception type. InnerLoopExhaustion(ValueError) hierarchy
unchanged; back-compat preserved.

Wiring in generate/stream.py:
  threshold mode  per-candidate rotor check after destination admit;
                  reject → log rotor score, retry next candidate;
                  exhaustion routes reason to ROTOR_REJECTION iff
                  any rotor rejection occurred in the step
  margin mode     rotor check on the top-ranked admissible candidate;
                  reject → immediate InnerLoopExhaustion(
                  reason=ROTOR_REJECTION) carrying the destination
                  ranking + the rejected rotor's score

Phase 4 keeps positivity (score > 0), not margin, on the rotor side.
No cross-case calibration evidence to inform a rotor-margin constant
yet; promoting to ranked-with-margin awaits Phase 5 diversified-
families evidence. Destination-side margin (ADR-0026) is unchanged.

Teaching boundary closed at Stance A — strictly hygiene-only.
Rotor rejections are deterministic geometric outcomes, not reviewed
teaching examples. CLAUDE.md §Teaching Safety forbids parallel
correction paths; entangling rotor rejection with reviewed teaching
would create one. Confirmed in ADR-0025 §"Teaching boundary".

Acceptance evidence (tests/test_rotor_admissibility.py, 11 passing):
  No-frame back-compat — frame_versor=None tokens identical to
    Phase 3 baseline
  Admit when aligned — frame_versor=seed direction admits
    seed→destination rotor
  Refuse with named axis — orthogonal frame raises
    InnerLoopExhaustion(reason=ROTOR_REJECTION); threshold mode
    also routes reason correctly
  versor_condition < 1e-6 preserved on admitted rotors
  Deterministic replay — 5 reruns identical for both admitted and
    refused turns

Suite results:
  full: 1048 passed, 2 skipped (+11 new rotor tests)

docs/runtime_contracts.md updated with "Rotor admissibility contract"
subsection documenting the seam, the algorithm, and the refusal
taxonomy.

Architectural invariants preserved:
  no new code in algebra/versor.py, field/propagate.py, vault/store.py
  no approximate recall, no cosine similarity, no HNSW/ANN
  no hot-path repair; check is pure typed-verdict
  InnerLoopExhaustion(ValueError) hierarchy unchanged
2026-05-17 15:16:32 -07:00

356 lines
15 KiB
Python

"""Phase 4 / ADR-0025 — rotor admissibility contract.
Pins the rotor-side admissibility shape:
* ``check_rotor_admissibility`` is a no-op admit when the region
carries no ``frame_versor`` (back-compat — every pre-Phase-4
region admits trivially).
* With a non-null ``frame_versor``, the rotor is admitted iff
``cga_inner(versor_apply(V, F), frame_versor) > 0``.
* Wired through ``generate()``: in margin mode the rotor check
runs on the top-ranked candidate after destination margin
admits; rotor refusal raises ``InnerLoopExhaustion`` with
``reason=ROTOR_REJECTION`` (not ``INNER_LOOP_EXHAUSTION``) so
the trace names the axis that ran out.
* In threshold mode, rotor rejection retries the next candidate
inside the per-attempt loop (same shape as destination rejection),
and only escalates to refusal when the loop exhausts. The
exhaustion ``reason`` is ``ROTOR_REJECTION`` iff *any* rotor
rejection occurred during the step.
* The ``versor_condition < 1e-6`` invariant is preserved on
admitted rotors — algebra closure asserts at apply time, not
in this module.
"""
from __future__ import annotations
import numpy as np
import pytest
from algebra.backend import versor_apply, versor_condition
from algebra.cga import cga_inner
from algebra.rotor import word_transition_rotor
from chat.runtime import ChatRuntime
from field.state import FieldState
from generate.admissibility import AdmissibilityRegion, RegionSource
from generate.exhaustion import InnerLoopExhaustion, RefusalReason
from generate.rotor_admissibility import (
RotorVerdict,
check_rotor_admissibility,
)
from generate.stream import generate
_BLADE_DIM = 32
def _zero_blade() -> np.ndarray:
return np.zeros(_BLADE_DIM, dtype=np.float32)
def _region(
*,
allowed: list[int],
blade: np.ndarray,
frame: np.ndarray | None = None,
label: str = "rotor-test",
) -> AdmissibilityRegion:
return AdmissibilityRegion(
allowed_indices=np.asarray(allowed, dtype=np.int64),
relation_blade=blade.astype(np.float32),
frame_versor=frame,
source=RegionSource.RELATION,
label=label,
)
def _v2_setup(rt: ChatRuntime, *, seed: str, admissible: list[str], blade_tok: str):
"""Construct the (state, indices, blade) tuple used by v2-style cases."""
vocab = rt.session.vocab
idx = vocab.index_of(seed)
F = np.asarray(vocab.get_versor(seed), dtype=np.float32)
state = FieldState(F=F.copy(), node=idx, step=0)
indices = np.asarray([vocab.index_of(t) for t in admissible], dtype=np.int64)
blade = np.asarray(vocab.get_versor(blade_tok), dtype=np.float32)
return state, indices, blade
# ---------------------------------------------------------------------------
# check_rotor_admissibility — pure unit tests
# ---------------------------------------------------------------------------
class TestRotorVerdictNoFrameConstraint:
"""A region with ``frame_versor is None`` always admits."""
def test_admits_when_frame_versor_is_none(self) -> None:
blade = _zero_blade()
blade[0] = 1.0
region = _region(allowed=[0], blade=blade, frame=None)
F = np.zeros(_BLADE_DIM, dtype=np.float32)
F[1] = 1.0
V = np.zeros(_BLADE_DIM, dtype=np.float32)
V[2] = 1.0
verdict = check_rotor_admissibility(region, field_current=F, rotor=V)
assert verdict.admitted is True
assert verdict.score == float("inf")
assert "no frame constraint" in verdict.reason
def test_admits_when_frame_versor_is_null_norm(self) -> None:
"""An all-zeros frame_versor is treated as no constraint
(norm < _NULL_TOLERANCE), not as a hostile bound."""
blade = _zero_blade()
blade[0] = 1.0
region = _region(allowed=[0], blade=blade, frame=_zero_blade())
F = np.zeros(_BLADE_DIM, dtype=np.float32)
F[1] = 1.0
V = np.zeros(_BLADE_DIM, dtype=np.float32)
V[2] = 1.0
verdict = check_rotor_admissibility(region, field_current=F, rotor=V)
assert verdict.admitted is True
assert verdict.score == float("inf")
class TestRotorVerdictWithFrame:
"""With a real frame versor, admission is gated by score > 0."""
def test_admits_when_score_positive(self) -> None:
rt = ChatRuntime()
state, _idx, blade = _v2_setup(
rt, seed="symbol", admissible=["question", "answer"], blade_tok="question"
)
# frame_versor = seed direction → the seed-to-seed rotor is
# identity-ish and the post-rotor field stays aligned with the
# frame. We use the seed-to-question rotor and check the
# frame=seed case.
frame = state.F.copy() # frame is seed direction
V = word_transition_rotor(
state.F, np.asarray(rt.session.vocab.get_versor("question"), dtype=np.float32)
)
region = _region(allowed=[0], blade=blade, frame=frame)
verdict = check_rotor_admissibility(region, field_current=state.F, rotor=V)
# Whether this is admitted depends on actual algebra — we
# assert the shape, not a specific score.
assert isinstance(verdict, RotorVerdict)
assert verdict.region_label.startswith("rotor-test")
def test_refuses_when_score_non_positive(self) -> None:
"""Construct a frame deliberately misaligned with the post-
rotor field. An orthogonal-ish frame versor (zeros except one
coordinate) will not be score-positive against the propagated
field unless by coincidence."""
rt = ChatRuntime()
state, _idx, blade = _v2_setup(
rt, seed="symbol", admissible=["question", "answer"], blade_tok="question"
)
frame = np.zeros(_BLADE_DIM, dtype=np.float32)
frame[15] = 1.0 # coordinate chosen to mismatch field alignment
V = word_transition_rotor(
state.F, np.asarray(rt.session.vocab.get_versor("question"), dtype=np.float32)
)
region = _region(allowed=[0], blade=blade, frame=frame)
verdict = check_rotor_admissibility(region, field_current=state.F, rotor=V)
# We can't guarantee non-positivity for arbitrary frame
# choices without running the algebra ourselves first.
# Verify by recomputing: if score is non-positive, refuse;
# if positive, admit. Either way the verdict shape is sound.
F_next = versor_apply(V, state.F)
expected_score = float(cga_inner(np.asarray(F_next, dtype=np.float32), frame))
if expected_score <= 0.0:
assert verdict.admitted is False
assert "not positive" in verdict.reason
assert pytest.approx(verdict.score, abs=1e-5) == expected_score
else:
assert verdict.admitted is True
# ---------------------------------------------------------------------------
# generate() wiring — integration via v2-like setup
# ---------------------------------------------------------------------------
class TestGenerateRotorAdmissibility:
"""End-to-end: rotor admissibility wired through generate()."""
def test_no_frame_versor_preserves_phase3_behavior(self) -> None:
"""When the region carries no frame_versor, margin mode
produces the same token as before Phase 4 (Phase 3 invariant
preserved). No rotor refusal possible."""
rt = ChatRuntime()
state, indices, blade = _v2_setup(
rt, seed="symbol", admissible=["answer", "question"], blade_tok="question"
)
region = AdmissibilityRegion(
allowed_indices=indices, relation_blade=blade,
frame_versor=None, source=RegionSource.RELATION, label="v2-001-no-frame",
)
r = generate(
state, rt.session.vocab, rt.session.persona,
max_tokens=1, region=region,
inner_loop_admissibility=True,
admissibility_mode="margin",
admissibility_margin=0.4,
)
assert r.tokens == ("question",)
def test_frame_aligned_with_seed_admits(self) -> None:
"""frame_versor = seed direction — the seed→question rotor
keeps the field aligned with frame; admit."""
rt = ChatRuntime()
state, indices, blade = _v2_setup(
rt, seed="symbol", admissible=["answer", "question"], blade_tok="question"
)
region = AdmissibilityRegion(
allowed_indices=indices, relation_blade=blade,
frame_versor=state.F.copy(),
source=RegionSource.RELATION, label="v2-001-frame-seed",
)
r = generate(
state, rt.session.vocab, rt.session.persona,
max_tokens=1, region=region,
inner_loop_admissibility=True,
admissibility_mode="margin",
admissibility_margin=0.4,
)
assert r.tokens == ("question",)
def test_frame_orthogonal_refuses_with_rotor_rejection(self) -> None:
"""A frame versor misaligned with the propagated field must
produce honest refusal under ``ROTOR_REJECTION``, not
``INNER_LOOP_EXHAUSTION`` — the trace names the axis."""
rt = ChatRuntime()
state, indices, blade = _v2_setup(
rt, seed="symbol", admissible=["answer", "question"], blade_tok="question"
)
frame = np.zeros(_BLADE_DIM, dtype=np.float32)
frame[15] = 1.0 # chosen to mismatch the algebra of this pack
region = AdmissibilityRegion(
allowed_indices=indices, relation_blade=blade, frame_versor=frame,
source=RegionSource.RELATION, label="v2-001-frame-ortho",
)
with pytest.raises(InnerLoopExhaustion) as exc_info:
generate(
state, rt.session.vocab, rt.session.persona,
max_tokens=1, region=region,
inner_loop_admissibility=True,
admissibility_mode="margin",
admissibility_margin=0.4,
)
exc = exc_info.value
assert exc.reason is RefusalReason.ROTOR_REJECTION
assert exc.region_label == "v2-001-frame-ortho"
# Evidence: the ranking AND the rejected rotor are present
assert len(exc.rejected_attempts) >= 1
words = [w for (_i, w, _s) in exc.rejected_attempts]
# The destination 'question' was chosen by margin then
# rotor-rejected; it must appear in the trace.
assert "question" in words
def test_versor_condition_preserved_on_admitted_rotor(self) -> None:
"""Sanity: when rotor admits, the actual propagation closes
within ``versor_condition < 1e-6``. This is algebra closure,
not rotor-admissibility business — the test pins that they
co-exist cleanly."""
rt = ChatRuntime()
state, indices, blade = _v2_setup(
rt, seed="symbol", admissible=["answer", "question"], blade_tok="question"
)
region = AdmissibilityRegion(
allowed_indices=indices, relation_blade=blade,
frame_versor=state.F.copy(),
source=RegionSource.RELATION, label="v2-001-frame-seed",
)
r = generate(
state, rt.session.vocab, rt.session.persona,
max_tokens=1, region=region,
inner_loop_admissibility=True,
admissibility_mode="margin",
admissibility_margin=0.4,
)
# final_state's F satisfies versor_condition
cond = float(versor_condition(np.asarray(r.final_state.F, dtype=np.float64)))
assert cond < 1e-6, f"versor_condition violated: {cond}"
def test_threshold_mode_rotor_rejection_routes_reason(self) -> None:
"""Threshold mode: when every candidate's rotor is rejected,
the InnerLoopExhaustion reason is ROTOR_REJECTION, not
INNER_LOOP_EXHAUSTION — the axis is named."""
rt = ChatRuntime()
state, indices, blade = _v2_setup(
rt, seed="symbol", admissible=["answer", "question"], blade_tok="question"
)
frame = np.zeros(_BLADE_DIM, dtype=np.float32)
frame[15] = 1.0
region = AdmissibilityRegion(
allowed_indices=indices, relation_blade=blade, frame_versor=frame,
source=RegionSource.RELATION, label="v2-001-thr-frame-ortho",
)
with pytest.raises(InnerLoopExhaustion) as exc_info:
generate(
state, rt.session.vocab, rt.session.persona,
max_tokens=1, region=region,
inner_loop_admissibility=True,
admissibility_threshold=0.0, # destination passes easily
)
assert exc_info.value.reason is RefusalReason.ROTOR_REJECTION
class TestRotorAdmissibilityDeterminism:
"""5 reruns of the same rotor-admissibility scenario produce
identical traces (replay invariance)."""
def test_admitted_rotor_replay_stable(self) -> None:
rt = ChatRuntime()
state, indices, blade = _v2_setup(
rt, seed="symbol", admissible=["answer", "question"], blade_tok="question"
)
region = AdmissibilityRegion(
allowed_indices=indices, relation_blade=blade,
frame_versor=state.F.copy(),
source=RegionSource.RELATION, label="v2-001-frame-seed",
)
first = generate(
state, rt.session.vocab, rt.session.persona,
max_tokens=1, region=region,
inner_loop_admissibility=True,
admissibility_mode="margin",
admissibility_margin=0.4,
)
first_canonical = first.admissibility_trace[0].canonical()
for _ in range(4):
r = generate(
state, rt.session.vocab, rt.session.persona,
max_tokens=1, region=region,
inner_loop_admissibility=True,
admissibility_mode="margin",
admissibility_margin=0.4,
)
assert r.tokens == first.tokens
assert r.admissibility_trace[0].canonical() == first_canonical
def test_rotor_refusal_replay_stable(self) -> None:
rt = ChatRuntime()
state, indices, blade = _v2_setup(
rt, seed="symbol", admissible=["answer", "question"], blade_tok="question"
)
frame = np.zeros(_BLADE_DIM, dtype=np.float32)
frame[15] = 1.0
region = AdmissibilityRegion(
allowed_indices=indices, relation_blade=blade, frame_versor=frame,
source=RegionSource.RELATION, label="v2-001-frame-ortho",
)
first_attempts: tuple[tuple[int, str, float], ...] | None = None
for _ in range(5):
with pytest.raises(InnerLoopExhaustion) as exc_info:
generate(
state, rt.session.vocab, rt.session.persona,
max_tokens=1, region=region,
inner_loop_admissibility=True,
admissibility_mode="margin",
admissibility_margin=0.4,
)
if first_attempts is None:
first_attempts = exc_info.value.rejected_attempts
else:
assert exc_info.value.rejected_attempts == first_attempts
assert exc_info.value.reason is RefusalReason.ROTOR_REJECTION