Flag-gated semantic change to generate(): when inner_loop_admissibility=True and a non-unconstrained region is supplied, each per-step selection is re-evaluated by check_transition with admissibility_threshold; rejected candidates are excluded and the walk re-selects until admitted or every admissible candidate is exhausted (ValueError = honest refusal, same shape as ADR-0022 §2). Default False — every legacy call site keeps ADR-0023 boundary-only semantics, and the new AdmissibilityTraceStep.rejected_attempts field is folded into canonical() only when non-empty, so trace_hash bytes are byte-identical with ADR-0023 turns. Invariants preserved: rotor V is only built for the admitted candidate, so versor_condition < 1e-6 still holds at propagate_step; no new normalization site; no new I/O / dynamic imports. Tests: tests/test_inner_loop_admissibility.py covers the four acceptance properties — default off preserves behavior, rejection drives re-selection, exhaustion raises ValueError, empty rejected_attempts is omitted from canonical(). Full pytest: 927 passed, 1 pre-existing unrelated failure (test_language_pack_cache).
8 KiB
ADR-0024 — Inner-Loop Per-Rotor Admissibility
| Field | Value |
|---|---|
| Status | Accepted |
| Date | 2026-05-17 |
| Supersedes | — |
| Extends | ADR-0022, ADR-0023 |
| Decision lead | Shay (with CORE assistant) |
Context
ADR-0022 introduced AdmissibilityRegion and routed it through
generate() as a boundary prefilter: the region's
allowed_indices array is intersected with the language / salience
candidate set before _nearest_next runs, and an empty intersection
raises ValueError (honest refusal). ADR-0023 then added
per-transition trace evidence: each step records candidates_before / candidates_after / selected_index / verdict, hashed into the
deterministic trace.
That is enough to make the token-set side of admissibility
load-bearing. It does not yet make the blade-direction side
load-bearing: check_transition is evaluated after selection and
recorded into the verdict, but the verdict does not influence which
candidate is chosen. Today, a candidate that survives the index
prefilter is always selected even if its versor's CGA inner product
against relation_blade is negative — the trace says "rejected" and
the walk emits it anyway.
ADR-0023 §Decision explicitly deferred this:
ADR-0024 will separately scope inner-loop admissibility (per-rotor admissibility checks after candidate prefilter) because that is a semantic change and interacts with the
versor_conditioninvariant.
This ADR scopes that change.
Decision
We add inner-loop per-rotor admissibility to generate(),
flag-gated and off by default.
When inner_loop_admissibility=True and a non-unconstrained region
is supplied:
_nearest_nextselects a destination from the index-filtered candidate set, exactly as today.check_transition(region, candidate_index, candidate_versor, threshold=admissibility_threshold)evaluates the candidate.- If
verdict.admittedisFalse, the candidate is appended to a step-localrejected_attemptslist, its index is added to a per-step exclude set, and_nearest_nextre-runs with that exclusion. The retry budget is bounded bylen(candidate_indices). - If every admissible candidate is rejected (selector returns an
already-excluded index, or the retry budget is exhausted), the
walk raises
ValueError(f"AdmissibilityRegion[{label}] inner-loop rejected all candidates at step {step_index}.")— the same honest-refusal shape ADR-0022 §2 already commits to for empty admissible sets. - The selected (admitted) candidate proceeds through the existing
rotor application:
V = word_transition_rotor(A, B)andpropagate_step(current, V). No new normalization site is introduced; the runtime versor invariantversor_condition(F) < 1e-6is still asserted after propagation exactly as before.
The rejected candidates are recorded in the trace via a new
AdmissibilityTraceStep.rejected_attempts: tuple[tuple[int, str, float], ...] field. The canonical form folds this field into the
trace hash only when non-empty, so any ADR-0023 turn (boundary-only
walk, or inner-loop on but no rejections) hashes to the same bytes it
hashed to before this ADR shipped.
Default is False. Legacy call sites (chat/runtime.py,
generate/proposition.py, the ADR-0023 ablation lane) keep their
boundary-only semantics until they opt in.
Why flag-gated
This is a real semantics change to the walk: it can divert selection from the geometric nearest to a non-nearest admitted candidate. That is exactly what ADR-0022 promised the admissibility region would do for the direction side of admissibility, but it changes the distribution of emitted tokens for every constrained turn that hits a rejection. Flag-gating means:
- every commit before this ADR's eval lanes light up
inner_loop_admissibility=Truecontinues to produce byte-identical trace hashes; - the change can be ramped per-call-site rather than as a global semantics flip;
- failures attributable to the inner loop are isolated by toggling the flag, not by reverting code.
Invariants preserved
- Versor condition. The rotor
Vis constructed fromvocab.get_versor_at(word_idx)for the admitted candidate; the rejected candidates never reachword_transition_rotor. Theversor_condition < 1e-6assertion atpropagate_stepis unaffected. CLAUDE.md §Non-Negotiable Field Invariant: not weakened. - No new normalization site. The inner loop is a selection-side
retry; it never rebalances
F, projects grades, or unitizes rotors. CLAUDE.md §Normalization Rules: respected (no addition to the forbidden list). - Exact CGA recall. Vault recall and
cga_innerare unchanged. CLAUDE.md §Core Primitives: not touched. - Honest refusal. Exhaustion raises
ValueErrorwith the region label in the message, the same surface ADR-0022 already commits to for empty admissible sets.
Trust boundary
No new I/O. No new dynamic imports. No new filesystem reads. The
rejected_attempts tuple is built from already-grounded vocabulary
indices and scores produced by cga_inner, both of which the existing
trust-boundary review (ADR-0022 §Trust Boundary) covers. No
user-controlled text enters the trace path.
Acceptance evidence
- Backward-compat trace hash.
compute_trace_hashover an ADR-0023 turn (no rejected attempts) produces the same bytes before and after this ADR. Covered bytests/test_admissibility_trace.py::TestComputeTraceHashBackwardCompatand by a newtests/test_inner_loop_admissibility.pycase asserting that an emptyrejected_attemptslist canonicalizes without the key. - Re-selection on rejection. A new test constructs a small
vocabulary where the geometric-nearest candidate would be rejected
by a region whose blade points away from it; with
inner_loop_admissibility=Trueand a positive threshold, the walk emits a different admitted token and the step'srejected_attemptsrecords the rejected one. - Exhaustion → honest refusal. A region whose blade rejects
every admissible candidate raises
ValueErrorwith the region label embedded in the message. - Default off preserves behavior. With the flag off, every
existing pipeline / runtime / eval lane test continues to pass byte-
for-byte; no test in
tests/had to be updated to absorb this change.
Out of scope
- Pipeline / runtime wiring. This ADR only adds the parameter on
generate(). Wiring the flag throughRuntimeConfig,CognitiveTurnPipeline, andchat/runtime.pyis left to a follow- up so the eval lane can demonstrate causal isolation against the inner loop without touching production defaults. - Frame-versor admissibility. ADR-0022's
frame_versor/rotor_constraintside of the region remains observed but unused for selection. That belongs in a future ADR after this one's trace evidence shows whether blade-direction admissibility alone closes the remaining causality gap. - Adaptive thresholds. The threshold is a static parameter. Adaptive thresholds (learned, frame-derived, or annealed) are a separate semantic change.
Risks
- Selection drift. A non-zero
admissibility_thresholdwill divert tokens. Mitigation: default0.0matches the ADR-0023 verdict computation; lanes ramp the threshold independently per case. - Cost. Up to
len(candidate_indices)extracheck_transitioncalls per step in the worst case. In practice the admissible set is small (chain length) and rejections terminate after the first admitted candidate. - Test brittleness. Tests that asserted exact tokens on constrained walks could shift if they enable the flag. Mitigation: flag stays off everywhere by default; opt-in is explicit per call site.
Rollback
Set inner_loop_admissibility=False (the default) at every call
site. The trace hash remains byte-identical to ADR-0023, so
deterministic replay over the existing corpus is unaffected.