core/docs/decisions/ADR-0025-rotor-frame-admissibility-design-note.md
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

16 KiB
Raw Permalink Blame History

ADR-0025 — Rotor / Frame Admissibility

Field Value
Status Accepted (2026-05-17)
Date 2026-05-17
Supersedes The design-note version of ADR-0025 (Draft)
Extends ADR-0022, ADR-0023, ADR-0024, ADR-0026
Decision lead Shay (with CORE assistant)

Status note

This ADR promotes the previous design-note draft to Accepted, with the architectural home decision reversed. The earlier draft leaned toward Option B (algebra/versor.py); after re-examining the trade-offs in light of CLAUDE.md doctrine and Phase 3's wiring, the correct placement is a fourth option the draft did not crystalise: a sibling-but-separate module under generate/generate/rotor_admissibility.py. See §"Architectural home" for the full argument.

The threshold-scheme question (draft's Question 2) is closed by inheritance from ADR-0026 / Phase 3: the rotor side reuses the ranked-with-margin contract — although, in Phase 4, the per-candidate rotor check operates at a simpler positivity bar (score > 0), matching the per-step gating shape of ADR-0024. A future iteration may extend rotor-side ranking with a margin, once we have multiple candidate rotors and a corpus that motivates it.

The teaching-loop question (draft's Question 3) is closed at Stance A — strictly hygiene-only, as recommended in the draft.


Context

ADR-0024 added per-step inner-loop admissibility for the destination-token / direction side of an AdmissibilityRegion: when a candidate's CGA inner product against relation_blade falls below the configured bar, the candidate is excluded and the walk re-selects (or refuses, ADR-0024 Phase 2 / ADR-0026 / Phase 3).

ADR-0024 explicitly deferred:

Frame-versor admissibility (does the rotor preserve / transform within the frame constraint?) remains out of scope.

That deferral is closed here. Rotor-side admissibility asks a different question than destination-side admissibility:

  • Destination admissibility (ADR-0024 / ADR-0026): does the candidate destination versor align with the relation blade?
  • Rotor admissibility (this ADR): does the rotor V applied to the current field F land within the region's frame-versor admissible cone?

The two are independent gates that compose: a candidate is admitted iff both pass. A region carrying both relation_blade and frame_versor exercises both gates; a region carrying only one exercises only that one; an unconstrained region exercises neither.


Architectural home — the decision

Three candidates the draft considered, plus a fourth option that crystalised during Phase 4 implementation:

Option A (rejected): single inner-loop in generate/stream.py

Inline the rotor check in the same per-step inner loop as the destination check. Draft's objection: "Pushes algebra-shaped invariants into a generation-shaped module ... bloats the hot path and entangles concerns."

Rejected here too — but for a more precise reason: it conflates the two semantic axes (destination vs rotor) into one decision site, making the trace harder to read and the refusal reason harder to classify. The fix is separation, not relocation.

Option B (rejected): algebra/versor.py (closure invariant)

Make rotor admissibility part of word_transition_rotor's construction — same shape as versor_condition < 1e-6.

The draft recommended this on doctrinal grounds ("algebra-owned closure belongs in algebra/"). Rejected on closer reading.

Admissibility is not closure. Closure asks "does the constructed versor satisfy the algebra's idempotency invariant?" — a structural property of the rotor itself. Admissibility asks "does the rotor's effect on the field land in a pack-grounded admissible region?" — a semantic property of the rotor as applied to specific field state in the presence of a pack-derived frame versor.

Putting the semantic test inside the algebra layer has two structural costs:

  1. Coupling. algebra/ becomes dependent on generate.admissibility.AdmissibilityRegion, a pack-derived construct. This crosses a layer boundary that was clean yesterday.
  2. Repair temptation. Once the check lives in algebra, the natural next move is "if a rotor isn't admissible, re-project it onto the frame cone." That is exactly the hot-path repair CLAUDE.md §Normalization Rules forbids — grade projection, monitoring, watchdogs whose only purpose is to repair another function.

Algebra closure must stay structural; semantic admissibility belongs upstream of algebra construction.

Option C (rejected): field/propagate.py (precondition guard)

Enforce just before propagate_step commits the new field. The draft flagged the doctrinal risk:

field/propagate.py is explicitly listed in CLAUDE.md as a forbidden site for normalization / drift repair / monitoring [...] An admissibility guard (raise on violation, never repair) is closer to a precondition than a monitor, but the boundary needs to be made explicit before this option is chosen.

Rejected. Even framed as precondition, a check in field/propagate.py blurs the rule and invites future "just one more guard" additions. The clean answer is to keep field/propagate.py as the no-conditional-branching tight loop it is documented to be.

Option D (Accepted): generate/rotor_admissibility.py

A new sibling module to generate/admissibility.py, at the same generation/propagation seam as ADR-0024's destination-side check.

Why this is the right home:

  • Same architectural seam as destination admissibility. Both rotor and destination checks operate at the boundary between candidate selection and field propagation. They share infrastructure (the inner loop, the rejected_attempts trace, the InnerLoopExhaustion refusal mechanism) but live in distinct files, so the concepts stay separable even as the seam stays unified.
  • No layer-boundary crossing. generate/ already depends on algebra/ (it calls versor_apply, word_transition_rotor, cga_inner). Putting rotor admissibility here keeps that dependency direction; Option B would have inverted it.
  • No hot-path repair temptation. The module's only public surface is check_rotor_admissibility(region, field_current, rotor) -> RotorVerdict. It returns a typed verdict, doesn't mutate field state, doesn't repair anything. The naming — admissibility, not projection or normalization — encodes the contract.
  • Trace parity with destination admissibility. Refusal flows through the same InnerLoopExhaustion plumbing wired in Phase 2, with a distinct RefusalReason.ROTOR_REJECTION so the trace names the axis that ran out without needing a parallel exception type.
  • File separation answers Option A's bloat objection. Endpoint admissibility (token-side, blade) stays in generate/admissibility.py. Rotor admissibility (rotor-side, frame) lives in generate/rotor_admissibility.py. The inner loop in generate/stream.py calls both, but neither file grows the other's concerns.

Decision

Rotor-side admissibility is enforced at the generation/propagation seam by a new module:

generate/rotor_admissibility.py
    RotorVerdict
    check_rotor_admissibility(region, *, field_current, rotor)

Wiring:

Surface Behavior
generate/stream.py (threshold mode) Per-candidate rotor check after destination admit; on reject, log rotor score in rejected_attempts, retry next candidate, escalate to InnerLoopExhaustion with reason=ROTOR_REJECTION on exhaustion (iff any rotor rejection occurred)
generate/stream.py (margin mode) Rotor check on the top-ranked admissible candidate; on reject, immediate InnerLoopExhaustion(reason=ROTOR_REJECTION) carrying the destination ranking plus the rejected rotor's score
generate/exhaustion.py RefusalReason.ROTOR_REJECTION enum member (Phase 2 plumbing carries it through traces unchanged)
docs/runtime_contracts.md "Rotor admissibility contract" subsection documenting the seam, the algorithm, and the refusal taxonomy

The check itself:

score = cga_inner(versor_apply(V, F_current), region.frame_versor)
admit iff score > 0   (basic positivity in the frame half-space)

A region with frame_versor is None (or null-norm) trivially admits every rotor with score = +inf — backward-compatible with every pre-Phase-4 region in the codebase.


Why Phase 4 keeps positivity (not margin) on the rotor side

Phase 3 / ADR-0026 replaced the static-threshold destination check with ranked-with-margin because Phase 4 characterization established that blade norms varied ~10× across cases, making a single absolute threshold geometrically invalid.

The rotor side does not yet have an equivalent corpus. The Phase 4 rotor check uses a simpler positivity bar (score > 0) — a strict "the rotor lands in the frame's half-space" test — because:

  1. There is no cross-case calibration evidence to inform a margin constant yet. Picking a rotor-margin delta today would be guessing.
  2. Positivity is the doctrinally cleanest semantic bar. Either the post-rotor field stays in the frame's positive cone or it doesn't; there is no static threshold to be wrong about.
  3. Phase 5 (diversified failure-mode families) is the right place to surface whether rotor-side margin matters. If a family shows that rotor scores cluster near zero (margin signal weak), the finding is architectural and motivates a rotor-margin extension at that point.

ADR-0026's margin is preserved on the destination side; rotor side stays at positivity until evidence demands otherwise.


Invariants preserved

  • versor_condition(F) < 1e-6check_rotor_admissibility does not mutate field state; the closure invariant is the algebra layer's responsibility on the actual propagate_step, which only runs after an admitted rotor. Test tests/test_rotor_admissibility.py::TestGenerateRotorAdmissibility::test_versor_condition_preserved_on_admitted_rotor pins this.
  • Deterministic replay — rotor refusal carries the same evidence shape as destination refusal (the typed exception + canonical trace step). Tests TestRotorAdmissibilityDeterminism::{test_admitted_rotor_replay_stable,test_rotor_refusal_replay_stable} assert 5-run replay equality for both admitted and refused turns.
  • No new code in field/propagate.py, algebra/versor.py, vault/store.py. Phase 4's surface is generate/-local.
  • No approximate recall, no cosine similarity, no HNSW/ANN. Only cga_inner and versor_apply, both already in the runtime path.
  • Honest refusal preservedRefusalReason.ROTOR_REJECTION is a new enum member, not a string; the typed-exception subclass hierarchy (InnerLoopExhaustionValueError) is unchanged, so every pre-Phase-4 except ValueError handler continues to catch rotor refusals byte-identically.

Trust boundary

generate/rotor_admissibility.py has no I/O, no learned state, no dynamic imports. It consumes AdmissibilityRegion.frame_versor (a numpy array from pack-derived construction) and the current FieldState.F (from upstream runtime). No new external surface; the trust boundary of ADR-0022 / ADR-0024 applies unchanged.


Teaching boundary

Stance A confirmed: hygiene-only. Rotor rejections, like destination rejections, are deterministic geometric outcomes derived from intents and frames. They are not reviewed teaching examples and do not enter the teaching loop. CLAUDE.md §Teaching Safety forbids parallel correction paths; entangling rotor rejection with reviewed teaching would create exactly that.

If a corpus emerges where rotor admissibility consistently rejects under conditions the operator considers wrong, the fix is to change the region construction (the upstream pack-grounded geometry that produces the frame versor), not the rotor check itself.


Acceptance evidence

  • No-frame back-compat. A region with frame_versor is None produces identical tokens before and after Phase 4. TestGenerateRotorAdmissibility::test_no_frame_versor_preserves_phase3_behavior.
  • Admit when aligned. frame_versor = seed direction admits the seed→destination rotor; the field stays in the seed-aligned half-space. test_frame_aligned_with_seed_admits.
  • Refuse with named axis when misaligned. An orthogonal frame versor refuses via InnerLoopExhaustion(reason=ROTOR_REJECTION), not INNER_LOOP_EXHAUSTION. test_frame_orthogonal_refuses_with_rotor_rejection, test_threshold_mode_rotor_rejection_routes_reason.
  • versor_condition preserved. test_versor_condition_preserved_on_admitted_rotor asserts < 1e-6 on the final field state of an admitted run.
  • Deterministic replay. 5-run replay equality for both admitted and refused turns. TestRotorAdmissibilityDeterminism.
  • Suite green. 1048 passed, 2 skipped on core test --suite full -q (+11 new rotor tests over the post-Phase-3 baseline of 1037).

Out of scope

  • Rotor-side margin. Phase 4 keeps positivity (score > 0). Promotion to ranked-with-margin awaits Phase 5 evidence.
  • Region construction for frame versors. This ADR enforces the check; it does not specify what frame versors a given intent produces. Frame derivation lives upstream (intent ratification, proposition graph) and is out of scope here.
  • Rotor-side teaching. Stance A above.
  • ChatRuntime.respond() materialisation. The Phase 2 residual silent path (the except ValueError: return "" in respond()) still applies to rotor refusals. Materialising refusal_reason on ChatResponse is a future ADR.

Risks

  • Frame versor mis-specification upstream. A wrongly-constructed frame versor will refuse rotors that should admit, surfacing as ROTOR_REJECTION exhaustion on real turns. Mitigation: the trace names the axis (ROTOR_REJECTIONINNER_LOOP_EXHAUSTION), so debugging starts at frame construction, not admissibility logic.
  • Positivity bar may be too strict. Some legitimate rotors may land at score == 0 exactly (boundary of the cone). Phase 4's bar score > 0 refuses these. Promoting to score >= 0 is one edit if evidence motivates it; left at strict > to mirror the load-bearing strict-> tie-break elsewhere.
  • Cost. Each per-candidate rotor check is one versor_apply + one cga_inner. In threshold mode this adds at most len(admissible_set) extra applies per step; in margin mode, exactly one extra apply per step. No approximation introduced.

Rollback

Construct regions with frame_versor=None (or omit the field, which defaults to None). The rotor check returns trivial admit with score = +inf and the runtime behaves identically to ADR-0026 / Phase 3. No trace-hash migration required.


  • ADR-0022 — Forward Semantic Control (region prefilter)
  • ADR-0023 — FSC proof evidence
  • ADR-0024 — Inner-loop per-rotor admissibility (token-side)
  • ADR-0026 — Ranked admissibility with margin (Phase 3)
  • generate/rotor_admissibility.py — the module
  • tests/test_rotor_admissibility.py — 11 tests pinning the contract
  • docs/runtime_contracts.md §"Rotor admissibility contract"