Implements the 4-phase documentation reorganization master plan. - Consolidation: Merged brief/, handoff/, planning/, and decisions/ into briefs/, handoffs/, plans/, and adr/ respectively (101 ADRs relocated) - Root Cleanup: Relocated HANDOFF-gpt55-*.md and key top-level docs (runtime_contracts.md, etc.) to canonical folders. Added superseded alerts. - Indices & Navigation: Created docs/README.md navigation document, docs/sessions/README.md index, docs/adr/README.md index - Note: Also includes prior commit adding ADR-0200+ corpus hygiene governance (ADR-0225, dependency map, backfilled cross-references)
13 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.
Addendum — Phase 1 v1/dev fixture retirement (2026-05-17)
Finding
Phase 4's characterization recorded that v1 chain-token outer-product
blades skipped 9/9 cases because chain_tokens (alpha, beta,
gamma, delta, mu, nu, omicron, pi, rho, sigma, tau,
upsilon, phi, chi, psi, omega, iota, lambda, eta,
theta, zeta, xenon, ytterbium, kappa) were synthetic and
ungrounded in en_core_cognition_v1. The Phase 2 corpus-observation
runner inherited the same fixtures, surfacing exhaustion_rate = 0.33
at t = 0 on v1+dev — above the 5 % benign-corpus ceiling.
Original assessment (pre-Phase-1): "v1 chain construction can produce ungrounded regions; fix the cause, not the symptom."
What Phase 1 did
The 1 v1 case and 8 dev cases were rewritten with pack-grounded tokens
drawn from en_core_cognition_v1:
tone → evidence → memory → wisdom(causes chain, replacingalpha → beta → gamma → delta)voice → memory → wisdom(means chain, replacingmu → nu → omicron)question → answer → understanding → wisdom(precedes chain, replacingpi → rho → sigma → tau)word → discourse → narrative(part_of chain, replacingupsilon → phi → chi)symbol → word → meaningwithimage/lightdistractors (adversarial branching, replacingeta/theta/zetaplusbeta/rhomeans-distractors)
All token-level grammar adjustments (e.g. "register cause memory" → "register causes memory") were corrected as part of the rewrite.
What changed about the finding
| Metric | Pre-rewrite | Post-rewrite |
|---|---|---|
| v1+dev cases skipped (region builds) | 9 / 9 | 0 / 9 |
causal_attribution_valid |
True | True |
code_path_residual |
0.0 | 0.0 |
inner_loop_t0 hash stability |
1.0 | 1.0 |
best_separation_quality (Phase 4) |
0.0 | 0.056 |
geometry_supports_static_threshold |
False | False |
inner_loop_t0 exhaustion (Phase 2) |
0.33 | 0.67 |
inner_loop_tpos exhaustion (Phase 2) |
(skipped) | 1.00 |
The original Phase 4 finding ("v1 chain-token blades unsuitable as the default region construction") was caused by two layered issues:
- Surface fixture rot — synthetic tokens prevented region construction at all. This is now fixed.
- Deeper architectural mismatch — v1's case schema
(
prime+chain_tokens) probes teaching-driven walk through a relation injected at runtime (ADR-0022 / ADR-0023's mechanism). The inner-loop admissibility lane (ADR-0024) tests blade-geometric region constraint. These are distinct mechanisms; the same case schema cannot meaningfully exercise both.
Exhaustion increased post-rewrite because the chain-blade region is now constructible and actively rejecting the boundary's off-chain picks. That is honest behavior of the inner-loop on cases it was not designed to evaluate.
Architectural conclusion
- v1 / dev cases belong to the ADR-0022 / ADR-0023 boundary-walk
lane (
evals/forward_semantic_control/runner.py). The rewrite improves that lane (real semantic substrate for teaching priming) and does not affect this ADR. - v2 cases belong to the ADR-0024 inner-loop admissibility lane
(
evals/forward_semantic_control/v2_runner.py). v2's schema (seed_token+admissible_tokens+relation_blade_token) is the correct fixture shape for blade-admissibility evaluation. - The Phase 2 corpus-observation runner's reuse of v1+dev cases was a categorical error. The runner remains useful as a corpus-style observation harness; it now needs a proper benign inner-loop corpus, authored with v2's schema. Authoring that corpus is Phase 5's job (stratified mechanism-isolation families subsume benign-corpus observation).
What this ADR addendum does not claim
- The exhaustion finding is not "closed" — it is reattributed. v1/dev inner-loop exhaustion at 67 % is an honest consequence of running the wrong mechanism on the wrong fixture, not a defect in the inner-loop implementation.
- The Phase 2 runner's exhaustion gate (
EXHAUSTION_CEILING = 0.05) remains valid as a contract; it cannot be satisfied on the current fixture mix. Phase 5 will produce the benign-corpus fixtures the gate was designed against. - Phase 4's "no static threshold passes" finding stands unchanged.
Tests pinning the new state
tests/test_inner_loop_phase4.py::TestV1ChainBladePostGrounding— assertsskipped_count == 0(replacing the pre-rewrite assertionskipped_count == case_count) andbest_separation_quality < 0.5(replacing== 0.0).tests/test_inner_loop_phase2.py(unchanged) — continues to assertcausal_attribution_validand inner-loop hash stability. No exhaustion gate assertion; exhaustion remains a recorded finding, not an invariant.