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
This commit is contained in:
Shay 2026-05-17 15:16:32 -07:00
parent 639e107442
commit 542e13d2f3
6 changed files with 981 additions and 238 deletions

View file

@ -1,287 +1,360 @@
# ADR-0025 — Rotor / Frame Admissibility (Design Note) # ADR-0025 — Rotor / Frame Admissibility
| Field | Value | | Field | Value |
|--------------|--------------------------------------| |---------------|------------------------------------------------|
| Status | **Draft — Design Note Only** | | Status | **Accepted** (2026-05-17) |
| Date | 2026-05-17 | | Date | 2026-05-17 |
| Supersedes | — | | Supersedes | The design-note version of ADR-0025 (Draft) |
| Extends | ADR-0022, ADR-0023, ADR-0024 | | Extends | ADR-0022, ADR-0023, ADR-0024, ADR-0026 |
| Decision lead| Shay (with CORE assistant) | | Decision lead | Shay (with CORE assistant) |
--- ---
## Status note ## Status note
This is a **design note**, not an implementation decision. No code This ADR promotes the previous design-note draft to Accepted, with
changes are proposed. Its purpose is to fix the home, scope, and the **architectural home decision reversed**. The earlier draft
boundary of the next admissibility step *before* anything is built — leaned toward Option B (`algebra/versor.py`); after re-examining the
so the implementation doesn't inherit the wrong architectural shape trade-offs in light of CLAUDE.md doctrine and Phase 3's wiring, the
by default. 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) The threshold-scheme question (draft's Question 2) is closed by
only after the design questions below are decided. 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 ## Context
ADR-0024 added per-rotor inner-loop admissibility for the ADR-0024 added per-step inner-loop admissibility for the
**destination-token / direction** side of an `AdmissibilityRegion`: *destination-token / direction* side of an `AdmissibilityRegion`:
when a candidate's CGA inner product against `relation_blade` falls when a candidate's CGA inner product against `relation_blade` falls
below `admissibility_threshold`, the candidate is excluded and the below the configured bar, the candidate is excluded and the walk
walk re-selects until admitted or exhausted. re-selects (or refuses, ADR-0024 Phase 2 / ADR-0026 / Phase 3).
ADR-0024 explicitly deferred: ADR-0024 explicitly deferred:
> Frame-versor admissibility (does the rotor preserve / transform > Frame-versor admissibility (does the rotor preserve / transform
> within the frame constraint?) remains out of scope. > within the frame constraint?) remains out of scope.
This note scopes that deferred work, but with two additional That deferral is closed here. Rotor-side admissibility asks a
constraints surfaced by the Phase 24 follow-up evidence: different question than destination-side admissibility:
1. **Phase 2 corpus observation** (`evals/forward_semantic_control/ * **Destination admissibility (ADR-0024 / ADR-0026):** does the
inner_loop_runner.py`): on the existing v1+dev corpus, the candidate destination versor align with the relation blade?
inner-loop mechanism is *wired, deterministic, causally * **Rotor admissibility (this ADR):** does the rotor `V` applied to
attributable* (null-control = boundary-only exactly, the current field `F` land within the region's frame-versor
`code_path_residual = 0.0`), but the chain-token outer-product admissible cone?
region produces `exhaustion_rate = 0.33` at `t = 0.0` — well
above the 5% benign-corpus ceiling.
2. **Phase 4 threshold characterization** (`threshold_ The two are independent gates that compose: a candidate is admitted
characterization.py`): **per-case the geometry separates cleanly** iff both pass. A region carrying both `relation_blade` and
(every mechanism-isolated v2 case has `correct_min > incorrect_max`), `frame_versor` exercises both gates; a region carrying only one
but **no static global threshold** delivers exercises only that one; an unconstrained region exercises neither.
`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?
--- ---
## 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 Inline the rotor check in the same per-step inner loop as the
loop in `generate/stream.py` that examines the *rotor* `V` (not just destination check. Draft's objection: "Pushes algebra-shaped
the destination versor) before propagation. 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 ### Option B (rejected): `algebra/versor.py` (closure invariant)
module.
* Trace evidence is uniform — one `AdmissibilityTraceStep` per
rotor-application.
**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. The draft recommended this on doctrinal grounds ("algebra-owned
`generate/` already orchestrates candidates, salience, attention, closure belongs in `algebra/`"). **Rejected on closer reading.**
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.
### 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 Putting the semantic test inside the algebra layer has two structural
`word_transition_rotor(A, B)` builds the rotor, it also checks the costs:
rotor against the active frame constraint. Violations raise — same
shape as the existing `versor_condition < 1e-6` invariant.
**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 Algebra closure must stay structural; semantic admissibility belongs
belongs in `algebra/`. upstream of algebra construction.
* No hot-path repair. The check is part of *construction*, not a
post-construction filter.
* Single invariant site — easier to reason about and prove.
**Cons:** ### Option C (rejected): `field/propagate.py` (precondition guard)
* Couples algebra to admissibility concepts (frame, relation_blade) Enforce just before `propagate_step` commits the new field. The
that today live in `generate/admissibility.py`. Either draft flagged the doctrinal risk:
`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.
### 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 Rejected. Even framed as precondition, a check in
`propagate_step` commits the new field state, verify the resulting `field/propagate.py` blurs the rule and invites future "just one
field stays within the frame's admissible cone. 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 A new sibling module to `generate/admissibility.py`, at the same
the field staying coherent under propagation, not about token generation/propagation seam as ADR-0024's destination-side check.
selection.
* `field/propagate.py` already owns the propagation invariant, so
this is a natural home for an additional propagation-time check.
**Cons:** **Why this is the right home:**
* `field/propagate.py` is explicitly listed in CLAUDE.md as a * **Same architectural seam as destination admissibility.** Both
*forbidden site* for normalization / drift repair / monitoring rotor and destination checks operate at the boundary between
("Do not add drift repair, grade projection, watchdogs, timers, candidate selection and field propagation. They share infrastructure
hot-path normalizers, or monitoring functions whose only purpose (the inner loop, the rejected_attempts trace, the
is to repair another function"). An admissibility *guard* (raise `InnerLoopExhaustion` refusal mechanism) but live in distinct
on violation, never repair) is closer to a precondition than a files, so the *concepts* stay separable even as the *seam* stays
monitor, but the boundary needs to be made explicit before this unified.
option is chosen — otherwise it sets a precedent that erodes the * **No layer-boundary crossing.** `generate/` already depends on
current rule. `algebra/` (it calls `versor_apply`, `word_transition_rotor`,
`cga_inner`). Putting rotor admissibility here keeps that
### Recommended preliminary stance dependency direction; Option B would have inverted it.
* **No hot-path repair temptation.** The module's only public
**Option B** is the most aligned with project doctrine and the surface is `check_rotor_admissibility(region, field_current,
cleanest invariant. Option C is the second-best, but only if the rotor) -> RotorVerdict`. It returns a typed verdict, doesn't
"guard vs. monitor" distinction is made explicit and respected — and mutate field state, doesn't repair anything. The naming —
even then, the construction-site discipline of Option B is preferable. *admissibility*, not *projection* or *normalization* — encodes
the contract.
Option A is rejected as inheriting the wrong architectural shape from * **Trace parity with destination admissibility.** Refusal flows
ADR-0024 by momentum. ADR-0024 lived in `generate/` because it was through the same `InnerLoopExhaustion` plumbing wired in Phase 2,
about *which destination to select*; rotor admissibility is about with a distinct `RefusalReason.ROTOR_REJECTION` so the trace names
*whether the rotor itself is valid*, which is a construction-site the axis that ran out without needing a parallel exception type.
question. * **File separation answers Option A's bloat objection.**
Endpoint admissibility (token-side, blade) stays in
**Decision required**: B vs. C. `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 Rotor-side admissibility is enforced at the generation/propagation
this manifold. The right scheme is *not yet decided*, but the seam by a new module:
candidates are:
1. **Per-candidate normalized score**: threshold = α · ‖blade‖, so ```text
the same fraction of blade self-score is required regardless of generate/rotor_admissibility.py
blade norm. Probable best first cut. RotorVerdict
check_rotor_admissibility(region, *, field_current, rotor)
```
2. **Cosine-similarity-style normalization**: replace `cga_inner` in Wiring:
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.
3. **Per-relation-type static threshold**: a small table mapping | Surface | Behavior |
relation type → threshold. Phase 4 suggests this is insufficient |----------------------------------------|-------------------------------------------------------------------|
because *blade norm dominates*, not relation type, but it could | `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) |
be a fallback if normalized scoring proves unstable. | `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 The check itself:
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.
**Decision required**: (1) is the recommended starting point. Final ```text
choice depends on Question 1 outcome and on a focused diagnostic score = cga_inner(versor_apply(V, F_current), region.frame_versor)
sweep over (1) and (3). admit iff score > 0 (basic positivity in the frame half-space)
```
**Out of scope for the eventual ADR**: learned thresholds, adaptive A region with `frame_versor is None` (or null-norm) trivially admits
thresholds, online tuning. Deterministic replay must be preserved; every rotor with `score = +inf` — backward-compatible with every
no learned policy enters the runtime path. 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/*` Phase 3 / ADR-0026 replaced the static-threshold destination check
corrects model behavior through reviewed mutation. An open question: with ranked-with-margin because Phase 4 characterization established
when inner-loop (or rotor) admissibility rejects a candidate, does that blade norms varied ~10× across cases, making a single absolute
that rejection become a *teachable event*? 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 1. **There is no cross-case calibration evidence to inform a margin
sees the final selected token, not the rejected ones. Rejection constant yet.** Picking a rotor-margin `delta` today would be
is a property of the deterministic admissibility region, not of guessing.
the reviewed teaching example. 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 ADR-0026's margin is preserved on the destination side; rotor side
examine `rejected_attempts` and decide whether the rejection was stays at positivity until evidence demands otherwise.
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.
--- ---
## Decisions to lock before ADR-0025 is implementable ## Invariants preserved
1. **Home**: Option B (algebra construction) vs. Option C (field * **`versor_condition(F) < 1e-6`** — `check_rotor_admissibility`
propagation guard). Reject A explicitly. does not mutate field state; the closure invariant is the algebra
2. **Threshold scheme**: blade-normalized fraction (recommended) vs. layer's responsibility on the actual `propagate_step`, which only
relation-typed table (fallback). Run a small diagnostic sweep runs after an admitted rotor. Test
on the v2 corpus + a small extension before committing. `tests/test_rotor_admissibility.py::TestGenerateRotorAdmissibility::test_versor_condition_preserved_on_admitted_rotor`
3. **Teaching boundary**: Stance A (hygiene-only) confirmed. State pins this.
explicitly in the eventual ADR's "Out of scope" section. * **Deterministic replay** — rotor refusal carries the same evidence
4. **Trace evidence**: extend `AdmissibilityTraceStep` to include shape as destination refusal (the typed exception + canonical
rotor-side verdict, or add a separate `RotorAdmissibilityTrace`? trace step). Tests
Lean toward extending the existing step to keep the trace shape `TestRotorAdmissibilityDeterminism::{test_admitted_rotor_replay_stable,test_rotor_refusal_replay_stable}`
simple. assert 5-run replay equality for both admitted and refused turns.
5. **Honest refusal**: at which layer does `ValueError` get raised * **No new code in `field/propagate.py`, `algebra/versor.py`,
on rotor rejection? Decided by (1) — same site as the check. `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 ## Evidence and links
* ADR-0022 — Forward Semantic Control (region prefilter). * ADR-0022 — Forward Semantic Control (region prefilter)
* ADR-0023 — Forward Semantic Control proof evidence. * ADR-0023 — FSC proof evidence
* ADR-0024 — Inner-loop per-rotor admissibility (token-side). * ADR-0024 — Inner-loop per-rotor admissibility (token-side)
* Phase 2 report — `evals/forward_semantic_control/results/ * ADR-0026 — Ranked admissibility with margin (Phase 3)
phase2_inner_loop_report.json` — causal attribution proven, but * `generate/rotor_admissibility.py` — the module
exhaustion gate fails on existing corpus. * `tests/test_rotor_admissibility.py` — 11 tests pinning the contract
* Phase 3 report — `evals/forward_semantic_control/results/ * `docs/runtime_contracts.md` §"Rotor admissibility contract"
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.

View file

@ -94,6 +94,46 @@ future ADR can wire materialisation (e.g. propagate the typed
exception to `ChatResponse.refusal_reason` or catch at the pipeline exception to `ChatResponse.refusal_reason` or catch at the pipeline
seam) without re-deriving the contract. 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 contract
`TurnEvent.surface` `TurnEvent.surface`

View file

@ -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 runtime/eval code paths continues to work byte-for-byte the carrying
of structured evidence is additive. of structured evidence is additive.
Reason codes are minimal in Phase 2. A single Reason codes:
``INNER_LOOP_EXHAUSTION`` reason covers both raise sites in
``generate/stream.py``: ``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 1. Pre-walk: the region's allowed-index intersection with the
candidate set is empty before any step ran. ``step_index = -1`` 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. inner-loop rejections were issued; the region was already empty.
2. In-walk: at some step, every candidate in the admissible set was 2. In-walk: at some step, every candidate in the admissible set was
rejected by ``check_transition`` at the configured threshold. rejected by ``check_transition`` (threshold mode) or
``step_index >= 0`` and ``rejected_attempts`` records the tried ``check_margin`` (Phase 3 / ADR-0026, margin mode). ``step_index
(index, word, score) triples in attempt order. >= 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_REJECTION`` (Phase 4 / ADR-0025) is the rotor-side analogue.
rotor-frame refusal introduces a third structurally distinct mode Raised when no admissible destination produces a rotor that lands
(ADR-0025). 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 __future__ import annotations
@ -51,6 +60,13 @@ class RefusalReason(Enum):
""" """
INNER_LOOP_EXHAUSTION = "inner_loop_exhaustion" 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): class InnerLoopExhaustion(ValueError):

