core/docs/decisions/ADR-0024-inner-loop-admissibility.md
Shay f0dbe9a57c feat(adr-0024): inner-loop per-rotor admissibility — Accepted
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).
2026-05-17 13:21:40 -07:00

184 lines
8 KiB
Markdown

# 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_condition`
> invariant.
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:
1. `_nearest_next` selects a destination from the index-filtered
candidate set, exactly as today.
2. `check_transition(region, candidate_index, candidate_versor,
threshold=admissibility_threshold)` evaluates the candidate.
3. If `verdict.admitted` is `False`, the candidate is appended to a
step-local `rejected_attempts` list, its index is added to a
per-step exclude set, and `_nearest_next` re-runs with that
exclusion. The retry budget is bounded by
`len(candidate_indices)`.
4. 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.
5. The selected (admitted) candidate proceeds through the existing
rotor application: `V = word_transition_rotor(A, B)` and
`propagate_step(current, V)`. No new normalization site is
introduced; the runtime versor invariant
`versor_condition(F) < 1e-6` is 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=True` continues 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 `V` is constructed from
`vocab.get_versor_at(word_idx)` for the *admitted* candidate; the
rejected candidates never reach `word_transition_rotor`. The
`versor_condition < 1e-6` assertion at `propagate_step` is
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_inner` are unchanged.
CLAUDE.md §Core Primitives: not touched.
* **Honest refusal.** Exhaustion raises `ValueError` with 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_hash` over an
ADR-0023 turn (no rejected attempts) produces the same bytes
before and after this ADR. Covered by
`tests/test_admissibility_trace.py::TestComputeTraceHashBackwardCompat`
and by a new `tests/test_inner_loop_admissibility.py` case asserting
that an empty `rejected_attempts` list 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=True` and a positive threshold, the walk
emits a *different* admitted token and the step's
`rejected_attempts` records the rejected one.
* **Exhaustion → honest refusal.** A region whose blade rejects
every admissible candidate raises `ValueError` with 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 through `RuntimeConfig`,
`CognitiveTurnPipeline`, and `chat/runtime.py` is 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_constraint` side 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_threshold` will
divert tokens. Mitigation: default `0.0` matches the ADR-0023
verdict computation; lanes ramp the threshold independently per
case.
* **Cost.** Up to `len(candidate_indices)` extra `check_transition`
calls 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.