Commit graph

1 commit

Author SHA1 Message Date
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