View file

@ -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",
)

View file

@ -30,6 +30,7 @@ from generate.admissibility import (
filter_candidates, filter_candidates,
rank_candidates_by_blade, rank_candidates_by_blade,
) )
from generate.rotor_admissibility import check_rotor_admissibility
from generate.attention import AttentionOperator from generate.attention import AttentionOperator
from generate.exhaustion import InnerLoopExhaustion, RefusalReason from generate.exhaustion import InnerLoopExhaustion, RefusalReason
from generate.result import GenerationResult from generate.result import GenerationResult
@ -437,6 +438,34 @@ def generate(
assert margin_verdict.top is not None # admitted => non-None assert margin_verdict.top is not None # admitted => non-None
word_idx = int(margin_verdict.top.index) word_idx = int(margin_verdict.top.index)
word = str(margin_verdict.top.word) 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 # Build a legacy AdmissibilityVerdict for trace storage so
# the AdmissibilityTraceStep shape is unchanged. Margin # the AdmissibilityTraceStep shape is unchanged. Margin
# info is encoded into ``reason`` for human inspection; # 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) len(candidate_indices) if (inner_loop_active and candidate_indices is not None)
else 1 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)): for _attempt in range(max(max_attempts, 1)):
word, word_idx = _nearest_next( word, word_idx = _nearest_next(
vocab, vocab,
@ -478,7 +511,34 @@ def generate(
region_label=effective_region_label, region_label=effective_region_label,
reason="unconstrained", 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: # `inner_loop_force_admit` is the Phase 2 null control:
# exercises the inner-loop code path (same attempt loop, # exercises the inner-loop code path (same attempt loop,
# same telemetry side effects) but force-breaks on the # same telemetry side effects) but force-breaks on the
@ -486,17 +546,34 @@ def generate(
# inner-loop run is causally attributable to rejection, # inner-loop run is causally attributable to rejection,
# not to incidental code-path differences. # not to incidental code-path differences.
break break
# Inner loop is on and verdict rejected this candidate. # Inner loop is on and either destination or rotor
rejected_attempts.append((int(word_idx), str(word), float(verdict.score))) # 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: if int(word_idx) in step_exclude:
# Selector returned the same exhausted candidate — no # Selector returned the same exhausted candidate — no
# further admissible destinations. Honest refusal. # further admissible destinations. Honest refusal.
# ADR-0024 Phase 2 — in-walk exhaustion site; carries the # ADR-0024 Phase 2 — in-walk exhaustion site; carries the
# ordered ``rejected_attempts`` accumulated this step so # ordered ``rejected_attempts`` accumulated this step so
# downstream layers can read refusal evidence without # 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( 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, region_label=effective_region_label,
step_index=step_index, step_index=step_index,
rejected_attempts=tuple(rejected_attempts), rejected_attempts=tuple(rejected_attempts),
@ -504,13 +581,15 @@ def generate(
step_exclude.add(int(word_idx)) step_exclude.add(int(word_idx))
else: else:
# max_attempts exhausted without break — every admissible # max_attempts exhausted without break — every admissible
# candidate was rejected by the inner-loop threshold. # candidate was rejected by the inner-loop threshold (or
# Same refusal shape as the same-candidate-loop site above; # rotor frame, Phase 4). Same refusal shape, with the
# both are structurally "inner-loop produced no admissible # reason routed to the load-bearing axis.
# candidate at this step". Splitting into separate reasons
# can wait for Phase 4 (rotor frame, ADR-0025).
raise InnerLoopExhaustion( 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, region_label=effective_region_label,
step_index=step_index, step_index=step_index,
rejected_attempts=tuple(rejected_attempts), rejected_attempts=tuple(rejected_attempts),

View file

@ -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