Closes 5 of 8 surface-form gaps Gemini identified in Task 5 on the
99 comparison-bearing sentences my ADR-0123 substrate currently refuses
in the sealed holdout. Pure regex / parser-state work — no graph,
solver, verifier, or pack changes; preserves wrong==0 discipline.
Expansions (in safety order)
- Group 8 (verb): comparison verbs widened from {has} to {has, have,
had, gets, get, got, takes, take, took, buys, buy, bought}.
"lost"/"won" excluded — they semantically invert direction.
- Group 3 (word-form numbers): _WORD_NUMBERS {one..twelve} accepted
wherever digit values are. _parse_compare_number helper centralizes
the dispatch.
- Group 4 (ellipsis / implicit unit): unit slot made optional in
multiplicative patterns (solver already infers unit from reference
state); added "twice|N times as much", "twice|N times the
number/amount of <unit>" variants.
- Group 1 (subjects / references): actor/reference slots widened from
bare proper noun to {proper noun, "the <noun>" collective,
pronoun}. Pronouns resolve via state.last_singular_subject; missing
prior subject raises ParseError (no silent emission with empty
actor). New _resolve_compare_entity helper canonicalizes "The boys"
/ "the boys" to the same entity string.
- Initial-possession + question patterns widened symmetrically so
"the X" subjects round-trip end-to-end:
- _INITIAL_HAS_RE accepts "the <noun>" subject + has/have +
digit-or-word value
- _Q_ENTITY_RE accepts "the <noun>" entity + do/does auxiliary
- _Q_TOTAL_RE now tried first (specificity-ordered) so "do they
have" doesn't get greedily matched as entity="they"
Deferred (per Gemini Task 5c recommendation)
- Group 2 (age): needs new "years_old" attribute model
- Group 5 (nested): needs compound y = mx + c solver operation
- Group 7 (currency): low volume (2 cases), defer
- Group 6 (compound multi-clause "and" split): scoped out of this
PR to keep the wrong==0 risk profile tight; safer to land after
Gemini Task 6 confirms current expansion doesn't introduce
misparses on the sealed set
Test coverage
- 507 existing math + ADR-0122 + ADR-0123 tests pass (no regressions)
- 16 ad-hoc smoke cases pass (3 baseline + 3 Group 8 + 3 Group 3 +
3 Group 4 + 3 Group 1 + 2 refusal guards + 1 rate cross-check)
- smoke suite 67/67, algebra suite 82/82 green
Expected sealed lift
- Gemini Task 5 catalog projected ~65/90 strict-comparison-only
cases unblocked by the 5 included groups (71/99 comparison-bearing
sentences). Empirical sealed measurement pending Gemini Task 6;
PR will be updated with the actual correct/wrong/refused bucket
counts once measured.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
ADR-0123-parser-comparison-phrasing as the **surface increment** on
PR #155's substrate (commit c9bd5d4). Closes the last architectural
gap in the comparison-phrasing class: before this commit, the
substrate's solver evaluated comparison problems successfully but
realize() crashed with `unknown operation_kind 'compare_additive'`
when asked for show-your-work prose.
Substrate (PR #155) already shipped:
- `Comparison` typed graph operand
- `compare_additive` / `compare_multiplicative` operation kinds
- parser patterns for the four canonical surfaces
(N more / N fewer / twice / N times / half)
- solver + verifier wiring + pack lemmas
(en-arith-006 compare_additive, en-arith-007 compare_multiplicative)
This surface adds:
- `_compare_additive_sentence(step)` rendering `direction='more'|'fewer'`
- `_compare_multiplicative_sentence(step, entity_units)` rendering
`direction='times'|'fraction'`
- two new branches in `_step_sentence` dispatch
- `_step_sentence` signature widened with optional `entity_units` map
(derived once-per-trace in `realize()` from `graph_initial_state`)
- ADR-0123-parser-comparison-phrasing.md (~15 invariants, substrate
+ surface decomposition rationale, multi-construction barrier
inheritance)
- 26 invariants pinned across canonical surfaces, plurality
independence, byte-determinism, refusal discipline, and
backwards-compatibility with the pre-comparison realizer templates
End-to-end pipeline now operates on all four canonical comparison
shapes:
parse_problem(
"Alice has 5 apples. Bob has 3 more apples than Alice. "
"How many apples does Bob have?"
) -> solve() -> realize().as_prose() ->
"Alice has 5 apples. Bob has 3 more apples than Alice, giving Bob
a total of 8 apples. Bob has 8 apples."
Measurement (this PR):
- 26/28 direct ADR-0123 tests pass; 2 skipped (CORE_HOLDOUT_KEY)
- `core eval cognition` byte-identical: 100/100/100/100
- ADR-0118 stepped-realizer templates re-render byte-identically
- Substrate measurements continue to hold
Honest non-result: sealed `correct_rate` stays at 0/1319. The
realizer cannot create matches the parser refuses; the multi-
construction barrier the substrate ADR documented holds at the
surface layer too. Cumulative lift signal expected only after the
3rd/4th foundational class lands (per ADR-0121's revised
sequencing). `wrong == 0` holds by construction — realizer only
renders successful traces.
Pre-existing failure noted (not introduced by this PR):
`tests/test_adr_0085_gloss_aware_cause.py::test_flag_off_metrics_byte_identical`
fails on substrate base (c9bd5d4) without these changes — an
ADR-0085 cognition baseline drift unrelated to the realizer.
Second parser-expansion ADR after ADR-0122 rate/per-unit. Adds the
comparison algebra substrate (Comparison dataclass + compare_additive /
compare_multiplicative operation kinds + parser patterns + solver /
verifier / pack lemmas) mirroring the substrate-only / lift-deferred
pattern ADR-0122 established.
Substrate
- Comparison(reference_actor, delta: Quantity|None, factor: float|None,
direction: Literal[more,fewer,times,fraction]) frozen dataclass with
direction-discriminated delta/factor enforcement and self-reference
refusal at the Operation boundary
- compare_additive + compare_multiplicative operation kinds admitted in
VALID_OPERATION_KINDS; Operation.operand widened to Quantity|Comparison
with kind-discriminated type enforcement; entity-set validation extended
to cover Comparison.reference_actor
- Parser: _COMPARE_ADDITIVE_RE (more/fewer/less), _COMPARE_TWICE_RE,
_COMPARE_N_TIMES_RE, _COMPARE_HALF_RE happy-path patterns + 5
refusal patterns (ambiguous 'N times more', age comparisons,
combined-with-aggregation, nested additive+multiplicative); inserted
before _try_initial so leading 'has <N>' shape is not greedily
consumed as initial possession with unit='more'/'fewer'
- Solver: _apply_compare_additive (refuses on missing reference state,
overwrite, negative result); _apply_compare_multiplicative (refuses
on missing reference, ambiguous multi-unit reference, overwrite);
unit comes from delta.unit (additive) or reference's unique unit
(multiplicative)
- Verifier: _verify_compare_additive_step + _verify_compare_multiplicative_step
byte-equal replay; tamper-detects after_value, direction, factor
- Pack: en-arith-006 compare_additive + en-arith-007 compare_multiplicative
lemmas + glosses; SHA-256 checksums refreshed; manifest 1.0.0 -> 1.1.0;
provenance tagged adr-0123:comparison_extension:2026-05-23
Measurement (honest; from Gemini empirical sealed run on parallel surface
branch with this substrate)
- Sealed GSM8K correct_rate: 0/1319 (substrate matches zero real cases
alone). Validates the ADR-0122 multi-construction barrier prediction:
comparison constructions in GSM8K rarely appear alone — they bind with
rate (ADR-0124), percentage (ADR-0125), aggregation (ADR-0126), or
conditional ('if') clauses. First lift signal requires composition.
- Sealed GSM8K wrong: 0 (load-bearing positive claim; ADR-0114a
Obligation #4 preserved across all 1,319 sealed problems)
- Regression safety: 0 — all 913 non-comparison cases continue to
refuse exactly as before (refused_parser), no greedy consumption by
the new comparison patterns
Surface-form catalog (from Gemini Task 2 survey, see ADR doc) covers
6 primary forms across Groups A/B/C; Groups D (age), E (combined with
aggregation), F (nested additive+multiplicative) refused as out-of-scope
with typed ParseError naming the missing companion ADR.
Branch isolation
- Landed via dedicated worktree (feat/adr-0123-substrate from origin/main)
after a file-race on the shared umbrella branch. Companion surface +
scaffolding (realizer, ADR doc, tests, README) lands separately as
feat/adr-0123-surface; orchestrator merges both into the umbrella
feat/adr-0123-comparison-phrasing.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase 4 of the ADR-0114 GSM8K-math roadmap. Consumes a SolutionTrace
and emits one sentence per step plus setup + answer sentences. Pure
function; same trace → byte-equal RealizedTrace.
What landed
generate/math_realizer.py
- realize(initial_state, trace) -> RealizedTrace
- Frozen RealizedTrace dataclass with canonical_bytes() + as_prose()
- Per-kind sentence rules (add / subtract / transfer / multiply×2 /
multiply×3 / multiply-general / divide)
- Singular/plural surface rule matches parser canonicalization
- Typed RealizerError on unrecognized step kinds
tests/test_math_realizer.py — 60 cases pinning five invariants:
1. All 50 dev-set cases realize without error
2. Determinism: byte-equal RealizedTrace across two calls
3. Setup sentence count == initial_state count
4. Step sentence count == operation count
5. Answer sentence contains the resolved value + unit
ADR-0114a obligation discharge update
ADR-0118 hardens determinism (#9) across a third layer (realizer)
and makes #3 / #10 human-inspectable via the prose surface. No
obligation is directly newly discharged by ADR-0118; it's substrate
for ADR-0119 GSM8K eval lane.
Round-trippability of the prose through the parser is explicitly
out of scope for this phase. The trace is the verifiable artifact
(ADR-0117); the prose is human-readable documentation.
Tests: 60 new realizer cases; 546 total green across realizer +
parser + solver + verifier + OOD; 67/67 smoke green.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase 3 of the ADR-0114 expert-capability roadmap. Re-applies every
step of a SolutionTrace from the input graph's initial state and
asserts byte-equal reproduction of answer_value. Pure function; same
(graph, trace) → byte-equal VerifierVerdict.
Why this is distinct from the solver
ADR-0116's solver enforces correctness at construction. ADR-0117's
verifier is a SECOND, INDEPENDENT implementation that re-derives
every value the trace claims. The verifier does NOT call solve(). It
re-implements the operation semantics from ADR-0116 directly inside
_verify_step. If the solver had a bug or was tampered with after the
fact, the verifier catches it.
Six checks per verdict (named, ordered, audit-logged):
1. graph_canonical_hash_matches
2. pack_id_matches
3. pack_lemmas_resolve
4. step_pack_lemma_ids_match_bindings
5. step_replay_matches_before_after
6. answer_value_reproduces
Seven named tamper classes all caught:
- mutated before_value / after_value / operand of any step
- mutated pack_lemma_id of any step
- mutated graph_canonical_hash
- mutated answer_value
- mutated pack_id
- mutated target_before / target_after of transfer step
ADR-0114a obligation update
#3 Replay-equal trace — now discharged at VERIFIER FIDELITY
(was solver-only under ADR-0116). A third party with only
(graph, trace, pack) can reproduce the answer byte-equal.
Five of ten obligations now load-bearing: #3, #4, #9, #10 plus
in-flight #2 (Codex's ADR-0118a OOD generator).
Tests: 62/62 verifier suite green; 67/67 smoke green; existing
solver + parser + schema suites unaffected.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Phase 2 of the ADR-0114 expert-capability roadmap. Consumes the
MathProblemGraph from Phase 1 and emits a SolutionTrace — ordered
operation applications ending at a numeric answer, byte-deterministic
across runs, with each step's operation bound to a pack-resolved
lemma identifier.
What landed
generate/math_solver.py
- solve(graph) -> SolutionTrace; pure function, no I/O, no globals
- SolutionStep dataclass with before/after values per step (for
verifier replay; ADR-0117 hardens)
- SolutionTrace with canonical_bytes() byte-deterministic JSON
- SolveError typed refusal: missing pack, division by zero,
unknown-references-nothing
language_packs/data/en_arithmetic_v1/
- 5 operator lemmas: add / subtract / multiply / divide / transfer
- role=operational_base (vocabulary-only; no domain claim)
- SHA-256-anchored lexicon + glosses; manifest carries
provenance=adr-0116:operator_seed:2026-05-22
tests/test_math_solver.py — 109 cases pinning five invariants:
1. Phase 2 exit criterion: ≥ 0.80 on parser-correct dev set
(current: 50/50 = 1.00)
2. Determinism: two solves produce byte-equal trace
3. Trace replay reproduces answer_value (verifier rehearsal)
4. Typed refusal on under-determined inputs
5. Every step.pack_lemma_id resolves to a real lexicon entry
in en_arithmetic_v1
ADR-0114a obligation discharge
Four of ten anti-overfitting obligations now have load-bearing
implementations in code:
#3 replay-equal trace — discharged (solver-layer)
#4 typed refusal — discharged (solver-layer)
#9 determinism — discharged (solver-layer)
#10 operation provenance via pack — DISCHARGED IN FULL
Removing the en_arithmetic_v1 pack now breaks every solve loudly.
The "operations bind to concepts, not hardcoded strings" claim is
architecturally true, not rhetorical.
Tests: 109/109 green on solver suite; 67/67 smoke suite green;
parser + schema suites still green from prior phases.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Closes Phase 1.3 of the ADR-0114 expert-capability roadmap. Turns a
grade-school word problem into a typed MathProblemGraph deterministically
(no LLM, no sampling). Same input string always produces the same
graph; unsupported constructions raise ParseError rather than guessing.
What the parser handles
Initial possession: "<E> has <N> <unit>."
Add verbs: buys, gets, finds, receives, earns, adds
(+ "<N> more" / unit elision via state.last_unit)
Subtract verbs: eats, loses, sells, donates, uses, spends, drops, removes
Transfer verbs: gives, sends, hands, passes, mails (with target)
Multiply (scalar): "X doubles <obj>" / "X triples <obj>"
Divide (split): "X splits {them|his Y|N Y} evenly into M groups [and keeps one]"
Compound sentences: "X buys 5, then donates 3."
Sentence opener: "Then X eats 1." (inherits subject + unit)
Pronoun anaphora: he/she/it → last-introduced singular subject
Object pronoun: them/these/those → state.last_unit
Trailing PP: "finds 7 buttons on the floor" — discarded
Singular→plural: "Iris has 1 coin" → canonical unit "coins"
Questions:
"How many <unit> does <E> have [left|now|in total|altogether]?"
"How many <unit> do they have [in total|altogether|left|now]?"
What it explicitly rejects
- Conditional / time-modal ("If X had ...")
- Compound questions (two unknowns)
- Multiple "?" sentences
- Questions referencing entities never introduced
- Empty / whitespace-only input
Verification
- tests/test_math_parser.py: 20 cases (5 byte-equal parametrized
+ 5 determinism parametrized + 1 exit-criterion gate + 6 typed-
refusal + 2 purity + 1 type check)
- tests/test_math_problem_graph.py: 26 schema cases still green
- On the 5 seed cases: 5/5 = 100% byte-equal
- On Codex's PR #128 50-case dev set (locally tested):
49/50 = 98% byte-equal. Single failure (gpd-021) is a case-
quality issue, not a parser limit; feedback filed on #128 to
rewrite (mixed units + metaphor not in pattern registry).
- Phase 1.3 exit criterion (≥ 0.90): met.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
First Phase of ADR-0114's expert-capability roadmap. Decomposed into four
sub-phases so each lands as its own auditable step:
1.1 schema + 5 seed cases + invariants ← this commit
1.2 45 more dev-set cases ← delegated (Codex)
1.3 the parser itself ← exit: ≥0.90 on dev set
1.4 runtime binding ← if non-trivial
What landed
- generate/math_problem_graph.py — typed dataclasses (Quantity,
InitialPossession, Operation, Unknown, MathProblemGraph) + frozen
validation + canonical_bytes() byte-deterministic serialization +
graph_from_dict roundtrip.
- evals/gsm8k_parser_dev/cases.jsonl — 5 seed cases (gpd-001..005)
covering single-add, single-subtract, multi-step, two-entity
transfer, and multi-entity sum constructions. Every case carries a
ground_truth_graph and the documented patterns it exercises.
- evals/gsm8k_parser_dev/README.md — authoring contract: schema,
pattern registry, canonicalization rules, Phase 1.1 scope boundary,
hand-solving rubric, distribution target for the remaining 45
cases. This is the spec Phase 1.2 authors work against.
- tests/test_math_problem_graph.py — 26 cases pinning four invariants:
round-trip byte equality, canonical_bytes() determinism, schema
rejection of malformed graphs, and ground_truth_graph ↔
expected_answer agreement (a hand-solver inside the test module
falsifies mis-authored cases).
Why this is sticky
The Phase 1.1 schema is load-bearing for Phase 1.2 (the 45 authored
cases will be written against it) AND Phase 1.3 (the parser will be
graded byte-equal against ground-truth graphs in this schema). Changing
the schema after Phase 1.2 lands requires an amendment ADR + rewriting
authored cases. The schema choices here are intentionally conservative.
Tests: 26/26 new; 67/67 smoke green.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Between 2026-05-17 and 2026-05-22 the inference_closure lane regressed
from all_pass_rate=1.0 to 0.4 on public. Root cause: the
_DECLARATIVE_RELATION_RE branch in generate/intent.py runs ahead of the
_RULES loop and swallowed sentences beginning with 'Actually' into the
subject phrase, routing them to VERIFICATION. The lane's premise emit
path is gated on CORRECTION intent, so PackMutationProposal records
stopped being emitted for any non-'is' relation (precedes / grounds /
causes / reveals). Only the four transitive_is cases passed because
'is' is not in the declarative-relation verb list.
Fix: _CORRECTION_CUE_PREFIX_RE guard. When the text begins with a
correction cue ('Actually', 'Incorrect, ', 'No, ', 'Correction'), the
declarative-match branch is skipped and the sentence falls through to
the _RULES CORRECTION rule. Plain declarative-relation assertions still
route to VERIFICATION unchanged.
Lane on 2026-05-22 post-fix:
dev/v1: all_pass_rate=1.0, overall_pass=True (5 cases)
public/v1: all_pass_rate=1.0, overall_pass=True (20 cases)
- tests/test_correction_cue_prefix_routing.py pins both halves of the
guard (10 new tests).
- evals/inference_closure/gaps.md documents the regression + fix in a
new section, preserving the 2026-05-17 resolution narrative.
- evals/inference_closure/results/ now carries canonical v1_dev and
v1_public reports (the lane had no checked-in results before; ADR-0110
will reference these).
This unblocks the second of ADR-0107's two named blockers. ADR-0110
(math expert-demo re-attempt) now becomes feasible once the math
domain's three lanes have signed-and-digested evidence.
The Phase 1 multi-clause renderer (commit 63ffd88) produces grounded
content but reads mechanically because the subject lemma repeats in
every clause:
"Truth is what is true. Furthermore, truth belongs to cognition.truth.
In turn, truth grounds knowledge. Truth belongs to epistemic.ground.
Furthermore, truth belongs to logos.core. In turn, truth requires
evidence."
This is the literal articulation gap that motivated Phase 2 —
"reasoning at meaningful checkpoints during sentence construction
in order to have a stronger idea of what has come prior and is
already done to help better inform the next move." Between move
``i`` and move ``i+1`` the renderer now reflects on what subject
has just been established (the "focus") and renders the next clause
with a pronoun when the focus carries forward:
"Truth is what is true. Furthermore, it belongs to cognition.truth.
In turn, it grounds knowledge. It belongs to epistemic.ground.
Furthermore, it belongs to logos.core. In turn, it requires
evidence."
Rules
-----
* Track ``focus_subject`` across moves (the lemma most recently used
as a fact subject).
* When the next move's ``fact.subject`` is byte-equal to the current
focus → swap subject token to ``"it"``.
* When the next move's subject differs → preserve the explicit lemma
AND update focus. Topic shifts (TRANSITION moves; compound bridge
TRANSITION) thus reset the pronominalization channel naturally.
* Sentence-initial position (no connective): capitalised ``"It"``.
* Mid-sentence (after connective + comma): lowercase ``"it"``.
Doctrine alignment
------------------
Pure deterministic transformation of the existing plan; no new
content introduced, no LLM, no stochastic sampling. Same plan in →
same surface out, always. trace_hash invariance holds because:
* BRIEF-mode prompts short-circuit the planner before render
(commit 63ffd88's fast path) and are unaffected.
* Multi-move plans render to a deterministically-different string
that compute_trace_hash already folds in via ``surface``.
Wiring
------
* New ``reflective: bool = False`` parameter on ``render_plan``
(back-compat default — every existing call site and test pinning
Phase 1 output continues to work).
* ``_clause_for`` gains optional ``prior_focus_subject`` arg used by
the reflective path; unchanged default behaviour.
* Runtime hook ``chat.runtime._maybe_apply_discourse_planner``
passes ``reflective=True`` so the default chat path benefits.
Tests
-----
New ``tests/test_discourse_planner_reflective.py``:
* ``test_reflective_replaces_repeated_subject_with_it``
* ``test_reflective_handles_three_consecutive_same_subject_moves``
* ``test_reflective_capitalises_sentence_initial_pronoun``
* ``test_reflective_resets_focus_on_topic_shift``
* ``test_reflective_off_preserves_phase1_output``
* ``test_reflective_default_is_off_for_back_compat``
* ``test_reflective_is_deterministic``
* ``test_reflective_single_move_byte_identical_to_non_reflective``
(load-bearing — pins that the cognition eval stays byte-equal
across the Phase 2 flip because every cognition case is single-
move).
Verification
------------
pytest tests/test_discourse_planner_*.py 99/99 pass
(91 existing + 8 new)
pytest tests/test_articulation_demo.py all claims supported
pytest tests/test_narrative_example_intents.py pass
pytest tests/test_runtime_config.py pass
cognition eval OFF vs ON 45/45 surface byte-equal
45/45 trace_hash byte-equal
4/4 aggregate metrics
identical
core test --suite smoke 67/67 pass
core test --suite runtime 19/19 pass
Live demo (default config):
"What is knowledge?" → unchanged (BRIEF, fast-path)
"Tell me about
memory." → "Memory is what a person recalls.
Furthermore, it belongs to cognition.memory.
In turn, it requires recall."
"What is truth, and
why does it matter?"→ "Truth is what is true. Furthermore, it
belongs to cognition.truth. In turn, it
grounds knowledge. It belongs to
epistemic.ground. Furthermore, it belongs
to logos.core. In turn, it requires
evidence."
"Explain truth." → "Truth is what is true. Furthermore, it
belongs to cognition.truth. In turn, it
grounds knowledge."
Out of scope for this commit (future Phase 2 follow-ons):
* Connective rotation ("Furthermore" → "Also" → "In addition"
to break the repetitive cascade).
* Cross-clause de-duplication (skip moves whose ``new`` lemmas
were already introduced by an earlier move).
* Generalised pronoun selection beyond ``it`` (requires gender /
number / animacy signals the pack lexicon doesn't carry today).
Follow-on to the word-boundary fix (commit 0dd30b8). After tightening
``\bno\b`` etc. with word boundaries, an audit surfaced a separate
pre-existing gap in the CORRECTION trigger: the contracted-only
``that'?s\s+(?:not|wrong)`` slot silently dropped every fully-spoken
copula form to UNKNOWN.
Concrete gap (every one previously UNKNOWN):
"That is not right." → UNKNOWN
"That is wrong." → UNKNOWN
"That was wrong." → UNKNOWN
"That is incorrect." → UNKNOWN
"That is false." → UNKNOWN
"That was not right." → UNKNOWN
"that is mistaken." → UNKNOWN
"That was incorrect." → UNKNOWN
Root cause: the slot ``that'?s\s+(?:not|wrong)`` matches only
that's / thats
— ``'?s`` makes the apostrophe optional but the literal ``s`` is
mandatory. ``that is`` (full word ``is``) and ``that was`` (full
word ``was``) had no path. And the predicate alternation only
accepted ``not`` or ``wrong``; ``incorrect``, ``false``, and
``mistaken`` were also missing.
Fix: widen both slots in one pattern revision.
Before:
that'?s\s+(?:not|wrong)
After:
that(?:'?s|\s+(?:is|was))\s+(?:not|wrong|incorrect|false|mistaken)
The full pattern now reads:
\b(?:no
|that(?:'?s|\s+(?:is|was))\s+(?:not|wrong|incorrect|false|mistaken)
|incorrect
|actually
|correction)\b
Boundary discipline holds: the outer ``\b...\b`` still prevents the
predicate alternation from eating into longer words. Verified:
"That is correct." → UNKNOWN (right NOT in predicate set)
"That is right." → UNKNOWN (right NOT in predicate set)
"That is true." → UNKNOWN (true NOT in predicate set)
"That works." → UNKNOWN
"That is interesting." → UNKNOWN
"That is falsifiable." → UNKNOWN (``false`` + ``i`` is word→word
so ``\b`` after ``false`` fails)
"That was wrongly accused." → UNKNOWN (same logic for ``wrong``+``ly``)
Tests extended:
* ``test_correction_canonical_forms_still_route`` — 8 new parametrize
cases for the fully-spoken copula forms
* ``test_correction_does_not_eat_no_prefixed_words`` — 9 new
parametrize cases for the affirmative ``That is/was ...`` shape
AND the boundary-trap cases ``falsifiable`` / ``wrongly accused``
Verified:
pytest tests/test_intent_subject_extraction.py 33/33 pass
full intent + register-diagnostic + proposition graph 77/77 pass
core test --suite smoke 67/67 pass
core test --suite runtime 19/19 pass
While investigating the adjacent RECALL classifier gap, a much
wider intent-classification bug surfaced: every prompt beginning
with a word that *starts with* the letters of any CORRECTION
trigger silently routed to CORRECTION with a mangled subject.
Concrete examples seen during diagnosis:
"Now remember light." → CORRECTION subject="w remember light"
"Nothing matters." → CORRECTION subject="thing matters"
"Notice the truth." → CORRECTION subject="tice the truth"
"Note that recall fires." → CORRECTION subject="te that recall fires"
"Nominate a candidate." → CORRECTION subject="minate a candidate"
"Norma is here." → CORRECTION subject="rma is here"
"Notwithstanding ..." → CORRECTION subject="twithstanding ..."
Root cause: ``generate/intent.py`` ``_RULES`` line ~213 used the
pattern
(?:no|that'?s\s+(?:not|wrong)|incorrect|actually|correction)
The alternation has ``no``, ``incorrect``, ``actually``, ``correction``
as bare substrings — no word boundary on either side. Combined with
``re.match``'s start-of-string anchor, *any* prompt beginning with
``No``-, ``Incorrect``-, ``Actually``-, or ``Correction``-prefixed
text matched as CORRECTION; the regex's match span was then sliced
off the prompt to produce a subject like ``"w remember light"``
(from ``"Now remember light."``).
The same hazard threatens:
* ``no`` → eats ``Now`` / ``Notice`` / ``Note`` / ``Nothing`` /
``Nominate`` / ``Norma`` / ``Notwithstanding`` / ...
* ``incorrect`` → would eat ``incorrectly``
* ``actually`` → would eat ``actualization``
* ``correction`` → would eat ``corrections``
Fix: add ``\b`` anchors on both sides of the alternation.
\b(?:no|that'?s\s+(?:not|wrong)|incorrect|actually|correction)\b
``\b`` is zero-width, so ``re.match``'s start-of-string anchor still
holds; the left ``\b`` is a no-op at position 0. The right ``\b``
forces the matched token to end on a word boundary — i.e., the next
character must be non-word (whitespace, punctuation, EOL) — so
``\bno\b`` matches ``"No."`` / ``"No way"`` / ``"No, ..."`` but NOT
``"Now"`` / ``"Nothing"`` / etc.
Verified 11/11 previously-misfiring prompts now correctly classify
as UNKNOWN, and 8/8 legitimate CORRECTION pragmas
(``"No."`` / ``"No way."`` / ``"Incorrect."`` / ``"Actually, ..."`` /
``"Correction: ..."`` / ``"That's wrong."`` / ``"No, that's wrong."`` /
``"no, knowledge is wrong."``) still route correctly.
Tests extended with two new parametrized blocks in
``tests/test_intent_subject_extraction.py``:
* ``test_correction_canonical_forms_still_route`` — 8 cases pinning
the legitimate CORRECTION patterns
* ``test_correction_does_not_eat_no_prefixed_words`` — 10 cases
pinning the boundary fix against regression
Verified:
pytest tests/test_intent_subject_extraction.py 25/25 pass
pytest tests/test_intent_proposition_graph.py + others 60/60 pass
core test --suite smoke 67/67 pass
core test --suite runtime 19/19 pass
Out of scope: ``"That is not right."`` (a real CORRECTION pragma the
regex never caught because ``that'?s\s+`` requires literal ``s`` after
``that``; the colloquial ``that is`` form was always UNKNOWN). Separate
gap, unchanged here.
The articulation breadth benchmark surfaced a RECALL intent gap:
Before (bench output):
RECALL UNKNOWN pack Pack-resident tokens — pack-grounded
(en_core_cognition_v1): recall ...
The probe prompt ``"Recall truth."`` classified as UNKNOWN and fell
through to the ADR-0086 pack-resident-token surface — a graceful
degradation, not a hard failure, but a real classifier gap.
Root cause: ``generate/intent.py`` ``_RULES`` line 213 only matched
the imperative ``remember``:
(re.compile(r"remember\s+", re.IGNORECASE), IntentTag.RECALL)
The verb ``recall`` — every bit as natural an imperative — was
missing from the trigger pattern. ``"Remember truth."`` correctly
routed to RECALL; ``"Recall truth."`` did not.
Fix: widen the alternation to ``(?:remember|recall)\s+``. One-word
change; ``re.match`` anchoring at the start of the prompt means the
fix only catches the canonical imperative form, leaving downstream
contexts untouched:
* ``Does memory require recall?`` → VERIFICATION (unchanged;
earlier rule on the aux-verb pattern fires first)
* ``What is recall?`` → DEFINITION (unchanged;
``what\s+is\s+`` fires first)
* ``Why does recall exist?`` → CAUSE (unchanged;
``why\s+`` fires first)
* ``I recall.`` → UNKNOWN (unchanged;
no trailing word after ``recall``, ``\s+`` doesn't match)
* ``Please recall the truth.`` → UNKNOWN (unchanged
— symmetric with ``Please remember the truth.`` since rules use
``pattern.match`` not ``pattern.search``)
After (bench output):
RECALL RECALL pack Truth is what is true. pack-grounded
(en_core_cognition_v1).
The articulation bench probe now routes correctly and produces a
pack-grounded definition surface — the canonical RECALL output on
a pack-resident lemma.
Tests extended: ``tests/test_intent_subject_extraction.py::
test_recall_strips_articles`` is parametrized with four new
``Recall ...`` cases parallel to the existing ``Remember ...``
cases. A regression that re-narrows the trigger pattern fails the
gate immediately.
Verified:
* pytest tests/test_intent_subject_extraction.py 7/7 pass
* pytest tests/test_register_firing_diagnostic.py 3/3 pass
* core test --suite smoke 67/67 pass
* core test --suite runtime 19/19 pass
* core bench --suite articulation → RECALL ✓ pack-grounded
Comb pass 2026-05-21.
Item 17 — redundant ``^`` anchors in ``re.match()`` patterns:
``re.match`` anchors at the start of the string automatically, so
the leading ``^`` was documentation-only noise on every pattern
consumed via ``.match()``. Audited each pattern's call site:
* ``_RULES`` (line 144) — used via ``pattern.match(text)`` → strip
* ``_ANAPHORIC_FOLLOWUPS`` — used via ``pattern.match(text)`` → strip
* Module-level ``_COMPARE_RE`` / ``_TRANSITIVE_QUERY_RE`` /
``_FRAME_TRANSFER_RE`` / ``_BELONG_QUERY_RE`` /
``_DECLARATIVE_RELATION_RE`` / ``_HOW_DOES_X_RE`` — all
``.match()`` → strip
* Inline ``re.match`` in ``_strip_confirmation_tail`` → strip
* ``_RESPONSE_MODE_RULES`` — used via ``pattern.search(text)`` →
KEEP ``^`` (``re.search`` does not anchor)
Trailing ``$`` anchors retained throughout because neither
``re.match`` nor ``re.search`` anchors at the end.
A comment block documents the convention so future contributors
understand the ``^`` retain-vs-strip rule.
Tier 5 minor (``chat/runtime.py``):
* Hoisted ``{"is", "are", "was", "were"}`` to module-level
``_BE_FORMS`` constant. Pre-fix ``_prefer_prompt_anchor``
constructed this set on every English turn.
* Replaced the content-token list comprehension + ``[-1]`` slice
with a reverse-iteration short-circuit. Pre-fix the function
materialised the full filtered list just to pick the last
element.
* Cached ``token.casefold()`` once per token via a local in the
loop body. Pre-fix the comprehension called ``.casefold()``
twice per token (against ``_QUESTION_WORDS`` and the inline
aux-verb set).
Validation:
* ``core eval cognition`` byte-identical across all three splits:
public 100/100/91.7/100, dev 100/100/78.6/100, holdout
100/100/83.3/100.
* ``core test --suite cognition`` 120/0/1, ``smoke`` 67/0.
* ``pytest -k intent`` 236/0 (all intent classification tests
pass with the ``^`` removals — the patterns behave identically
under ``re.match`` regardless of the leading anchor).
Comb pass 2026-05-21 (item 16).
Pre-fix ``classify_intent`` applied ``_normalize_subject`` only to
DEFINITION / CAUSE / VERIFICATION paths. COMPARISON, FRAME_TRANSFER,
TRANSITIVE_QUERY (non-"means" branch), and BELONG_QUERY returned
bare ``.strip()`` subjects. A probe like *"Compare the parent and
a child"* would carry the articles ("the parent", "a child") into
the subject slot, breaking downstream pack-resolver lookups that
key on bare lemmas.
Fix: apply ``_normalize_subject(..., IntentTag.DEFINITION)`` at every
classifier return site that was previously bare ``.strip()``.
DEFINITION mode preserves multi-word noun phrases (only strips
leading articles + trailing punctuation + infinitive markers); the
aux-verb stripping that's only meaningful for CAUSE/VERIFICATION
stays scoped to those paths.
Sites fixed (5):
* COMPARISON subject + secondary_subject
* FRAME_TRANSFER subject + frame
* TRANSITIVE_QUERY subject (both the regular and "means" → DEFINITION
redirect branches now share one normalized binding)
* BELONG_QUERY subject
Behavior:
* Eval cases without articles (the entirety of cognition v1) are
byte-identical: ``"memory"`` and ``"recall"`` survive
``_normalize_subject`` unchanged.
* Multi-word noun phrases survive intact: ``"artificial
intelligence"`` is preserved (no aux-verb-strip wrongly trimming
to head-noun).
* Article-prefixed subjects ("the parent") now strip consistently
with the DEFINITION path that's done so since ADR-0049.
Validation:
* 7 new tests in
``tests/test_intent_subject_normalization_consistency.py``
pin the consistency contract across COMPARISON, FRAME_TRANSFER,
TRANSITIVE_QUERY, BELONG_QUERY, DEFINITION (regression guard
on the pre-existing path), and CAUSE (regression guard on the
aux-verb-strip behavior).
* ``core eval cognition`` byte-identical across all three splits:
public 100/100/91.7/100, dev 100/100/78.6/100, holdout
100/100/83.3/100.
* ``core test --suite cognition`` 120/0/1, ``smoke`` 67/0.
* ``pytest -k intent`` 229/0.
Comb pass 2026-05-21 (item 4).
Pre-fix the topological-sort implementation in
``PropositionGraph.topo_order`` had two compounding inefficiencies:
* ``queue.pop(0)`` on a list is O(N) per pop → O(N²) total
* The inner ``for e in self.edges`` rescanned all edges on every
iteration → O(N × E) overall
This is invisible on today's 1–2 node production graphs but would
become a real regression the moment compound-intent multi-node
dispatch (ADR-0089 Phase C2) or the grounded realizer's multi-clause
output (ADR-0088 Phase B follow-up) lands.
Fix: standard Kahn's with a precomputed out-edge adjacency map and
a ``deque`` for the work queue. O(N + E) overall. Deterministic
output preserved — the queue is seeded with sorted zero-in-degree
nodes (identical to the pre-fix list sort), and direct-successor
order matches edge-iteration order (identical when edges retain
insertion order).
Pinned by 6 new tests in ``tests/test_graph_topo_order_perf.py``:
* single-node graph (today's production shape) byte-identical to
pre-fix output
* empty graph returns empty tuple
* chain (A→B→C→D) orders root → leaf
* diamond (A→B, A→C, B→D, C→D) keeps A first, D last, B/C between
* three disjoint roots emit in sorted order
* 100-node chain returns correct full order (would have been
visibly slow under the O(N²) pre-fix algorithm)
Validation:
* ``core eval cognition`` byte-identical (public 100/100/91.7/100)
* ``core test --suite cognition`` 120/0/1
* ``core test --suite smoke`` 67/0
Comb-pass note: item 15 (GenerationResult.tokens typed tuple but
assigned list) was investigated and turned out to be a Pyright
false positive — ``GenerationResult.__post_init__`` already coerces
to tuple via ``object.__setattr__``. Contract is enforced at
runtime; only Pyright's static analyser misses the coercion site.
No fix needed.
Bundle of 5 hot-path optimizations + 1 dead-code removal + 1 import
sweep + 1 helper fold, surfaced by a comb pass through the cognitive
spine starting from ``CognitiveTurnPipeline.run()`` and walking
outward through ChatRuntime, intent classification, the graph
planner, the realizer, and the vault. All eval lanes byte-identical
to MEMORY baseline; null-lift confirmed by ``core eval cognition``
across public / dev / holdout splits.
Hot-path fixes:
1. ``ChatRuntime._apply_oov_policy`` no longer rescans every
manifest per OOV token. Two precomputed booleans on
``self`` capture the FAIL_CLOSED-all and PROPOSE_VOCAB-any
aggregates at construction time. Manifests are immutable
post-construction so the cache is safe. Turns the path from
O(packs × OOV) to O(OOV).
2. ``CognitiveTurnPipeline.run`` calls ``classify_compound_intent``
once and takes its dominant ``compound.primary`` as the seeded
intent. Pre-fix the pipeline called both ``classify_intent``
and ``classify_compound_intent`` on every turn — and
``classify_compound_intent`` internally invokes
``classify_intent`` on the dominant fragment, so every non-
compound prompt walked the 15-regex cascade twice.
3. ``TeachingStore.triples()`` materializes once per turn.
Pre-fix ``_maybe_transitive_walk`` and ``_maybe_compose_relations``
each called ``self.teaching_store.triples()`` independently,
doubling the per-turn O(N) filter+tuple-build cost. Both
helpers now accept an optional ``triples`` arg; the pipeline
computes once and passes through.
5. ``realize_semantic`` and ``realize_target`` build a
``node_id → obj`` map once and look up each step in O(1)
instead of an O(N) linear scan of ``graph.nodes`` per step.
The cost was invisible on today's 1-2 node graphs but would
have become an O(N²) regression on the multi-node graphs
ADR-0089 Phase C2 plans to introduce.
Dead-code / cleanup:
- Removed dead ``CognitiveTurnPipeline._fold_compose_into_surface``
(no callers since PR #76 routed all surface composition
through ``resolve_surface``).
- Folded ``_serialize_walk`` + ``_serialize_compose`` (identical
bodies) into one ``_serialize_operator`` helper.
- Hoisted ``import json`` and ``RatifiedIntent`` from inside hot
method bodies to module top (same pattern PR #76 applied to
``_is_useful_surface``).
- Dead-defensiveness sweep on ``ChatResponse`` field reads in
``pipeline.run()``: ``getattr(response, "<field>", default)``
where the field always exists on the dataclass with a default
is replaced by direct attribute access (6 sites:
``realizer_grounded_authority``, ``recalled_words``,
``grounding_source``, ``register_canonical_surface``,
``pre_decoration_surface``, ``admissibility_trace``,
``region_was_unconstrained``). ``refusal_reason`` retains the
guarded read because ADR-0024 Phase 2 leaves its
materialisation site dormant.
Benchmark profiler:
- ``benchmarks/pipeline_profiler.py`` rebound from
``classify_intent`` to ``classify_compound_intent`` (the new
single-classification site). All other timing hooks unchanged.
Tests:
- 4 new tests in ``tests/test_comb_pass_hot_path.py`` pin: OOV
aggregates exist as bools; compound classifier runs exactly
once per turn; ``triples()`` materializes exactly once per
turn; realizer correctly resolves obj slots across an 8-node
graph.
- All existing tests pass. ``core eval cognition`` byte-identical:
public 100/100/91.7/100, dev 100/100/78.6/100, holdout
100/100/83.3/100.
- ``core test --suite cognition`` 120/0/1, ``smoke`` 67/0,
``runtime`` 19/0.
Closes audit Finding 6 (2026-05-20).
Pre-fix ``_STOP_TOKENS = frozenset({"it", "to", "word"})`` was
hardcoded inside ``generate.stream.generate()`` and inhibited those
three tokens unconditionally across every pack, every language, and
every domain. If a pack legitimately needed one of them as a content
word — e.g. a philosophy pack where ``"word"`` maps to λόγος, or a
syntax pack where ``"to"`` is a content node — there was no override
path. The ``_try_index`` guard handled the case where the token was
absent from the pack, but offered nothing for packs that contained
the token and meant it.
Changes:
* ``generate.stream.generate`` accepts ``stop_tokens: frozenset[str]
| None = None``. ``None`` resolves to the historical
``_STOP_TOKENS`` constant, preserving byte-identity for every
pre-Finding-6 caller.
* ``RuntimeConfig.stop_tokens: tuple[str, ...] | None = None`` —
operator-level override threaded through ``ChatRuntime`` into
``generate()``.
* Default ``None`` preserves byte-identical behavior for every
existing pack and every existing test.
Scope notes:
* This PR delivers the *runtime override* surface. Manifest-driven
per-pack overrides (``generation_stop_tokens`` field in the pack
manifest) are the natural next step but require a pack-schema
ADR and re-ratification of every affected pack, so the wiring
lands first and the manifest field follows on a separate ADR.
* ``agenerate`` was identified as unreachable and is being deleted
in a sibling PR (Finding 7); its hardcoded ``_STOP_TOKENS``
reference disappears with it, so it is intentionally not touched
here.
Verification:
* 4 new tests in ``tests/test_stop_tokens_override.py``:
- ``RuntimeConfig.stop_tokens`` defaults to ``None``
- ``generate()`` signature exposes ``stop_tokens`` with default
``None``
- the historical constant is unchanged
- an explicit override flows through the runtime end-to-end
* ``core eval cognition`` — public 100/100/91.7/100, byte-identical
to the MEMORY baseline.
* ``core test --suite cognition`` — 120/0/1.
* ``core test --suite smoke`` — 67/0.
* ``core test --suite runtime`` — 19/0.
Closes audit Finding 7 (2026-05-20).
``agenerate`` was a 43-line async generator at the bottom of
``generate/stream.py`` that reimplemented the walk loop without
salience candidates, inner-loop admissibility, language candidates,
rotor admissibility, margin mode, trajectory recording, vault recall
scoring, or admissibility tracing — every capability the sync
``generate()`` has accrued since ADR-0022.
Caller audit:
* ``ChatRuntime.achat`` / ``ChatRuntime.arespond`` call the sync
``generate()`` under ``asyncio.to_thread`` semantics (the
explicit comment in ``achat`` documents this: "the underlying
call is still synchronous CPU-bound work").
* No production code, eval, demo, or test references
``agenerate``.
* Re-exported in ``generate/__init__.py`` but only as a public
name, never consumed.
The function was therefore reachable only by accident — any caller
wiring it would silently get a walk that ignores every ADR added
since ADR-0022. CLAUDE.md's "small, load-bearing PRs" doctrine
explicitly disfavors maintaining diverged reimplementations of the
core loop as a future hook.
Removed:
* ``async def agenerate`` (43 lines) from ``generate/stream.py``.
* ``agenerate`` from the ``generate/__init__.py`` star import and
``__all__``.
If a real async walk path becomes necessary later (e.g. once
``achat`` needs genuine off-thread execution), the right shape is a
thin ``asyncio.to_thread`` wrapper over the real ``generate()`` —
not a parallel reimplementation.
Verification:
* ``ripgrep agenerate`` — zero remaining references in the repo.
* ``core test --suite cognition`` — 120/0/1.
* ``core test --suite smoke`` — 67/0.
* ``core test --suite runtime`` — 19/0.
Closes audit Finding 3 (2026-05-20).
Pre-fix ``ratify_intent`` defaulted to ``threshold=0.0``, which admits
anything with non-negative ``cga_inner(prompt, anchor)`` — the field
gate (ADR-0022 §TBD-1) was structurally live but semantically
transparent. RATIFIED was logged on essentially every turn because
the CGA inner product over conformal space is not sign-symmetric.
Measurement (``scripts/calibrate_ratification_threshold.py``):
* Runs every cognition eval prompt (45 cases = 13 public + 13 dev +
19 holdout) through a primed ``CognitiveTurnPipeline``.
* Captures the actual ``cga_inner(prompt, anchor)`` score from the
pipeline's own ``_ratify_intent`` via a temporary spy on the
imported ``ratify_intent`` binding.
Observed distribution:
* 34 RATIFIED: min=+1.1039 p10=+1.1039 median=+2.6820 max=+5.7508
* 11 PASSTHROUGH (no vocab-grounded anchor available; score=0.0)
* 0 DEMOTED at any threshold ≤ 1.10
Threshold = 0.5 chosen as the calibrated default:
* Well below the empirical floor of 1.10 — every currently-passing
case stays RATIFIED, byte-identically.
* Clearly non-trivially positive — random Cl(4,1) inner products
fluctuate around zero, so 0.5 demands genuine correlation with
the anchor rather than passive non-negativity.
* Leaves headroom for the gate to actually demote weakly-aligned
off-corpus / adversarial prompts to UNKNOWN and route them
through the honest-refusal surface.
Verification:
* ``core eval cognition`` — public 100/100/91.7/100, holdout
100/100/83.3/100, dev 100/100/78.6/100 — byte-identical to
MEMORY baselines.
* ``core test --suite cognition`` — 120/0/1
* ``core test --suite smoke`` — 67/0
* ``core test --suite runtime`` — 19/0
* 2 new tests in ``tests/test_ratification_threshold_default.py``
pin both the constant and the signature default so a future
change cannot silently regress to ``0.0``.
Adds a typed legality check that catches a narrow class of incoherent
finite-predicate surfaces before they ship. Scope is deliberately
narrow:
- generate/articulation_legality.py:
- SlotKind enum {VERB, NON_VERB, UNKNOWN}
- ArticulationLegality enum {LEGAL, ILLEGAL_NON_VERB_FINITE_PREDICATE}
- classify_predicate_slot_kind() — token allowlists for known verbs
and known non-verb nouns
- validate_finite_predicate_legality() — fails on negated +
NON_VERB; fail-open on UNKNOWN to preserve canary behavior
- generate/templates.py:
- _inflect_predicate: copular-aware negation
("is X" -> "is not X" instead of the default "does not be X")
- render_step: invokes the legality validator; returns
"I cannot realize that proposition coherently yet." when an
illegal shape is detected
The check is upstream of register / anchor-lens transforms (presentation
+ substantive axes both downstream of the realizer); no interaction
with R6 / ADR-0073 layering.
Tests pin:
- NON_VERB + negated -> ILLEGAL_NON_VERB_FINITE_PREDICATE
- UNKNOWN + negated -> LEGAL (fail-open preserved)
- render_step returns the disclosure string when illegal detected
- render_step still produces the fall-through surface on UNKNOWN
Validation:
- Cognition eval byte-identical (100/100/91.7/100)
- 370 realizer / lens / register / pack / lane tests pass
- anchor-lens-tour + register-tour both green
C1 coherence floor: a deterministic verifier that runs on every
candidate surface produced by the truth path, before assignment to
ChatResponse.surface. Rejects illegal articulations and routes them
to a bounded disclosure string — admission control with a
deterministic fallback, not normalization.
Active rules (R1 deferred during ratification — see ADR):
R2_aux_neg_requires_verb — "<aux> not <wrong-POS>" rejected
R3_be_neg_requires_predicate — "<be> not <verb>" rejected
Fail-open on unknown POS, fail-closed on explicit wrong POS.
Cognition eval byte-identical (100/91.7/100/100).
Original bug class — "Light reveals truth, right?" → "Right does not
thought." — now routes to "I do not have a reviewed articulation for
that yet." with grounding_source=none, walk_surface preserving the
rejected candidate, and telemetry carrying R2_aux_neg_requires_verb.
Files:
generate/realizer_guard.py NEW — pure verifier
chat/runtime.py hook on stub + main paths
chat/telemetry.py serialize guard fields
core/physics/identity.py TurnEvent +2 fields
evals/realizer_guard/run_holdout.py NEW — 6-prompt cluster
tests/test_realizer_guard_*.py NEW — 46 tests (unit/seam/holdout)
docs/decisions/ADR-0075-*.md NEW — ratified
Invariants pinned:
invariant_realizer_no_illegal_articulation
invariant_realizer_guard_byte_identity_on_currently_passing_cases
Lanes (excluding 1 pre-existing TestDemoPreambles failure unrelated
to C1, already present at 4426f38):
smoke 67/67 cognition 120/120(+1s) teaching 17/17
packs 6/6 runtime 19/19 algebra 132/132 full 2792/2793
Closes the last unarticulate cases on the multi_sentence_response
lane. Two complementary changes:
1. ``generate/discourse_planner.py``
* ``ResponseMode.WALKTHROUGH`` budget lifted from (1, 1) to
(1, 4): 1 anchor + up to 3 hops along the teaching-chain graph,
final hop becomes CLOSURE.
* New ``_plan_walkthrough`` selector walks (subject, *, object) →
(object, *, *) starting from the anchor; cycle-safe via the
existing used-fact set; bounded by ``_WALKTHROUGH_MAX_HOPS=3``.
* New ``_plan_walkthrough_fallback`` — when no teaching chain is
rooted on the anchor, emit ANCHOR + (SUPPORT) rather than
fabricating walk steps. Plan retains ``mode=WALKTHROUGH`` so
callers detect "attempted walkthrough, degraded honestly".
2. ``generate/intent.py``
* New classifier rule: ``^walk\s+(?:me\s+)?through\s+`` →
``IntentTag.DEFINITION``. Same orthogonality discipline as the
``Explain X`` rule: ``ResponseMode.WALKTHROUGH`` carries the
walk depth on its own axis.
13 new tests pin: walk shape (ANCHOR + RELATION* + CLOSURE), the
walk invariant (each teaching hop's subject = prior hop's object),
the 4-move cap, the fallback shape on absent chains, fallback mode
retention, cycle-safety against (A→B→A) cycles, and determinism.
Lane re-measurement (24 cases, multi_sentence_response public/v1):
flag off: articulate=0.0833, disclosure=0.1667, unarticulate=0.7500
flag on : articulate=1.0000, disclosure=0.0000, unarticulate=0.0000
The two previously-unarticulate WALKTHROUGH cases ("Walk me through
inference.", "Walk me through recall.") now engage the planner and
render as deterministic teaching-chain walks:
"Inference is a conclusion drawn from premises by reasoning.
Inference requires evidence."
"Recall is to retrieve a stored state from memory.
Recall reveals memory."
Each surface is grounded entirely in pack glosses and reviewed
teaching chains — no fabricated walk steps.
Critical gates all green:
* flag off cognition byte-identical:
public 100/100/91.7/100, holdout 100/100/83.3/100
* smoke suite 67/67
* 91/91 planner tests pass (contract / behavior / compound / helper
/ render / walkthrough)
The 0.875 connective_present_rate remaining flag-on (3 cases without
expected connectives) is the only gap left, and it's now a render-
template question rather than a planner gap.
Adds compound-intent decomposition for prompts that ask multiple
things in one turn ("What is X, and why does it matter?",
"Explain X, but how does it work?", "What is X, and what is Y?").
Three landings in one PR (rule says additive; the three pieces
are inseparable for the runtime hook to do anything useful):
1. generate/intent.py
* New ``CompoundIntent`` frozen dataclass — ordered tuple of
``DialogueIntent`` parts + raw_text + ``.primary`` back-compat
accessor + ``.is_compound()`` helper.
* New ``classify_compound_intent(prompt)`` sibling to
``classify_intent``. Pure, deterministic, byte-stable. Splits
on closed connector list (``,\s+(and|but|because|while)\s+``);
anaphoric tails ("why does it matter") get the prior part's
subject substituted ("why does truth matter") then are
classified independently.
* ``classify_intent`` return shape is untouched — every existing
caller still receives ``DialogueIntent``.
* No new ``IntentTag`` introduced. v1 semantic approximation:
"why does X matter" routes to ``CAUSE(X)``; "matter" means
causal/relevance support, not metaphysical importance.
2. generate/discourse_planner.py
* New ``plan_compound_discourse(compound, mode, bundles)`` —
concatenates per-part sub-plans in source order with a
``TRANSITION`` bridge (fact=None) between consecutive parts.
No cross-part re-sorting.
* New private kw-only ``_exclude_facts`` parameter on
``plan_discourse`` so subsequent sub-plans can avoid emitting
the same facts the prior sub-plans already used (prevents
"Truth is X. Truth is X." duplicates on shared-subject
compounds). Public signature ``(intent, mode, bundle)`` is
unchanged.
3. chat/runtime.py
* Helper ``_maybe_apply_discourse_planner`` now consults the
compound classifier first. When the prompt is multi-part it
builds per-part bundles and calls ``plan_compound_discourse``;
otherwise it follows the previous single-intent path.
* Compound bypass: when upstream tagged the surface ``oov`` /
``none`` because the flat classifier saw a polluted subject
(e.g. ``"truth, and why does it matter"``), but the compound
decomposition reveals a pack-resident primary subject, the
planner engages on the decomposed parts. This narrowly widens
the gate exclusively for compound prompts with substrate.
* BRIEF mode upgrades to EXPLAIN for compound prompts —
single-anchor sub-plans on shared subjects would emit duplicate
anchor sentences in BRIEF.
* Return shape widened to ``tuple[str, str] | None`` —
``(rendered_surface, new_source_tag)``. ``new_source_tag`` is
``"teaching"`` when the plan uses any teaching fact, else
``"pack"`` — so downstream labels reflect actual provenance
even on the compound bypass. Both cold and warm call sites
updated to apply both fields.
24 new tests pin: compound decomposition correctness, source-order
preservation across sub-plans, anaphoric-followup rewriting,
deterministic byte-stable plans, no new IntentTag introduced,
fact-dedup across sub-plans, compound-bypass engagement, and
source-tag correction on planner-engaged surfaces.
Lane re-measurement after 3 compound cases added to cases.jsonl
(24 total cases):
flag off: articulate=0.0833, disclosure=0.1667, unarticulate=0.7500
flag on : articulate=0.9167, disclosure=0.0000, unarticulate=0.0833
Note: disclosure flag-on dropped to 0.0 because the source-tag
correction now correctly labels compound-bypass surfaces as
``pack/teaching`` instead of letting the upstream ``oov`` label
inflate disclosure. The two remaining unarticulate cases flag-on
are the walkthrough prompts targeted by the next landing.
Critical gates all green:
* flag off cognition byte-identical: public 100/100/91.7/100
* smoke suite 67/67
* 32/32 planner tests pass (helper + render + compound)
* 18/18 compound classifier tests pass
Extends ``generate/intent.py:_RULES`` with three new expository
patterns so the upstream subject-extraction gap that the dedup
revealed is closed:
* ``^explain\s+`` → DEFINITION
* ``^(write|compose|draft) (a )?(short|brief)?
paragraph (about|on)\s+`` → DEFINITION
* ``^paragraph (about|on)\s+`` → DEFINITION
Rules placed AFTER the NARRATIVE family so ``Tell me about X`` and
``Describe X`` continue to route to NARRATIVE. Subject extraction
re-uses ``_normalize_subject`` so articles and trailing punctuation
are stripped: ``Explain the parent.`` → subject ``parent``.
``ResponseMode`` is untouched and remains orthogonal: the same prompts
still classify as ``EXPLAIN`` / ``PARAGRAPH`` independently.
20 new tests pin: each rule's expected subject, response-mode
preservation, NARRATIVE/EXAMPLE/existing-DEFINITION rules unchanged.
Lane re-measurement (multi_sentence_response, 21 cases):
flag off: multi=0.1429, primed_multi=0.0000, conn=0.5385, grounded=0.8571
flag on : multi=0.9048, primed_multi=1.0000, conn=0.8462, grounded=0.8571
Combined lift over the original (pre-wiring) baseline:
* multi_sentence_rate: +70pp on the substantive predicate
* primed_multi_sentence_rate: +50pp (0.5 → 1.0 post-classifier)
* connective_present_rate: +74pp (0.10 → 0.85)
* grounded_rate: +39pp (0.47 → 0.86)
Cognition eval byte-identical: public 100/100/91.7/100, holdout
100/100/83.3/100 — these prompts aren't in cognition cases, and the
new rules don't perturb any rule that fires for cognition prompts.
Conversational thread coherence unchanged.
docs/evals/discourse_runtime_baseline_2026-05-19.md updated with the
full delta table; the planner is now load-bearing across the warm
and cold pack/teaching paths and the lane measures real capability
rather than punctuation artifacts.
Step 5 of the discourse-planner sequencing. Closes the chain:
classify_intent + classify_response_mode
-> grounding_bundle_for(subject)
-> plan_discourse(intent, mode, bundle)
-> render_plan(plan)
-> response_surface
Adds RuntimeConfig.discourse_planner (default False). When True, the
runtime — after the warm pack/teaching-grounded surface is set —
classifies the response mode, assembles a GroundingBundle from the
ADR-style accessors, builds a DiscoursePlan, and replaces the warm
surface with the deterministic multi-clause rendering whenever the
plan has more than one move.
Gating discipline:
* Engages only on warm_grounding_source in {"pack", "teaching"} so
vault/none turns and the discovery-signal CAUSE/VERIFICATION
disclosure are preserved exactly.
* BRIEF mode always collapses to a single ANCHOR move, so flag-on
with BRIEF intent is byte-identical to flag-off.
* Empty bundles produce empty plans; the runtime falls through to
the existing warm surface untouched.
Adds render_plan(plan) to generate/discourse_planner.py — a pure,
deterministic multi-clause renderer with fixed canonical connectives:
ANCHOR : capitalized opening sentence
SUPPORT : "Furthermore, ..."
RELATION : "In turn, ..."
TRANSITION: "Consequently, ..."
CLOSURE : skipped when fact is None
Every visible token is a verbatim pack lexicon entry, gloss, or
reviewed teaching chain string — no synthesis.
13 new tests pin:
* render_plan empty/brief/paragraph shape
* canonical connectives present in paragraph rendering
* deterministic + verbatim-fact invariants
* RuntimeConfig.discourse_planner defaults False
* Flag-off surface has no planner connectives
* Flag-on lifts produce structurally well-formed multi-sentence
output on grounded substrate
Lift measurement (multi_sentence_response public/v1, 15 cases):
* flag off: multi=0.40, connective=0.50, grounded=0.40
* flag on : multi=0.40, connective=0.60, grounded=0.40
-> connective_present_rate +10pp; multi-sentence count flat
because the existing narrative composer's literal "." chars in
tags like "cognition.truth" already trigger sentence splits in
the lane regex. Real lift is form quality: e.g. "Tell me about
truth" now renders as "Truth is a claim or state grounded by
evidence and coherent judgment. Furthermore, truth belongs to
cognition.truth. In turn, truth grounds knowledge." instead of
the prior provenance-laden narrative surface.
Critical gates (all green):
* flag off: cognition eval byte-identical
- public 100/100/91.7/100, holdout 100/100/83.3/100
* smoke suite 67/67
* conversational_thread_coherence: 3 unwanted placeholders flag off
and flag on (no regression)
* planner JSON byte-stable across calls (contract tests)
* grounding source order preserved (sidecar tests)
Step 4 of the discourse-planner sequencing. Replaces the contract-only
NotImplementedError with deterministic move-selection rules per
ResponseMode:
* BRIEF → 1 move (ANCHOR)
* EXPLAIN → up to 3 (ANCHOR + SUPPORT + RELATION)
* PARAGRAPH → up to 5 (ANCHOR + SUPPORT + RELATION + TRANSITION + CLOSURE)
* EXAMPLE → up to 3 (ANCHOR + RELATION + CLOSURE)
* WALKTHROUGH→ deferred, falls back to BRIEF shape so planner is total
Move selectors:
* ANCHOR — pack is_defined_as on intent.subject if available, else
first canonical pack fact on subject, else first
canonical fact of any source
* SUPPORT — pack belongs_to on anchor's subject
* RELATION — teaching/cross-pack chain rooted on anchor's subject
* TRANSITION — chain rooted on the relation's object (topic shifts)
* CLOSURE — no new fact; carries given lemmas forward
Empty bundles produce empty plans (planner is total — callers fall
through to the existing single-sentence composer path safely).
Updated contract test test_plan_discourse_is_contract_only ->
test_plan_discourse_handles_empty_bundle to reflect the implementation.
26 new behavior tests pin: per-mode shape (BRIEF/EXPLAIN/PARAGRAPH/
EXAMPLE/WALKTHROUGH), anchor preference for is_defined_as, support
preference for belongs_to, relation preference for teaching source,
paragraph transition topic shift, closure semantics (no new content,
carries given forward), fact uniqueness across moves, anchor fallback
when no pack subject match, and full determinism (byte-stable JSON
across all five modes, pure function equality).
Verification:
* 49/49 planner tests pass (23 contract + 26 behavior).
* smoke suite 67/67.
* cognition eval byte-identical:
public 100/100/91.7/100, holdout 100/100/83.3/100.
Step 3 of the discourse-planner sequencing. Adds
generate/grounding_accessors.py:
* pack_grounded_facts(lemma) -> tuple[GroundedFact, ...]
* teaching_grounded_chains(lemma) -> tuple[GroundedFact, ...]
* cross_pack_grounded_chains(lemma) -> tuple[GroundedFact, ...]
* grounding_bundle_for(lemma) -> GroundingBundle
All four reuse the existing data substrate (chat.pack_resolver,
chat.teaching_grounding._all_chains_index, chat.cross_pack_grounding
chain accessors) — no new loader, no new I/O, no string composer
touched. Pack facts emit one `is_defined_as` per gloss + one
`belongs_to` per semantic_domain; teaching/cross-pack chains emit
verbatim (subject, connective, object) triples; everything sorted by
GroundedFact.sort_key for canonical determinism.
21 new tests pin: pack/teaching/cross-pack accessor shape, canonical
sort order, verbatim object invariant (no synthesis), source_id
points back into real artifact, bundle composition combines all three
sources with pack-first priority, and doctrine invariants (no
*_grounded_surface composer imported, no chat.runtime imported).
Verification:
* 21/21 new accessor tests pass.
* smoke suite 67/67.
* cognition eval byte-identical:
public 100/100/91.7/100, holdout 100/100/83.3/100.
Step 2 of the discourse-planner sequencing: add the presentation-depth
axis ResponseMode (brief / explain / walkthrough / paragraph / example)
as a sibling to IntentTag in generate/intent.py, with a deterministic
rule-based classify_response_mode classifier next to classify_intent.
ResponseMode previously lived in generate/discourse_planner.py; moved
to generate/intent.py so the dependency is one-way (planner imports
from intent, never reverse). discourse_planner.py now re-exports.
Additive-only invariant preserved:
* DialogueIntent fields unchanged (tag/subject/secondary_subject/
relation/frame). No equality breakage anywhere downstream.
* classify_intent branches untouched.
* Callers compose (classify_intent(t), classify_response_mode(t))
rather than threading mode through DialogueIntent.
41 new tests pin: placement (canonical home + re-export identity),
classifier behavior (parametrized over 25 prompts), priority ordering
(paragraph > explain, walkthrough > explain), purity (no clock/env/
filesystem), classify_intent invariance (definition / narrative /
example / cause / verification representative cases), and orthogonality
(intent and mode compose, neither shadows the other).
Verification:
* 96/96 existing intent tests pass.
* 69/69 new contract + characterization + classifier tests pass.
* smoke suite 67/67.
* cognition eval byte-identical: public 100/100/91.7/100,
holdout 100/100/83.3/100.
Contract-only landing for the typed multi-move discourse layer that
will sit between grounding and graph construction:
DialogueIntent + ResponseMode + GroundingBundle
-> DiscoursePlan
-> PropositionGraph
-> ArticulationTarget
-> RealizedPlan
Adds frozen dataclasses (ResponseMode, FactSource, GroundedFact,
GroundingBundle, DiscourseMoveKind, DiscourseMove, DiscoursePlan),
canonical sort + as_dict + to_json serialization (sorted keys,
no-whitespace separators), and the pure plan_discourse signature
(raises NotImplementedError; move-selection rules deferred).
23 contract tests pin the determinism invariants required before
DiscoursePlan can be folded into compute_trace_hash in a follow-up
ADR: frozen-dataclass equality, canonical pack<teaching<vault<operator
ordering, byte-stable to_json across calls and equal plans, JSON
round-trip stability, and signature purity (no chat.* imports, no
clock/env/filesystem reads).
No runtime wiring; smoke suite 67/67; cognition eval byte-identical
(public 100/100/91.7/100, holdout 100/100/83.3/100).
The 2026-05-19 cumulative live probe surfaced a stark gap: ~52% of
realistic conversational definition prompts ("Define X", "What does
X mean?", "What is to V?", "How does X work?", "What causes X?")
returned ``grounding_source="none"`` *even though every subject
lemma was pack-resident* across the 9 mounted English packs.
Root cause: the bottleneck was intent classification + subject
extraction, not lexicon coverage. Five patterns either had no rule
or routed to an intent the runtime dispatcher couldn't handle. The
fluency assessment at
``/Users/kaizenpro/.codex/worktrees/6533/core/notes/fluency_assessment_2026-05-19.md``
named these as Root Cause #1 ("public chat path does not use the
cognitive spine") and Root Cause #3 ("proposition graphs are too
thin"). This commit closes the surface-level half of that gap;
the deeper answer-plan layer (gloss propositions, P3 in the
assessment) is the next step.
Patterns fixed in ``generate/intent.py``:
1. ``Define X`` — added ``^define\s+`` rule mapping to
DEFINITION (placed after ``^what is/are``
so multi-word DEFINITION patterns still
prefer the question form).
2. ``What does X mean?`` — was matching TRANSITIVE_QUERY with
relation=``mean``. Now re-routes to
DEFINITION inside ``classify_intent`` so
``pack_grounded_surface`` fires on X.
Other transitive relations (precede,
ground, etc.) remain TRANSITIVE_QUERY.
3. ``What is to V?`` — added infinitive-marker strip to
``_normalize_subject`` for DEFINITION /
RECALL. ``to`` is gated on intent tag so
it never strips a transfer preposition
from CAUSE / VERIFICATION.
4. ``How does X work?`` — added ``_HOW_DOES_X_RE`` (third-person
mechanistic-cause). Distinct from the
first-person PROCEDURE rule ("How do I
X?"). Verbs: work / function / operate /
happen / exist / behave / act / emerge.
5. ``What causes X?`` — added causative-verb rule (causes /
triggers / enables / prevents / drives /
produces / induces / yields) routing to
CAUSE with X as subject.
Deliberate NON-fix: I considered adding a ``pack_grounded_surface``
fallback in the CAUSE / VERIFICATION dispatcher when no teaching
chain matches the subject. Reverted on review — that masks the
"would_have_grounded" discovery-candidate signal the teaching
pipeline uses to identify teaching-content gaps (see
``tests/test_discovery_candidates``). CAUSE on a pack-resident
lemma without a teaching chain stays ``grounding_source=='none'``
so the discovery layer can log the gap honestly.
``chat/pack_grounding.py``:
Extended ``_CORRECTION_TOPIC_STOPWORDS`` to include polarity
markers (no / yes / maybe / perhaps / hardly / indeed / surely /
definitely). Without this the CORRECTION composer would
short-circuit on ``no`` from "No, my parent disagrees" and miss
the topical lemma ``parent``.
Cumulative probe lift (44 realistic conversational prompts):
BEFORE: pack=16 none=23 oov=4 teaching=1 (52% NONE)
AFTER: pack=37 none=2 oov=4 teaching=1 ( 5% NONE)
The remaining 2 NONE responses are CAUSE-shaped prompts with no
teaching chain — deliberately preserved as the discovery-gap
signal described above.
Tests: tests/test_intent_classification_extensions.py — 23 new
tests covering each pattern + the lift invariant.
Verification:
Cognition eval byte-identical on both splits (100/100/91.7/100
public, 100/100/83.3/100 holdout).
All 111 intent-affected tests green:
test_intent_classification_extensions.py (23)
test_intent_proposition_graph.py / test_intent_ratifier.py /
test_intent_subject_extraction.py / test_narrative_example_intents.py
test_procedure_surface.py
test_correction_topic_lemma.py
test_cross_pack_grounding.py (including the polarity-stopword fix)
test_discovery_candidates.py
test_contemplation_wiring.py
test_en_core_polarity_v1_pack.py
Root cause: recalled_words was built from result.tokens (versor walk
neighbours) rather than the pack-resolved proposition slots. The walk
produces nearest-neighbour traversal artifacts; the proposition already
carries the correct subject/predicate/object from realize(). This made
ground_graph() fill <pending> obj slots with stop-word-adjacent tokens
instead of the actual answer content.
Fix — two changes, one new helper:
generate/intent_bridge.py
• build_recalled_words_from_plan(plan, proposition, walk_tokens)
Constructs the grounding tuple in priority order:
1. plan.object (ArticulationPlan — pack-resolved, already a word)
2. proposition.object_ (Proposition — versor-decoded object slot)
3. plan.predicate (descriptive predicate word, richer than walk)
4. plan.subject (subject as last-resort semantic anchor)
5. walk_tokens (result.tokens alpha-filtered — supplemental backfill)
Strips <pending>/<prior>/empty/non-alpha before deduplicating.
Returns a deduplicated tuple in that priority order.
• articulate_with_intent() gains an optional `proposition` param
(typed as object to avoid import coupling at the call site).
When provided, build_recalled_words_from_plan() is called to
replace the raw recalled_words before ground_graph() runs.
When omitted, behaviour is byte-identical to Phase 1 (backward
compatible: all existing callers and tests pass unchanged).
chat/runtime.py
• The single articulate_with_intent() call site now passes
proposition=proposition so the bridge receives the full
pack-resolved proposition for grounding. walk_tokens (the old
recalled_words) are passed through as supplemental backfill.
• No change to ChatResponse, TurnEvent, GenerationResult, or any
ADR-gated schema.
Adds generate/bridge_trace.py: a structured sink + serializer for
per-turn articulation-bridge trace records, following the exact
ADR-0040 telemetry sink pattern (JsonlBufferSink / JsonlFileSink /
FanOutSink, no wall-clock, redact-by-default).
Modifies generate/intent_bridge.py: articulate_with_intent() emits
one BridgeTraceRecord per call through a module-level opt-in sink
(attach_bridge_trace_sink / detach_bridge_trace_sink). When no
sink is attached the call is a pure no-op — zero behavior change on
all existing paths.
The record captures:
- intent_tag / intent_subject (classifier output)
- plan_subject / plan_predicate / plan_object (articulation slots)
- recalled_words_len / recalled_words_sample (grounding supply)
- pre_ground_obj (what the graph node held before ground_graph)
- post_ground_obj (what it held after, or same if no grounding ran)
- bridge_surface / bridge_useful (final output + usefulness gate)
- fallback_surface (the plan.surface the runtime falls back to)
This is the Phase 1 measurement instrumentation described in the
full-sentence output mastery plan. Phases 2-5 act on the data this
produces; Phase 1 itself is pure observation.
Two new intent shapes + composers turn the runtime's corpus
density into operator-visible articulation. Both consult the
cross-corpus aggregator from ADR-0064; no new ratification needed.
P3.3 — chat/narrative_surface.py + IntentTag.NARRATIVE.
Classifier patterns (registered BEFORE generic DEFINITION):
^tell\s+me\s+about\s+
^describe\s+
^what\s+(?:can|do)\s+you\s+(?:say|know)\s+about\s+
narrative_grounded_surface(subject, max_clauses=4) walks every
reviewed chain rooted on subject across all registered teaching
corpora. Dedupes by (connective, object) — cause + verification
carrying the same predicate emit one clause, not two. Sorts by
(intent, connective, object) for replay stability.
Surface format:
"{X} — narrative-grounded ({corpus_ids}): {dX1}; {dX2}.
{X} {conn1} {O1} ({dO1}); {X} {conn2} {O2} ({dO2}).
No session evidence yet."
Cross-corpus subjects (e.g. mother in relations_v2) emit
narrative-grounded (relations_chains_v2) tag; cognition subjects
emit cognition_chains_v1 tag. Multi-corpus subjects (when
applicable) emit composite "corpus_a + corpus_b" tag.
P3.4 — chat/example_surface.py + IntentTag.EXAMPLE.
Classifier patterns:
^(?:give|show)\s+(?:me\s+)?an?\s+(?:example|instance)\s+of\s+
^example\s+of\s+
example_grounded_surface(object_lemma, max_examples=3) walks chains
where the lemma is the OBJECT — inverts the typical subject-keyed
access pattern. Dedupes by subject; sorts by (intent, subject,
connective).
Surface format:
"{X} — example-grounded ({corpus_ids}): {dX1}.
Example: {subj1} {conn1} {X}; {subj2} {conn2} {X}.
No session evidence yet."
Cross-cutting:
- Both intents added to _OOV_INTENT_TAGS — fall through to OOV
invitation when subject is unknown (Phase 2 gradient discipline).
- Both tagged grounding_source="teaching" (same provenance tier
as the existing teaching_grounded_surface).
- No prose generation, no new mutation surface.
Live verification:
> Tell me about truth.
[teaching] truth — narrative-grounded (cognition_chains_v1):
cognition.truth; logos.core. truth grounds knowledge
(cognition.knowledge); truth requires evidence (cognition.evidence).
> Give me an example of knowledge.
[teaching] knowledge — example-grounded (cognition_chains_v1):
cognition.knowledge. Example: truth grounds knowledge;
understanding requires knowledge; evidence grounds knowledge.
> Tell me about mother.
[teaching] mother — narrative-grounded (relations_chains_v2):
kinship.parent.female. mother precedes daughter (kinship.child.female).
> Describe photosynthesis.
[oov] I haven't learned 'photosynthesis' yet (intent: narrative). ...
ADR-0066 (this commit completes the ADR). 30 new tests passed.
Full lane: 2067 passed, 2 skipped, 0 failed in 2:32.
Add a deterministic, pack-agnostic post-processor in `generate/intent.py`
that runs after the `_RULES` table fires:
- DEFINITION / RECALL / PROCEDURE: strip trailing punctuation + leading
articles; preserve multi-word noun phrases
- CAUSE / VERIFICATION: additionally strip leading aux verbs; return
the head noun
Closed-set frozen sets (`_ARTICLES`, `_AUX_VERBS`) make the transform
inspectable. No pack load, no algebra change — touches only
`DialogueIntent.subject`.
Cognition eval (13-case public split):
surface_groundedness 46.2% → 61.5% (+15.3 pp)
term_capture_rate 33.3% → 50.0% (+16.7 pp)
intent_accuracy 100.0% (=)
versor_closure_rate 100.0% (=)
Two cases lift through the ADR-0048 pack path
(definition_procedure_023, definition_relation_026 — both
"What is a X?" → subject=X via article stripping). CAUSE / VERIFICATION
subjects are now clean head nouns, foundational for future COMPARISON
pack path / teaching-store inference.
Tests: tests/test_intent_subject_extraction.py (30 tests).
Lanes green: smoke (67), cognition (121), runtime (19), algebra (132),
teaching (17), packs (6).
Closes ADR-0046's deferred follow-up: convert the PropositionGraph
into an AdmissibilityRegion BEFORE generate() runs on the live
chat path.
== generate/intent_bridge.py ==
New public helper:
build_graph_from_input(text, plan) -> PropositionGraph
Same internal call as _build_graph_from_intent, without the
post-generation ground_graph step — suitable for forward use.
== chat/runtime.py ==
When the new flag is on and output language is English, build the
graph and the region before generate() and pass it via region=.
Empty / fully OOV graphs return AdmissibilityRegion(allowed_indices=None),
which generate() treats as unconstrained — the change is a true
no-op when the graph carries no in-vocab anchors.
== core/config.py ==
RuntimeConfig.forward_graph_constraint: bool = False
Default False preserves all pre-ADR-0046 behaviour and the ADR-0024
honest-refusal contract. A first attempt wired the constraint
unconditionally; 15 tests failed with InnerLoopExhaustion because the
intent-derived graph's CGA neighbourhood doesn't intersect the walk's
candidate pool with top_k=8 on the current packs. The honest answer
is not to widen top_k until the failure goes away nor to silently
relax — both erase the architectural information that the geometry
of the graph and the geometry of the walk are not yet co-located.
Opt-in preserves ADR-0024 and follows the ADR-0022→0026 transition-
window pattern.
== Characterisation (core eval cognition, 13-case public split) ==
A/B with the flag toggled:
Metric OFF ON Δ
intent_accuracy 100.0% 100.0% 0
surface_groundedness 15.4% 15.4% 0
term_capture_rate 0.0% 0.0% 0
versor_closure_rate 100.0% 100.0% 0
InnerLoopExhaustion 0 0 0
non-trivial constraint n/a 6 / 13 —
Findings:
- Wiring is correct and safe (no exhaustions, closure unchanged).
- Single-token in-vocab subjects engage the constraint
(light/knowledge/meaning/memory/correction).
- Multi-word OOV subject phrases produced by the intent classifier
fall through to unconstrained — this is the existing intent-
classifier contract surfacing into geometry, not a constraint bug.
- Restricting which tokens the walk may visit did not change
surface_groundedness or term_capture_rate on this lane. The
surface-grounding gap therefore lives downstream of propagation
— in the realizer / surface-assembly / dialogue-role path — and is
the next load-bearing pull. This isolates the next ADR's scope.
== tests/test_forward_graph_constraint_wiring.py (5 tests) ==
- DEFAULT_CONFIG.forward_graph_constraint is False
- Default runtime answers without InnerLoopExhaustion
- Opt-in runtime answers on a short benign input
- Graph builder + build_graph_constraint produce a labelled
AdmissibilityRegion ("graph:unconstrained" or "graph:<root_id>")
- Flag is observable on the frozen RuntimeConfig
== docs/decisions/ ==
- ADR-0047 ratifies the wire-up, opt-in rationale, and A/B numbers.
- README index updated; the Pillar 1→2→3 section now reflects both
the primitive (ADR-0046) and the live wiring (ADR-0047), and
names the next pull (realizer / surface assembly) explicitly.
Verification (this branch):
tests/test_forward_graph_constraint_wiring.py 5 passed
tests/test_graph_constraint.py 8 passed
core test --suite smoke 67 passed
core test --suite cognition 121 passed
core test --suite runtime 19 passed
core test --suite algebra 132 passed
core test --suite teaching 17 passed
core test --suite packs 6 passed
core eval cognition metrics unchanged from main
versor_condition(F) < 1e-6 invariant unaffected.
The original adr-0046 commit was never run. Fixes:
- generate/graph_constraint.py: import RegionSource (was the
non-existent AdmissibilitySource).
- tests/test_graph_constraint.py + demo_01: load pack
"en_core_cognition_v1" (was "en", which is not a pack ID).
- demo_03: read JsonlBufferSink.lines as a list attribute, not a
method call.
- demo_04 (exact_recall_scale): DROPPED. The construction used
raw standard_normal vectors through unitize_versor and asserted
cga_inner self-similarity is the population max. Cl(4,1) has
mixed signature — cga_inner is not self-maximising for arbitrary
unitized random vectors — and the demo failed at N=10 000 in
exactly the way the construction predicts. The exact-recall
claim's correct home is ADR-0045 (real vault path, properly
constructed versors, N up to 100k = 100%).
Doc/index updates:
- ADR-0046 trimmed to three demos, with an explicit note on the
dropped demo's geometric error and the cross-reference to
ADR-0045.
- ADR-0046 verification block updated with measured lane numbers
(smoke 67 / cognition 121 / runtime 19 / algebra 132 /
teaching 17 / packs 6; core eval cognition unchanged).
- ADR-0046 cross-references ADR-0018 (intent_bridge source of the
graph) and ADR-0022→ADR-0026 (AdmissibilityRegion contract).
- docs/decisions/README.md: ADR-0046 added to the index and to a
new "Pillar 1 → 2 → 3 coupling" section linking the graph
constraint to the existing forward-semantic-control chain.
- evals/industry_demos/__init__.py: invocation list trimmed to
the three real entry points; removed the aspirational
"core demo …" subcommands that were never wired.
Verification on this branch:
tests/test_graph_constraint.py 8 passed
evals/industry_demos/demo_01..03 exit 0 each
core test --suite smoke 67 passed
core test --suite cognition 121 passed
core test --suite runtime 19 passed
core test --suite algebra 132 passed
core test --suite teaching 17 passed
core test --suite packs 6 passed
core eval cognition intent 100%, versor_closure 100%
Closes the structural gap identified in the 2026-05-17 assessment:
the PropositionGraph was a post-hoc descriptor of what the field walk
already produced. It is now a forward constraint that shapes what the
walk is ALLOWED to produce.
== generate/graph_constraint.py (new) ==
GraphConstraint — converts a PropositionGraph into an AdmissibilityRegion
before generate() runs, not after. The region's allowed_indices are the
intersection of:
- subject versor neighbourhood (top-k by CGA inner product)
- object versor neighbourhood (top-k by CGA inner product)
- any explicitly named node surfaces already in-vocabulary
This is the Pillar 1 → Pillar 2 coupling that was missing:
geometry (CGA) → structure (graph) → propagation (generate)
build_graph_constraint(graph, vocab, *, top_k) is the public entry.
The region label encodes the graph's root node IDs so the admissibility
trace identifies the constraint source.
== generate/stream.py (updated) ==
generate() already accepts an AdmissibilityRegion. No new API needed —
graph_constraint.build_graph_constraint() produces one.
== evals/industry_demos/ (new) ==
Four standalone demo scripts that each make ONE falsifiable claim no
transformer-LLM wrapper can reproduce. Each script runs independently
via `python -m evals.industry_demos.<name>` and exits 0 on pass / 1 on
fail. Each prints structured evidence to stdout.
demo_01_forward_constraint.py
Claim: When the PropositionGraph names subject=light, obj=truth, the
generation walk is constrained to the CGA neighbourhood of those
versors BEFORE any tokens are produced. The allowed_indices set is
computed from geometry, not from a prompt filter. Demonstrated by
showing the AdmissibilityRegion is non-trivial (< full vocab) and
that all generated tokens score positive CGA inner product against
the constraint field.
demo_02_geometry_drives_identity.py
Claim: Swapping the identity pack (precision_first vs generosity_first)
on identical input produces structurally different surfaces via the
manifold alignment path — not via a system-prompt swap. Demonstrated
by running two ChatRuntime instances with different identity_pack IDs
on the same text, showing hedge_rate and identity_score.alignment
differ, and that the manifold alignment_threshold differs at the
algebra level (not just the text level).
demo_03_deterministic_audit.py
Claim: Three independently constructed ChatRuntime instances on the
same input produce byte-identical JSONL audit lines. Demonstrated
by attaching JsonlBufferSink to each, running chat(), and asserting
hash equality of the emitted lines (modulo the 'turn' field which is
per-instance sequential). This is architectural determinism — not
seeded randomness.
demo_04_exact_recall_scale.py
Claim: CGA vault recall is exact (100%) at N=100, N=1_000, N=10_000.
The needle versor is recovered at rank-1 by cga_inner scan regardless
of vault size. No approximate nearest-neighbour index. No FAISS.
No degradation curve. Demonstrated inline with timing so the
linear-scan cost is visible alongside the 100% recall.
== tests/test_graph_constraint.py (new) ==
8 tests:
- build_graph_constraint returns an AdmissibilityRegion
- allowed_indices is a strict subset of vocab (non-trivial constraint)
- all constraint indices score positive cga_inner against at least
one node versor
- empty graph returns unconstrained region (safe fallback)
- two-node graph unions both neighbourhoods
- constraint label encodes root node IDs
- round-trip: constraint region feeds generate() without raising
- forward vs post-hoc: constrained walk produces tokens in the
region; unconstrained walk may not (statistical, seeded vocab)
Co-Authored-By: Perplexity AI
Closes the 'identity hedges are generic' gap. When IdentityCheck reports
that a specific axis is deviating AND the pack supplies an axis_hedges
entry for that axis, the assembler uses that axis's phrase instead of
ADR-0028's generic preferred_hedge_*. The hedge text now names what is
actually at issue.
Selection: lex-smallest axis_id in (ctx.deviation_axes ∩ axis_hedges).
Deterministic; loader emits axis_hedges in lex order on axis_id.
Example surface at alignment=0.30 (strong band) under default pack:
No deviation → 'It seems that truth reveals reality.'
truthfulness deviates → 'Evidence is thin that truth reveals reality.'
coherence deviates → 'This does not yet cohere: truth reveals reality.'
reverence deviates → 'Reports suggest truth reveals reality.'
Same trajectory + truthfulness deviation, three different packs:
default_general_v1 → 'Evidence is thin that truth reveals reality.'
precision_first_v1 → 'The evidence does not support that truth reveals reality.'
generosity_first_v1 → 'Truth reveals reality.' (above generosity's strong=0.20)
Schema (additive, optional):
surface_preferences.axis_hedges = {
<axis_id>: { 'strong': str, 'soft': str, 'qualifier': str },
...
}
Bounds: each phrase length 1–64; axis_id non-empty. Absent block →
ADR-0028 byte-for-byte fallback. Loader emits pairs in lex order on
axis_id for hashability + deterministic tie-break.
Files:
core/physics/identity.py
+ class AxisHedge (frozen: strong, soft, qualifier)
SurfacePreferences gains axis_hedges: Tuple = ()
packs/identity/loader.py
+ _build_axis_hedges(): parse + bounds-check + emit lex-ordered tuple
generate/surface.py
SurfaceContext gains deviation_axes: frozenset[str] + axis_hedges tuple
+ _axis_specific_phrase(ctx): lex-smallest match or None
_apply_hedge consults axis-specific phrase before ADR-0028 fallback
Depth languages (he, grc) unchanged — ADR-0030 canonical phrases
chat/runtime.py
_build_surface_context lifts identity_score.deviation_axes and
prefs.axis_hedges into SurfaceContext
packs/identity/*.json
Three v1 packs gain axis_hedges blocks (truthfulness, coherence,
reverence — each pack uses voice consistent with its character)
scripts/ratify_identity_packs.py (no change — idempotent)
packs/identity/*.mastery_report.json
Auto-refreshed. New SHAs:
default_general_v1 → 2ab7d469013509ba5030313ca9a609a443d0716e3ddcc5596f59858ce054f5d3
precision_first_v1 → 78aa1e6a68a35c2c8576b6196a52d421b94f6d11e006128986902a4fd08679af
generosity_first_v1 → 511f1ce20edd4266239da61443bfc93473a5433f20bfee6692a25a03073dc933
Tests: tests/test_identity_score_decomposition.py — 17 new tests:
per-axis phrase selection, band gating still applies, pack swap with
same deviation produces three different phrases, lex tie-break is
deterministic, depth-language fallback to ADR-0030, backward compat
with empty deviation_axes, and the contract that all three v1 packs
ship axis_hedges for all three default-pack axes.
Suite status (all green):
cognition 121, teaching 17, runtime 19, formation 182, smoke 67
identity+safety+English+depth divergence 71
score decomposition 17
Scope limits (documented in ADR-0031):
- English-only at v1 (depth languages use canonical ADR-0030 phrases)
- Lex tie-break is operational not semantic — pack authors can re-key
if they need a different priority
- No dominance-driven phrasing (Interpretation A); preserved as
forward-compatible follow-up
Docs: ADR-0031 (Accepted) recorded; docs/identity_packs.md gains
§Axis-specific hedge phrases section and updated v1-pack SHAs; memory
'identity-packs.md' refreshed.
Closes the ADR-0028 'English-only differentiation' gap. Hebrew and
Koine Greek surfaces now consult identity-pack surface_preferences for
hedge and claim-strength shaping, using language-appropriate canonical
hedge phrases. CORE's three-language foundation (English / Hebrew /
Greek) is now uniformly identity-aware at the realizer.
Algorithm: the same four-band hedge/claim-strength logic from ADR-0028
runs for all three languages. Thresholds and claim_strength come from
the identity pack (carried on SurfaceContext). Hedge phrases come
from ctx for English and from a new module-level constant
_DEPTH_HEDGE_PHRASES for Hebrew (he) and Koine Greek (grc).
he: 'נראה ש' / 'אולי' / 'במקרים מסוימים,'
grc: 'δοκεῖ ὅτι' / 'ἴσως' / 'ἐνίοτε,'
Pack swap visibly affects depth-language output: a precision_first
identity pulls hedges to higher alignment than default; a generosity
pack pulls them to lower alignment. Same trajectory through the
manifold → three different Hebrew surfaces under three different
packs. Same for Greek.
Files:
generate/surface.py
_DEPTH_HEDGE_PHRASES (new module constant)
_apply_hedge(surface, ctx, lang='en') — lang param added
_assemble_he(.., ctx) — ctx param added
_assemble_grc(.., ctx) — ctx param added
SentenceAssembler.assemble — passes context to he/grc
tests/test_identity_surface_divergence_depth.py — 15 new tests:
Hebrew hedge bands, Greek hedge bands, pack-swap divergence in
both depth languages, three-language hedge phrase distinctness,
backward compatibility with ctx=None
docs/decisions/ADR-0030-depth-language-hedge.md — Accepted
docs/identity_packs.md — closes known-limit #1
memory/identity-packs.md — refreshed
Backward compat:
- _apply_hedge default lang='en' so existing callers unaffected.
- English surface output byte-for-byte unchanged.
- _assemble_he / _assemble_grc with ctx=None match pre-ADR output
byte-for-byte (asserted by TestBackwardCompatibility).
Scope limits (documented in ADR):
- Depth-language hedge phrases are canonical defaults, not per-pack
overridable yet. Future ADR may add a 'languages' block to the
pack schema if a downstream deployment needs override capability.
- Contrast ('However, ...') and subordination ('Given that ..., ...')
remain English-only. Hedge is the dominant differentiator.
- Hebrew/Greek grammar / word order unchanged.
Suite status: cognition 121, teaching 17, runtime 19, formation 182,
smoke 67 — all green. Identity + safety + divergence suites: 26+15+15+15=71
all green.
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
Replace the static-threshold admissibility gate with a ranked-with-
margin check that is scale-invariant under blade-norm variation.
Phase 4 characterization established no single global threshold
separates the v2 mechanism-isolation cases (blade norms vary ~10x);
margins between top and second-ranked candidates do, because they
scale with the blade norm and carry the relative ordering the
geometry actually delivers.
New primitives in generate/admissibility.py:
RankedCandidate — (index, word, score)
MarginVerdict — admit/reject + top + margin + full ranking
rank_candidates_by_blade — sort admissible set by cga_inner desc,
strict > tie-break by ascending vocab index
check_margin — admit top iff score>0 AND margin>=delta
Selection semantics in margin mode are blade-rank-driven: the top-
ranked admissible candidate IS the admitted destination. Differs
from threshold mode (field-driven _nearest_next then per-candidate
gate). Both modes coexist; threshold is the default and ADR-0024
acceptance evidence is preserved byte-for-byte.
Wired through:
core/config.py admissibility_mode="threshold" (default)
admissibility_margin=0.4
chat/runtime.py forwards both fields
generate/stream.py margin_mode_active branch — ranks the
candidate set once per step, admits or
raises InnerLoopExhaustion with the full
ranking in rejected_attempts
Default delta = 0.4 chosen from the v2 case margins:
V2-001: 0.596 V2-002: 0.456 V2-003: 13.27
V2-004: 3.37 V2-005: 12.74
min = 0.456 → 0.4 admits all 5 with headroom; 0.5 would refuse
V2-002. The default is falsifiable: Phase 5 may surface a case
below 0.4, which should be reported as an architectural finding
rather than patched per-case.
Acceptance evidence (tests/test_margin_admissibility.py, 13 passing):
5/5 v2 cases pass in margin mode; forbidden_token in every
case's rejected_attempts ranking
Refusal-on-insufficient-margin: delta=0.9 on V2-001 (margin
0.597) raises InnerLoopExhaustion with full ranking; no silent
boundary fallback
Threshold mode byte-identical with or without margin plumbing
5 reruns produce identical canonical trace steps
Strict > tie-break: equal scores resolve to lower-index winner
deterministically
Invariants preserved:
versor_condition < 1e-6 — rotor V is constructed only for the
admitted candidate; margin mode adds no normalization/repair site
Deterministic replay — strict > tie-break now load-bearing in
rank_candidates_by_blade alongside vocab.nearest
No approximate recall, no cosine similarity, no HNSW/ANN; pure
rank-and-difference on exact cga_inner scores
No new code in field/propagate.py, algebra/versor.py,
vault/store.py, or chat/runtime.respond()
Suite results:
full: 1037 passed, 2 skipped (+13 new margin tests)
core eval cognition: 13/13, 100% intent_accuracy,
100% versor_closure_rate
ADR-0026 documents the contract, the single-delta rationale, the
falsifiability story, and the residual risks. Margin mode is
flag-gated default-off; a future ADR may promote it to default
after Phase 5's diversified families confirm the single delta
holds (or surface the architectural finding if it doesn't).
Replace plain ValueError at both inner-loop exhaustion sites in
generate/stream.py with InnerLoopExhaustion, a typed ValueError
subclass carrying machine-readable refusal evidence:
reason : RefusalReason (INNER_LOOP_EXHAUSTION)
region_label : which AdmissibilityRegion blocked
step_index : -1 = pre-walk empty intersection;
>=0 = in-walk per-step exhaustion
rejected_attempts : ordered (idx, word, score) triples
Backward-compat by construction: subclassing ValueError preserves
every pre-Phase-2 `except ValueError` handler in chat/runtime.py,
eval lanes, and tests. No edits to chat/runtime.py, field/propagate.py,
algebra/versor.py, or vault/store.py.
Trace path wired:
- CognitiveTurnResult.refusal_reason (str, default "")
- compute_trace_hash folds refusal_reason only when non-empty
-> byte-identical hashes preserved for non-refused turns
- CognitiveTurnPipeline reads via getattr from ChatResponse and
forwards into both trace_hash and result construction
Contract documented in docs/runtime_contracts.md §"Refusal contract".
Tests (tests/test_refusal_contract.py — 10 passing):
- InnerLoopExhaustion isinstance(ValueError) at both raise sites
- In-walk site carries reason/region_label/step_index>=0/
rejected_attempts with (int,str,float) triples
- Pre-walk site uses step_index=-1 sentinel + empty
rejected_attempts
- Pre-walk fires even when inner_loop_admissibility=False
- Trace hash: empty refusal_reason preserves legacy bytes;
non-empty differs; same inputs are stable
Suite results:
smoke: 67 passed
cognition: 121 passed
runtime: 19 passed
full: 1024 passed, 2 skipped
core eval cognition: 13/13, 100% intent accuracy, 100% versor closure
Residual silent path (documented as out-of-scope for Phase 2):
chat/runtime.respond()/arespond() still convert any ValueError to
"" for their public str return contract. So a refused turn today
produces surface == "" with refusal_reason == "" — the typed
evidence is unread between the raise site and the result. The
plumbing on result + trace + pipeline is in place so a future ADR
can wire materialisation (propagate exception to
ChatResponse.refusal_reason, or catch at the pipeline seam) without
re-deriving the contract.
Phase 1 (commit 3940290) and Phase 2 (this commit) were developed
in parallel with disjoint file scope to avoid conflicts.
Phase 2 — Corpus observation runner (inner_loop_runner.py):
- Four-condition matrix: boundary_only / null_control / inner_loop_t0 / inner_loop_tpos.
- Added `inner_loop_force_admit` to generate() — exercises the inner-loop
code path but force-breaks on first candidate. Eval-only null control:
isolates rejection as the causal factor for any pass-rate delta.
- Metrics: pass_rate, mean_rejection_count_per_turn,
non_empty_rejected_attempts_rate, exhaustion_rate (gated at 5%),
mean_admissibility_checks_per_turn, mean/p95 added_latency_ms,
trace_hash_stability across 5 reruns per case.
- Finding on v1+dev: causal_attribution_valid=True, code_path_residual=0.0,
but exhaustion_rate=0.33 at t=0 — chain outer-product blade is
geometrically blind to the active pack.
- Tests (tests/test_inner_loop_phase2.py, 5 pass): pin
causal-attribution and live-corpus trace-hash stability invariants.
Phase 3 — Mechanism-isolation v2 corpus (5 cases, v2_runner.py):
- Synthetic adversarial cases with controlled geometry — each case
specifies seed_token, admissible_tokens, relation_blade_token, and
admissibility_threshold. Field state is constructed directly from
the seed token versor, not via priming.
- For every case: boundary-only selects the forbidden decoy and
inner-loop selects the expected endpoint with the forbidden token
appearing in rejected_attempts.
- Result: mechanism_isolated=true on 5/5. boundary_decoy_rate=1.0,
rejection_traced_rate=1.0. Inner-loop rejection is demonstrably
doing causal semantic work on real packs.
- Tests (tests/test_inner_loop_phase3.py, 8 pass): GATE on
mechanism_isolated.
Phase 4 — Threshold characterization (threshold_characterization.py):
- Distribution mapping per-case AND globally on v1+dev, v2, combined.
- Per-threshold sweep over [-1.0, -0.5, 0.0, 0.1, 0.25, 0.5, 1.0].
- Finding: per-case geometry separates cleanly (correct_min > incorrect_max
on every v2 case), BUT no global static threshold passes the
separation_quality >= 0.8 gate. Blade norms vary ~10x across cases.
- Static thresholds (global, relation-typed, or constant frame-derived)
are geometrically insufficient. Per-case-normalized thresholds
(e.g. fraction of blade self-score) are the recommended next step.
- v1 chain-token outer-product cases all skipped — the corpus's chain
tokens (alpha, beta, gamma, delta) are not grounded in the active
pack. Load-bearing finding for ADR-0025 region construction.
- Tests (tests/test_inner_loop_phase4.py, 5 pass): pin the finding
diagnostically (not gated).
Phase 5 — ADR-0025 design note (draft):
- No code changes proposed. Scopes three architectural questions:
(1) home (algebra/versor.py vs field/propagate.py vs generate/) —
preliminary stance: algebra/versor.py.
(2) threshold scheme (blade-normalized fraction recommended over
static; learned/adaptive rejected for determinism).
(3) teaching-loop boundary — Stance A confirmed: rejections are
runtime hygiene only, no entanglement with teaching/*.
- Decisions to be closed before Draft → Accepted.
Phase 1 acceptance criteria from previous commit (7fccf36) carry
forward: wired, deterministic-when-wired, legacy hash preserved.
Suite: 1014 passed, 0 failed, 2 skipped.