diff --git a/docs/decisions/ADR-0025-rotor-frame-admissibility-design-note.md b/docs/decisions/ADR-0025-rotor-frame-admissibility-design-note.md index 21f3a751..b4272229 100644 --- a/docs/decisions/ADR-0025-rotor-frame-admissibility-design-note.md +++ b/docs/decisions/ADR-0025-rotor-frame-admissibility-design-note.md @@ -1,287 +1,360 @@ -# ADR-0025 — Rotor / Frame Admissibility (Design Note) +# ADR-0025 — Rotor / Frame Admissibility -| Field | Value | -|--------------|--------------------------------------| -| Status | **Draft — Design Note Only** | -| Date | 2026-05-17 | -| Supersedes | — | -| Extends | ADR-0022, ADR-0023, ADR-0024 | -| Decision lead| Shay (with CORE assistant) | +| 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 is a **design note**, not an implementation decision. No code -changes are proposed. Its purpose is to fix the home, scope, and -boundary of the next admissibility step *before* anything is built — -so the implementation doesn't inherit the wrong architectural shape -by default. +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. -It will be promoted from Draft to a real ADR (Proposed → Accepted) -only after the design questions below are decided. +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-rotor inner-loop admissibility for the -**destination-token / direction** side of an `AdmissibilityRegion`: +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 `admissibility_threshold`, the candidate is excluded and the -walk re-selects until admitted or exhausted. +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. -This note scopes that deferred work, but with two additional -constraints surfaced by the Phase 2–4 follow-up evidence: +That deferral is closed here. Rotor-side admissibility asks a +different question than destination-side admissibility: -1. **Phase 2 corpus observation** (`evals/forward_semantic_control/ - inner_loop_runner.py`): on the existing v1+dev corpus, the - inner-loop mechanism is *wired, deterministic, causally - attributable* (null-control = boundary-only exactly, - `code_path_residual = 0.0`), but the chain-token outer-product - region produces `exhaustion_rate = 0.33` at `t = 0.0` — well - above the 5% benign-corpus ceiling. +* **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? -2. **Phase 4 threshold characterization** (`threshold_ - characterization.py`): **per-case the geometry separates cleanly** - (every mechanism-isolated v2 case has `correct_min > incorrect_max`), - but **no static global threshold** delivers - `separation_quality ≥ 0.8`. Blade norms vary ~10× across cases, - so the same threshold value means different things case-to-case. - Static thresholds — global, relation-typed, or frame-derived as a - constant — are insufficient. - -These findings change the framing. The next step is not "extend the -same idea to the rotor side." It is two distinct questions: - -* What level of the stack should enforce rotor/frame admissibility? -* What threshold scheme is geometrically valid given Phase 4? +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. --- -## Question 1 — Architectural home +## Architectural home — the decision -Three candidate homes for rotor-side admissibility: +Three candidates the draft considered, plus a fourth option that +crystalised during Phase 4 implementation: -### Option A — Generation-time filter (`generate/`) +### Option A (rejected): single inner-loop in `generate/stream.py` -Inherit ADR-0024's shape. Add a check inside the same per-step inner -loop in `generate/stream.py` that examines the *rotor* `V` (not just -the destination versor) before propagation. +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." -**Pros:** +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. -* Locality with ADR-0024. All admissibility decisions live in one - module. -* Trace evidence is uniform — one `AdmissibilityTraceStep` per - rotor-application. +### Option B (rejected): `algebra/versor.py` (closure invariant) -**Cons:** +Make rotor admissibility part of `word_transition_rotor`'s +construction — same shape as `versor_condition < 1e-6`. -* Pushes algebra-shaped invariants into a generation-shaped module. - `generate/` already orchestrates candidates, salience, attention, - vault recall, persona — adding rotor invariant enforcement here - bloats the hot path and entangles concerns. -* Re-creates the "hot-path repair" anti-pattern CLAUDE.md explicitly - warns against, because the check would re-validate something - algebra already constructed. +The draft recommended this on doctrinal grounds ("algebra-owned +closure belongs in `algebra/`"). **Rejected on closer reading.** -### Option B — Versor construction invariant (`algebra/versor.py`) +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. -Make rotor/frame admissibility part of sandwich closure. When -`word_transition_rotor(A, B)` builds the rotor, it also checks the -rotor against the active frame constraint. Violations raise — same -shape as the existing `versor_condition < 1e-6` invariant. +Putting the semantic test inside the algebra layer has two structural +costs: -**Pros:** +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. -* Aligned with the CLAUDE.md doctrine that algebra-owned closure - belongs in `algebra/`. -* No hot-path repair. The check is part of *construction*, not a - post-construction filter. -* Single invariant site — easier to reason about and prove. +Algebra closure must stay structural; semantic admissibility belongs +upstream of algebra construction. -**Cons:** +### Option C (rejected): `field/propagate.py` (precondition guard) -* Couples algebra to admissibility concepts (frame, relation_blade) - that today live in `generate/admissibility.py`. Either - `algebra/versor.py` grows a dependency on admissibility, or - admissibility primitives must be lifted to a shared layer. -* Honest refusal would surface deeper in the stack — callers that - today catch `ValueError` from `generate()` would also need to - catch from `propagate_step` or earlier. +Enforce just before `propagate_step` commits the new field. The +draft flagged the doctrinal risk: -### Option C — Field propagation guard (`field/propagate.py`) +> `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. -Enforce at the *application* site: after rotor construction, before -`propagate_step` commits the new field state, verify the resulting -field stays within the frame's admissible cone. +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. -**Pros:** +### Option D (Accepted): `generate/rotor_admissibility.py` -* Closest to the *claim*: rotor admissibility is fundamentally about - the field staying coherent under propagation, not about token - selection. -* `field/propagate.py` already owns the propagation invariant, so - this is a natural home for an additional propagation-time check. +A new sibling module to `generate/admissibility.py`, at the same +generation/propagation seam as ADR-0024's destination-side check. -**Cons:** +**Why this is the right home:** -* `field/propagate.py` is explicitly listed in CLAUDE.md as a - *forbidden site* for normalization / drift repair / monitoring - ("Do not add drift repair, grade projection, watchdogs, timers, - hot-path normalizers, or monitoring functions whose only purpose - is to repair another function"). 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 — otherwise it sets a precedent that erodes the - current rule. - -### Recommended preliminary stance - -**Option B** is the most aligned with project doctrine and the -cleanest invariant. Option C is the second-best, but only if the -"guard vs. monitor" distinction is made explicit and respected — and -even then, the construction-site discipline of Option B is preferable. - -Option A is rejected as inheriting the wrong architectural shape from -ADR-0024 by momentum. ADR-0024 lived in `generate/` because it was -about *which destination to select*; rotor admissibility is about -*whether the rotor itself is valid*, which is a construction-site -question. - -**Decision required**: B vs. C. +* **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. --- -## Question 2 — Threshold scheme +## Decision -Phase 4 surfaced that static thresholds are geometrically invalid on -this manifold. The right scheme is *not yet decided*, but the -candidates are: +Rotor-side admissibility is enforced at the generation/propagation +seam by a new module: -1. **Per-candidate normalized score**: threshold = α · ‖blade‖, so - the same fraction of blade self-score is required regardless of - blade norm. Probable best first cut. +```text +generate/rotor_admissibility.py + RotorVerdict + check_rotor_admissibility(region, *, field_current, rotor) +``` -2. **Cosine-similarity-style normalization**: replace `cga_inner` in - the threshold check with - `cga_inner(v, blade) / (‖v‖ · ‖blade‖)`. Rejected on doctrinal - grounds — CLAUDE.md says "do not add cosine similarity ... to the - runtime path." Listed for completeness only. +Wiring: -3. **Per-relation-type static threshold**: a small table mapping - relation type → threshold. Phase 4 suggests this is insufficient - because *blade norm dominates*, not relation type, but it could - be a fallback if normalized scoring proves unstable. +| 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 | -4. **Frame-derived threshold**: threshold is a property of the frame - versor, not the candidate or the relation. Requires the frame - versor to be the primary admissibility object — i.e. Option B - above — and may collapse Question 1 and Question 2 into one - decision. +The check itself: -**Decision required**: (1) is the recommended starting point. Final -choice depends on Question 1 outcome and on a focused diagnostic -sweep over (1) and (3). +```text +score = cga_inner(versor_apply(V, F_current), region.frame_versor) +admit iff score > 0 (basic positivity in the frame half-space) +``` -**Out of scope for the eventual ADR**: learned thresholds, adaptive -thresholds, online tuning. Deterministic replay must be preserved; -no learned policy enters the runtime path. +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. --- -## Question 3 — Teaching loop boundary +## Why Phase 4 keeps positivity (not margin) on the rotor side -ADR-0024 lives in `generate/`. The teaching loop in `teaching/*` -corrects model behavior through reviewed mutation. An open question: -when inner-loop (or rotor) admissibility rejects a candidate, does -that rejection become a *teachable event*? +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. -Two stances: +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: -* **A. Rejections are runtime hygiene only.** The teaching loop - sees the final selected token, not the rejected ones. Rejection - is a property of the deterministic admissibility region, not of - the reviewed teaching example. +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. -* **B. Rejections are correction signals.** A teaching review can - examine `rejected_attempts` and decide whether the rejection was - correct (reinforce) or over-aggressive (loosen). This entangles - the teaching loop with admissibility geometry. - -### Recommended stance: **A — strictly hygiene-only.** - -Rationale: - -* The teaching loop's contract is *reviewed mutation of identity / - pack / vault*. Admissibility regions are deterministic geometric - objects derived from intents and frames; they are not learned, and - there is no review surface for them today. -* Entangling teaching with admissibility would create a parallel - correction path — explicitly forbidden by CLAUDE.md ("Do not - create a parallel correction/learning path"). -* Phase 4 showed that what needs to change is the threshold *scheme*, - not the per-event rejection decisions. Scheme changes belong in - the eventual ADR-0025 implementation, not in reviewed teaching - examples. - -The decision must be **stated in the final ADR**, not left as a -silent default, so the next person who touches both systems doesn't -have to re-derive the boundary. +ADR-0026's margin is preserved on the destination side; rotor side +stays at positivity until evidence demands otherwise. --- -## Decisions to lock before ADR-0025 is implementable +## Invariants preserved -1. **Home**: Option B (algebra construction) vs. Option C (field - propagation guard). Reject A explicitly. -2. **Threshold scheme**: blade-normalized fraction (recommended) vs. - relation-typed table (fallback). Run a small diagnostic sweep - on the v2 corpus + a small extension before committing. -3. **Teaching boundary**: Stance A (hygiene-only) confirmed. State - explicitly in the eventual ADR's "Out of scope" section. -4. **Trace evidence**: extend `AdmissibilityTraceStep` to include - rotor-side verdict, or add a separate `RotorAdmissibilityTrace`? - Lean toward extending the existing step to keep the trace shape - simple. -5. **Honest refusal**: at which layer does `ValueError` get raised - on rotor rejection? Decided by (1) — same site as the check. +* **`versor_condition(F) < 1e-6`** — `check_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 preserved** — `RefusalReason.ROTOR_REJECTION` is + a new enum member, not a string; the typed-exception subclass + hierarchy (`InnerLoopExhaustion` ⊂ `ValueError`) 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_REJECTION` ≠ `INNER_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. --- ## Evidence and links -* ADR-0022 — Forward Semantic Control (region prefilter). -* ADR-0023 — Forward Semantic Control proof evidence. -* ADR-0024 — Inner-loop per-rotor admissibility (token-side). -* Phase 2 report — `evals/forward_semantic_control/results/ - phase2_inner_loop_report.json` — causal attribution proven, but - exhaustion gate fails on existing corpus. -* Phase 3 report — `evals/forward_semantic_control/results/ - phase3_v2_report.json` — mechanism isolated on real pack, - `mechanism_isolated = true` on 5/5 cases. -* Phase 4 reports — `evals/forward_semantic_control/results/ - phase4_characterization_{v1_plus_dev,v2,combined}.json` — static - thresholds geometrically insufficient. -* Tests pinning the findings: `tests/test_inner_loop_phase2.py`, - `tests/test_inner_loop_phase3.py`, `tests/test_inner_loop_phase4.py`. - ---- - -## What this note does NOT decide - -This note does not: - -* Choose between Options B and C — that requires a short focused - spike on the algebra-vs-propagation tradeoff. -* Specify the threshold scheme — that requires a small diagnostic - sweep over normalized-fraction vs. relation-typed schemes on the - v2 corpus. -* Authorize any code changes. Promotion from Draft to Accepted - requires the open questions to be closed. +* 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" diff --git a/docs/runtime_contracts.md b/docs/runtime_contracts.md index 1a28b2ad..d5e0bd83 100644 --- a/docs/runtime_contracts.md +++ b/docs/runtime_contracts.md @@ -94,6 +94,46 @@ future ADR can wire materialisation (e.g. propagate the typed exception to `ChatResponse.refusal_reason` or catch at the pipeline seam) without re-deriving the contract. +### Rotor admissibility contract (ADR-0025 / Phase 4) + +The destination-side admissibility documented above (token-side blade +alignment, ADR-0024 / Phase 3) is complemented by a rotor-side check: +when a region carries a non-null `frame_versor`, the inner loop +additionally verifies that the rotor's effect on the current field +stays within the frame's admissible cone: + +```text +F' = versor_apply(V, F_current) +score = cga_inner(F', frame_versor) +admit iff score > 0 +``` + +`generate.rotor_admissibility.check_rotor_admissibility` performs +this pure semantic check. It lives at the same generation/propagation +seam as the inner loop — in `generate/rotor_admissibility.py`, a +sibling-but-separate module to `generate/admissibility.py` — **not** +in `algebra/versor.py` (admissibility is a pack-semantic test, not a +closure invariant) and **not** in `field/propagate.py` (forbidden +normalization/repair site). The placement is the load-bearing +architectural decision in ADR-0025. + +Refusal is materialised through the same `InnerLoopExhaustion` +mechanism as destination-side refusal, but with +`RefusalReason.ROTOR_REJECTION` instead of `INNER_LOOP_EXHAUSTION`, +so the trace names the axis that ran out. In threshold mode, a step +that exhausts after *any* rotor rejection is reported under +`ROTOR_REJECTION`; pure destination exhaustion stays +`INNER_LOOP_EXHAUSTION`. In margin mode, the rotor check runs on the +top-ranked admissible candidate after destination margin admits; on +rotor refusal the typed exception carries the full destination +ranking plus the rejected rotor's score as evidence. + +The `versor_condition(F) < 1e-6` invariant remains the algebra +layer's responsibility on actual propagation. `check_rotor_admissibility` +does not mutate field state and does not enforce closure — it only +asks whether applying `V` to `F` would leave the field in the +frame's half-space. + ## TurnEvent contract `TurnEvent.surface` diff --git a/generate/exhaustion.py b/generate/exhaustion.py index 69451a17..5b51f926 100644 --- a/generate/exhaustion.py +++ b/generate/exhaustion.py @@ -17,9 +17,11 @@ the same effect on caller control flow. The exception subclasses runtime/eval code paths continues to work byte-for-byte — the carrying of structured evidence is additive. -Reason codes are minimal in Phase 2. A single -``INNER_LOOP_EXHAUSTION`` reason covers both raise sites in -``generate/stream.py``: +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`` @@ -27,13 +29,20 @@ Reason codes are minimal in Phase 2. A single 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`` at the configured threshold. - ``step_index >= 0`` and ``rejected_attempts`` records the tried - (index, word, score) triples in attempt order. + 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). -Splitting these into separate reasons can wait for Phase 4, when -rotor-frame refusal introduces a third structurally distinct mode -(ADR-0025). +``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 @@ -51,6 +60,13 @@ class RefusalReason(Enum): """ 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): diff --git a/generate/rotor_admissibility.py b/generate/rotor_admissibility.py new file mode 100644 index 00000000..a2077dad --- /dev/null +++ b/generate/rotor_admissibility.py @@ -0,0 +1,179 @@ +""" +Rotor / frame admissibility — ADR-0025 (Accepted). + +Sibling to ``generate/admissibility.py``. Where that module checks +the *destination versor's* alignment with a region's +``relation_blade`` (token-side admissibility, ADR-0024), this module +checks the *rotor's effect on the field* against a region's +``frame_versor`` (rotor-side admissibility, ADR-0025). + +The check is: + + F' = versor_apply(V, F_current) (hypothetical apply) + score = cga_inner(F', region.frame_versor) + admit iff score > 0 (basic positivity) + +A rotor is admitted iff applying it to the current field state lands +the post-rotor field within the region's frame-versor admissible +cone — i.e. the rotor preserves the frame. This is the rotor-side +analogue of "the destination versor aligns with the relation blade." + +Architectural placement +----------------------- + +Three candidate homes were on the table when ADR-0025 was a draft +design note: ``algebra/versor.py`` (Option B), ``field/propagate.py`` +(Option C), and ``generate/`` (Option A — rejected by the design +note as inheriting ADR-0024's shape "by momentum"). + +ADR-0025 (Accepted) reverses that recommendation and places the +check **here**, in ``generate/rotor_admissibility.py`` — a +sibling-but-separate module to ``generate/admissibility.py``: + + * Not ``algebra/versor.py``: admissibility is a *semantic* test + (does this rotor's effect land in the pack's admissible region), + not a *closure* invariant. Putting it inside algebra couples + algebra to pack-derived admissibility state and creates the + structural temptation to "repair" inadmissible rotors via + grade projection — exactly the hot-path repair CLAUDE.md + §Normalization Rules forbids. + * Not ``field/propagate.py``: CLAUDE.md lists this as a + forbidden normalization / repair site. Even a "guard" framed + as precondition sets a precedent that erodes the rule. + * Same architectural seam as ADR-0024 / Phase 3 / Phase 2 — + between selection and propagation — but in its own file so + endpoint admissibility (token-side, blade) and rotor admissibility + (rotor-side, frame) remain conceptually separable. The bloat + objection from Option A is answered by file separation; the + algebra-shaped-invariant objection is answered by recognising + that this is not algebra — it is a pack-grounded semantic test. + +The check is pure: no I/O, no learned state, no dynamic imports. +It does not mutate the field; it asks "would this rotor preserve +the frame?" and returns a typed verdict. Honest refusal at the +caller site uses the same ``InnerLoopExhaustion`` mechanism wired +in Phase 2, with ``RefusalReason.ROTOR_REJECTION`` distinguishing +rotor-side refusal from destination-side refusal in the trace. + +This module does NOT compute the rotor; it only checks one. +Rotor construction lives in ``algebra.rotor.word_transition_rotor``; +field application lives in ``algebra.backend.versor_apply``; the +``versor_condition(F') < 1e-6`` invariant is enforced by the +algebra layer's closure on the apply, not by this module. This +module's only contribution is the *semantic* frame-cone check. +""" + +from __future__ import annotations + +from dataclasses import dataclass + +import numpy as np + +from algebra.backend import versor_apply +from algebra.cga import cga_inner + +from generate.admissibility import AdmissibilityRegion + + +_NULL_TOLERANCE = 1e-8 + + +@dataclass(frozen=True, slots=True) +class RotorVerdict: + """Result of rotor-side admissibility on one candidate rotor. + + Attributes + ---------- + admitted: + True iff the rotor's effect on the current field lands within + the region's frame-versor admissible cone, or there is no + frame constraint. + score: + ``cga_inner(F', frame_versor)`` where ``F' = versor_apply(V, + F_current)``. ``float('inf')`` is the sentinel for the + no-frame-constraint case so callers comparing scores never + treat "no constraint" as a hard rejection. + region_label: + The region label, surfaced into the failure surface. + reason: + Human-readable explanation — admissible cone / no constraint / + below positivity bar. + """ + + admitted: bool + score: float + region_label: str + reason: str = "" + + +def check_rotor_admissibility( + region: AdmissibilityRegion, + *, + field_current: np.ndarray, + rotor: np.ndarray, +) -> RotorVerdict: + """Check that applying ``rotor`` to ``field_current`` stays inside + the region's frame-versor admissible cone. + + Behavior: + + 1. If the region carries no ``frame_versor`` (``None`` or + null-norm), the rotor is admitted trivially with + ``score = +inf`` and ``reason = "no frame constraint"``. No + sandwich application is performed. + + 2. Otherwise: + F' = versor_apply(rotor, field_current) + score = cga_inner(F', frame_versor) + admit iff ``score > 0`` (basic positivity in the frame's + half-space). Refuse otherwise with the computed score in + ``RotorVerdict.score`` so margin-style telemetry can rank + rotors across candidates. + + The function does NOT mutate ``field_current`` and does NOT + enforce ``versor_condition(F') < 1e-6`` — that invariant is the + algebra layer's responsibility on the actual ``propagate_step``, + not a precondition of this semantic check. If the algebra's + closure asserts at apply time, the assertion surfaces from + ``versor_apply`` directly; this module's contract is the frame + semantic only. + """ + if region.frame_versor is None: + return RotorVerdict( + admitted=True, + score=float("inf"), + region_label=region.label, + reason="no frame constraint", + ) + frame = np.asarray(region.frame_versor, dtype=np.float32) + if float(np.linalg.norm(frame)) < _NULL_TOLERANCE: + return RotorVerdict( + admitted=True, + score=float("inf"), + region_label=region.label, + reason="no frame constraint (null frame versor)", + ) + F = np.asarray(field_current, dtype=np.float32) + V = np.asarray(rotor, dtype=np.float32) + F_next = versor_apply(V, F) + # Cast back to float32 for the cga_inner — versor_apply may run + # at float64 in the closed Rust path; we keep the score in the + # same dtype as the blade/inner-product algebra elsewhere. + F_next32 = np.asarray(F_next, dtype=np.float32) + score = float(cga_inner(F_next32, frame)) + if score <= 0.0: + return RotorVerdict( + admitted=False, + score=score, + region_label=region.label, + reason=( + f"post-rotor field score {score:.6f} not positive in " + f"frame {region.label!r}" + ), + ) + return RotorVerdict( + admitted=True, + score=score, + region_label=region.label, + reason="ok", + ) diff --git a/generate/stream.py b/generate/stream.py index 61a087e1..56b3360b 100644 --- a/generate/stream.py +++ b/generate/stream.py @@ -30,6 +30,7 @@ from generate.admissibility import ( filter_candidates, rank_candidates_by_blade, ) +from generate.rotor_admissibility import check_rotor_admissibility from generate.attention import AttentionOperator from generate.exhaustion import InnerLoopExhaustion, RefusalReason from generate.result import GenerationResult @@ -437,6 +438,34 @@ def generate( assert margin_verdict.top is not None # admitted => non-None word_idx = int(margin_verdict.top.index) word = str(margin_verdict.top.word) + # Phase 4 / ADR-0025 — rotor admissibility on the top- + # ranked candidate. If the rotor would push the field + # outside the frame's admissible cone, refuse with a + # distinct ROTOR_REJECTION reason so the trace shows + # *which* axis ran out (destination-blade vs rotor-frame). + # The ranked list is preserved as evidence, with the + # rotor score appended as the failed candidate's score. + if active_region is not None and active_region.frame_versor is not None: + A_now = vocab.get_versor_at(current.node) + B_cand = vocab.get_versor_at(word_idx) + V_hyp = word_transition_rotor(A_now, B_cand) + rv = check_rotor_admissibility( + active_region, + field_current=current.F, + rotor=V_hyp, + ) + if not rv.admitted: + rotor_attempt = ( + int(word_idx), + str(word), + float(rv.score), + ) + raise InnerLoopExhaustion( + reason=RefusalReason.ROTOR_REJECTION, + region_label=effective_region_label, + step_index=step_index, + rejected_attempts=tuple(rejected_attempts) + (rotor_attempt,), + ) # Build a legacy AdmissibilityVerdict for trace storage so # the AdmissibilityTraceStep shape is unchanged. Margin # info is encoded into ``reason`` for human inspection; @@ -455,6 +484,10 @@ def generate( len(candidate_indices) if (inner_loop_active and candidate_indices is not None) else 1 ) + # Phase 4 — track whether any rotor rejection occurred this + # step so the exhaustion exception can carry the right + # ``RefusalReason`` (rotor-side vs destination-side). + step_had_rotor_rejection = False for _attempt in range(max(max_attempts, 1)): word, word_idx = _nearest_next( vocab, @@ -478,7 +511,34 @@ def generate( region_label=effective_region_label, reason="unconstrained", ) - if not inner_loop_active or verdict.admitted or inner_loop_force_admit: + # Phase 4 / ADR-0025 — if the destination admits and the + # region carries a frame versor, also check rotor-side + # admissibility: would the rotor applied to current.F + # land in the frame's admissible cone? On reject, treat + # as a per-candidate rejection like the destination side + # — log the rotor score and retry the next candidate. + rotor_admitted = True + rotor_score_for_log: float | None = None + if ( + active_region is not None + and verdict.admitted + and inner_loop_active + and active_region.frame_versor is not None + ): + A_now = vocab.get_versor_at(current.node) + B_cand = vocab.get_versor_at(word_idx) + V_hyp = word_transition_rotor(A_now, B_cand) + rv = check_rotor_admissibility( + active_region, + field_current=current.F, + rotor=V_hyp, + ) + rotor_admitted = rv.admitted + if not rv.admitted: + rotor_score_for_log = float(rv.score) + if not inner_loop_active or ( + verdict.admitted and rotor_admitted + ) or inner_loop_force_admit: # `inner_loop_force_admit` is the Phase 2 null control: # exercises the inner-loop code path (same attempt loop, # same telemetry side effects) but force-breaks on the @@ -486,17 +546,34 @@ def generate( # inner-loop run is causally attributable to rejection, # not to incidental code-path differences. break - # Inner loop is on and verdict rejected this candidate. - rejected_attempts.append((int(word_idx), str(word), float(verdict.score))) + # Inner loop is on and either destination or rotor + # rejected this candidate. Log the rejection with the + # score that produced it. + if rotor_score_for_log is not None: + step_had_rotor_rejection = True + rejected_attempts.append( + (int(word_idx), str(word), rotor_score_for_log) + ) + else: + rejected_attempts.append( + (int(word_idx), str(word), float(verdict.score)) + ) if int(word_idx) in step_exclude: # Selector returned the same exhausted candidate — no # further admissible destinations. Honest refusal. # ADR-0024 Phase 2 — in-walk exhaustion site; carries the # ordered ``rejected_attempts`` accumulated this step so # downstream layers can read refusal evidence without - # re-parsing the exception message. + # re-parsing the exception message. Phase 4 / ADR-0025 + # — if any rotor rejection occurred this step, the + # exhaustion is reported under ROTOR_REJECTION so the + # trace names the load-bearing axis. raise InnerLoopExhaustion( - reason=RefusalReason.INNER_LOOP_EXHAUSTION, + reason=( + RefusalReason.ROTOR_REJECTION + if step_had_rotor_rejection + else RefusalReason.INNER_LOOP_EXHAUSTION + ), region_label=effective_region_label, step_index=step_index, rejected_attempts=tuple(rejected_attempts), @@ -504,13 +581,15 @@ def generate( step_exclude.add(int(word_idx)) else: # max_attempts exhausted without break — every admissible - # candidate was rejected by the inner-loop threshold. - # Same refusal shape as the same-candidate-loop site above; - # both are structurally "inner-loop produced no admissible - # candidate at this step". Splitting into separate reasons - # can wait for Phase 4 (rotor frame, ADR-0025). + # candidate was rejected by the inner-loop threshold (or + # rotor frame, Phase 4). Same refusal shape, with the + # reason routed to the load-bearing axis. raise InnerLoopExhaustion( - reason=RefusalReason.INNER_LOOP_EXHAUSTION, + reason=( + RefusalReason.ROTOR_REJECTION + if step_had_rotor_rejection + else RefusalReason.INNER_LOOP_EXHAUSTION + ), region_label=effective_region_label, step_index=step_index, rejected_attempts=tuple(rejected_attempts), diff --git a/tests/test_rotor_admissibility.py b/tests/test_rotor_admissibility.py new file mode 100644 index 00000000..d4d47710 --- /dev/null +++ b/tests/test_rotor_admissibility.py @@ -0,0 +1,356 @@ +"""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