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
128 lines
5.3 KiB
Python
128 lines
5.3 KiB
Python
"""
|
|
InnerLoopExhaustion — evidence-carrying honest refusal at the walk site.
|
|
|
|
ADR-0024 Phase 2. When the inner-loop admissibility check leaves no
|
|
admissible destination for the next step, the walk must refuse rather
|
|
than silently relax the constraint. Phase 1 raised a plain
|
|
``ValueError`` with a message string; Phase 2 promotes that to a
|
|
typed exception that carries the refusal evidence (region label,
|
|
step index, rejected attempts, machine-readable reason) so downstream
|
|
layers can materialise the refusal into trace evidence without
|
|
re-parsing the message.
|
|
|
|
This is *not* normalization or repair (CLAUDE.md §Normalization Rules).
|
|
The walk still refuses at the same site, with the same algebra, with
|
|
the same effect on caller control flow. The exception subclasses
|
|
``ValueError`` so every existing ``except ValueError`` handler in the
|
|
runtime/eval code paths continues to work byte-for-byte — the carrying
|
|
of structured evidence is additive.
|
|
|
|
Reason codes:
|
|
|
|
``INNER_LOOP_EXHAUSTION`` covers two raise sites in
|
|
``generate/stream.py`` — both express "destination-side admissibility
|
|
produced no admissible candidate":
|
|
|
|
1. Pre-walk: the region's allowed-index intersection with the
|
|
candidate set is empty before any step ran. ``step_index = -1``
|
|
and ``rejected_attempts = ()`` distinguish this site — no
|
|
inner-loop rejections were issued; the region was already empty.
|
|
|
|
2. In-walk: at some step, every candidate in the admissible set was
|
|
rejected by ``check_transition`` (threshold mode) or
|
|
``check_margin`` (Phase 3 / ADR-0026, margin mode). ``step_index
|
|
>= 0`` and ``rejected_attempts`` records the tried (index, word,
|
|
score) triples in attempt order (threshold) or the full ranking
|
|
(margin).
|
|
|
|
``ROTOR_REJECTION`` (Phase 4 / ADR-0025) is the rotor-side analogue.
|
|
Raised when no admissible destination produces a rotor that lands
|
|
the field within the region's frame versor cone. Carries the same
|
|
trace shape: ``rejected_attempts`` records the rotor-side scores
|
|
(``cga_inner(F_after, frame_versor)``) for each rejected candidate.
|
|
Splitting this from ``INNER_LOOP_EXHAUSTION`` keeps the trace
|
|
self-explanatory — a refused turn says *which* admissibility axis
|
|
ran out, destination-blade or rotor-frame.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
from enum import Enum, unique
|
|
|
|
|
|
@unique
|
|
class RefusalReason(Enum):
|
|
"""Machine-readable refusal taxonomy.
|
|
|
|
The string value is what flows into ``trace_hash`` payloads when
|
|
refusal materialisation is wired through a future ADR. Stable
|
|
string values are part of the replay contract.
|
|
"""
|
|
|
|
INNER_LOOP_EXHAUSTION = "inner_loop_exhaustion"
|
|
# Phase 4 / ADR-0025 — rotor side: every candidate's rotor would
|
|
# push the field outside the region's frame-versor admissible
|
|
# cone (``cga_inner(F_after, frame_versor) <= 0`` after
|
|
# sandwiching). Distinct from INNER_LOOP_EXHAUSTION so the
|
|
# trace can tell destination-blade refusal from rotor-frame
|
|
# refusal without re-parsing the message.
|
|
ROTOR_REJECTION = "rotor_rejection"
|
|
|
|
|
|
class InnerLoopExhaustion(ValueError):
|
|
"""Honest refusal raised by the generation walk.
|
|
|
|
Subclasses ``ValueError`` so pre-Phase-2 ``except ValueError``
|
|
handlers in ``chat/runtime.py``, ``evals/...``, and tests still
|
|
catch it without modification.
|
|
|
|
Attributes
|
|
----------
|
|
reason : RefusalReason
|
|
Machine-readable taxonomy code. Phase 2 uses a single value;
|
|
Phase 4 (rotor frame admissibility) is expected to add more.
|
|
region_label : str
|
|
The ``AdmissibilityRegion.label`` that produced the refusal —
|
|
the operator-visible identifier of which constraint blocked
|
|
propagation.
|
|
step_index : int
|
|
Index of the step at which the inner loop exhausted. ``-1``
|
|
marks the pre-walk site (region intersection empty before any
|
|
step ran); non-negative values identify the in-walk site.
|
|
rejected_attempts : tuple[tuple[int, str, float], ...]
|
|
Ordered record of ``(candidate_index, word, score)`` triples
|
|
that the inner-loop check rejected at the step that failed.
|
|
Empty tuple for the pre-walk site.
|
|
|
|
``str(exc)`` returns the human-readable message — preserving the
|
|
Phase 1 exception message contract for callers that only inspected
|
|
``str(e)``. Instances are logically immutable: attributes are set
|
|
once in ``__init__`` and should not be reassigned.
|
|
"""
|
|
|
|
__slots__ = ("reason", "region_label", "step_index", "rejected_attempts")
|
|
|
|
def __init__(
|
|
self,
|
|
*,
|
|
reason: RefusalReason,
|
|
region_label: str,
|
|
step_index: int,
|
|
rejected_attempts: tuple[tuple[int, str, float], ...] = (),
|
|
message: str | None = None,
|
|
) -> None:
|
|
self.reason = reason
|
|
self.region_label = region_label
|
|
self.step_index = step_index
|
|
self.rejected_attempts = tuple(rejected_attempts)
|
|
super().__init__(message if message is not None else self._default_message())
|
|
|
|
def _default_message(self) -> str:
|
|
if self.step_index < 0:
|
|
return (
|
|
f"AdmissibilityRegion[{self.region_label}] left no walk candidates."
|
|
)
|
|
return (
|
|
f"AdmissibilityRegion[{self.region_label}] inner-loop "
|
|
f"rejected all candidates at step {self.step_index}."
|
|
)
|