core/docs/adr/ADR-0024-inner-loop-admissibility.md

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_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.


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, replacing alpha → beta → gamma → delta)
  • voice → memory → wisdom (means chain, replacing mu → nu → omicron)
  • question → answer → understanding → wisdom (precedes chain, replacing pi → rho → sigma → tau)
  • word → discourse → narrative (part_of chain, replacing upsilon → phi → chi)
  • symbol → word → meaning with image/light distractors (adversarial branching, replacing eta/theta/zeta plus beta/rho means-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:

  1. Surface fixture rot — synthetic tokens prevented region construction at all. This is now fixed.
  2. 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 — asserts skipped_count == 0 (replacing the pre-rewrite assertion skipped_count == case_count) and best_separation_quality < 0.5 (replacing == 0.0).
  • tests/test_inner_loop_phase2.py (unchanged) — continues to assert causal_attribution_valid and inner-loop hash stability. No exhaustion gate assertion; exhaustion remains a recorded finding, not an invariant.