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
356 lines
15 KiB
Python
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
|