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 |
|
||||
|--------------|--------------------------------------|
|
||||
| Status | **Draft — Design Note Only** |
|
||||
| Date | 2026-05-17 |
|
||||
| Supersedes | — |
|
||||
| Extends | ADR-0022, ADR-0023, ADR-0024 |
|
||||
| Decision lead| Shay (with CORE assistant) |
|
||||
| Field | Value |
|
||||
|---------------|------------------------------------------------|
|
||||
| Status | **Accepted** (2026-05-17) |
|
||||
| Date | 2026-05-17 |
|
||||
| Supersedes | The design-note version of ADR-0025 (Draft) |
|
||||
| Extends | ADR-0022, ADR-0023, ADR-0024, ADR-0026 |
|
||||
| Decision lead | Shay (with CORE assistant) |
|
||||
|
||||
---
|
||||
|
||||
## Status note
|
||||
|
||||
This is a **design note**, not an implementation decision. No code
|
||||
changes are proposed. Its purpose is to fix the home, scope, and
|
||||
boundary of the next admissibility step *before* anything is built —
|
||||
so the implementation doesn't inherit the wrong architectural shape
|
||||
by default.
|
||||
This ADR promotes the previous design-note draft to Accepted, with
|
||||
the **architectural home decision reversed**. The earlier draft
|
||||
leaned toward Option B (`algebra/versor.py`); after re-examining the
|
||||
trade-offs in light of CLAUDE.md doctrine and Phase 3's wiring, the
|
||||
correct placement is a *fourth option* the draft did not crystalise:
|
||||
a sibling-but-separate module under `generate/` —
|
||||
**`generate/rotor_admissibility.py`**. See §"Architectural home" for
|
||||
the full argument.
|
||||
|
||||
It will be promoted from Draft to a real ADR (Proposed → Accepted)
|
||||
only after the design questions below are decided.
|
||||
The threshold-scheme question (draft's Question 2) is closed by
|
||||
inheritance from ADR-0026 / Phase 3: the rotor side reuses the
|
||||
ranked-with-margin contract — although, in Phase 4, the per-candidate
|
||||
rotor check operates at a simpler positivity bar (`score > 0`),
|
||||
matching the per-step gating shape of ADR-0024. A future iteration
|
||||
may extend rotor-side ranking with a margin, once we have multiple
|
||||
candidate rotors and a corpus that motivates it.
|
||||
|
||||
The teaching-loop question (draft's Question 3) is closed at
|
||||
**Stance A — strictly hygiene-only**, as recommended in the draft.
|
||||
|
||||
---
|
||||
|
||||
## Context
|
||||
|
||||
ADR-0024 added per-rotor inner-loop admissibility for the
|
||||
**destination-token / direction** side of an `AdmissibilityRegion`:
|
||||
ADR-0024 added per-step inner-loop admissibility for the
|
||||
*destination-token / direction* side of an `AdmissibilityRegion`:
|
||||
when a candidate's CGA inner product against `relation_blade` falls
|
||||
below `admissibility_threshold`, the candidate is excluded and the
|
||||
walk re-selects until admitted or exhausted.
|
||||
below the configured bar, the candidate is excluded and the walk
|
||||
re-selects (or refuses, ADR-0024 Phase 2 / ADR-0026 / Phase 3).
|
||||
|
||||
ADR-0024 explicitly deferred:
|
||||
|
||||
> Frame-versor admissibility (does the rotor preserve / transform
|
||||
> within the frame constraint?) remains out of scope.
|
||||
|
||||
This note scopes that deferred work, but with two additional
|
||||
constraints surfaced by the Phase 2–4 follow-up evidence:
|
||||
That deferral is closed here. Rotor-side admissibility asks a
|
||||
different question than destination-side admissibility:
|
||||
|
||||
1. **Phase 2 corpus observation** (`evals/forward_semantic_control/
|
||||
inner_loop_runner.py`): on the existing v1+dev corpus, the
|
||||
inner-loop mechanism is *wired, deterministic, causally
|
||||
attributable* (null-control = boundary-only exactly,
|
||||
`code_path_residual = 0.0`), but the chain-token outer-product
|
||||
region produces `exhaustion_rate = 0.33` at `t = 0.0` — well
|
||||
above the 5% benign-corpus ceiling.
|
||||
* **Destination admissibility (ADR-0024 / ADR-0026):** does the
|
||||
candidate destination versor align with the relation blade?
|
||||
* **Rotor admissibility (this ADR):** does the rotor `V` applied to
|
||||
the current field `F` land within the region's frame-versor
|
||||
admissible cone?
|
||||
|
||||
2. **Phase 4 threshold characterization** (`threshold_
|
||||
characterization.py`): **per-case the geometry separates cleanly**
|
||||
(every mechanism-isolated v2 case has `correct_min > incorrect_max`),
|
||||
but **no static global threshold** delivers
|
||||
`separation_quality ≥ 0.8`. Blade norms vary ~10× across cases,
|
||||
so the same threshold value means different things case-to-case.
|
||||
Static thresholds — global, relation-typed, or frame-derived as a
|
||||
constant — are insufficient.
|
||||
|
||||
These findings change the framing. The next step is not "extend the
|
||||
same idea to the rotor side." It is two distinct questions:
|
||||
|
||||
* What level of the stack should enforce rotor/frame admissibility?
|
||||
* What threshold scheme is geometrically valid given Phase 4?
|
||||
The two are independent gates that compose: a candidate is admitted
|
||||
iff both pass. A region carrying both `relation_blade` and
|
||||
`frame_versor` exercises both gates; a region carrying only one
|
||||
exercises only that one; an unconstrained region exercises neither.
|
||||
|
||||
---
|
||||
|
||||
## Question 1 — Architectural home
|
||||
## Architectural home — the decision
|
||||
|
||||
Three candidate homes for rotor-side admissibility:
|
||||
Three candidates the draft considered, plus a fourth option that
|
||||
crystalised during Phase 4 implementation:
|
||||
|
||||
### Option A — Generation-time filter (`generate/`)
|
||||
### Option A (rejected): single inner-loop in `generate/stream.py`
|
||||
|
||||
Inherit ADR-0024's shape. Add a check inside the same per-step inner
|
||||
loop in `generate/stream.py` that examines the *rotor* `V` (not just
|
||||
the destination versor) before propagation.
|
||||
Inline the rotor check in the same per-step inner loop as the
|
||||
destination check. Draft's objection: "Pushes algebra-shaped
|
||||
invariants into a generation-shaped module ... bloats the hot path
|
||||
and entangles concerns."
|
||||
|
||||
**Pros:**
|
||||
Rejected here too — but for a more precise reason: it conflates the
|
||||
two semantic axes (destination vs rotor) into one decision site,
|
||||
making the trace harder to read and the refusal reason harder to
|
||||
classify. The fix is *separation*, not relocation.
|
||||
|
||||
* Locality with ADR-0024. All admissibility decisions live in one
|
||||
module.
|
||||
* Trace evidence is uniform — one `AdmissibilityTraceStep` per
|
||||
rotor-application.
|
||||
### Option B (rejected): `algebra/versor.py` (closure invariant)
|
||||
|
||||
**Cons:**
|
||||
Make rotor admissibility part of `word_transition_rotor`'s
|
||||
construction — same shape as `versor_condition < 1e-6`.
|
||||
|
||||
* Pushes algebra-shaped invariants into a generation-shaped module.
|
||||
`generate/` already orchestrates candidates, salience, attention,
|
||||
vault recall, persona — adding rotor invariant enforcement here
|
||||
bloats the hot path and entangles concerns.
|
||||
* Re-creates the "hot-path repair" anti-pattern CLAUDE.md explicitly
|
||||
warns against, because the check would re-validate something
|
||||
algebra already constructed.
|
||||
The draft recommended this on doctrinal grounds ("algebra-owned
|
||||
closure belongs in `algebra/`"). **Rejected on closer reading.**
|
||||
|
||||
### Option B — Versor construction invariant (`algebra/versor.py`)
|
||||
Admissibility is not closure. Closure asks "does the constructed
|
||||
versor satisfy the algebra's idempotency invariant?" — a
|
||||
*structural* property of the rotor itself. Admissibility asks "does
|
||||
the rotor's *effect on the field* land in a pack-grounded admissible
|
||||
region?" — a *semantic* property of the rotor as applied to specific
|
||||
field state in the presence of a pack-derived frame versor.
|
||||
|
||||
Make rotor/frame admissibility part of sandwich closure. When
|
||||
`word_transition_rotor(A, B)` builds the rotor, it also checks the
|
||||
rotor against the active frame constraint. Violations raise — same
|
||||
shape as the existing `versor_condition < 1e-6` invariant.
|
||||
Putting the semantic test inside the algebra layer has two structural
|
||||
costs:
|
||||
|
||||
**Pros:**
|
||||
1. **Coupling.** `algebra/` becomes dependent on
|
||||
`generate.admissibility.AdmissibilityRegion`, a pack-derived
|
||||
construct. This crosses a layer boundary that was clean
|
||||
yesterday.
|
||||
2. **Repair temptation.** Once the check lives in algebra, the
|
||||
natural next move is "if a rotor isn't admissible, re-project it
|
||||
onto the frame cone." That is exactly the hot-path repair
|
||||
CLAUDE.md §Normalization Rules forbids — grade projection,
|
||||
monitoring, watchdogs whose only purpose is to repair another
|
||||
function.
|
||||
|
||||
* Aligned with the CLAUDE.md doctrine that algebra-owned closure
|
||||
belongs in `algebra/`.
|
||||
* No hot-path repair. The check is part of *construction*, not a
|
||||
post-construction filter.
|
||||
* Single invariant site — easier to reason about and prove.
|
||||
Algebra closure must stay structural; semantic admissibility belongs
|
||||
upstream of algebra construction.
|
||||
|
||||
**Cons:**
|
||||
### Option C (rejected): `field/propagate.py` (precondition guard)
|
||||
|
||||
* Couples algebra to admissibility concepts (frame, relation_blade)
|
||||
that today live in `generate/admissibility.py`. Either
|
||||
`algebra/versor.py` grows a dependency on admissibility, or
|
||||
admissibility primitives must be lifted to a shared layer.
|
||||
* Honest refusal would surface deeper in the stack — callers that
|
||||
today catch `ValueError` from `generate()` would also need to
|
||||
catch from `propagate_step` or earlier.
|
||||
Enforce just before `propagate_step` commits the new field. The
|
||||
draft flagged the doctrinal risk:
|
||||
|
||||
### Option C — Field propagation guard (`field/propagate.py`)
|
||||
> `field/propagate.py` is explicitly listed in CLAUDE.md as a
|
||||
> forbidden site for normalization / drift repair / monitoring [...]
|
||||
> An admissibility guard (raise on violation, never repair) is
|
||||
> closer to a precondition than a monitor, but the boundary needs to
|
||||
> be made explicit before this option is chosen.
|
||||
|
||||
Enforce at the *application* site: after rotor construction, before
|
||||
`propagate_step` commits the new field state, verify the resulting
|
||||
field stays within the frame's admissible cone.
|
||||
Rejected. Even framed as precondition, a check in
|
||||
`field/propagate.py` blurs the rule and invites future "just one
|
||||
more guard" additions. The clean answer is to keep
|
||||
`field/propagate.py` as the no-conditional-branching tight loop it
|
||||
is documented to be.
|
||||
|
||||
**Pros:**
|
||||
### Option D (Accepted): `generate/rotor_admissibility.py`
|
||||
|
||||
* Closest to the *claim*: rotor admissibility is fundamentally about
|
||||
the field staying coherent under propagation, not about token
|
||||
selection.
|
||||
* `field/propagate.py` already owns the propagation invariant, so
|
||||
this is a natural home for an additional propagation-time check.
|
||||
A new sibling module to `generate/admissibility.py`, at the same
|
||||
generation/propagation seam as ADR-0024's destination-side check.
|
||||
|
||||
**Cons:**
|
||||
**Why this is the right home:**
|
||||
|
||||
* `field/propagate.py` is explicitly listed in CLAUDE.md as a
|
||||
*forbidden site* for normalization / drift repair / monitoring
|
||||
("Do not add drift repair, grade projection, watchdogs, timers,
|
||||
hot-path normalizers, or monitoring functions whose only purpose
|
||||
is to repair another function"). An admissibility *guard* (raise
|
||||
on violation, never repair) is closer to a precondition than a
|
||||
monitor, but the boundary needs to be made explicit before this
|
||||
option is chosen — otherwise it sets a precedent that erodes the
|
||||
current rule.
|
||||
|
||||
### Recommended preliminary stance
|
||||
|
||||
**Option B** is the most aligned with project doctrine and the
|
||||
cleanest invariant. Option C is the second-best, but only if the
|
||||
"guard vs. monitor" distinction is made explicit and respected — and
|
||||
even then, the construction-site discipline of Option B is preferable.
|
||||
|
||||
Option A is rejected as inheriting the wrong architectural shape from
|
||||
ADR-0024 by momentum. ADR-0024 lived in `generate/` because it was
|
||||
about *which destination to select*; rotor admissibility is about
|
||||
*whether the rotor itself is valid*, which is a construction-site
|
||||
question.
|
||||
|
||||
**Decision required**: B vs. C.
|
||||
* **Same architectural seam as destination admissibility.** Both
|
||||
rotor and destination checks operate at the boundary between
|
||||
candidate selection and field propagation. They share infrastructure
|
||||
(the inner loop, the rejected_attempts trace, the
|
||||
`InnerLoopExhaustion` refusal mechanism) but live in distinct
|
||||
files, so the *concepts* stay separable even as the *seam* stays
|
||||
unified.
|
||||
* **No layer-boundary crossing.** `generate/` already depends on
|
||||
`algebra/` (it calls `versor_apply`, `word_transition_rotor`,
|
||||
`cga_inner`). Putting rotor admissibility here keeps that
|
||||
dependency direction; Option B would have inverted it.
|
||||
* **No hot-path repair temptation.** The module's only public
|
||||
surface is `check_rotor_admissibility(region, field_current,
|
||||
rotor) -> RotorVerdict`. It returns a typed verdict, doesn't
|
||||
mutate field state, doesn't repair anything. The naming —
|
||||
*admissibility*, not *projection* or *normalization* — encodes
|
||||
the contract.
|
||||
* **Trace parity with destination admissibility.** Refusal flows
|
||||
through the same `InnerLoopExhaustion` plumbing wired in Phase 2,
|
||||
with a distinct `RefusalReason.ROTOR_REJECTION` so the trace names
|
||||
the axis that ran out without needing a parallel exception type.
|
||||
* **File separation answers Option A's bloat objection.**
|
||||
Endpoint admissibility (token-side, blade) stays in
|
||||
`generate/admissibility.py`. Rotor admissibility (rotor-side,
|
||||
frame) lives in `generate/rotor_admissibility.py`. The inner loop
|
||||
in `generate/stream.py` calls both, but neither file grows the
|
||||
other's concerns.
|
||||
|
||||
---
|
||||
|
||||
## Question 2 — Threshold scheme
|
||||
## Decision
|
||||
|
||||
Phase 4 surfaced that static thresholds are geometrically invalid on
|
||||
this manifold. The right scheme is *not yet decided*, but the
|
||||
candidates are:
|
||||
Rotor-side admissibility is enforced at the generation/propagation
|
||||
seam by a new module:
|
||||
|
||||
1. **Per-candidate normalized score**: threshold = α · ‖blade‖, so
|
||||
the same fraction of blade self-score is required regardless of
|
||||
blade norm. Probable best first cut.
|
||||
```text
|
||||
generate/rotor_admissibility.py
|
||||
RotorVerdict
|
||||
check_rotor_admissibility(region, *, field_current, rotor)
|
||||
```
|
||||
|
||||
2. **Cosine-similarity-style normalization**: replace `cga_inner` in
|
||||
the threshold check with
|
||||
`cga_inner(v, blade) / (‖v‖ · ‖blade‖)`. Rejected on doctrinal
|
||||
grounds — CLAUDE.md says "do not add cosine similarity ... to the
|
||||
runtime path." Listed for completeness only.
|
||||
Wiring:
|
||||
|
||||
3. **Per-relation-type static threshold**: a small table mapping
|
||||
relation type → threshold. Phase 4 suggests this is insufficient
|
||||
because *blade norm dominates*, not relation type, but it could
|
||||
be a fallback if normalized scoring proves unstable.
|
||||
| Surface | Behavior |
|
||||
|----------------------------------------|-------------------------------------------------------------------|
|
||||
| `generate/stream.py` (threshold mode) | Per-candidate rotor check after destination admit; on reject, log rotor score in `rejected_attempts`, retry next candidate, escalate to `InnerLoopExhaustion` with `reason=ROTOR_REJECTION` on exhaustion (iff *any* rotor rejection occurred) |
|
||||
| `generate/stream.py` (margin mode) | Rotor check on the top-ranked admissible candidate; on reject, immediate `InnerLoopExhaustion(reason=ROTOR_REJECTION)` carrying the destination ranking plus the rejected rotor's score |
|
||||
| `generate/exhaustion.py` | `RefusalReason.ROTOR_REJECTION` enum member (Phase 2 plumbing carries it through traces unchanged) |
|
||||
| `docs/runtime_contracts.md` | "Rotor admissibility contract" subsection documenting the seam, the algorithm, and the refusal taxonomy |
|
||||
|
||||
4. **Frame-derived threshold**: threshold is a property of the frame
|
||||
versor, not the candidate or the relation. Requires the frame
|
||||
versor to be the primary admissibility object — i.e. Option B
|
||||
above — and may collapse Question 1 and Question 2 into one
|
||||
decision.
|
||||
The check itself:
|
||||
|
||||
**Decision required**: (1) is the recommended starting point. Final
|
||||
choice depends on Question 1 outcome and on a focused diagnostic
|
||||
sweep over (1) and (3).
|
||||
```text
|
||||
score = cga_inner(versor_apply(V, F_current), region.frame_versor)
|
||||
admit iff score > 0 (basic positivity in the frame half-space)
|
||||
```
|
||||
|
||||
**Out of scope for the eventual ADR**: learned thresholds, adaptive
|
||||
thresholds, online tuning. Deterministic replay must be preserved;
|
||||
no learned policy enters the runtime path.
|
||||
A region with `frame_versor is None` (or null-norm) trivially admits
|
||||
every rotor with `score = +inf` — backward-compatible with every
|
||||
pre-Phase-4 region in the codebase.
|
||||
|
||||
---
|
||||
|
||||
## Question 3 — Teaching loop boundary
|
||||
## Why Phase 4 keeps positivity (not margin) on the rotor side
|
||||
|
||||
ADR-0024 lives in `generate/`. The teaching loop in `teaching/*`
|
||||
corrects model behavior through reviewed mutation. An open question:
|
||||
when inner-loop (or rotor) admissibility rejects a candidate, does
|
||||
that rejection become a *teachable event*?
|
||||
Phase 3 / ADR-0026 replaced the static-threshold destination check
|
||||
with ranked-with-margin because Phase 4 characterization established
|
||||
that blade norms varied ~10× across cases, making a single absolute
|
||||
threshold geometrically invalid.
|
||||
|
||||
Two stances:
|
||||
The rotor side does not yet have an equivalent corpus. The Phase 4
|
||||
rotor check uses a simpler positivity bar (`score > 0`) — a strict
|
||||
"the rotor lands in the frame's half-space" test — because:
|
||||
|
||||
* **A. Rejections are runtime hygiene only.** The teaching loop
|
||||
sees the final selected token, not the rejected ones. Rejection
|
||||
is a property of the deterministic admissibility region, not of
|
||||
the reviewed teaching example.
|
||||
1. **There is no cross-case calibration evidence to inform a margin
|
||||
constant yet.** Picking a rotor-margin `delta` today would be
|
||||
guessing.
|
||||
2. **Positivity is the doctrinally cleanest semantic bar.** Either
|
||||
the post-rotor field stays in the frame's positive cone or it
|
||||
doesn't; there is no static threshold to be wrong about.
|
||||
3. **Phase 5 (diversified failure-mode families) is the right place
|
||||
to surface whether rotor-side margin matters.** If a family
|
||||
shows that rotor scores cluster near zero (margin signal weak),
|
||||
the finding is architectural and motivates a rotor-margin
|
||||
extension at that point.
|
||||
|
||||
* **B. Rejections are correction signals.** A teaching review can
|
||||
examine `rejected_attempts` and decide whether the rejection was
|
||||
correct (reinforce) or over-aggressive (loosen). This entangles
|
||||
the teaching loop with admissibility geometry.
|
||||
|
||||
### Recommended stance: **A — strictly hygiene-only.**
|
||||
|
||||
Rationale:
|
||||
|
||||
* The teaching loop's contract is *reviewed mutation of identity /
|
||||
pack / vault*. Admissibility regions are deterministic geometric
|
||||
objects derived from intents and frames; they are not learned, and
|
||||
there is no review surface for them today.
|
||||
* Entangling teaching with admissibility would create a parallel
|
||||
correction path — explicitly forbidden by CLAUDE.md ("Do not
|
||||
create a parallel correction/learning path").
|
||||
* Phase 4 showed that what needs to change is the threshold *scheme*,
|
||||
not the per-event rejection decisions. Scheme changes belong in
|
||||
the eventual ADR-0025 implementation, not in reviewed teaching
|
||||
examples.
|
||||
|
||||
The decision must be **stated in the final ADR**, not left as a
|
||||
silent default, so the next person who touches both systems doesn't
|
||||
have to re-derive the boundary.
|
||||
ADR-0026's margin is preserved on the destination side; rotor side
|
||||
stays at positivity until evidence demands otherwise.
|
||||
|
||||
---
|
||||
|
||||
## Decisions to lock before ADR-0025 is implementable
|
||||
## Invariants preserved
|
||||
|
||||
1. **Home**: Option B (algebra construction) vs. Option C (field
|
||||
propagation guard). Reject A explicitly.
|
||||
2. **Threshold scheme**: blade-normalized fraction (recommended) vs.
|
||||
relation-typed table (fallback). Run a small diagnostic sweep
|
||||
on the v2 corpus + a small extension before committing.
|
||||
3. **Teaching boundary**: Stance A (hygiene-only) confirmed. State
|
||||
explicitly in the eventual ADR's "Out of scope" section.
|
||||
4. **Trace evidence**: extend `AdmissibilityTraceStep` to include
|
||||
rotor-side verdict, or add a separate `RotorAdmissibilityTrace`?
|
||||
Lean toward extending the existing step to keep the trace shape
|
||||
simple.
|
||||
5. **Honest refusal**: at which layer does `ValueError` get raised
|
||||
on rotor rejection? Decided by (1) — same site as the check.
|
||||
* **`versor_condition(F) < 1e-6`** — `check_rotor_admissibility`
|
||||
does not mutate field state; the closure invariant is the algebra
|
||||
layer's responsibility on the actual `propagate_step`, which only
|
||||
runs after an admitted rotor. Test
|
||||
`tests/test_rotor_admissibility.py::TestGenerateRotorAdmissibility::test_versor_condition_preserved_on_admitted_rotor`
|
||||
pins this.
|
||||
* **Deterministic replay** — rotor refusal carries the same evidence
|
||||
shape as destination refusal (the typed exception + canonical
|
||||
trace step). Tests
|
||||
`TestRotorAdmissibilityDeterminism::{test_admitted_rotor_replay_stable,test_rotor_refusal_replay_stable}`
|
||||
assert 5-run replay equality for both admitted and refused turns.
|
||||
* **No new code in `field/propagate.py`, `algebra/versor.py`,
|
||||
`vault/store.py`.** Phase 4's surface is `generate/`-local.
|
||||
* **No approximate recall, no cosine similarity, no HNSW/ANN.**
|
||||
Only `cga_inner` and `versor_apply`, both already in the runtime
|
||||
path.
|
||||
* **Honest refusal preserved** — `RefusalReason.ROTOR_REJECTION` is
|
||||
a new enum member, not a string; the typed-exception subclass
|
||||
hierarchy (`InnerLoopExhaustion` ⊂ `ValueError`) is unchanged, so
|
||||
every pre-Phase-4 `except ValueError` handler continues to catch
|
||||
rotor refusals byte-identically.
|
||||
|
||||
---
|
||||
|
||||
## Trust boundary
|
||||
|
||||
`generate/rotor_admissibility.py` has no I/O, no learned state, no
|
||||
dynamic imports. It consumes `AdmissibilityRegion.frame_versor` (a
|
||||
numpy array from pack-derived construction) and the current
|
||||
`FieldState.F` (from upstream runtime). No new external surface;
|
||||
the trust boundary of ADR-0022 / ADR-0024 applies unchanged.
|
||||
|
||||
---
|
||||
|
||||
## Teaching boundary
|
||||
|
||||
**Stance A confirmed: hygiene-only.** Rotor rejections, like
|
||||
destination rejections, are deterministic geometric outcomes derived
|
||||
from intents and frames. They are not reviewed teaching examples and
|
||||
do not enter the teaching loop. CLAUDE.md §Teaching Safety forbids
|
||||
parallel correction paths; entangling rotor rejection with reviewed
|
||||
teaching would create exactly that.
|
||||
|
||||
If a corpus emerges where rotor admissibility consistently rejects
|
||||
under conditions the operator considers wrong, the fix is to change
|
||||
the *region construction* (the upstream pack-grounded geometry that
|
||||
produces the frame versor), not the rotor check itself.
|
||||
|
||||
---
|
||||
|
||||
## Acceptance evidence
|
||||
|
||||
* **No-frame back-compat.** A region with `frame_versor is None`
|
||||
produces identical tokens before and after Phase 4.
|
||||
`TestGenerateRotorAdmissibility::test_no_frame_versor_preserves_phase3_behavior`.
|
||||
* **Admit when aligned.** `frame_versor = seed direction` admits
|
||||
the seed→destination rotor; the field stays in the seed-aligned
|
||||
half-space.
|
||||
`test_frame_aligned_with_seed_admits`.
|
||||
* **Refuse with named axis when misaligned.** An orthogonal frame
|
||||
versor refuses via `InnerLoopExhaustion(reason=ROTOR_REJECTION)`,
|
||||
not `INNER_LOOP_EXHAUSTION`.
|
||||
`test_frame_orthogonal_refuses_with_rotor_rejection`,
|
||||
`test_threshold_mode_rotor_rejection_routes_reason`.
|
||||
* **`versor_condition` preserved.**
|
||||
`test_versor_condition_preserved_on_admitted_rotor` asserts
|
||||
`< 1e-6` on the final field state of an admitted run.
|
||||
* **Deterministic replay.** 5-run replay equality for both admitted
|
||||
and refused turns.
|
||||
`TestRotorAdmissibilityDeterminism`.
|
||||
* **Suite green.** 1048 passed, 2 skipped on `core test --suite
|
||||
full -q` (+11 new rotor tests over the post-Phase-3 baseline of
|
||||
1037).
|
||||
|
||||
---
|
||||
|
||||
## Out of scope
|
||||
|
||||
* **Rotor-side margin.** Phase 4 keeps positivity (`score > 0`).
|
||||
Promotion to ranked-with-margin awaits Phase 5 evidence.
|
||||
* **Region construction for frame versors.** This ADR enforces the
|
||||
check; it does not specify *what* frame versors a given intent
|
||||
produces. Frame derivation lives upstream (intent ratification,
|
||||
proposition graph) and is out of scope here.
|
||||
* **Rotor-side teaching.** Stance A above.
|
||||
* **`ChatRuntime.respond()` materialisation.** The Phase 2
|
||||
residual silent path (the `except ValueError: return ""` in
|
||||
`respond()`) still applies to rotor refusals. Materialising
|
||||
`refusal_reason` on `ChatResponse` is a future ADR.
|
||||
|
||||
---
|
||||
|
||||
## Risks
|
||||
|
||||
* **Frame versor mis-specification upstream.** A wrongly-constructed
|
||||
frame versor will refuse rotors that should admit, surfacing as
|
||||
`ROTOR_REJECTION` exhaustion on real turns. Mitigation: the
|
||||
trace names the axis (`ROTOR_REJECTION` ≠ `INNER_LOOP_EXHAUSTION`),
|
||||
so debugging starts at frame construction, not admissibility logic.
|
||||
* **Positivity bar may be too strict.** Some legitimate rotors may
|
||||
land at `score == 0` exactly (boundary of the cone). Phase 4's
|
||||
bar `score > 0` refuses these. Promoting to `score >= 0` is one
|
||||
edit if evidence motivates it; left at strict `>` to mirror the
|
||||
load-bearing strict-`>` tie-break elsewhere.
|
||||
* **Cost.** Each per-candidate rotor check is one
|
||||
`versor_apply` + one `cga_inner`. In threshold mode this adds at
|
||||
most `len(admissible_set)` extra applies per step; in margin mode,
|
||||
exactly one extra apply per step. No approximation introduced.
|
||||
|
||||
---
|
||||
|
||||
## Rollback
|
||||
|
||||
Construct regions with `frame_versor=None` (or omit the field, which
|
||||
defaults to `None`). The rotor check returns trivial admit with
|
||||
`score = +inf` and the runtime behaves identically to ADR-0026 /
|
||||
Phase 3. No trace-hash migration required.
|
||||
|
||||
---
|
||||
|
||||
## Evidence and links
|
||||
|
||||
* ADR-0022 — Forward Semantic Control (region prefilter).
|
||||
* ADR-0023 — Forward Semantic Control proof evidence.
|
||||
* ADR-0024 — Inner-loop per-rotor admissibility (token-side).
|
||||
* Phase 2 report — `evals/forward_semantic_control/results/
|
||||
phase2_inner_loop_report.json` — causal attribution proven, but
|
||||
exhaustion gate fails on existing corpus.
|
||||
* Phase 3 report — `evals/forward_semantic_control/results/
|
||||
phase3_v2_report.json` — mechanism isolated on real pack,
|
||||
`mechanism_isolated = true` on 5/5 cases.
|
||||
* Phase 4 reports — `evals/forward_semantic_control/results/
|
||||
phase4_characterization_{v1_plus_dev,v2,combined}.json` — static
|
||||
thresholds geometrically insufficient.
|
||||
* Tests pinning the findings: `tests/test_inner_loop_phase2.py`,
|
||||
`tests/test_inner_loop_phase3.py`, `tests/test_inner_loop_phase4.py`.
|
||||
|
||||
---
|
||||
|
||||
## What this note does NOT decide
|
||||
|
||||
This note does not:
|
||||
|
||||
* Choose between Options B and C — that requires a short focused
|
||||
spike on the algebra-vs-propagation tradeoff.
|
||||
* Specify the threshold scheme — that requires a small diagnostic
|
||||
sweep over normalized-fraction vs. relation-typed schemes on the
|
||||
v2 corpus.
|
||||
* Authorize any code changes. Promotion from Draft to Accepted
|
||||
requires the open questions to be closed.
|
||||
* ADR-0022 — Forward Semantic Control (region prefilter)
|
||||
* ADR-0023 — FSC proof evidence
|
||||
* ADR-0024 — Inner-loop per-rotor admissibility (token-side)
|
||||
* ADR-0026 — Ranked admissibility with margin (Phase 3)
|
||||
* `generate/rotor_admissibility.py` — the module
|
||||
* `tests/test_rotor_admissibility.py` — 11 tests pinning the contract
|
||||
* `docs/runtime_contracts.md` §"Rotor admissibility contract"
|
||||
|
|
|
|||
|
|
@ -94,6 +94,46 @@ future ADR can wire materialisation (e.g. propagate the typed
|
|||
exception to `ChatResponse.refusal_reason` or catch at the pipeline
|
||||
seam) without re-deriving the contract.
|
||||
|
||||
### Rotor admissibility contract (ADR-0025 / Phase 4)
|
||||
|
||||
The destination-side admissibility documented above (token-side blade
|
||||
alignment, ADR-0024 / Phase 3) is complemented by a rotor-side check:
|
||||
when a region carries a non-null `frame_versor`, the inner loop
|
||||
additionally verifies that the rotor's effect on the current field
|
||||
stays within the frame's admissible cone:
|
||||
|
||||
```text
|
||||
F' = versor_apply(V, F_current)
|
||||
score = cga_inner(F', frame_versor)
|
||||
admit iff score > 0
|
||||
```
|
||||
|
||||
`generate.rotor_admissibility.check_rotor_admissibility` performs
|
||||
this pure semantic check. It lives at the same generation/propagation
|
||||
seam as the inner loop — in `generate/rotor_admissibility.py`, a
|
||||
sibling-but-separate module to `generate/admissibility.py` — **not**
|
||||
in `algebra/versor.py` (admissibility is a pack-semantic test, not a
|
||||
closure invariant) and **not** in `field/propagate.py` (forbidden
|
||||
normalization/repair site). The placement is the load-bearing
|
||||
architectural decision in ADR-0025.
|
||||
|
||||
Refusal is materialised through the same `InnerLoopExhaustion`
|
||||
mechanism as destination-side refusal, but with
|
||||
`RefusalReason.ROTOR_REJECTION` instead of `INNER_LOOP_EXHAUSTION`,
|
||||
so the trace names the axis that ran out. In threshold mode, a step
|
||||
that exhausts after *any* rotor rejection is reported under
|
||||
`ROTOR_REJECTION`; pure destination exhaustion stays
|
||||
`INNER_LOOP_EXHAUSTION`. In margin mode, the rotor check runs on the
|
||||
top-ranked admissible candidate after destination margin admits; on
|
||||
rotor refusal the typed exception carries the full destination
|
||||
ranking plus the rejected rotor's score as evidence.
|
||||
|
||||
The `versor_condition(F) < 1e-6` invariant remains the algebra
|
||||
layer's responsibility on actual propagation. `check_rotor_admissibility`
|
||||
does not mutate field state and does not enforce closure — it only
|
||||
asks whether applying `V` to `F` would leave the field in the
|
||||
frame's half-space.
|
||||
|
||||
## TurnEvent contract
|
||||
|
||||
`TurnEvent.surface`
|
||||
|
|
|
|||
|
|
@ -17,9 +17,11 @@ the same effect on caller control flow. The exception subclasses
|
|||
runtime/eval code paths continues to work byte-for-byte — the carrying
|
||||
of structured evidence is additive.
|
||||
|
||||
Reason codes are minimal in Phase 2. A single
|
||||
``INNER_LOOP_EXHAUSTION`` reason covers both raise sites in
|
||||
``generate/stream.py``:
|
||||
Reason codes:
|
||||
|
||||
``INNER_LOOP_EXHAUSTION`` covers two raise sites in
|
||||
``generate/stream.py`` — both express "destination-side admissibility
|
||||
produced no admissible candidate":
|
||||
|
||||
1. Pre-walk: the region's allowed-index intersection with the
|
||||
candidate set is empty before any step ran. ``step_index = -1``
|
||||
|
|
@ -27,13 +29,20 @@ Reason codes are minimal in Phase 2. A single
|
|||
inner-loop rejections were issued; the region was already empty.
|
||||
|
||||
2. In-walk: at some step, every candidate in the admissible set was
|
||||
rejected by ``check_transition`` at the configured threshold.
|
||||
``step_index >= 0`` and ``rejected_attempts`` records the tried
|
||||
(index, word, score) triples in attempt order.
|
||||
rejected by ``check_transition`` (threshold mode) or
|
||||
``check_margin`` (Phase 3 / ADR-0026, margin mode). ``step_index
|
||||
>= 0`` and ``rejected_attempts`` records the tried (index, word,
|
||||
score) triples in attempt order (threshold) or the full ranking
|
||||
(margin).
|
||||
|
||||
Splitting these into separate reasons can wait for Phase 4, when
|
||||
rotor-frame refusal introduces a third structurally distinct mode
|
||||
(ADR-0025).
|
||||
``ROTOR_REJECTION`` (Phase 4 / ADR-0025) is the rotor-side analogue.
|
||||
Raised when no admissible destination produces a rotor that lands
|
||||
the field within the region's frame versor cone. Carries the same
|
||||
trace shape: ``rejected_attempts`` records the rotor-side scores
|
||||
(``cga_inner(F_after, frame_versor)``) for each rejected candidate.
|
||||
Splitting this from ``INNER_LOOP_EXHAUSTION`` keeps the trace
|
||||
self-explanatory — a refused turn says *which* admissibility axis
|
||||
ran out, destination-blade or rotor-frame.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
|
@ -51,6 +60,13 @@ class RefusalReason(Enum):
|
|||
"""
|
||||
|
||||
INNER_LOOP_EXHAUSTION = "inner_loop_exhaustion"
|
||||
# Phase 4 / ADR-0025 — rotor side: every candidate's rotor would
|
||||
# push the field outside the region's frame-versor admissible
|
||||
# cone (``cga_inner(F_after, frame_versor) <= 0`` after
|
||||
# sandwiching). Distinct from INNER_LOOP_EXHAUSTION so the
|
||||
# trace can tell destination-blade refusal from rotor-frame
|
||||
# refusal without re-parsing the message.
|
||||
ROTOR_REJECTION = "rotor_rejection"
|
||||
|
||||
|
||||
class InnerLoopExhaustion(ValueError):
|
||||
|
|
|
|||
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,
|
||||
rank_candidates_by_blade,
|
||||
)
|
||||
from generate.rotor_admissibility import check_rotor_admissibility
|
||||
from generate.attention import AttentionOperator
|
||||
from generate.exhaustion import InnerLoopExhaustion, RefusalReason
|
||||
from generate.result import GenerationResult
|
||||
|
|
@ -437,6 +438,34 @@ def generate(
|
|||
assert margin_verdict.top is not None # admitted => non-None
|
||||
word_idx = int(margin_verdict.top.index)
|
||||
word = str(margin_verdict.top.word)
|
||||
# Phase 4 / ADR-0025 — rotor admissibility on the top-
|
||||
# ranked candidate. If the rotor would push the field
|
||||
# outside the frame's admissible cone, refuse with a
|
||||
# distinct ROTOR_REJECTION reason so the trace shows
|
||||
# *which* axis ran out (destination-blade vs rotor-frame).
|
||||
# The ranked list is preserved as evidence, with the
|
||||
# rotor score appended as the failed candidate's score.
|
||||
if active_region is not None and active_region.frame_versor is not None:
|
||||
A_now = vocab.get_versor_at(current.node)
|
||||
B_cand = vocab.get_versor_at(word_idx)
|
||||
V_hyp = word_transition_rotor(A_now, B_cand)
|
||||
rv = check_rotor_admissibility(
|
||||
active_region,
|
||||
field_current=current.F,
|
||||
rotor=V_hyp,
|
||||
)
|
||||
if not rv.admitted:
|
||||
rotor_attempt = (
|
||||
int(word_idx),
|
||||
str(word),
|
||||
float(rv.score),
|
||||
)
|
||||
raise InnerLoopExhaustion(
|
||||
reason=RefusalReason.ROTOR_REJECTION,
|
||||
region_label=effective_region_label,
|
||||
step_index=step_index,
|
||||
rejected_attempts=tuple(rejected_attempts) + (rotor_attempt,),
|
||||
)
|
||||
# Build a legacy AdmissibilityVerdict for trace storage so
|
||||
# the AdmissibilityTraceStep shape is unchanged. Margin
|
||||
# info is encoded into ``reason`` for human inspection;
|
||||
|
|
@ -455,6 +484,10 @@ def generate(
|
|||
len(candidate_indices) if (inner_loop_active and candidate_indices is not None)
|
||||
else 1
|
||||
)
|
||||
# Phase 4 — track whether any rotor rejection occurred this
|
||||
# step so the exhaustion exception can carry the right
|
||||
# ``RefusalReason`` (rotor-side vs destination-side).
|
||||
step_had_rotor_rejection = False
|
||||
for _attempt in range(max(max_attempts, 1)):
|
||||
word, word_idx = _nearest_next(
|
||||
vocab,
|
||||
|
|
@ -478,7 +511,34 @@ def generate(
|
|||
region_label=effective_region_label,
|
||||
reason="unconstrained",
|
||||
)
|
||||
if not inner_loop_active or verdict.admitted or inner_loop_force_admit:
|
||||
# Phase 4 / ADR-0025 — if the destination admits and the
|
||||
# region carries a frame versor, also check rotor-side
|
||||
# admissibility: would the rotor applied to current.F
|
||||
# land in the frame's admissible cone? On reject, treat
|
||||
# as a per-candidate rejection like the destination side
|
||||
# — log the rotor score and retry the next candidate.
|
||||
rotor_admitted = True
|
||||
rotor_score_for_log: float | None = None
|
||||
if (
|
||||
active_region is not None
|
||||
and verdict.admitted
|
||||
and inner_loop_active
|
||||
and active_region.frame_versor is not None
|
||||
):
|
||||
A_now = vocab.get_versor_at(current.node)
|
||||
B_cand = vocab.get_versor_at(word_idx)
|
||||
V_hyp = word_transition_rotor(A_now, B_cand)
|
||||
rv = check_rotor_admissibility(
|
||||
active_region,
|
||||
field_current=current.F,
|
||||
rotor=V_hyp,
|
||||
)
|
||||
rotor_admitted = rv.admitted
|
||||
if not rv.admitted:
|
||||
rotor_score_for_log = float(rv.score)
|
||||
if not inner_loop_active or (
|
||||
verdict.admitted and rotor_admitted
|
||||
) or inner_loop_force_admit:
|
||||
# `inner_loop_force_admit` is the Phase 2 null control:
|
||||
# exercises the inner-loop code path (same attempt loop,
|
||||
# same telemetry side effects) but force-breaks on the
|
||||
|
|
@ -486,17 +546,34 @@ def generate(
|
|||
# inner-loop run is causally attributable to rejection,
|
||||
# not to incidental code-path differences.
|
||||
break
|
||||
# Inner loop is on and verdict rejected this candidate.
|
||||
rejected_attempts.append((int(word_idx), str(word), float(verdict.score)))
|
||||
# Inner loop is on and either destination or rotor
|
||||
# rejected this candidate. Log the rejection with the
|
||||
# score that produced it.
|
||||
if rotor_score_for_log is not None:
|
||||
step_had_rotor_rejection = True
|
||||
rejected_attempts.append(
|
||||
(int(word_idx), str(word), rotor_score_for_log)
|
||||
)
|
||||
else:
|
||||
rejected_attempts.append(
|
||||
(int(word_idx), str(word), float(verdict.score))
|
||||
)
|
||||
if int(word_idx) in step_exclude:
|
||||
# Selector returned the same exhausted candidate — no
|
||||
# further admissible destinations. Honest refusal.
|
||||
# ADR-0024 Phase 2 — in-walk exhaustion site; carries the
|
||||
# ordered ``rejected_attempts`` accumulated this step so
|
||||
# downstream layers can read refusal evidence without
|
||||
# re-parsing the exception message.
|
||||
# re-parsing the exception message. Phase 4 / ADR-0025
|
||||
# — if any rotor rejection occurred this step, the
|
||||
# exhaustion is reported under ROTOR_REJECTION so the
|
||||
# trace names the load-bearing axis.
|
||||
raise InnerLoopExhaustion(
|
||||
reason=RefusalReason.INNER_LOOP_EXHAUSTION,
|
||||
reason=(
|
||||
RefusalReason.ROTOR_REJECTION
|
||||
if step_had_rotor_rejection
|
||||
else RefusalReason.INNER_LOOP_EXHAUSTION
|
||||
),
|
||||
region_label=effective_region_label,
|
||||
step_index=step_index,
|
||||
rejected_attempts=tuple(rejected_attempts),
|
||||
|
|
@ -504,13 +581,15 @@ def generate(
|
|||
step_exclude.add(int(word_idx))
|
||||
else:
|
||||
# max_attempts exhausted without break — every admissible
|
||||
# candidate was rejected by the inner-loop threshold.
|
||||
# Same refusal shape as the same-candidate-loop site above;
|
||||
# both are structurally "inner-loop produced no admissible
|
||||
# candidate at this step". Splitting into separate reasons
|
||||
# can wait for Phase 4 (rotor frame, ADR-0025).
|
||||
# candidate was rejected by the inner-loop threshold (or
|
||||
# rotor frame, Phase 4). Same refusal shape, with the
|
||||
# reason routed to the load-bearing axis.
|
||||
raise InnerLoopExhaustion(
|
||||
reason=RefusalReason.INNER_LOOP_EXHAUSTION,
|
||||
reason=(
|
||||
RefusalReason.ROTOR_REJECTION
|
||||
if step_had_rotor_rejection
|
||||
else RefusalReason.INNER_LOOP_EXHAUSTION
|
||||
),
|
||||
region_label=effective_region_label,
|
||||
step_index=step_index,
|
||||
rejected_attempts=tuple(rejected_attempts),
|
||||
|
|
|
|||
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