Commit graph

464 commits

Author SHA1 Message Date
Shay
aa15dc1f3d feat(adr-0174-phase4): in-loop contemplate + en_core_names_v1 pack
ADR-0174 Phase 4 — deterministic search adapter for evidence that
disambiguates surviving hypothesis sets. First load-bearing use case:
gendered-pronoun resolution via the en_core_names_v1 pack — turns
the Phase 3a multi-actor defense from refuse-on-ambiguity into
admit-via-evidence when an unambiguous gendered name binds the
pronoun to one antecedent.

generate/comprehension/contemplate.py (new, ~310 lines):
  - Resolution dataclass (closed-set kind + source + evidence shape)
  - VALID_RESOLUTION_KINDS = {eliminate, admit_unknown}
  - VALID_RESOLUTION_SOURCES = {vault, pack, audit_history}
  - contemplate() orchestrator — adapters consulted in precedence
    order: vault > pack > audit_history (ADR-0174 §Open Q#3)
  - _consult_packs() — gendered-pronoun resolution implementation
  - _consult_vault() and _consult_audit_history() — stubs (Phase 4b)
  - _PRONOUN_GENDER closed map (she/he gendered; they/them epicene)
  - _load_names_pack() with @lru_cache; refusal-preferring on
    absent pack

language_packs/data/en_core_names_v1/ (new pack):
  - gender.jsonl — 59 unambiguously-gendered English first names
    (30 female, 29 male), alphabetically sorted, JSONL with schema
    {name: str, gender: 'female'|'male'}.  Covers names appearing
    in train_sample/v1 GSM8K problems (Alice, Bob, Daniel, Malcolm,
    Erica, Jan, Tina, etc.).  Deliberately excludes ambiguous-
    gender names (Jordan, Alex, Casey, Pat, Taylor, Morgan, Sam,
    Chris, Robin, Riley).
  - manifest.json — pack metadata with sha256 checksum
    (f65836e7a25a9db8aae984d259b60e161574ff3b4bb135a924aa767a794fbd21),
    entry count, schema declaration, ambiguity discipline,
    expansion pathway through HITL corridor, wrong=0 protection
    contract.

generate/math_candidate_graph.py:
  - Phase 4 wiring at the multi-actor defense site (was: refuse
    on len(_distinct_priors) > 1; now: invoke contemplate first,
    fall through to defense when contemplate returns None).
  - On contemplate.kind='admit_unknown' from pack source: extract
    chosen antecedent from evidence, override _antecedent, clear
    _multi_actor_ambiguous, proceed to admit-via-PronounResolution.
  - On contemplate=None: fire new 'ambiguous_unresolvable'
    contemplate trace event AND original 'no_antecedent_ambiguous'
    lookback event, drop candidates.

tests/test_adr_0174_phase4_contemplate.py (new):
  27 acceptance tests covering: primitive contract (empty/single-
  survivor noop), Resolution dataclass invariants (5 refusal
  paths), names pack load + content spot-checks, pronoun gender
  lookup (gendered + epicene), 6 gendered-pronoun resolution
  cases (she/he success, same-gender refusal, unknown-name
  refusal, epicene refusal, no-matching-gender refusal), end-to-
  end wiring through parse_and_solve, determinism (two calls
  byte-identical, evidence sorted), closed-set contracts,
  wrong=0 + case-0050 canary.

tests/test_adr_0174_phase3_lookback.py + phase3b_compound_clause.py:
  Updated the multi-actor defense tests to use SAME-GENDER
  antecedents (Alice + Mary) so Phase 4 contemplate cannot
  disambiguate via gender pack — the Phase 3a defense still
  fires. (For mixed-gender antecedents the new behavior is
  correct admit-via-evidence; that's tested in Phase 4 suite.)

End-to-end answer-correctness caveat (documented in test
docstrings):
  Phase 4 trace events fire correctly when the recognizer-
  injection path encounters multi-actor pronoun cases that the
  pack disambiguates.  However the regex parser ALSO produces
  candidates for simpler pronoun-subject shapes (without
  intervening prepositional phrases); those compete in the
  Cartesian product and the contemplate-resolved binding may be
  shadowed.  This is the latent regex-path pronoun hazard tracked
  in project-adr-0174-multi-actor-pronoun-hazard memory.  Full
  answer lift on train_sample requires regex-path defense (Phase 5
  regex retirement work).

Acceptance:
- 285/285 ADR-0174 + math_problem_graph tests pass
- Smoke 67/67, packs 141/141
- train_sample 3/47/0 preserved (wrong=0 held)
- Phase 4 trace event fires end-to-end on multi-PP pronoun-subject
  case: contemplate/resolved with chosen=Alice, evidence pack
  Alice=female + Bob=male

References: ADR-0174 §In-loop contemplation, CLAUDE.md §Lookback
Review Discipline, docs/handoff/ADR-0174-PHASE-3B-4-COMBINED-SCOPE.md,
docs/handoff/phase-3b-4-skeleton/ (skeleton dispatch source),
project-adr-0174-multi-actor-pronoun-hazard memory.
2026-05-28 12:09:52 -07:00
Shay
4b277d4e84 feat(adr-0174-phase3b): compound-clause held hypotheses
ADR-0174 Phase 3b — emit N anchors for compound-clause discrete-count
sentences sharing one subject + one verb. Architectural substrate;
score on train_sample preserved at 3/47/0 (compound cases like 0027
admit past the recognizer-injection refusal but the rest of the
problem still has downstream complexity — fractions, percent — that
needs Phase 4 + solver work).

generate/comprehension/state.py:
  HYPOTHESIS_CAP raised 4 → 8. Case 0040 emits 5 anchors; cap=8
  gives headroom (7-item lists) without becoming permissive.

generate/recognizer_match.py:
  _try_extract_compound_discrete_count_anchors() — new extractor
  emitting tuple of anchors for compound sentences. Refusal-
  preferring on:
    - no conjunctive separator (single-anchor path)
    - multiplicative/percent/fraction markers
    - head verb not in whitelist
    - any tail clause without grounded (count, observed_noun) pair
    - exceeding HYPOTHESIS_CAP
    - unaccounted digit in tail (wrong=0 hazard defense surfaced by
      2026-05-28 implementation review: bogusnoun would silently fail
      to produce anchor while leaving the digit unaccounted, admitting
      partial state)
  Wired into _match_discrete_count_statement dispatch as fallback when
  single-anchor extraction fails.

tests/test_adr_0174_phase3b_compound_clause.py:
  11 acceptance tests passing — pure conjunctive lists (proper-noun
  + pronoun-subject + single-actor antecedent), refusal-preferring
  discipline (mixed-verb, multiplicative-tail, non-whitelisted-head,
  partial-grounding all-or-nothing), HYPOTHESIS_CAP enforcement,
  multi-actor pronoun defense preserved on compound, wrong=0 +
  case-0050 canary.

tests/test_adr_0174_phase1_held_hypothesis_state.py:
  Updated test_hypothesis_cap_is_four → test_hypothesis_cap_is_eight
  with rationale for the raise.

Phase 3b implementation lookback review (per CLAUDE.md doctrine):
  - Surfaced silent-partial-admission hazard in tail extraction;
    fixed with digit-accounting check before commit
  - Surfaced LATENT regex-path multi-actor pronoun hazard (not
    introduced by Phase 3b; documented in test docstring with
    cross-reference to project-adr-0174-multi-actor-pronoun-hazard
    memory for follow-up)
  - case 0040 ('He now has...') remains refused — 'now' adverb between
    subject and verb defeats the existing canonical regex. Adverb-
    stripping is separate scope (not Phase 3b).

Acceptance:
- 258/258 ADR-0174 + math_problem_graph tests pass
- Smoke 67/67, packs 141/141
- train_sample 3/47/0 preserved (wrong=0 held)
- Case 0027 'Malcolm has 240 followers on Instagram and 500 followers
  on Facebook' now admits via the compound extractor — verified by
  refusal moving to the next sentence (which has 'half' fraction)
2026-05-28 11:49:57 -07:00
Shay
619cd62227 fix(adr-0174-phase3a): multi-actor pronoun hazard defense + test backfills + ADR amendment
All findings from the 2026-05-28 Phase 1-3a lookback review addressed
in one commit on the Phase 3a branch:

Wrong=0 hazard defense (the load-bearing fix):
- generate/math_candidate_graph.py: Phase 3a wiring now collects the
  set of distinct proper-noun subjects seen in prior context. When
  more than one exists, refuses with no_antecedent_ambiguous trace
  event rather than guessing the most-recent (which was gender-blind
  single-binding — wrong attribution in multi-actor problems).
- Refusals from the statement loop now preserve _statement_trace via
  reader_trace in CandidateGraphResult (pre-existing latent issue:
  Phase 2/3 trace events were dropped on early statement refusal).
- New tests assert: ambiguous case refuses with correct trace; single-
  actor case still resolves normally.

Test coverage backfills (closes the 13 untested predicate-name gaps):
- TestCheckConstraintsInitialPredicateNames — 3 tests asserting the
  exact predicate name on initial.value_grounds / initial.unit_grounds
  / initial.entity_grounds failure paths.
- TestCheckConstraintsOperationPredicateNames — 3 tests asserting
  operation.verb_grounds / operation.value_grounds / operation.unit_grounds
  failure-predicate-name parity.
- TestCheckConstraintsComposedInitialPath — 4 tests for the RAT-1
  composed_initial path which was entirely untested in Phase 2
  (parity manually verified during lookback review; now automated).

ADR amendment (honest doc vs impl drift):
- docs/decisions/ADR-0174-held-hypothesis-comprehension.md: appended
  'Implementation Notes' section documenting:
  - reevaluate signature differs from spec text (shipped is more
    composable; treat as amended)
  - Phase 2 wires per-candidate, not per-token (per-token is Phase 5)
  - Lookback recompute is candidate-level, not token-level
  - Hypothesis.constraint_state is never populated by Phase 2
  - Multi-actor pronoun hazard defense rationale
  - Honest LOC accounting: Phases 1-3a net +1,500 lines (Phase 5
    delivers the projected net removal)
  - Test coverage backfill summary

Cosmetic:
- lookback.py:297 unreachable raise — added # type: ignore[unreachable]
  with comment explaining defensive future-proofing for Phase 3b.

Acceptance verified:
- 124/124 Phase 1+2+3a + reader tests pass (was 95/95 before backfills)
- Smoke 67/67, packs 141/141
- train_sample 3/47/0 preserved (wrong=0 invariant held)
- Multi-actor hazard live-tested: parse_and_solve refuses the
  Alice/Bob/She case with no_antecedent_ambiguous trace event

See CLAUDE.md §Lookback Review Discipline and memory
feedback-lookback-review-discipline for the doctrine that surfaced
all of these issues at the right time.
2026-05-28 10:49:20 -07:00
Shay
5d1f1001f4 feat(adr-0174-phase3a): lookback re-evaluation operator + pronoun resolution substrate
ADR-0174 Phase 3a — substrate for held-hypothesis lookback.
Score unchanged at 3/47/0 (this PR is correctly-engineered
infrastructure; eval impact gated on ADR-0163.x recognizer expansion
documented in the follow-up brief).

Adds generate/comprehension/lookback.py:
- VALID_REFINEMENT_KINDS, VALID_UNRESOLVED_SLOTS — closed sets
  contracted with reader_trace consumer
- PronounResolution refinement dataclass (pronoun + resolved_to +
  evidence_source, all validated)
- Refinement Union (Phase 3b will widen with CompoundClauseExpansion)
- ReevaluateResult dataclass with admit/eliminate consistency
- reevaluate(hypothesis, refinement) operator — applies refinement,
  re-runs check_constraints, returns refined Hypothesis or None.
- _rebuild_candidate_with_resolved_actor — rebuilds
  CandidateOperation / CandidateInitial replacing the semantic actor
  field (op.actor / initial.entity) while preserving matched_actor_token
  / matched_entity_token as the pronoun (so grounding still passes
  against the held statement's source span).

Modifies generate/recognizer_match.py:
- _try_extract_discrete_count_anchor: pronoun-subject statements now
  emit anchors with subject_role=<pronoun> + requires_pronoun_resolution
  marker, rather than refusing at the _REFUSED_SUBJECT_TOKENS check.
  The other narrowness layers (clause split, verb whitelist) still
  refuse; only the pronoun layer changes.

Modifies generate/math_candidate_graph.py:
- After inject_from_match, when any parsed_anchor carries
  requires_pronoun_resolution, the candidates are held as Hypothesis
  objects with unresolved=('actor_pronoun',). The lookback path then
  resolves via the existing _discourse_prior_subjects map and runs
  PronounResolution refinements through reevaluate.  Resolved
  hypotheses flow into per_sentence_choices as if the regex parser
  had produced them; unresolved hypotheses drop cleanly (refusal-
  preferring).  Emits 'lookback' JSON trace events with
  outcome ∈ {admitted, eliminated, no_antecedent}.

Tests:
- tests/test_adr_0174_phase3_lookback.py — 17 acceptance tests
  covering operator semantics on Operation/Initial, dataclass
  invariants, closed-set constants, end-to-end wiring on synthetic
  problems, and wrong=0 preservation on train_sample.

Phase 3.1 follow-up brief:
- docs/handoff/PHASE-3.1-FOLLOWUP-RECOGNIZER-EXPANSION.md documents
  the empirical finding that the train_sample bottleneck is
  verb-coverage (recognizer scope, ADR-0163.x) not lookback
  (ADR-0174 scope). 11 verbs identified for HITL contemplation pass.
  Recommends sequencing: Phase 3a now (substrate), ADR-0163.x verb
  expansion next, Phase 3b after coverage matures.

Acceptance verified:
- 17/17 Phase 3a tests pass
- 95/95 existing tests pass (Phase 1 + Phase 2 + brief_11 + reader_phase2)
- Smoke 67/67, packs 141/141, lanes 8/8
- wrong=0 preserved, score unchanged 3/47/0 (intentional per brief)

Stacks on Phase 2 (PR #420). Rebases onto main after #416 + #420 land.
2026-05-28 10:49:20 -07:00
Shay
3357c5fc71 feat(adr-0174-phase2): continuous constraint propagation in comprehension reader
ADR-0174 Phase 2 — hoist _initial_admissible / roundtrip_admissible into
hypothesis-based constraint checks with structured elimination tracing.
Admission semantics are byte-equivalent to today; the change is structural.

Adds generate/comprehension/constraint_propagation.py:
- VALID_PREDICATE_NAMES: closed set of 17 sub-check names spanning
  initial / composed_initial / operation admissibility predicates.
  Adding new names requires an ADR amendment (structural contract with
  reader_trace consumer).
- ConstraintResult dataclass: admitted bool + predicates_run trace +
  elimination_reason. Validates admitted-vs-reason consistency.
- Elimination dataclass: confidence_rank + predicate + reason for one
  hypothesis being eliminated.  Serialisable as a reader_trace event.
- hypothesis_from_initial / hypothesis_from_operation: adapters wrapping
  CandidateInitial / CandidateOperation as Phase-1 Hypothesis objects
  with caller-supplied confidence_rank.
- _check_initial / _check_composed_initial / _check_operation:
  decomposed sub-check implementations of the existing admissibility
  predicates with first-failure short-circuit (matches current
  semantics).  Each sub-check populates predicates_run with (name, ok|
  fail|skip) so the consumer sees exactly which predicate decided.
- check_constraints: dispatches on candidate type.
- eliminate_violating: bulk filter; returns (survivors, eliminations);
  survivors are re-densified to satisfy ProblemReadingState's
  open_hypotheses post_init invariant (dense-from-0 ranks);
  eliminations carry the original confidence_rank for trace fidelity.

Wires into generate/math_candidate_graph.py at the recognizer
injection site (line 825+): replaces inline _initial_admissible /
roundtrip_admissible dispatch with eliminate_violating. Elimination
events become JSON entries in reader_trace with layer=
'constraint_propagation', phase=2, predicate, reason, sentence_index.

Phase 2 acceptance verified:
- 24/24 ADR-0174 Phase 2 tests pass (emission, parity with existing
  predicates on 9 admit/reject cases, redensification, dataclass
  invariants, integration).
- 71/71 existing reader + Phase 1 tests still pass.
- Smoke 67/67, packs 141/141, lanes 8/8.
- train_sample/v1 byte-identical across two runs with use_reader=True.
- Score preserved: correct=3 refused=47 wrong=0 — semantics identical
  because the decomposed sub-checks short-circuit on the same predicates
  the inline checks would have caught.

Trace-event behavior: today's injectors are conservative enough that
zero eliminations occur on train_sample/v1 (no false positives, no
mid-pipeline failures).  The wiring is exercised by
test_phase2_event_shape_when_synthesized which proves the trace shape
on a synthetic CandidateInitial that fails initial.unit_grounds.  When
Phase 3 begins emitting partial hypotheses from apply_word, the
elimination path will fire on real candidates and the trace will
populate.

Stacks on Phase 1 (feat/adr-0174-phase1-held-hypothesis-state, PR
#416).  Merges cleanly into main after PR #416 lands.
2026-05-28 10:16:33 -07:00
Shay
7a09b70a5e
Merge pull request #416 from AssetOverflow/feat/adr-0174-phase1-held-hypothesis-state
feat(adr-0174-phase1): held-hypothesis state primitive in comprehension reader
2026-05-28 10:15:32 -07:00
Shay
d17fec6801 fix(math-graph): refuse contradictory initial possessions (wrong=0 hazard)
MathProblemGraph.__post_init__ now raises MathGraphError when two
InitialPossession entries share the same (entity, unit) key but
declare different quantity values.

Pre-fix behavior surfaced by 2026-05-28 ADR-0174 Phase 3 post-merge
diagnostic: math_solver.solve() line 207 used last-write-wins dict
assignment when consolidating initial state. Two contradictory
inputs would silently overwrite without trace:

  'Sam has 5 marbles. Sam has 3 marbles. How many marbles does Sam have?'
   → returned 3.0 (wrong=0 violation: definite answer from
     contradictory input)

Post-fix: same input refuses with 'no branch produced a solvable
graph' — refusal-preferring discipline as wrong=0 doctrine requires.

Identical duplicates (same value) are admitted as redundant (no
contradiction). Different units for same actor admitted. Different
actors for same unit admitted. Single-value cases (the dominant
real-world pattern) unchanged.

This is an extraction-layer hazard discovered while investigating
Phase 3b scope: Phase 3b compound-clause held hypotheses would
emit multiple CandidateInitial entries per sentence, exercising
exactly this consolidation path. Fixing the silent overwrite NOW
ensures Phase 3b admission doesn't silently produce wrong answers.

Acceptance:
- 4 new tests in TestContradictoryInitialPossessionsRefuse
- 165/165 test_math_problem_graph tests pass (was 161/161)
- Smoke 67/67, packs 141/141 unchanged
- train_sample 3/47/0 unchanged (no real case exercised the
  overwrite — but the hazard was latent)

References: CLAUDE.md §Lookback Review Discipline (the doctrine
that surfaced this), CLAUDE.md §Non-Negotiable Field Invariant
(make illegal states difficult to represent).
2026-05-28 09:51:14 -07:00
Shay
a713d2db33 feat(adr-0174-phase1): held-hypothesis state primitive in comprehension reader
ADR-0174 Phase 1 — substrate only, no admission behavior change.

Adds to generate/comprehension/state.py:
- HYPOTHESIS_CAP (=4, structural assertion per ADR-0174 §Constraints)
- VALID_HYPOTHESIS_CONFIDENCE_RANKS (closed set, no probabilistic ranking)
- Hypothesis dataclass (frozen, slots) — candidate, category_assignments,
  constraint_state, confidence_rank, unresolved. The 'candidate' field is
  typed as object to avoid circular import on math_roundtrip /
  math_candidate_graph candidate types; Phase 2 will pin canonical_bytes
  contract over real candidates.
- UnknownHeld dataclass — token, position, narrowed_categories (frozenset).
  Substrate for Phase 3 'hold instead of refuse' on unknown words; Phase 1
  introduces only the type.
- ProblemReadingState.open_hypotheses + unknown_held fields, both default
  to () (empty tuple). Defaults preserve today's single-committed behavior
  exactly. Confidence-rank uniqueness + density-from-0 enforced at
  __post_init__ as structural invariants.
- Canonical-bytes serializer extended to handle frozenset (sorted list).

Phase 1 acceptance verified:
- 29/29 ADR-0174 Phase 1 tests pass (construction, validation, cap
  enforcement, canonical-bytes determinism, frozenset stability).
- 42/42 existing reader tests pass (test_brief_11_audit +
  test_reader_phase2) — default-empty fields preserve byte-identity.
- Smoke 67/67, packs 141/141.
- train_sample/v1 byte-identical across two runs with use_reader=True.
- wrong=0 invariant held: 3/47/0 unchanged.

No apply_word body changes. The 'thread the hypothesis set' requirement
at Phase 1 is satisfied by field defaults that propagate through every
ProblemReadingState construction site in lifecycle.py without code edits.

Phase 2 (continuous constraint propagation) and Phase 3 (lookback
re-evaluation) will populate these fields with real hypothesis data and
wire the EMIT / ELIMINATE / HOLD operators.
2026-05-28 08:09:00 -07:00
Shay
86d4e98d5c fix(roundtrip): multi-word units ground when every component appears in source
_unit_grounds() previously refused multi-word units like 'Pokemon cards'
even when both component words appeared as tokens in the source span.
The function checked unit_token against the haystack as a single key,
but the tokenizer splits source into per-word tokens — 'Pokemon cards'
was never going to match.

Fix is conjunctive by design: every component word must appear in the
haystack. A missing component refuses, preserving wrong=0.

Truth-test: case 0023 (Nicole/Pokemon cards) previously refused with
'recognizer matched but produced no injection' on its first sentence.
After this fix, sentence 1 passes injection cleanly; the case now
refuses on sentence 2 (Cindy/Rex compositional clause) — a more
honest refusal reason that reflects the actual remaining gap.

Score unchanged at 3/47/0 (no overall lift; correctness win).
smoke 67/67, packs 141/141, lanes 8/8 all green.
2026-05-28 07:49:24 -07:00
Shay
d6427ae4ec fix(invariants): exclude .claude/ from architectural scan + prune stale worktrees
Both INV-02/INV-21/INV-24 scan functions walked into .claude/worktrees/
and found vault recall/write callsites in the stale
step-3-submission-invariants checkout, producing 3 false failures.

Fix: add '.claude' to the os.walk exclusion set (INV-02) and to
EXCLUDED_DIRS (INV-21/INV-24). Defensive against any future worktree
that agents create under .claude/worktrees/.

Also pruned 58 stale worktree git-dir entries via git worktree prune
and removed the step-3-submission-invariants worktree directory.

Smoke suite: 67/67 passed.
2026-05-28 07:12:19 -07:00
Shay
89defef30b chore(audit): substrate cleanup — dead spike, gitignore, deprecation, reader diagnosis
C1: delete generate/math_versor_arithmetic.py and its 3 tests (ADR-0139
add-only arithmetic spike; no runtime consumers, no pipeline wiring,
follow-on lift paused per module docstring).

C3: gitignore engine_state runtime artifacts (manifest.json,
recognizers.jsonl, discovery_candidates.jsonl). Module code
(engine_state/__init__.py) remains tracked; generated checkpoint
files should not be.

C5: document reader zero-delta root cause in train_sample/v1/README.md.
Both Phase 2 (whole-problem) and Phase 1 (question-only) reader paths are
called but inert because all 47 refusals are statement-level NO_INJECTOR
gaps, not question-sentence gaps. Reader unblocks when injector coverage
expands (C2 work). report.json use_reader flag corrected to reflect last run.

C6: add deprecation header to generate/math_parser.py pointing at
generate.math_candidate_graph.parse_and_solve as the live path.

C2/C4 briefs: docs/handoff/CLEANUP-C2-run-lane-migration.md and
docs/handoff/CLEANUP-C4-compositions-compile.md added as operator
dispatch docs for the medium-scope wiring tasks.
2026-05-28 07:00:33 -07:00
Shay
36f3dbfc4e fix(D): address Sourcery review findings on PR #410
Three review fixes:

1. Security: validate lane/split/version against ^[a-z0-9_]+$ before
   building the runner module name. The runner_args list is passed to
   subprocess.run without shell=True (no shell injection possible),
   but defense-in-depth blocks arbitrary token characters from
   reaching Python's -m module loader. Bad input now errors at the
   CLI boundary with a clear message.

2. Bug-risk: _classify_refusal docstring referenced a
   no_admissible_candidate bucket that the implementation never
   emitted. Aligned docstring with actual buckets
   (no_admissible_question / no_admissible_statement). Also made all
   matching consistently case-insensitive (was mixed — some checks
   used raw reason, one used .lower()).

3. Bug-risk: fetch_committed_baseline wrote to
   .git/coverage_baseline_tmp.json. Replaced with tempfile.mkstemp in
   the system temp dir — avoids (a) failures in non-git worktrees
   where .git is a file pointer, (b) concurrent-access collisions
   between simultaneous operators.

Tests (+3 new):
- test_classify_refusal_is_case_insensitive
- test_classify_docstring_matches_implementation_buckets
- test_fetch_committed_baseline_uses_system_temp

All 16 coverage tests green. Verified the validation:
  core teaching coverage --lane 'evil; rm -rf /'
  → ERROR: lane='evil; rm -rf /' must match ^[a-z0-9_]+$
2026-05-27 21:20:01 -07:00
Shay
d91ea3d36e feat(D): core teaching coverage — per-shape admission histogram
Brief D from PR #407. Closes the "flying blind on per-shape coverage"
gap identified in RAT-1's audit (finding 6).

After this PR, every operator can run a single command to see exactly
which refusal modes their work moved (or didn't), without re-eyeballing
report.json by hand.

Modules
-------
- teaching/coverage.py — pure aggregator:
  - _classify_refusal — maps each per-case refusal reason to a
    stable bucket (recognizer_empty_injection(<ShapeCategory>),
    no_admissible_question, no_admissible_statement,
    unexpected_question_count, other)
  - build_coverage_report — reads a lane's report.json + emits a
    CoverageReport with counts, refusal_taxonomy (sorted by count
    desc), case_0050_verdict, optional delta vs baseline
  - fetch_committed_baseline — uses `git show HEAD:<relpath>` to
    pull the baseline report.json for delta computation

- core/cli.py:
  - cmd_teaching_coverage — formats the report for terminal output
  - core teaching coverage [--lane gsm8k_math] [--split train_sample]
    [--version v1] [--use-reader] [--run] [--delta] [--json]

CLI output example
------------------
  Lane: gsm8k_math/train_sample/v1 (use_reader=True)
  Counts: correct=3 refused=47 wrong=0

  Refusal taxonomy:
     21  recognizer_empty_injection(discrete_count_statement)
      6  no_admissible_statement
      5  recognizer_empty_injection(multiplicative_aggregation)
      4  no_admissible_question
      4  recognizer_empty_injection(currency_amount)
      3  recognizer_empty_injection(rate_with_currency)
      2  recognizer_empty_injection(descriptive_setup_no_quantity)
      2  recognizer_empty_injection(temporal_aggregation)

  Wrong=0: ✓
  Case 0050 hazard pin: refused ✓

Tests (13 new)
--------------
tests/test_teaching_coverage_cli.py — classification narrowness,
counts aggregation, case 0050 verdict capture, delta computation,
missing-baseline path, missing-report error, taxonomy sort order,
wrong=0 invariant visibility via as_dict.

Suite results
-------------
core test --suite teaching -q → 106 passed (93 → +13)
core test --suite runtime  -q → 20 passed
core test --suite packs    -q → 127 passed
core eval gsm8k_math --split public → 150/150, wrong=0

Note on Brief E (lexical auto-compile): the audit was WRONG. The
lexicon loader (generate/comprehension/lexicon.py::load_lexicon)
reads from the per-category source files directly; the compiled
lexicon.jsonl is only a manifest-checksum pin, not the source of
truth at runtime. apply_lexical_claim() writes a new entry → next
turn the loader sees it. Brief E is a non-issue; closing without a
code PR.

Verified by direct test: stage a clone of the math pack, write a
synthetic lemma to drain_token.jsonl, clear the lexicon cache, load
again → new entry present. So 3 of the 5 audit gaps closed (A, D,
E-as-correction); B and C remain as the next operator dispatch
targets.

Independent of PR #406 (RAT-1) and PR #408 (WAVE-A). Based on main.
2026-05-27 21:20:00 -07:00
Shay
a092d2e8c2
Merge pull request #411 from AssetOverflow/feat/contemplation-ratifiable-claims
feat(brief-B): enrich contemplation payload — composition_reclassification directly ratifiable
2026-05-27 21:06:36 -07:00
Shay
7441b42bf5 feat(wave-a): first non-DCS injector — multiplicative_aggregation w/ value extraction
Addresses 5 of 47 train_sample "recognizer matched but produced no
injection" refusals (the largest single failure-mode bucket
identified in RAT-1's audit).

Modules
-------
- generate/recognizer_match.py:
  - _MULT_AGG_EACH_WEIGHING_RE — regex for "<Subject> <bake-verb>
    <M> <outer-noun>, each <weigh-verb>ing <N> <unit>" pattern
  - _try_extract_each_weighing_anchor — extracts M, N, subject,
    inner unit; emits pre-composed CandidateInitial(value=M*N) with
    composition_evidence so RAT-1's _composed_initial_admissible
    gate verifies INPUT tokens ground (preserves wrong=0)
  - _match_multiplicative_aggregation dispatches to the value
    extractor when spec carries extract_values=True; specs without
    that flag get the existing detection-only return path
    (byte-identical legacy behavior)

- generate/recognizer_anchor_inject.py:
  - inject_multiplicative_aggregation — new per-category injector;
    narrow by anchor.kind so ME-3/ME-4 additive/subtractive anchors
    (which share the same matcher entry point) continue to flow
    through composition_registry consult instead of WAVE-A's direct
    path
  - registered in _INJECTORS dict (2nd entry after DCS)

- core/cli.py:
  - seed-recognizer CLI gains --extract-values flag to opt the
    canonical_pattern into the value-extracting matcher path

Seeded artifacts
----------------
- proposals.jsonl: rat1-seed-4dc30608fb783bc7 — multiplicative_
  aggregation recognizer with anchor_kind=multiplicative_aggregate,
  extract_values=True, observed_units covering ounces/strawberries/
  questions/etc.

Live result on train_sample
---------------------------
- wrong == 0 preserved (3/47/0 baseline)
- Case 0050 hazard pin held
- public 150/150 preserved
- packs suite: 127 → 131 (+4 new WAVE-A tests, all green)
- teaching suite 93 unchanged
- runtime suite 20 unchanged

End-to-end synthetic solve (FIRST WAVE-A admission):
  "Lilibeth fills 6 baskets where each basket holds 50 strawberries.
   How many strawberries does Lilibeth have?"  → answer=300

Cases that moved (statement now admits; refusal shifted downstream):
- Case 0025 (Lilibeth): statement admits via WAVE-A; refusal moved
  to question parser ("If three of Lilibeth's friends pick the same
  amount, how many strawberries do Lilibeth and her friends pick in
  all?")
- Case 0047 (John bakes 12 macaroons): statement 1 admits; refusal
  moved to statement 2

Eval correct count unchanged because the QUESTION parser (and
multi-statement cross-sentence reasoning) is the next bottleneck.
RAT-1's audit identified that gap; WAVE-A closes the injector half.

The remaining 3 multiplicative_aggregation refusals (0006, 0013,
0045) have different shape patterns the WAVE-A regex does not yet
cover; they're follow-up matcher extensions in the same architecture.

Tests
-----
- tests/test_wave_a_multiplicative_aggregation_injector.py (10
  tests): each-weighing + each-basket-holds admission shapes,
  detection-only path preserved when extract_values absent,
  unobserved unit / pronoun / zero count refusals, end-to-end
  inject_from_match dispatch, the Lilibeth canary solve,
  wrong=0 preserved, case 0050 hazard pin

Stacks on PR #406 (RAT-1).
2026-05-27 20:50:04 -07:00
Shay
193764e3fd feat(brief-B): enrich composition_reclassification payload to be directly ratifiable
Adds surface_pattern, composition_category, and polarity to the
proposed_change_payload for composition_reclassification proposals so
operators can call apply_composition_claim() without field synthesis.

Dispatch by missing_operator:
- quantity_extraction → multiplicative_composition + bound(count) × bound(unit_cost)
- multi_quantity_composition → additive_composition + bound(qty_a) + bound(qty_b)

All other change kinds (matcher_extension, injector_sub_shape,
frame_reclassification) keep the existing evidence-aggregation payload.
Legacy fields (evidence_count, group_key, modal_sub_type) preserved.

Adds tests/test_contemplation_ratifiable_payload.py with 11 tests
including a round-trip from decompose_audit → apply_composition_claim.
2026-05-27 20:46:10 -07:00
Shay
d5c91e1ac1 feat(RAT-1): close ratify→runtime gap + first live composition admission
The user's question — "shouldn't we be running it multiple times so
it can learn? or is that part broken?" — exposed that the math
teaching loop's `ratify → admit` closure had been structurally
broken at the connector between operator ratification and runtime
visibility. The handlers wrote source files (compositions/, frames/)
that the runtime loader never read because no compile step
regenerated the runtime artifacts.

This PR fixes the gap end-to-end AND fires the first live composition
admission on the canonical pack.

Modules
-------
- language_packs/compile_pack.py — unified compile step that
  regenerates frames.jsonl + compositions.jsonl + updates
  manifest.{frame,composition}_checksum atomically. Idempotent.

- teaching/math_composition_ratification.py — apply_composition_claim
  now calls compile_pack at end of successful ratification. Closes
  the source-file→runtime-artifact gap.

- teaching/math_frame_ratification.py — same auto-compile wire for
  apply_frame_claim.

- generate/math_candidate_parser.py — CandidateInitial gains optional
  composition_evidence Mapping field. When populated, signals the
  candidate was produced by a registry-gated composition (ADR-0169);
  the value/unit/entity are DERIVED arithmetic over grounded inputs.

- generate/math_candidate_graph.py — new _composed_initial_admissible
  predicate that branches on composition_evidence. Wrong=0 preserved
  by requiring each composition INPUT token (count, amount) to ground
  in source_span literally; the derived value is admitted because the
  arithmetic over grounded inputs is deterministic.

- generate/math_candidate_graph.py — discourse-level prior_subject
  tracking: capture proper-noun subjects from ALL statement sentences
  (including ADR-0136.S.0 context-filler sentences that get filtered
  out before the candidate loop). Without this, "John adopts a dog"
  (no numbers) is dropped and the cross-sentence subject resolver for
  case 0019 sees prior_subject=None.

- generate/recognizer_match.py — all four composition matchers
  (ME-1 currency-per-unit same-sentence, ME-2 cross-sentence, ME-3
  additive, ME-4 subtractive) now populate composition_evidence in
  CandidateInitial. Also added standalone " each " / " apiece " to
  _PER_UNIT_TOKENS so currency_amount detection-only matcher refuses
  per-item costs instead of swallowing them.

CLIs
----
- core teaching compile-pack — explicit operator surface for
  regenerating runtime artifacts. JSON output for CI integration.

- core teaching seed-recognizer — operator surface for seeding a
  RatifiedRecognizer entry in the proposal log for a given
  (shape_category, anchor_kind). Writes created + transition(accepted)
  events directly via ProposalLog._append.

Seeded artifacts (the actual loop closure)
------------------------------------------
- proposals.jsonl: new rat1-seed-48dd2673d6ad673d RatifiedRecognizer
  entry for shape_category=rate_with_currency,
  anchor_kind=currency_per_unit_composition.

- compositions/multiplicative_composition.jsonl: ratified
  "bound(count) × bound(unit_cost)" affirms entry sourced from
  case 0019 evidence.

- compositions.jsonl + manifest.composition_checksum: compiled
  runtime artifact + manifest pin (RAT-1 auto-compile).

Live result on train_sample
---------------------------
- wrong == 0 preserved (3 correct / 47 refused / 0 wrong)
- Case 0050 hazard pin holds (refused)
- public split 150/150 preserved
- Case 0019 sentence 1 ("requires 3 vet appointments, which cost
  $400 each") NOW ADMITS via composition. Previously refused with
  "recognizer matched but produced no injection". The refusal moved
  downstream to sentence 2 (a different currency_amount detection
  bottleneck that is its own follow-up).

This is the first time a composition ratification on the canonical
pack actually reaches the runtime. The flywheel turned one
revolution.

Tests
-----
- tests/test_rat1_end_to_end_admission.py — 4 new live tests:
  composition statement admits on isolated synthetic problem, case
  0019 cross-sentence admission, wrong=0 preserved on train_sample,
  case 0050 hazard pin.

- tests/test_consumption_empty_registry_no_op.py — refactored to use
  isolated synthetic packs (the canonical pack may now carry ratified
  entries).

- tests/test_math_{frame,composition}_ratification.py — updated
  "manifest checksum unchanged" tests to "lexicon checksum
  preserved" semantics: RAT-1 auto-compile may add the new optional
  checksum fields; pre-existing lexicon checksum stays untouched.

Suite results: teaching 93, packs 131 (+4), runtime 20. All green.
2026-05-27 20:09:47 -07:00
Shay
9b8f6bb991 feat(matcher-extension/ME-5): integration smoke + ME-1..ME-5 milestone
Final PR of the matcher-extension wave. Ships:

1. tests/test_me5_all_categories_integration.py — 4 new tests:
   - test_all_three_canaries_admit_through_full_pipeline: stages a
     pack with all three SAFE_COMPOSITION_CATEGORIES entries +
     ratifies, runs Maria/Sam/Tom canaries through matcher →
     inject_from_match, asserts admission for all three
   - test_partial_pack_only_admits_present_categories: refusal-
     preferring when only one category is ratified
   - test_all_safe_categories_have_extension_admission: pins that
     SAFE_COMPOSITION_CATEGORIES is exactly the three covered
     categories (breaks if future ADR widens without matcher)
   - test_falsifies_uniformly_suppresses_across_categories:
     polarity discipline holds across all three matchers

2. docs/handoff/ME1-ME5-MILESTONE.md — wave milestone doc:
   - architecture diagram (audit → ratify → compile → load →
     match → consult → admit)
   - SAFE_COMPOSITION_CATEGORIES coverage matrix
   - invariants preserved across the entire stack
   - scope boundary (what does NOT fire yet — RAT-1 follow-up)
   - recommended next dispatch

3. Test registration in core/cli.py packs suite.

Across the full ME-1..ME-5 stack:
- 5 stacked PRs (#400/#401/#402/#403/#404)
- 1 foundation PR (#398 — consumption wiring)
- 114 new tests, all green
- packs suite 127 passed
- core eval gsm8k_math --split public → 150/150, wrong=0
- All three SAFE_COMPOSITION_CATEGORIES have matcher extensions

Anti-regression invariants preserved across the entire stack:
- wrong == 0 on public split
- Case 0050 hazard pin (parametrized over all three categories)
- ADR-0166 — no new eval lanes
- ADR-0167 partition — no cognition imports
- ADR-0169 mutation boundary — registry is a gate, not arithmetic
- All matcher detection paths byte-identical
- engine_state/* never committed
- SAFE_COMPOSITION_CATEGORIES enforced at write AND load
- polarity falsifies honored uniformly

Live train_sample admission requires operator-seeded ratifications
(RAT-1 follow-up). Wiring is end-to-end correct, verified by ME-5
integration tests.

Memory: milestone-me1-me5-matcher-extensions-complete saved.

Stacks on PR #403 (base: feat/matcher-extension-subtractive).
2026-05-27 17:35:10 -07:00
Shay
11d7e0b607 feat(matcher-extension/ME-4): subtractive composition matcher
Extends _match_multiplicative_aggregation with a new branch keyed on
anchor_kind="subtractive_quantity_composition". Pattern:

  <Subject> <init-verb> <N> <unit>(,| then| ;| and then| and)
  <sub-verb> <M> <unit>

Same-unit only. Emits a pre-composed CandidateInitial(N - M, unit) +
composition_shape="bound(initial) − bound(removed)".

Verb whitelists:
  initial: had/has/got/owns/owned/earned/saved/made/received/bought
  removal: lost/spent/gave/donated/paid/removed/sold/used/consumed

Removal verbs accept an optional " away" suffix ("gave away 20 apples").

Refusal-preferring discipline:
- count_b >= count_a → refuse (non-negative remainder; wrong>0 hazard)
- Pronoun / determiner subject → refuse
- Cross-unit → refuse (no v1 conversion table)
- Unobserved unit → refuse
- Unknown initial/removal verb → refuse

Tests (17 new, all green):
- canonical subtractive ("Sam had 50 apples, gave 20" → 30)
- then/and connectives
- gave away variant
- negative + equal remainder refused (hazard pin)
- pronoun + determiner subject refused
- cross-unit refused
- unobserved unit refused
- unknown initial/removal verbs refused
- additive (ME-3) path unaffected
- multiplicative_aggregate detection unaffected
- anchor audit fields complete
- end-to-end via composition_registry: affirms admits, falsifies suppresses

Registered in core/cli.py "packs" suite.

core test --suite packs -q → 123 passed (106 + 17 new)
core eval gsm8k_math --split public → 150/150, wrong=0

Anti-regression invariants preserved across ME-1..ME-4 stack:
- wrong == 0 on gsm8k_math public 150/150
- Case 0050 hazard pin holds
- ADR-0166 — no new eval lanes
- ADR-0167 partition — no cognition imports
- All prior matcher paths unaffected (test pins)
- engine_state/* not committed
- All three SAFE_COMPOSITION_CATEGORIES (multiplicative / additive /
  subtractive) now have matcher extensions wired

Stacks on PR #402 (base: feat/matcher-extension-multi-quantity).
2026-05-27 17:23:35 -07:00
Shay
1215944a20 feat(matcher-extension/ME-3): additive composition matcher
Extends _match_multiplicative_aggregation with a new branch keyed on
anchor_kind="additive_quantity_composition". When a statement carries
"<Subject> <verb> <N> <unit> and <M> <unit>" (same unit) shape, emits
a pre-composed CandidateInitial(N+M, unit) and publishes
composition_shape="bound(qty_a) + bound(qty_b)".

Subject binding under Option A (refuse on pronoun / determiner / no
proper-noun head). Cross-sentence subject support (mirroring ME-2)
is deferred — not needed for the v1 ME-3 canaries.

Verb whitelist: lost / gained / earned / saved / made / paid / spent /
bought / sold / added / removed / received. Verbs that route through
CandidateInitial.matched_anchor's existing post-init whitelist;
unmapped verbs fall back to "had".

Unit normalization: rstrip 's' for plural matching (pounds vs pound).
Cross-unit composition refused — no conversion table in v1.

Tests (15 new, all green):
- same-unit admission with sum
- pronoun subject refuses
- determiner subject refuses
- cross-unit refuses
- unobserved unit refuses
- zero count refuses
- plural normalization
- unknown verb refuses
- multiplicative_aggregate detection path unaffected
- wrong anchor_kind refuses
- anchor audit fields complete
- source_span substring invariant
- no match returns None
- end-to-end admission via composition_registry
- end-to-end falsifies suppresses

Registered in core/cli.py "packs" suite. core test --suite packs -q →
106 passed (91 existing + 15 new).

Anti-regression invariants preserved:
- wrong == 0 on gsm8k_math public 150/150
- Case 0050 hazard pin holds
- ADR-0166 — no new eval lanes
- ADR-0167 partition — no cognition imports
- Original multiplicative_aggregate detection path byte-identical
- ME-1 currency-per-unit path unaffected
- ME-2 cross-sentence path unaffected
- engine_state/* not committed

Live train_sample admission requires the same operator workflow as
ME-2: a RatifiedRecognizer for the new anchor_kind + composition_registry
entry for "bound(qty_a) + bound(qty_b)" under additive_composition.
Without those, the wiring is correctly positioned but dormant — no
regression in the live eval.

Stacks on PR #401 (base: feat/matcher-extension-cross-sentence-subject).
2026-05-27 17:12:34 -07:00
Shay
8a9b51af9e feat(matcher-extension/ME-2): cross-sentence subject binding for composition
Admits case 0019's composition sentence via prior_subject resolved
from upstream sentences. Stacks on PR #400 (ME-1).

Modules
-------
- generate/recognizer_match.py:
  - _CROSS_SENTENCE_COMPOSITION_RE — regex for "requires N noun, which
    cost(s) $X each" (no subject prefix)
  - try_extract_cross_sentence_composition_anchor(statement, spec,
    prior_subject) — refuses on None / empty / pronoun prior_subject;
    publishes the same composition_shape + composed_initial payload as
    ME-1, sourced via prior_subject
  - extract_proper_noun_subject(statement) — head proper-noun extractor
    used by callers to track running prior_subject; rejects determiners,
    sentence-initial connectors (After/How/Every/...), and pronouns
  - match() dispatcher gains keyword-only prior_subject parameter;
    when a per-category matcher returns None for a RATE_WITH_CURRENCY
    recognizer with currency_per_unit_composition anchor_kind AND
    prior_subject is supplied, the cross-sentence helper is tried as
    a fallback

- generate/math_candidate_graph.py:
  - tracks _prior_subject across statement_sentences iteration
  - passes prior_subject to recognizer_match.match()
  - updates _prior_subject from each sentence's head proper-noun

Tests (19 new, all green)
-------------------------
- test_me2_cross_sentence_subject.py (15 tests)
  - subject extraction narrowness (proper noun / determiner / connector
    / pronoun / non-string)
  - cross-sentence helper happy path + refusals (None, empty, pronoun,
    unobserved currency / per_unit, wrong anchor_kind, zero count,
    multi-match)
  - source_span substring invariant
  - kind label "currency_per_unit_composition_cross_sentence"

- test_me2_case_0019_admits.py (4 tests)
  - case_0019_admits_with_prior_subject_john — the truth test
  - case_0019_refuses_without_prior_subject — ME-1 Option A still holds
  - case_0019_refuses_with_pronoun_prior — refusal-preferring
  - maria_same_sentence_unaffected_by_prior_subject — ME-1 path intact

Registered in core/cli.py "packs" suite.

Suite results
-------------
core test --suite packs    -q → 91 passed (existing + ME-1's 21 + 19 new)
core test --suite runtime  -q → 20 passed
core eval gsm8k_math --split public → 150/150, wrong=0

Scope boundary
--------------
The wiring is load-bearing AND tested end-to-end via synthetic
recognizer registry (test_case_0019_admits_with_prior_subject_john
proves the full chain match → inject → admit).

For the LIVE train_sample case 0019 admission, two ratifications must
also be seeded (operator workflow outside this PR's code scope):

  1. A RatifiedRecognizer in the proposal log with shape_category=
     RATE_WITH_CURRENCY and canonical_pattern carrying
     anchor_kind="currency_per_unit_composition"
  2. A composition_registry entry for "bound(count) × bound(unit_cost)"
     under multiplicative_composition with polarity=affirms

With both ratifications in place, case 0019 admits via the wiring
this PR ships. Without them, the live train_sample run remains at
the 3/47 baseline (preserved; no regression).

Anti-regression invariants preserved
------------------------------------
- wrong == 0 on gsm8k_math public
- Case 0050 hazard pin holds (no _COMPOSITION_SUBJECT_BUY_RE or
  _CROSS_SENTENCE_COMPOSITION_RE match on case 0050's sentences)
- ADR-0166 — no new eval lanes
- ADR-0167 partition — no cognition imports
- ME-1 Maria same-sentence path byte-identical (test pins)
- Existing currency_per_unit_rate path unaffected (test pins)
- prior_subject is keyword-only on match() (additive; old callers
  unaffected)
- engine_state/* not committed

Stacks on PR #400 (base: feat/matcher-extension-currency-per-unit-composition).
2026-05-27 17:00:08 -07:00
Shay
8d43eac45a feat(matcher-extension/ME-1): currency-per-unit composition admission
Lights up the dormant consumption path from PR #398. Extends
_match_rate_with_currency with a new branch keyed on
anchor_kind="currency_per_unit_composition" — when a statement
carries the "<Subject> bought <count> <noun> at $<amount> each" shape
with a same-sentence proper-noun subject, the matcher publishes:

  - composition_shape = "bound(count) × bound(unit_cost)"
  - composed_initial  = CandidateInitial(entity=Subject,
                                         quantity=Quantity(count*amount,
                                                           dollars))

The PR #398 consumption wire in inject_from_match consults
composition_registry on composition_shape: an affirms entry admits
the pre-composed CandidateInitial; falsifies suppresses; absence
refuses.

Subject binding under Option A (refuse when same-sentence subject
absent). Option B (placeholder) forbidden by the brief; Option C
(cross-sentence lookup) is ME-2.

Truth-test scorecard (6-row binding table from PR #399):

  #1 Synthetic Maria admits ........ PASS
  #2 Case 0050 stays refused ....... PASS
  #3 train_sample 3/47, no regress . PASS (3 correct preserved)
  #4 wrong == 0 preserved .......... PASS
  #5 public 150/150 unchanged ...... PASS
  #6 All PR #398 tests still pass .. PASS (38 tests + new 21 = 59)

Case 0019 stays refused (Option A) — admitting it requires
cross-sentence subject lookup (ME-2 brief).

Tests (21 new, all green):
- test_matcher_extension_currency_per_unit.py (15)
- test_matcher_extension_case_0050_hazard_pin.py ( 2)
- test_matcher_extension_end_to_end_admission.py ( 4)

Registered in core/cli.py "packs" suite.

Suite results:
  core test --suite runtime  -q → 20 passed
  core test --suite packs    -q → 51 passed (existing) + 21 new
  core test --suite teaching -q → 93 passed
  core eval gsm8k_math --split public → 150/150, wrong=0

Anti-regression invariants preserved:
- wrong == 0 on gsm8k_math public
- Case 0050 hazard pin holds
- ADR-0166 — no new eval lanes
- ADR-0167 partition — no cognition imports
- Existing currency_per_unit_rate path byte-identical (test pins)
- Refusal-preferring: subject-absent → no composition emission
- engine_state/* not committed

Stacks on PR #398 (base: feat/composition-frame-consumption-wiring).
2026-05-27 16:48:21 -07:00
Shay
78ddab79b4
feat(consumption-wiring): CW-1 + CW-2 — Frame + Composition registry loaders (#398)
Closes the consumption-half of the math teaching loop for two of three
sub-types per docs/handoff/CONSUMPTION-WIRING-DISPATCH-PACK.md (PR #397).
Companion to the doctrinal brief in PR #396.

Modules
-------
- language_packs/compile_frames.py — byte-deterministic compile of
  frames/*.jsonl → frames.jsonl (sorted by (frame_category, surface_form))
- language_packs/compile_compositions.py — same shape for
  compositions/*.jsonl → compositions.jsonl
- generate/comprehension/frame_registry.py — load_frame_registry()
  mirroring load_lexicon: cache by (path, mtime, sha256), manifest
  checksum verification (optional frame_checksum field), polarity
  validation, conflict detection, empty-registry no-op
- generate/comprehension/composition_registry.py — same shape PLUS:
    * SAFE_COMPOSITION_CATEGORIES enforced at LOAD (defense in depth;
      raises WrongCompositionCategory on any unsafe category — protects
      against pack edits that bypass the handler)
    * polarity "falsifies" exposed via is_falsified() (consumer must
      suppress; not silently treated as affirms)
- language_packs/compiler.py — manifest verification extended for
  frame_checksum + composition_checksum, mirroring the proven
  glosses_checksum pattern (optional fields; backward-compatible)
- generate/recognizer_anchor_inject.py — inject_from_match consults
  composition_registry when the per-category injector returns empty
  AND the matcher publishes ``composition_shape`` in parsed_anchors.
  Registry is a gate (admissibility) not an arithmetic primitive
  (ADR-0169 §"Mutation boundary").

Tests (38 new, all green)
-------------------------
tests/test_frame_registry_load.py            (11 tests)
tests/test_composition_registry_load.py      (11 tests)
tests/test_composition_consult_in_injector.py ( 6 tests)
tests/test_consumption_case_0050_hazard_pin.py( 3 tests, parametrized
                                                 over allowlist)
tests/test_consumption_empty_registry_no_op.py( 4 tests)
tests/test_consumption_partition.py           ( 3 tests)

Registered in core/cli.py "packs" suite.

Suite results
-------------
core test --suite teaching -q  → 93 passed
core test --suite runtime  -q  → 20 passed
core test --suite packs    -q  → 51 passed
core eval gsm8k_math --split public → 150/150, wrong=0

Truth-test rows (6-row binding table in dispatch pack):

  #1 Case 0019 admits ............. PARTIAL — see Scope Boundary below
  #2 Case 0050 stays refused ....... PASS
  #3 train_sample 3/47 → ≥4/46 ..... PARTIAL — same as #1
  #4 wrong == 0 preserved .......... PASS
  #5 public split 150/150 .......... PASS
  #6 Empty-registry no-op .......... PASS

Scope Boundary (honest finding)
-------------------------------
Rows #1 and #3 (case 0019 admission) require a matcher extension that
publishes ``composition_shape`` + a pre-composed CandidateInitial in
parsed_anchors. The existing currency_amount / multiplicative_aggregation
matchers in generate/recognizer_match.py are detection-only (return
empty parsed_anchors). This PR ships the consumption infrastructure
correctly but the runtime path remains dormant until a follow-up PR
extends the matcher. The dispatch pack's truth test #1/#3 cannot fire
without that extension.

The wiring is positioned correctly: inject_from_match → consult
composition_registry → admit on affirms-with-payload, suppress on
falsifies, refuse on absence. A synthetic recognizer match with
populated composition_shape + composed_initial DOES admit through the
new path (covered by 6 tests in test_composition_consult_in_injector.py).

A follow-up brief naming the matcher-extension work is the
recommended next step.

Anti-regression invariants verified
-----------------------------------
- wrong == 0 on core eval gsm8k_math (public 150/150)
- case 0050 stays refused (parametrized over allowlist categories)
- ADR-0166 — no new eval lanes
- ADR-0167 partition — no cognition imports in any new module
- Empty-registry runtime byte-identical to today (no-op test)
- SAFE_COMPOSITION_CATEGORIES enforced at write AND load
- polarity semantics (affirms vs falsifies) honored
- engine_state/* never committed
2026-05-27 16:17:03 -07:00
Shay
44c0aa2896
feat(ADR-0169/CC-2+CC-3): CompositionClaim ratification handler + decomposer heuristic tightening (#393)
PR-β of the CompositionClaim wave (CC-2 + CC-3 bundled per the brief
pack — CC-3's heuristic depends on CC-2's new change_kind Literal value).
Mirrors the F1 / ADR-0168 FrameClaim template 1:1 with composition-specific
substitutions.

CC-2 — handler implementation
  - teaching/math_composition_proposal.py — MathCompositionClaimProposal
    adapter per ADR-0169.1 §"Data shape". Frozen dataclass, deterministic
    proposal_id / claim_signature, source="math_audit" pin at the
    proposal layer (rejects "corpus" laundering).
  - teaching/math_composition_ratification.py — apply_composition_claim()
    handler. SAFE_COMPOSITION_CATEGORIES = {multiplicative,
    additive, subtractive}_composition per ADR-0169 §"Initial safe
    category scope". New WrongCompositionCategory exception per
    ADR-0169.1 §"Trip-wires" #8. Writes only to
    language_packs/data/en_core_math_v1/compositions/{category}.jsonl;
    no solver / parser / decomposer / runtime mutation.
  - workbench/readers.py — _HANDLER_DISPATCH now routes
    composition_reclassification → CompositionClaim; suggested_cli
    branch added for both read_math_proposal and ratify_math_proposal.
  - teaching/math_contemplation_proposal.py — ChangeKind Literal +
    _VALID_CHANGE_KINDS frozenset extended with
    composition_reclassification.
  - language_packs/data/en_core_math_v1/compositions/.gitkeep —
    reviewed-pack scaffold.
  - tests/test_math_composition_ratification.py — 22 tests including
    case 0050 hazard pin, cross-process replay equivalence, queue-order
    independence, partition, no-corpus-laundering, dispatch wire,
    Literal acceptance, JSONL round-trip.
  - tests/test_adr_0172_w1_shape_proposal.py — parametrize round-trip
    over all 5 change_kinds.
  - core/cli.py — teaching suite tuple includes new test file.

CC-3 — decomposer heuristic tightening
  - teaching/math_contemplation.py::_CHANGE_KIND_BY_PAIR:
    + (incomplete_operation, quantity_extraction)         → composition_reclassification
    + (incomplete_operation, multi_quantity_composition)  → composition_reclassification
    - (unexpected_category, multi_subject_sentence)       demoted to injector_sub_shape
      (was frame_reclassification; FrameClaim SAFE_FRAME_CATEGORIES doesn't
       cover this — needs ReferenceClaim/CompositionClaim)
    - (unexpected_category, descriptive_frame_question)   demoted to injector_sub_shape
      (was frame_reclassification; needs SlotClaim, not FrameClaim)
    Updated hypothesis-step justification text to reflect new dispatch
    table.
  - tests/test_adr_0172_w2_decomposer.py — distribution assertion
    tightened from "≥3 matcher, ≥2 frame" to exact counts:
    3 matcher / 2 composition / 3 injector / 0 frame. New
    per-pair tests for the four CC-3 dispatch changes.

Verification on real audit_brief_11.json (20 of 47 highest-leverage
refusals now routable):

  2  composition_reclassification   (12 quantity_extraction + 8 multi_quantity_composition)
  3  injector_sub_shape             (2 multi_subject + 2 descriptive_frame + 4 unattached_quantity)
  3  matcher_extension              (9 pre_frame_filler + 4 fraction_percentage + 4 pronoun)
  0  frame_reclassification         (the two prior misroutes are gone)

Workbench POST /math-proposals/{id}/ratify on either composition
proposal now returns 200/routed with a real apply_composition_claim()
command instead of 501.

Suites green:
  - core test --suite teaching -q  → 71 passed
  - core test --suite runtime -q   → 20 passed

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-27 15:15:11 -07:00
Shay
c6a9bb0096
feat(ADR-0168/F1): FrameClaim ratification handler (Tier 1.5) (#389)
Implements the FrameClaim ratification handler per ADR-0168 doctrine
and the ADR-0168.1 MathFrameClaimProposal adapter.  Mirrors the
ADR-0167 W2-D LexicalClaim template (apply_lexical_claim) but lifts
the safe surface from drain-class lexical entries to allowlisted
frame-opening categories — the next sub-type up the wrong=0 hazard
ladder.

teaching/math_frame_proposal.py
  + MathFrameClaimProposal dataclass (ADR-0168.1 §"Data shape")
  + MathReaderRefusalEvidencePointer with source pinned to "math_audit"
  + build_evidence_pointer() — only sanctioned constructor; rejects None
    missing_operator
  + build_frame_claim_proposal() — enforces:
      • surface_form non-empty after normalization
      • frame_category in the ADR-0168 allowlist
      • polarity in {affirms, falsifies}
      • ≥1 evidence pointer with source="math_audit"
      • source="corpus" rejected as schema-illegal (ADR-0168.1
        §"Evidence floor")
  + compute_claim_signature() / compute_proposal_id() / canonical_bytes()
    — deterministic identity per ADR-0168 §"Replay obligations" #1

teaching/math_frame_ratification.py
  + SAFE_FRAME_CATEGORIES = {increment_frame, decrement_frame,
    transfer_frame, remainder_frame} — no other categories
  + Error hierarchy: RatificationError, WrongClaimSubType,
    WrongZeroViolationCandidate, AlreadyRatified, EvidenceTampering,
    UnknownCategory, InvalidPolarity, EvidenceLaundering
  + FrameRatificationReceipt dataclass with before/after SHA + evidence_hash
  + apply_frame_claim(claim, frame_category, polarity, reviewer, pack_root,
    evidence_source="math_audit"):
      • rejects evidence_source != "math_audit" (ADR-0168.1 §"Evidence floor")
      • rejects polarity outside {affirms, falsifies}
      • rejects claim.sub_type != "frame"
      • rejects evidence_hash tampering (recomputes from audit_row)
      • rejects frame_category outside SAFE_FRAME_CATEGORIES
      • writes language_packs/data/en_core_math_v1/frames/{category}.jsonl
      • idempotent: same (surface, category, polarity, evidence_hash)
        raises AlreadyRatified
      • duplicate evidence appends evidence_hash to existing row
        (ADR-0168.1 §"Idempotency" path #1)
      • polarity=falsifies records non-opener; never appends to compiled
        lexicon or manifest

language_packs/data/en_core_math_v1/frames/.gitkeep
  Directory scaffold for the reviewed frame source files.

workbench/readers.py
  _HANDLER_DISPATCH gains "frame_reclassification" → "FrameClaim".
  GET /math-proposals/{id} detail and POST /math-proposals/{id}/ratify
  now return suggested_cli pointing at apply_frame_claim().

core/cli.py
  teaching test-suite tuple gains tests/test_math_frame_ratification.py.

tests/test_math_frame_ratification.py — 14 tests:
   1. SAFE_FRAME_CATEGORIES is exactly the ADR-0168 allowlist
   2. apply writes a frame entry for a safe category
   3. receipt records before/after sha + evidence_hash
   4. idempotent same-evidence → AlreadyRatified
   5. rejects non-frame sub_type → WrongClaimSubType
   6. rejects categories outside SAFE_FRAME_CATEGORIES → WrongZeroViolationCandidate
   7. rejects invalid polarity → InvalidPolarity
   8. rejects evidence_hash tampering → EvidenceTampering
   9. rejects source="corpus" → EvidenceLaundering (ADR-0168.1 §"Evidence floor")
  10. case 0050 hazard pin — after ratification, case 0050 still refuses
  11. polarity=falsifies branch records non-opener; affirms+falsifies coexist
  12. duplicate evidence appends evidence_hash, does not create a second row
  13. manifest.json checksum unchanged by frame ratification
  14. alphabetical sort by surface_form preserved across writes

Suite verification
  core test --suite teaching -q → 47 passed (was 33; +14 new)
  core test --suite runtime  -q → 20 passed
  tests/test_math_lexical_ratification.py → 15 passed (untouched, regression-clean)
  tests/test_adr_0172_w4_workbench_e2e.py → 7 passed (existing dispatch tests still hold)

Doctrine invariants preserved
  - wrong=0: case 0050 still refuses after ratification
  - replay equivalence: claim_signature and proposal_id are deterministic
    (sha256 of canonical identity, clock-time-independent)
  - refusal-first: no runtime mutation; handler is the only mutation
    boundary and writes only the reviewed frames/ source tree
  - ADR-0167 partition: math-audit evidence stays math-domain; corpus
    evidence is rejected loudly

Brief-correction note: the brief named the scaffold path
"packs/en_core_math_v1/frames/.gitkeep" but the existing math pack lives
at language_packs/data/en_core_math_v1/ (no top-level packs/en_core_math_v1
exists).  Scaffold placed at language_packs/data/en_core_math_v1/frames/
to mirror the existing lexicon/ source-tree convention; apply_frame_claim
defaults pack_root to that location.
2026-05-27 14:10:43 -07:00
Shay
3109fdcbd1
feat(ADR-0172/W5): MathReaderInferenceProposal schema (Tier 2) (#388)
teaching/math_inference_proposal.py
  - MathReaderInferenceProposal frozen dataclass + ArmResult record
  - build_inference_proposal() enforces all 9 invariants:
      ≥3 evidence rows, ≥6 trace steps including {abstraction,
      test_design, test_application, test_result}, both-REJECT guard,
      arm2 PASS requires cases_changed_answer==0,
      ratification_effect_kind Literal=="canonicalization_bridge",
      JSON-serializable payload, wrong_zero ≥40 chars
  - canonical_bytes() for content-addressable inference_id
  - to_jsonl_record() / from_jsonl_record() self-contained JSONL
    persistence — mirrors post-#386 pattern from W1

tests/test_adr_0172_w5_inference_proposal.py — 21 tests across 11 obligations

core/cli.py — teaching suite tuple updated to include W5 test file
2026-05-27 14:01:50 -07:00
Shay
131e711054
feat(ADR-0172/tightening): three follow-ups — self-contained JSONL, widened dispatch, shape_category gap (#386)
Bundles three post-Tier-1 follow-ups into one PR (no scope change, no
new ADR — implementation tightening on the already-shipped corridor).

(1) Standalone JSONL self-containment
  teaching/math_contemplation_proposal.py
    + to_jsonl_record() — emits proposal_id + full evidence_pointers
      (nested dicts including audit_row) + full reasoning_trace.steps
    + from_jsonl_record() — inverse; goes through build_proposal()
      so all invariants are re-validated; raises on proposal_id mismatch
    canonical_bytes() UNCHANGED (still the content-hash function;
    trace_id/proposal_id stability preserved)
  core/cli.py W3 lane now writes to_jsonl_record() output instead of
    canonical_bytes() — same compact-JSON encoding (sort_keys=True,
    ensure_ascii=False, separators=(",", ":"))
  workbench/readers.py loads via self-contained record fields directly;
    decompose_audit() re-run removed.  read_math_proposal() now reads
    reasoning_trace.steps and evidence_pointers from the JSONL record.

(2) Widened change_kind heuristic dispatch
  teaching/math_contemplation.py
    + _CHANGE_KIND_BY_PAIR table on (refusal_reason, missing_operator):
      (unexpected_category, pre_frame_filler_sentence) → matcher_extension
      (unexpected_category, multi_subject_sentence)    → frame_reclassification
      (unexpected_category, fraction_percentage_literal) → matcher_extension
      (unexpected_category, descriptive_frame_question) → frame_reclassification
      (unresolved_pronoun, pronoun_resolution)         → matcher_extension
    Single-key fallback (lexicon_entry/narrowness_violation/
    frame_unrecognized) retained for completeness.
    hypothesis-step justification text updated to reflect new table.

  Result on audit_brief_11.json:
    3  matcher_extension       (was 0)
    2  frame_reclassification  (was 0)
    3  injector_sub_shape      (was 8)
    0  vocabulary_addition     (no unknown_word group ≥2 in train sample)

(3) shape_category structural gap
  MathReaderRefusalEvidence does not carry shape_category, so the
  proposal cannot derive it.  All proposals continue to emit
  ShapeCategory.UNCATEGORIZED with a structural-gap comment.  No
  invented values — handler dispatch decision (per ADR-0167-FOLLOWUPS
  §1) drives ratification routing today, not shape_category.

Tests
  + W1: 5 new tests (to_jsonl_record self-containment, round-trip,
    byte stability, proposal_id mismatch rejection, canonical_bytes
    unchanged invariant)
  + W2: 3 new pair-dispatch tests + real-audit change_kind distribution
    test + shape_category-uncategorized test
  + W3: 2 new tests (records are self-contained, round-trip via
    from_jsonl_record); existing byte-comparison test updated to use
    proposal_id ordering instead of canonical_bytes
  + W4: existing 6 tests updated to build JSONL via to_jsonl_record;
    + 1 new decoupling test that drops teaching.math_contemplation from
    sys.modules and verifies the workbench still loads + serves detail

Verification
  - core eval math-contemplation produces the expected 3/2/3 distribution
  - core test --suite teaching -q → 33 passed
  - core test --suite runtime  -q → 20 passed
  - All 57 ADR-0172 W1-W4 tests pass (49 existing + 8 new)

Determinism / invariants preserved
  - canonical_bytes() byte-stable (test pins this)
  - to_jsonl_record() byte-stable via sort_keys=True + no floats
  - wrong=0 invariant: proposals stay evidence-only; no auto-apply
  - ChangeKind Literal unchanged (4 values; no new ones invented)
2026-05-27 13:43:16 -07:00
Shay
93d244f4bf
feat(ADR-0172/W4): workbench math-proposals integration + e2e tests (#385)
Wires teaching/math_proposals/proposals.jsonl into the CORE Workbench
API (ADR-0160) alongside the existing cognition proposal queue:

workbench/schemas.py
  - MathReasoningStep, MathProposalSummary, MathProposalDetail,
    MathRatifyResult schemas

workbench/readers.py
  - MATH_PROPOSALS_JSONL + _DEFAULT_MATH_AUDIT_PATH constants
  - teaching/math_proposals added to ALLOWED_ARTIFACT_ROOTS
  - _HANDLER_DISPATCH table (vocabulary_addition→LexicalClaim; all
    others not yet implemented)
  - list_math_proposals(), read_math_proposal(), ratify_math_proposal()
  - read_math_proposal() re-runs decompose_audit() to recover full
    4-step reasoning trace (canonical_bytes only carries trace_id)
  - ratify_math_proposal() raises NotImplementedError with clear
    "handler not yet implemented: {change_kind}" for unhandled kinds

workbench/api.py
  - GET /math-proposals, GET /math-proposals/{id}
  - POST /math-proposals/{id}/ratify → _math_ratify()
    (vocabulary_addition→200/routed; unhandled→501 with loud message)

tests/test_adr_0172_w4_workbench_e2e.py — 6 tests:
  1. loads from JSONL
  2. renders domain:math badge (distinct from cognition /proposals)
  3. ratify-vocabulary_addition routes to LexicalClaim (200)
  4. ratify-matcher_extension fails loudly (501 "handler not yet
     implemented")
  5. all 4 trace steps visible in detail response
  6. no cross-contamination between math and cognition queues

teaching + runtime suites green (28 + 20 passed).

Brief-gap note: canonical_bytes() excludes proposal_id and serialises
evidence pointers as hashes only. D1 loader derives proposal_id via
sha256(line_bytes) and re-runs decompose_audit() to recover full trace
for read_math_proposal(). This works but means the JSONL cannot be
loaded without the original audit file. If a future wave needs
standalone JSONL loading, C1 should emit a richer format.
2026-05-27 13:16:23 -07:00
Shay
fbbc57edff
feat(ADR-0172/W3): core eval math-contemplation CLI lane (#384)
Wires `decompose_audit()` into a new `core eval math-contemplation`
subcommand:

- `cmd_eval_math_contemplation` in `core/cli.py` dispatched via `cmd_eval`
  when `lane == "math-contemplation"`
- `--audit` (default: audit_brief_11.json) + `--output` (default:
  teaching/math_proposals/proposals.jsonl) with path-traversal validation
  (absolute paths and directory-escaping relative paths → exit 2)
- exit 0 success / exit 1 audit-not-found / exit 2 parse-error or rejection
- `--json` flag for machine-readable output
- idempotent: re-run on same audit writes byte-identical JSONL
- output sorted by proposal_id (inherits decomposer sort contract)
- forbidden: no auto-apply, no writes outside teaching/math_proposals/,
  no audit-file mutation
- `teaching/math_proposals/.gitkeep` directory scaffold committed
- `.gitignore` entry for `teaching/math_proposals/proposals.jsonl`
- 11 tests in `tests/test_adr_0172_w3_cli_lane.py`; runtime suite green
2026-05-27 12:58:31 -07:00
Shay
af3821f0ed
feat(ADR-0172/W2): audit-corpus decomposer (#383)
Add decompose_audit(audit_path) to teaching/math_contemplation.py.
Groups audit_brief_11.json refusal rows by
(refusal_reason, missing_operator), emits one
MathReaderRefusalShapeProposal per group of >=2 rows, each carrying a
4-step ReasoningTrace (observation -> grouping -> hypothesis ->
conclusion).

Determinism:
- Group iteration sorted by (refusal_reason, missing_operator).
- Evidence per group sorted by case_id.
- Output tuple sorted by proposal_id.
- 10x rerun -> byte-identical proposals + trace_ids.

Pure read-only: audit file is not mutated, no proposals written to
disk, no chat/field/generate/algebra imports.

Tests (tests/test_adr_0172_w2_decomposer.py): real-audit emission,
determinism (10x), evidence floor, change-kind dispatch over all four
heuristic branches, four-step trace, case_id sort, proposal_id sort,
empty input -> empty tuple, unmapped operator skip, missing file ->
FileNotFoundError, no-mutation contract.

Added to core test --suite teaching.
2026-05-27 12:39:53 -07:00
Shay
87790ad60b
test(ADR-0172/W0.1): add trace replay-equivalence pinning tests (#382) 2026-05-27 12:36:51 -07:00
Shay
981d764810
feat(ADR-0172/W1): MathReaderRefusalShapeProposal schema (#380)
New module `teaching/math_contemplation_proposal.py` defines the
`MathReaderRefusalShapeProposal` dataclass — the math-domain analog of
`TeachingChainProposal` for the Tier-1 contemplation corridor.

- `build_proposal` enforces all seven invariants: math domain, ShapeCategory
  enum membership, ≥2 evidence pointers, valid ChangeKind Literal, JSON-
  serializable payload, ≥40-char wrong_zero_assertion, and non-None
  reasoning_trace with a non-empty trace_id.
- `canonical_bytes` / `compute_proposal_id` produce stable sha256-based IDs;
  evidence reduced to evidence_hash, trace to trace_id for stability.
- `ReasoningTrace` imported under TYPE_CHECKING only (W0/A1 not yet merged);
  duck-typed at runtime via trace_id attribute.
- 16 tests cover all eight brief obligations plus freeze and sensitivity checks.
- `core test --suite teaching -q` green (17 passed).
2026-05-27 12:25:49 -07:00
Shay
f16ac96fb7
feat(teaching/W0): ReasoningTrace substrate for ADR-0172 Tier 1 (#379)
Schema-only module defining ReasoningStep / ReasoningTrace with
byte-identical canonical serialization and sha256 trace_id derivation.
Replay-equivalence is enforced by:

- sorted-key JSON, no whitespace, ensure_ascii=False, allow_nan=False
- recursive rejection of float values in payloads (replay hazard)
- step_index monotonicity from 0
- empty trace rejected
- Literal-checked step_kind across all eight Tier 1+2 kinds

No runtime hook. No import from chat/field/generate/algebra.
Downstream (W1 ShapeProposal, W2 decomposer) consume this schema.

Tests: 12 new, full teaching suite green (17 passed).
2026-05-27 12:21:59 -07:00
Shay
b190f3b6c5
feat(ADR-0170/W2): DCS-S1 acquisition verbs — first CandidateOperation emission (#377)
Second implementation PR of the ADR-0170 wave. Extends the DCS injector
to emit ``CandidateOperation(kind='add')`` for acquisition verbs
alongside the existing ``CandidateInitial`` emission for possession
verbs. Proves the W1 type-widening with real emission of both union
members.

## What changes

### `generate/recognizer_match.py`
- New `_ACQUISITION_VERBS` frozenset (12 verbs: collect/get/receive/buy
  inflections). Each member is a subset of `ADD_VERBS` so the downstream
  CandidateOperation post-init whitelist accepts the matched_verb token.
- Extractor now accepts either possession OR acquisition verbs and
  records `anchor_kind` (`"possession"` | `"acquisition"`) plus
  `verb_token` in the parsed anchor schema.

### `generate/recognizer_anchor_inject.py`
- `inject_discrete_count_statement` dispatches on `anchor_kind`:
  - `"possession"` → `CandidateInitial` (existing behavior unchanged)
  - `"acquisition"` → `CandidateOperation(add)` (new)
- New helper `_build_operation_from_discrete_count_acquisition`
  constructs the operation. Operand uses `_resolve_count_value`;
  matched_verb uses `_locate_token` for round-trip ground check.
- Return type uses `InjectorEmission` from W1.

### Tests
- `tests/test_adr_0170_w2_dcs_acquisition_verbs.py` (new) — 22 tests:
  - Verb-set membership pins
  - Acquisition ⊂ ADD_VERBS sanity check
  - Possession + Acquisition disjoint
  - Extractor records anchor_kind correctly
  - Injector emits CandidateOperation for acquisition verbs
  - Possession path still emits CandidateInitial unchanged
  - Deliberate exclusions (gained / donated / saved) still refuse
  - Case 0050 hazard pinned (does/contemplates not in either set)
  - Determinism + roundtrip_admissible passes

- Updated `tests/test_adr_0163_d2_discrete_count_injection.py` to
  reflect new anchor schema fields (anchor_kind, verb_token).

- Updated `tests/test_adr_0170_w1_injector_type_widening.py` —
  the DCS injector now legitimately returns
  `tuple[InjectorEmission, ...]` (not narrower).

## Deliberate exclusions

These verbs are NOT in `_ACQUISITION_VERBS` and the extractor refuses
them — preserving wrong=0:

- `gained / gains / gain` — delta-of-attribute (weight, age), not
  acquisition. Admitting as add-operation would risk wrong>0 on
  questions that ask total state.
- `donated / donates / donate` — SUBTRACT semantics (actor gives away).
- `saved / saves / save` — ambiguous (time vs money vs effort).

Widening this set is operator-reviewable per `feedback-wrong-zero-
hazard-case-0050` discipline.

## ADR-0131.G.1 branch-disagreement discipline preserved

The regex parser already emits `CandidateOperation(add)` for
acquisition verbs via `ADD_VERBS` for single-word units. The new DCS
injector path emits the same kind of operation for multi-word units
(where the regex parser fails). Collapsed-tie when both paths emit
identical operations on overlapping shapes; no disagreement.

## Test plan

- tests/test_adr_0170_w2_dcs_acquisition_verbs.py: 22 passed (new)
- tests/test_adr_0163_d2_discrete_count_injection.py: ~30 passed
  (existing tests updated for new schema fields)
- tests/test_adr_0170_w1_injector_type_widening.py: 6 passed
- tests/test_recognizer_skip_wrong_zero.py + brief_11b + brief_11 +
  candidate_graph_wiring + candidate_domain_partition: passed
- evals/gsm8k_math/train_sample/v1: counts=correct=3 refused=47 wrong=0
  unchanged (case 0023 still has S2/S3 downstream blockers; W2's value
  is infrastructure, not direct lift)

## Hard invariants

- `wrong == 0` preserved (case 0050 hazard pin + deliberate verb
  exclusions + roundtrip_admissible gate)
- ADR-0166: no new eval lanes
- No teaching-store / pack mutation
- ADR-0131.G.1 branch-disagreement discipline preserved (acquisition →
  operation, not initial)
- Five-layer wrong=0 safety net (ADR-0163.D.2) intact and extended

## W3 NOT in this PR — honest skip

Initial plan was to bundle W2 + W3 (A1 currency_amount injector).
Inspection of the 4 actual `currency_amount` GSM8K refusals showed
none match A1's narrow form (`<ProperNoun> earns|charges $<amount>`):

| Case | Statement | Reason narrow form doesn't fit |
|---|---|---|
| 0019 | "this requires 3 vet appointments, which cost $400 each" | anaphoric subject + multi-quantity |
| 0026 | "Aaron and his brother Carson each saved up $40" | multi-subject + "each" |
| 0028 | "It cost $100,000 to open initially" | pronoun subject |
| 0043 | "Her mother gave her an additional $4, and her father twice as much" | multi-clause + comparative + transfer |

Shipping W3 as-designed would have re-introduced the dead-code pattern
#373 just cleaned up. Skipped honestly; ADR-0172 Tier 1's decomposer
(the next wave) will surface category-shape mismatches like this
programmatically.
2026-05-27 12:07:54 -07:00
Shay
35a29ed2de
fix(tests): G2 comparative-counter excludes recognizer-path refusals + refresh report.json (#375)
The G.2 test \`_comparative_clause_refusal_count\` reads \`report.json\`
and counts refusals whose reason quotes a statement clause containing
comparative anchors ("more/less than", "twice as many", etc.). After
#359's wrong=0 fix, the candidate-graph emits two refusal-reason
families that both quote a statement:

1. "no admissible candidate for statement: '...'" — parser-path
   refusal (the comparative-parse-failure family this metric tracks).
2. "recognizer matched but produced no injection for statement:
   '...'" — recognizer-path refusal; the quoted statement may
   incidentally contain comparative anchors but the refusal cause is
   the missing injector, NOT the comparative parse.

The pre-#359 counter only saw family (1) reasons; post-#359 it
over-counts whenever a recognizer-path refusal quotes a statement
containing comparative anchors. This was the test failure A2's PR
(#369) and the cleanup PR (#373) both surfaced.

## Fix

Filter the counter to exclude family (2) explicitly. Recognizer-path
refusals are tracked separately by the recognizer-wiring test suite;
they don't belong in the G.2 metric.

Result on current main:
- total statements with comparative anchors in refusal reasons: 2
- parser-path: 1 (case 0009, the legitimate G.2-tracked refusal)
- recognizer-path: 1 (filtered out — incidental anchor in #359-format reason)
- G.2 metric correctly reports 1 < baseline 2 → assertion passes

## Also: refresh report.json

The checked-in \`report.json\` was generated pre-#359 with the legacy
refusal-reason format. The runner now emits the new format on every
run; checking in the current output makes the baseline reproducible
and clears the CI friction that A2 originally flagged.

## Test plan

- tests/test_adr_0131_G2_comparatives.py: 25 passed (was 24 pass / 1 fail)
- tests/test_adr_0131_G4_multi_clause.py + G5_aggregate + S1_rate_events: 105 passed
- tests/test_brief_11b_audit_artifact + step2_lexicon + recognizer_skip + brief_11_audit + wiring + partition + adr_0163_d2: 89 passed
- Total: 219 passed

## Hard invariants

- No runtime change
- wrong=0 invariant preserved
- ADR-0166: no new eval lanes
- No teaching-store / pack mutation
2026-05-27 11:26:25 -07:00
Shay
eb452da9be
feat(ADR-0170/W1): widen inject_from_match return type — no behavior change (#374)
First implementation PR of the ADR-0170 wave. Type-level widening only:
the recognizer-injector dispatch now returns
``tuple[InjectorEmission, ...]`` where
``InjectorEmission = CandidateInitial | CandidateOperation``.

The existing ``inject_discrete_count_statement`` continues to emit only
``CandidateInitial`` — the widening unlocks but does not exercise
operation emission. Subsequent W2-W5 PRs ship the per-injector emission
shapes:

- W2 — DCS-S1 acquisition verbs (CandidateOperation(add))
- W3 — A1 currency_amount (CandidateInitial reimplementation)
- W4 — A3 multiplicative_aggregation (CandidateInitial(product))
- W5 — A4 temporal_aggregation (deferred until apply_rate primitive)

## Changes

### `generate/recognizer_anchor_inject.py`
- New `InjectorEmission = Union[CandidateInitial, CandidateOperation]`
- `inject_from_match` return type widened to
  `tuple[InjectorEmission, ...]`
- `__all__` exports `InjectorEmission`
- Documentation comment names ADR-0170 §"Implementation outline"

### `generate/math_candidate_graph.py` (admissibility dispatch)
The per-statement admission loop now dispatches admissibility on the
concrete candidate type:

  if isinstance(c, CandidateInitial):
      if _initial_admissible(c): admitted.append(c)
  elif isinstance(c, CandidateOperation):
      if roundtrip_admissible(c): admitted.append(c)

No new admission semantics — each type is gated by the predicate it was
already gated by elsewhere in the codebase. The dispatch unifies the
injector path with the parser path.

### `tests/test_adr_0170_w1_injector_type_widening.py` (new)
- Pin: `InjectorEmission` union members are exactly the two candidate types
- Pin: `inject_from_match` return type is widened
- Pin: `inject_discrete_count_statement` still emits CandidateInitial (W1
  is type-level only)
- Hazard pin: case 0050 remains refused
- Hazard pin: unparseable-verb refusal path (#359) unchanged
- Anti-regression: canonical DCS narrow-form extraction still works

## Test plan

- tests/test_adr_0170_w1_injector_type_widening.py: 6 passed (new)
- tests/test_adr_0163_d2_discrete_count_injection.py: 21 passed
  (existing D.2 v1 injector regression)
- tests/test_brief_11b_audit_artifact.py + step2_lexicon +
  recognizer_skip_wrong_zero + brief_11_audit: 55 passed
- tests/test_candidate_graph_recognizer_wiring.py: 7 passed
- tests/test_candidate_domain_partition.py: 5 passed
- tests/test_adr_0131_G2_comparatives + G4 + G5 + S1_rate_events:
  130 passed
- Total: 225 passed
- evals/gsm8k_math/train_sample/v1: counts=correct=3 refused=47 wrong=0
  (unchanged; verified no behavioral regression)

## Hard invariants

- `wrong == 0` preserved (admissibility dispatch is type-aware but
  semantically identical to the parser path's gating)
- ADR-0166: no new eval lanes
- No teaching-store / pack mutation
- Five-layer wrong=0 safety net (ADR-0163.D.2) intact
- Reader path unchanged
2026-05-27 11:23:08 -07:00
Shay
ecc0072ea1
chore: remove stub injector + superseded docs (cleanup-as-you-find) (#373)
Three concrete cleanup items from the day's work, per the
cleanup-as-you-find memory principle.

## 1. Remove inject_rate_with_currency stub

PR #369 (A2 rate_with_currency) shipped a function that always returns
() with an extensive docstring documenting the Rate-not-in-SentenceChoice
schema gap. The function is dead at runtime — `_INJECTORS.get(category)`
returning None has the same downstream behavior as the function
returning (). The 16 tests pinned the empty-tuple return; the case-0050
hazard pin is duplicated in test_recognizer_skip_wrong_zero.py and
test_brief_11b_step2_lexicon.py.

The schema gap is now properly documented in ADR-0170 (PR #372). A
dispatch-table comment at the removal site retains the at-code pointer
to that ADR for anyone wiring a new injector.

Removed:
- `inject_rate_with_currency` function in generate/recognizer_anchor_inject.py
- Its `_INJECTORS` dispatch table entry
- Its `__all__` export
- tests/test_injector_rate_with_currency.py (371 lines, 16 tests)

## 2. Remove docs/handoff/GPT55-MOBILE-DISPATCH.md

Single-session travel-time scaffolding. The 5 tasks it named are
complete or superseded by ADR-0170's findings. Pure historical artifact.

## 3. Remove docs/handoff/WAVE-NEXT-INJECTORS.md

Superseded by docs/handoff/WAVE-NEXT-REVISED.md, which captures
everything load-bearing from the original brief in its A1–A4 findings
table. The "kept for history" justification didn't survive scrutiny:
the document was misframed (over-promised lift; misframed schema work
as injector work). Lessons captured in REVISED + ADR-0170.

Updated cross-references:
- WAVE-NEXT-REVISED.md: removed the "supersedes ... kept for history"
  pointer; tightened cross-reference list
- ADR-0167-FOLLOWUPS.md §7: rewrote pointer to name ADR-0170 + REVISED
  as the live plan rather than "the original is retained"

## Test plan

- 219 tests passed across G.2/G.4/G.5/S1/Brief 11/B1/B11A/wiring/partition/DCS-D.2
- evals/gsm8k_math/train_sample/v1/report.json untouched (regen
  surfaces a separate stale-baseline test issue — out of cleanup scope)
- No runtime behavior change

## Net impact

- 5 files removed (~1200 lines)
- 1 file modified for explanatory comment (~30 lines)
- 2 doc files updated to remove dangling cross-references
- 0 behavioral change
2026-05-27 11:08:14 -07:00
Shay
f3e0e694b8
fix(tests): update wiring test assertions to post-#359 refusal-reason semantics (#370)
The wrong=0 fix in #359 changed the candidate-graph's refusal-reason
format when a ratified recognizer matches but its v1 injector returns
():

- Pre-#359: silently drop the recognized statement and admit a partial
  graph from the rest — a wrong>0 hazard analogous to case 0050.
- Post-#359: refuse explicitly with reason "recognizer matched but
  produced no injection" naming the statement and recognizer category.

Three tests in `test_candidate_graph_recognizer_wiring.py` were written
against the pre-#359 silent-drop behavior:

1. `test_empty_registry_preserves_existing_refusal_reason` — asserted
   the old "no admissible candidate" was the only valid format. Updated
   to accept either the legacy format OR the new explicit-refusal
   format.

2. `test_recognized_rate_statement_no_longer_triggers_per_statement_refusal`
   — asserted that recognized statements should NOT cause a per-statement
   refusal (encoding the silent-drop premise). Inverted to assert the
   correct post-#359 behavior: recognized-but-uninjectable statements
   refuse EXPLICITLY, and the statement IS named in the diagnostic.
   Renamed to `_refuses_explicitly_post_wrong_zero_fix`.

3. `test_recognized_descriptive_statement_no_longer_triggers_per_statement_refusal`
   — same inversion + rename.

Renames preserve the original sites for git-blame continuity while
making the post-#359 contract the documented behavior.

No runtime change. wrong=0 invariant preserved.

Test plan:
- tests/test_candidate_graph_recognizer_wiring.py: 7 passed (was 3 fail / 4 pass)
- tests/test_candidate_domain_partition.py: 5 passed (no cognition regression)
- tests/test_brief_11b_audit_artifact.py + step2_lexicon + recognizer_skip_wrong_zero + brief_11_audit: 55 passed
- Total: 62 passed
2026-05-27 10:25:22 -07:00
Shay
b288c2fc5c
feat(injector/A2): rate_with_currency — explicit schema-refusal (#369)
Wave-Next A2 brief outcome: the Rate type (ADR-0122) DOES structurally
model a per-unit rate, but it is not a member of the per-sentence
injector contract's SentenceChoice union (CandidateInitial |
CandidateOperation). The injector therefore returns () and documents
the schema gap inline plus in audit_brief_11.md.

Lift count: 0 (expected — the brief explicitly anticipates this
outcome when the schema decision is "no"). Documenting the gap is
the deliverable.

- generate/recognizer_anchor_inject.py: new inject_rate_with_currency
  + dispatch-table entry routing ShapeCategory.RATE_WITH_CURRENCY.
- tests/test_injector_rate_with_currency.py: 16 tests pinning schema
  evidence, schema refusal, dispatch wiring, case 0050 hazard,
  determinism, and wrong=0 invariant.
- evals/gsm8k_math/train_sample/v1/audit_brief_11.md: appended
  Wave-Next A2 section documenting the schema decision, eval delta
  (3/0/47 unchanged), case 0050 hazard verification, and the
  CandidateRate follow-up sequencing.

Case 0050 hazard pin: sentence 0 ("Mark does a gig every other day
for 2 weeks.") carries no currency symbol — rate_with_currency
never matches it; case stays refused at sentence_index=0.
2026-05-27 10:16:53 -07:00
Shay
9792f66f90
feat(brief-B1): lexicon closure wave 3 — unknown_word 5→3, wrong=0 preserved (#368)
Adds 3 drain_token lemmas to en_core_math_v1 closing 2 of 3 remaining
lexicon_entry refusals from the prior wave:

- path (case 0049, new lemma)
- journey (case 0049 follow-on after path resolved)
- sees → alias of existing "see" lemma (case 0040)

The third remaining lexicon_entry refusal (case 0001, '+') is
deliberately NOT closed: '+' is an arithmetic operator literal, not a
lexical token. Adding it as drain_token would silently drop arithmetic
content from problems like "5 + 3 apples", a wrong=0 hazard. Documented
in the PR body and audit artifact.

Refusal taxonomy shift:
- unknown_word: 5 → 3 (-2)
- unresolved_pronoun: 3 → 4 (+1) — case 0049's pronoun barrier surfaced
- incomplete_operation: 20 → 21 (+1) — case 0049's quantity gap surfaced

Hard invariants:
- wrong == 0 (admitted=0, verified)
- case 0050 hazard pinned (refused at sentence_index=0)
- manifest checksum unchanged (per-category source file edit)
- no teaching-store mutation; no reader runtime change
2026-05-27 10:13:09 -07:00
Shay
00c3968937
fix(ADR-0167): route contemplation and proposal replay by candidate domain (#363)
* fix(teaching): select proposal replay gate from candidate domain

* test(teaching): pin domain-selected proposal replay gates

* fix(teaching): make contemplation probes domain-aware

* test(teaching): pin domain-aware contemplation partition
2026-05-27 09:43:16 -07:00
Shay
dbeb1b2f00
fix(ADR-0167): replace brittle partition git-status assertion with behavioral invariant (#362)
* fix(tests): replace brittle git-status partition assertion with behavioral invariant

* docs(ADR-0167): record closure of brittle partition git-status assertion

* fixup: restore FOLLOWUPS §6 (holonomy ablation) — unresolved, just shipped in #360
2026-05-27 09:31:13 -07:00
Shay
97b0ee0e13
fix(wrong=0): refuse on recognized-but-uninjectable statements + audit taxonomy + 2 surfaced regressions (#359)
## Summary

Two test failures on origin/main both trace to PR #315 (ADR-0163.D.2 —
discrete_count_statement recognizer + admissibility-intent chain). Earlier
runs treated them as "pre-existing unrelated" — they are not unrelated.
The first is a real wrong>0 hazard.

## Failure 1: silent admission via recognized-but-uninjectable statement

The ratified `discrete_count_statement` recognizer over-matches: ANY
sentence containing a number + noun resolves it, irrespective of the verb.
When `inject_from_match` returns `()` (the round-2 default for v1
categories without an injector), the old code path used `continue` to
silently drop the statement — and the solver then answered from whatever
initial state remained.

Reproduction:
  parse_and_solve("Sam has 5 apples. Sam contemplates 3 apples. "
                  "How many apples does Sam have?")
  → is_admitted=True, answer=5.0  (silent admission of partial graph)

This is exactly the case-0050-class hazard wearing a different hat
(silently admitting an incomplete graph at the problem level).
ADR-0167 / Brief 11 §"correct-count greed" established the principle on
the reader path; this commit extends it to the recognizer path.

Fix: when a recognizer matches but produces no injection, REFUSE.

  generate/math_candidate_graph.py:
    - Replaced the skip-only `continue` with a CandidateGraphResult
      refusal carrying the recognizer category in the reason.

  tests/test_math_candidate_graph.py:
    - test_unparseable_statement now accepts either the legacy
      "no admissible candidate" reason or the new
      "recognizer matched but produced no injection" reason.
      Both legitimately refuse; what matters is is_admitted=False.

  tests/test_recognizer_skip_wrong_zero.py (NEW):
    - 5 regression tests pinning the wrong=0 invariant:
      * 3 parametrized verbs unknown to both regex parser and reader
        (contemplates / ponders / memorises) — must all refuse
      * Nonsense token — must refuse
      * Anti-regression: known initial + known operation still admits

## Failure 2: cognition audit drop-reason taxonomy

The audit test hardcoded `dropped.reason.startswith("superseded_by:")`
as the only valid drop-reason prefix.  Commit da70919 (ADR-0163.D.2)
ratified an admissibility-intent chain that the audit categorizes with
reason `unsupported_intent:admissibility`, which fails this assertion.

Fix: tests/test_teaching_audit.py — expand the allowed-prefix set to
include `unsupported_intent:` with a written rationale.  Future drop
classes extend the allowlist deliberately rather than silently
broadening the assertion to any non-empty reason.

## Surfaced regression: partition-test allowlist (ADR-0167 FOLLOWUPS §2)

This PR modifies three test files that the
test_existing_cognition_tests_untouched assertion would reject under
its named-allowlist scheme.  Added the three test paths to the allowlist
as the tactical fix; the architectural fix (retire / move to CI / move
to CODEOWNERS) is queued in docs/handoff/ADR-0167-FOLLOWUPS.md §2.

## Test plan

  uv run pytest tests/test_recognizer_skip_wrong_zero.py \
                tests/test_math_candidate_graph.py \
                tests/test_teaching_audit.py \
                tests/test_candidate_domain_partition.py \
                tests/test_math_evidence_e2e.py \
                tests/test_math_evidence_schema.py \
                tests/test_math_contemplation_adapter.py \
                tests/test_math_claim_signature.py \
                tests/test_math_lexical_ratification.py \
                tests/test_brief_11b_audit_artifact.py \
                tests/test_brief_11b_step2_lexicon.py \
                tests/test_brief_11_audit.py
  → 152 passed

## Hard invariants

- wrong == 0 — restored on the recognizer path (was silently violated on main)
- ADR-0166 — no new eval lanes
- No teaching-store mutation, no pack mutation
- The reader path was already correct (it refused these cases); this fix
  brings the regex/recognizer path back in line
2026-05-27 07:42:54 -07:00
Shay
cc6f13a939
feat(ADR-0167/W3-A): e2e determinism + cognition regression — LexicalClaim slice closed (#357)
Wave 3, closes the LexicalClaim slice of ADR-0167.  After this PR the
math reader's refusal taxonomy is evidence, not terminus: lexical
refusals flow through audit row → typed evidence → dedup signature →
HITL ratification (W2-D) → pack write → next-audit-pass-resolves.

Deliverables
------------
- tests/test_math_evidence_e2e.py (new, 7 tests):
  * test_full_pipeline_from_audit_to_evidence
  * test_e2e_replay_equivalence
  * test_lexical_ratification_advances_unknown_word_row (case 0040 'sees')
  * test_e2e_determinism_across_processes
  * test_cognition_teaching_corridor_unaffected
  * test_evidence_dedup_via_claim_signature
  * test_audit_artifact_round_trip_with_signatures
- evals/gsm8k_math/train_sample/v1/audit_brief_11.md: Post-W2 baseline
  table + cognition regression line + case 0050 hazard status + pointer
  to the new e2e regression module.
- tests/test_candidate_domain_partition.py: minimal allowlist patch to
  test_existing_cognition_tests_untouched so that future ADR-0167 PRs
  can add their own evidence test files without tripping a structurally
  brittle hard-coded whitelist (W2-C partition risk; recorded in PR body).

Hard constraints held
---------------------
- wrong == 0: case 0050 hazard still refuses at sentence_index 0
  after the tmpdir-pack 'sees' ratification; no admission introduced.
- Cognition regression: zero modifications to cognition test bodies;
  only the W2-C whitelist assertion was loosened.
- Determinism: in-process and cross-process evidence_hash byte-identical.
- No real-pack mutation: a per-test digest fixture asserts
  language_packs/data/en_core_math_v1/ is byte-identical before and
  after each test.

Out of scope
------------
- Frame/Composition/Reference/Slot ratification handlers (follow-up ADRs).
- Workbench v1 wiring of math candidates (ADR-0167 §Q4).
- Auto-ratification — HITL only, forever.
- The two partition risks Gemini flagged in W2-C (cognition pack indexing,
  replay-gate default) remain follow-up.

With this PR merged the engine can ratify math-domain lexical claims
from its own refusal evidence through the existing HITL teaching
corridor — the thesis claim of ADR-0167 becomes a concrete green test.
2026-05-27 07:27:24 -07:00
Shay
e2e53362f5
feat(ADR-0167/W2-D): lexical ratification handler (#354) 2026-05-27 06:57:37 -07:00
Shay
85bfa188ed
feat(ADR-0167/W2-B): lexical claim signature + dedup (#353)
Adds `teaching/math_claim_signature.py` with `lexical_claim_signature()`:
sha256 hex of a normalised lexical token, collapsing two refusal cases on
the same surface token into one teaching-corpus candidate.

Normalisation pipeline (documented in module, breaking-change surface):
  1. Lowercase surface
  2. Strip string.punctuation from both ends (!"#$%&'()*+,-./:;<=>?@[\]^_`{|}~)
  3. Extract token from refusal_detail via r"no primitive or lexicon match for '([^']+)'"
  4. Fallback: use stripped-lowercase surface if regex doesn't match
  5. Canonical: "lexical:" + extracted_token
  6. sha256 hex of UTF-8 bytes → 64-char lowercase hex

Also adds `teaching/math_contemplation.py` (W2-A adapter included as
union-merge; W2-A worktree was not yet dispatched):
  - `audit_to_evidence()`: AuditRow iterable → MathReaderRefusalEvidence tuple
  - `audit_problem_to_evidence()`: convenience wrapper for tests and W3-A
  - Lexical evidence: claim_signature filled; evidence_hash recomputed to include it
  - Non-lexical sub_types: claim_signature stays "" (deferred per ADR-0167 §Q1)

Real-data result on audit_brief_11.json:
  - 14 distinct lexical tokens → 14 distinct signatures (no false collisions)
  - No duplicate tokens in the 50-case sample; dedup logic verified deterministic

Wave 2, parallel with W2-C/D; depends on W1-A branch.
wrong=0 verified by passing regression suite.
2026-05-27 06:56:36 -07:00
Shay
9da61b96a0
feat(ADR-0167/W2-A): audit-to-evidence adapter (#352)
Wave 2, parallel with W2-B/C/D. Implements the type-A→type-B converter
from AuditRow to MathReaderRefusalEvidence per ADR-0167 W2-A brief.

Deliverables:
- teaching/math_contemplation.py:
  - audit_to_evidence(audit_rows): pure deterministic adapter, uses
    SUB_TYPE_FOR_OPERATOR for subtype assignment, skips rows where
    missing_operator is None, leaves claim_signature="" (W2-B will fill)
  - audit_problem_to_evidence(problem_text, case_id): convenience wrapper
    that runs the reader and adapts the output
- tests/test_math_contemplation_adapter.py: 8 tests covering
  determinism, input-order preservation, sub-type mapping
  exhaustiveness, distinct hashes across cases, empty input handling,
  None-operator skip, and round-trip from problem text

Invariants:
- Deterministic across reruns (verified by determinism rerun)
- No I/O in adapter path
- Input order preserved (no internal sort)
- claim_signature == "" for all W2-A records (W2-B coordination)

Validation:
- tests/test_math_contemplation_adapter.py: 8 passed
- tests/test_math_evidence_schema.py: 11 passed (W1-A regression)
- tests/test_brief_11b_audit_artifact.py + step2_lexicon + brief_11_audit:
  45 passed (regression)
- Determinism rerun: identical results
2026-05-27 06:44:46 -07:00
Shay
05aaff224e
feat(ADR-0167/W2-C): domain discriminator + cross-domain audit (#351)
* feat(ADR-0167/W1-A): MathReaderRefusalEvidence schema + canonical-bytes

Foundation type for routing comprehension-reader refusals into the
teaching corridor.  Frozen dataclass with sha256 evidence_hash computed
from deterministic canonical bytes (mirrors state.to_canonical_bytes
pattern).  Includes SUB_TYPE_FOR_OPERATOR mapping table covering all 13
missing_operator values in the current audit artifact.

Wave 1 only — no runtime mutation, no teaching-store integration, no
admission path.  Downstream W2-A/B/C/D type-import from this module.

* feat(ADR-0167/W2-C): domain discriminator + cross-domain audit

- Links to the audit doc: docs/handoff/ADR-0167-W2C-cross-domain-audit.md
- Inventory details: 5 construction sites, 8 consumption sites
- Verification: 0 cognition test files were modified; all tests are green
- Downstream partition work flagged: contemplation indexing (in teaching/contemplation.py) and replay gate (in teaching/proposals.py)
2026-05-27 06:44:29 -07:00
Shay
99c11d160a
feat(ADR-0167/W1-A): MathReaderRefusalEvidence schema + canonical-bytes (#350)
Foundation type for routing comprehension-reader refusals into the
teaching corridor.  Frozen dataclass with sha256 evidence_hash computed
from deterministic canonical bytes (mirrors state.to_canonical_bytes
pattern).  Includes SUB_TYPE_FOR_OPERATOR mapping table covering all 13
missing_operator values in the current audit artifact.

Wave 1 only — no runtime mutation, no teaching-store integration, no
admission path.  Downstream W2-A/B/C/D type-import from this module.
2026-05-27 06:30:21 -07:00
Shay
66ef4ad07c
feat(brief-11/11B-step-2): lexicon closure — unknown_word 11→5, wrong=0 preserved (#348)
## Summary

Lexicon-entry closure track per Brief 11D recommendation (Candidate A,
sub-PR 1). Adds 12 drain_token lemmas + 1 alias to `en_core_math_v1`.

`unknown_word` row strictly decreases: **11 → 5** (-6 cases moved past
the first-pass vocabulary gap). `wrong == 0` preserved. `correct` does
not move because admitted=0 (the unblocked cases now refuse at
downstream frames — real new work becoming visible, not regression, per
Brief 11 §Gate 1).

## Additions (all category=drain_token)

| Lemma     | Surfaced from              |
|-----------|----------------------------|
| along     | case 0049 (3rd-wave)       |
| animals   | case 0040 (3rd-wave)       |
| decrease  | case 0005                  |
| jacks     | case 0024 (jumping jacks)  |
| length    | case 0006 (3rd-wave)       |
| previous  | case 0006                  |
| reach     | case 0015                  |
| stray     | case 0040                  |
| too       | case 0039                  |
| uphill    | case 0049                  |
| which     | case 0001                  |
| your      | case 0001 (3rd-wave)       |
| weight → weights (alias) | case 0021     |

All classified as `drain_token` (the only category that cannot open a
frame and therefore cannot create wrong admissions per Brief 11
§"correct-count greed" doctrine). Reclassifying any as
accumulation/depletion/transfer verbs would risk wrong>0 by opening a
malformed operation_frame.

## wrong=0 verification

- `assert audit_problem(case_0050)` returns `ReaderRefusal` at
  sentence_index 0 (pinned by `test_hazard_case_0050_remains_refused_pre_frame`)
- 50-case audit: `admitted=0, refused=50` (pinned by
  `test_no_case_admits_after_lexicon_closure`)
- No reader runtime changes; pack-only mutation in a single
  per-category source file
- Manifest checksum unchanged: source-file edit doesn't regenerate the
  compiled `lexicon.jsonl`; loader reads per-category sources for
  alias-aware entries (see `generate/comprehension/lexicon.py:127`)

## Test plan

- 11 new tests in `tests/test_brief_11b_step2_lexicon.py`:
  - 4 pack-additions pinning (categories, provenance, aliases, sort order)
  - 4 reader-effect / hazard tests (admitted=0, case 0050 refused,
    unknown_word row strictly decreased, manifest checksum unchanged)
  - 2 loader-integrity tests (new lemmas + aliases resolve through
    `load_lexicon` → `lookup`)
- 12 existing tests in `tests/test_brief_11b_audit_artifact.py` pass
  (taxonomy counts updated to post-step-2 values)
- 23 existing tests in `tests/test_brief_11_audit.py` pass

## Hard invariants preserved

- `wrong == 0` — no admissions, no frame-opener miscategorisation
- ADR-0166 — no new canonical eval lanes; existing
  `gsm8k_math/train_sample/v1/` artifact updated in-place
- No teaching-store mutation; pack mutation is explicit, single-file,
  reviewed
- Manifest checksum unchanged (compiled lexicon.jsonl byte-identical)

## Follow-up

- 3 lexicon_entry refusals remain (case 0001 '+', case 0040 'sees',
  case 0049 'path'). Not addressed in this PR: '+' is an arithmetic
  literal (would change semantics of drain), 'sees' and 'path' have
  many other downstream barriers. Address with next-bottleneck PR.
- The 6 cases now refusing at later frames feed directly into Brief
  11D Candidate A sub-PR 2 (which bottleneck class to attack next).
2026-05-27 06:06:41 -07:00