Commit graph

6 commits

Author SHA1 Message Date
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
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
da70919f94
feat(ADR-0163.D.2): parsed_anchors → MathProblemGraph state — discrete_count_statement injection v1 (#315)
First PR plumbing recognizer parsed_anchors into the candidate-graph as
typed CandidateInitial primitives. Scope limited to discrete_count_statement;
other five round-2 categories route to the round-2 skip-only fallback until
follow-up D.2.x PRs.

Five-layer wrong=0 safety net:
1. Matcher narrowness — _try_extract_discrete_count_anchor refuses on any
   ambiguity (multi-subject, pronoun subject, non-possession verb,
   multi-count, clause-split, unobserved counted_noun, unobserved
   count_kind).
2. Extraction correctness — refusal-preferring; populated parsed_anchors
   only when ALL narrowness rules hold.
3. Injection correctness — _initial_admissible gates every constructed
   CandidateInitial; failure to ground returns () (under-admit).
4. Replay gate — propose-time admissibility_replay_gate auto-rejects any
   matcher change that would lift GSM8K wrong count.
5. Multi-branch decision rule — injected candidate disagreeing with
   another branch triggers refuse path.

Re-baseline (GSM8K train_sample v1):
- Old (#309 alone): correct=3 refused=47 wrong=0
- New (#309 + D.2 v1): correct=3 refused=47 wrong=0
- Empirical lift in v1 = 0 cases; framework operational. No GSM8K
  train_sample case has a discrete_count statement that simultaneously
  meets all narrowness rules AND is missed by the existing parser.
  Bottleneck moves to other recognizer categories (D.2.2+).

Validation:
- tests/test_adr_0163_d2_discrete_count_injection.py: 34 passed
- tests/test_recognizer_match.py + test_candidate_graph_recognizer_wiring
  + test_admissibility_replay_gate: 27 passed
- adr_0131_* (G1..G5 + S1 wrong=0 invariant): 222 passed / 2 pre-existing
  report-comparison failures / 3 skipped — byte-identical to pre-D.2
- Solver code: unchanged

Operator caveat: round-1's ratified discrete_count_statement spec is
unchanged. Matcher behavior on the spec's canonical_pattern has been
extended from detection-only to populated parsed_anchors. Re-ratification
is not required; if policy requires it on matcher-behavior changes, the
registry digest provides byte-stable provenance.
2026-05-26 18:32:05 -07:00
Shay
1f5ffcf6c7
feat(ADR-0163.C.2): extend exemplar ingest + synthesis + matchers for round-2 categories (#307)
Unblocks the four Phase B round-2 exemplar corpora (PR #306) so they
can flow through `core teaching propose-from-exemplars`.  The corpora
were committed in #306 but Phase C's ingest validator + synthesizer
were hard-coded to round-1 categories; this PR closes that gap.

Extends three modules with the three new categories
(discrete_count_statement, multiplicative_aggregation, currency_amount):

- teaching/exemplar_ingest.py — per-category validator dispatch +
  _SUPPORTED_CATEGORIES.  The file-stem rule loosens from
  exact ``<category>_v1`` to ``<category>_v<N>`` so the
  temporal_aggregation v2 widening from #306 ingests.
- teaching/recognizer_synthesis.py — per-category synthesizers
  following the same observed_*-set + coverage-histogram pattern as
  round 1.  Determinism, narrowness rule (narrower-not-broader),
  rules-only — same discipline.
- generate/recognizer_match.py — per-category matchers shipped as
  DETECTION-ONLY (return empty parsed_anchors).  Consistent with
  Phase D's current skip-only wiring (PR #302).  Real value
  extraction lands when Phase D.2 plumbs parsed_anchors into the
  solver; until then, detection-only is the right shape and
  preserves wrong=0 by construction.

  graph_intent Literal expanded to include "count" and "amount".

Test updates:
- tests/test_exemplar_ingest.py: extend _ROUND_1 with _ROUND_2;
  test_list_corpora_loads_every_round_1_file now asserts every
  committed corpus (round 1 + round 2) loads.
- tests/test_recognizer_registry.py: rename + repair
  test_live_proposal_log_has_phase_c_pending_proposals →
  test_live_proposal_log_has_phase_c_proposals.  The original
  asserted state=="pending"; PR #304 ratified the three, so the
  test now asserts state=="accepted" and registry length matches.
  Pre-existing failure on main, fixed here.

Validation:
- 132 passed across exemplar_ingest, recognizer_synthesis,
  recognizer_match, recognizer_registry, candidate_graph_wiring,
  admissibility_exemplars, refusal_taxonomy_lane,
  admissibility_replay_gate
- 222 capability-axis tests passed / 2 pre-existing main failures /
  3 skipped — G1..G5 + S1 wrong=0 invariant intact
- 67 smoke passed
- End-to-end CLI sanity check: `core teaching propose-from-exemplars
  teaching/admissibility_exemplars/discrete_count_statement_v1.jsonl
  --log /tmp/test.jsonl` produced proposal_id 8c7645b4..., state
  pending, replay_equivalent=True, wrong_count_delta=0

Empirical projection: of 47 still-refused GSM8K train_sample
statements, ~22 match the discrete_count_statement recognizer, ~2
match multiplicative_aggregation, plus 3 rate_with_currency + 3
temporal_aggregation + 18 descriptive_setup_no_quantity recognized
under the existing round-1 wiring.  After operator ratifies round-2
proposals, the candidate-graph skip-only wiring will drop those
sentences from the math state and a meaningful lift is projected.
wrong=0 preserved at every level by Phase D's skip-only
construction.

Scope: enables the round-2 pipeline; does NOT ratify anything;
does NOT modify generate/math_candidate_graph.py.  Operator runs
propose-from-exemplars + review --accept after merge.
2026-05-26 15:08:41 -07:00
Shay
e9b7eb0b1f
feat(ADR-0163.D): wire ratified RecognizerSpecs into math_candidate_graph admissibility surface (#302)
* chore(ADR-0163.C): land three Phase C pending proposals in live log

Phase C (#301) shipped the CLI but its PR dry-run wrote to a tmp log
path.  This commit moves the three Phase C proposals into the live
teaching/proposals/proposals.jsonl so the Phase B→C audit trail is
visible in the proposal log and the proposals are ready for the
operator to ratify after Phase D ships.

Proposals (all state=pending, kind="exemplar_corpus"):
- 59223f13722f906a1cf9b65d9b01c990 — descriptive_setup_no_quantity
- 46ce297f797ff16da12db5de422ca3c9 — rate_with_currency
- a3b892546977c5f0f64c578d6052adbd — temporal_aggregation

Produced by `core teaching propose-from-exemplars --all` against the
live Phase B corpora.  No ratification (ADR-0161 §5 — only the repo
owner ratifies).  The Phase D admissibility-replay gate confirmed
replay_equivalent=true, wrong_count_delta=0 for all three.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

* feat(ADR-0163.D): wire ratified RecognizerSpecs into math_candidate_graph admissibility surface

Phase D is the first PR to extend the math admission surface.  The
audit (#294) said the gap was admission, not operators, algebra,
substrate, or packs.  Phase A measured the refusal taxonomy.  Phase B
authored seeds.  Phase C synthesized recognizers.  Phase D wires
those recognizers into generate/math_candidate_graph.py.

Modules
- generate/recognizer_registry.py — pure projection over the proposal
  log.  Only proposals with source.kind="exemplar_corpus" AND
  review_state="accepted" enter the tuple.  Sorted by
  (review_date, proposal_id).  In-process cache keyed on log
  (mtime, sha256) — no filesystem cache (ADR-0161 §1).  Malformed
  accepted specs raise RegistryLoadError citing the offending
  proposal_id; silent drops are forbidden.
- generate/recognizer_match.py — per-category rules-only matchers
  (no LLM, no embedding, no learned classifier).  Honors the Phase C
  synthesizer's narrowness rule: out-of-corpus currency symbols,
  window units, and per-unit values do NOT match.  Three matchers:
  _match_descriptive_setup_no_quantity (zero-quantity surface),
  _match_temporal_aggregation (event_count_per_window with
  observed_window_units/quantifiers honored), _match_rate_with_currency
  (currency_per_unit_rate with observed currency/per-unit/amount-kind
  honored).
- generate/math_candidate_graph.py — narrowest-edit guard at the
  per-statement choice loop.  Before the existing
  "no admissible candidate for statement" refusal, consult the
  ratified registry.  Recognized statements are dropped from
  per_sentence_choices (zero math state) so the Cartesian product is
  identical to "this statement was never there."  Empty registry is
  a no-op — backward compatibility preserved byte-identically.
  Downstream consumption of parsed_anchors (turning recognized
  rate/temporal surfaces into solver state that produces concrete
  answers) is Phase E follow-up.

Tests (32 new)
- tests/_phase_d_fixture.py — synthetic in-memory ratified registry
  built from the three Phase C pending proposals' content.  Per
  ADR-0161 §5 the agent does NOT ratify the live log; the synthetic
  registry round-trips the real RecognizerSpec bytes the operator
  will ratify after Phase D ships.
- tests/test_recognizer_registry.py (9) — empty/pending/wrong-kind
  filtering, sort order, malformed-spec rejection, cache hit +
  invalidation, live-log Phase C audit check.
- tests/test_recognizer_match.py (14) — per-category positive cases,
  narrowness (out-of-corpus surface forms rejected), no-LLM import
  check.
- tests/test_candidate_graph_recognizer_wiring.py (7) — empty registry
  preserves existing refusal; synthetic registry: recognized
  statements no longer trigger per-statement refusal;
  wrong_count_delta == 0 on GSM8K train_sample; capability axes G1..
  G5+S1 wrong=0 unchanged; per-category admission counts on the
  refused-set; unrecognized statements still refuse with the
  existing reason.
- tests/test_phase_d_replay_evidence.py (2) — full admissibility
  replay gate under synthetic registry: replay_equivalent=true,
  wrong_count_delta=0, every capability axis wrong=0; each
  ratified recognizer admits >= 1 train_sample statement (wiring
  is consequential).

Per-category fixture-based admission counts (synthetic registry vs
GSM8K train_sample refused-set sentences):
- descriptive_setup_no_quantity: 40
- rate_with_currency:             2
- temporal_aggregation:           7

Narrowness-invariant negative case results (matcher correctly
returns None on out-of-corpus / load-bearing-math surfaces):
- rate_with_currency:           "She paid $5 for the book." (no per-unit)
- temporal_aggregation:         "On Saturday she went to the store." (single day token)
- descriptive_setup_no_quantity: "There are some kids in camp." (indefinite quantifier)

Candidates for Phase B round 2 (3 of 20 temporal seeds match the
spec's structural commitment but not my surface regex — author_notes
explicitly flagged these as schema-gap edge cases):
- ta-v1-0004 "Mark does a gig every other day for 2 weeks."
- ta-v1-0012 "Robin walks 4 dogs every other day around the park."
- ta-v1-0019 "The pump fills the tank with 80 gallons over 6 hours."

Three landed wirings DO NOT shift the GSM8K train_sample baseline
counts under fixture (correct=3, wrong=0, refused=47 unchanged) —
Phase D's narrow wiring is wrong=0 safe by construction; lift to
"correct" requires Phase E's downstream parser-side consumption of
parsed_anchors.  Capability axes G1..G5+S1 wrong=0 unchanged.

Cross-refs: ADR-0163 (Phase D), ADR-0057 (proposal review),
ADR-0151 (auto-proposal), ADR-0161 §5 (ratification boundary),
Phase A PR #297, Phase B PR #298, Phase C PR #301.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-26 13:11:47 -07:00