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:
parent
639e107442
commit
542e13d2f3
6 changed files with 981 additions and 238 deletions
|
|
@ -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 2–4 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.
|
|
||||||
|
|
|
||||||
|
|
@ -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`
|
||||||
|
|
|
||||||
|
|
@ -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):
|
||||||
|
|
|
||||||
179
generate/rotor_admissibility.py
Normal file
179
generate/rotor_admissibility.py
Normal 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",
|
||||||
|
)
|
||||||
|
|
@ -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),
|
||||||
|
|
|
||||||
356
tests/test_rotor_admissibility.py
Normal file
356
tests/test_rotor_admissibility.py
Normal 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
|
||||||
Loading…
Reference in a new issue