diff --git a/generate/recognizer_anchor_inject.py b/generate/recognizer_anchor_inject.py index befdcce4..b9d8e937 100644 --- a/generate/recognizer_anchor_inject.py +++ b/generate/recognizer_anchor_inject.py @@ -46,7 +46,12 @@ from typing import Mapping, Union from evals.refusal_taxonomy.shape_categories import ShapeCategory from generate.math_candidate_parser import CandidateInitial, CandidateOperation -from generate.math_problem_graph import InitialPossession, MathGraphError, Quantity +from generate.math_problem_graph import ( + InitialPossession, + MathGraphError, + Operation, + Quantity, +) from generate.recognizer_match import RecognizerMatch # ADR-0170 — the widened injector emission type. Per-category injectors @@ -92,26 +97,49 @@ def inject_from_match( def inject_discrete_count_statement( match: RecognizerMatch, sentence: str, -) -> tuple[CandidateInitial, ...]: - """Build CandidateInitial(s) from ``discrete_count`` parsed anchors. +) -> tuple[InjectorEmission, ...]: + """Build CandidateInitial OR CandidateOperation from ``discrete_count`` + parsed anchors, dispatched on the matcher's ``anchor_kind``. - v1 narrowness: the matcher emits at most one anchor (further anchors - refuse extraction). When the anchor is absent (detection-only - fallback), the injector returns ``()`` and the candidate-graph - continues with the round-2 skip-only behavior. + Per ADR-0170 W2 — the matcher records ``anchor_kind`` as either + ``"possession"`` (verbs ``has/have/had``) or ``"acquisition"`` + (verbs in ``_ACQUISITION_VERBS``). + + - ``possession`` → ``CandidateInitial`` (existing behavior; the + sentence asserts an initial state) + - ``acquisition`` → ``CandidateOperation(kind='add')`` (new in W2; + the sentence asserts an add-operation, preserving + ADR-0131.G.1's branch-disagreement discipline — the regex + parser's ADD_VERBS path emits the same kind of operation for + single-word units, so the injector path complements it on + multi-word units without conflicting) + + v1 narrowness: at most one anchor per match; absent or + unconstructable anchors return ``()``. """ if not match.parsed_anchors: return () - out: list[CandidateInitial] = [] + out: list[InjectorEmission] = [] for anchor in match.parsed_anchors: - cand = _build_initial_from_discrete_count(anchor, sentence) + anchor_kind = anchor.get("anchor_kind", "possession") + if anchor_kind == "possession": + cand: InjectorEmission | None = _build_initial_from_discrete_count( + anchor, sentence + ) + elif anchor_kind == "acquisition": + cand = _build_operation_from_discrete_count_acquisition( + anchor, sentence + ) + else: + # Unknown anchor_kind — under-admit. Future widenings (e.g. + # "depletion" verbs as CandidateOperation(subtract)) extend + # this branch. + return () if cand is None: - # Under-admit on any failure to construct. The other - # already-built candidates for this sentence remain - # admissible only if they all pass; partial admission would - # mean the downstream Cartesian product enumerates a graph - # missing state — under-admit instead. + # Under-admit on any failure to construct. Partial + # admission would mean the downstream Cartesian product + # enumerates a graph missing state. return () out.append(cand) return tuple(out) @@ -194,6 +222,104 @@ def _build_initial_from_discrete_count( return None +def _build_operation_from_discrete_count_acquisition( + anchor: Mapping[str, object], + sentence: str, +) -> CandidateOperation | None: + """Construct one CandidateOperation(kind='add') from a discrete_count + anchor whose ``anchor_kind == "acquisition"``. + + Per ADR-0170 W2 — acquisition verbs (``collected``, ``received``, + ``bought``, ``got``) are routed to operations, not initials, in + accordance with ADR-0131.G.1's branch-disagreement discipline. The + solver's defaults-from-zero rule resolves single-statement + acquisitions correctly (``0 + N = N``). + + Refuses (returns ``None``) when any field cannot be coerced, when + the literal verb token cannot be located in the surface, or when + the constructed ``CandidateOperation`` would violate its post-init + invariants. The result is admissibility-checked upstream by + ``roundtrip_admissible``. + + Anchor schema (same as possession, with ``anchor_kind`` discriminator): + { + "kind": "discrete_count", + "anchor_kind": "acquisition", + "subject_role": , + "count_token": , + "count_kind": <"integer"|"word">, + "counted_noun": , + "verb_token": , # e.g. "collected" + } + """ + subject_role = anchor.get("subject_role") + count_token = anchor.get("count_token") + count_kind = anchor.get("count_kind") + counted_noun = anchor.get("counted_noun") + verb_token = anchor.get("verb_token") + + if ( + not isinstance(subject_role, str) or not subject_role + or not isinstance(count_token, str) or not count_token + or not isinstance(count_kind, str) + or not isinstance(counted_noun, str) or not counted_noun + or not isinstance(verb_token, str) or not verb_token + ): + return None + + value = _resolve_count_value(count_token, count_kind) + if value is None: + return None + + # Locate the literal verb surface in the sentence so the + # round-trip ground check in ``roundtrip_admissible`` succeeds. + # The matcher already confirmed ``verb_token`` is in + # ``_ACQUISITION_VERBS`` (which is itself a subset of + # ``ADD_VERBS``), so the downstream CandidateOperation post-init + # whitelist accepts the matched_verb token. + located_verb = _locate_token(sentence, verb_token) + if located_verb is None: + return None + + try: + operand = Quantity(value=value, unit=counted_noun) + op = Operation( + actor=subject_role, + kind="add", + operand=operand, + ) + except MathGraphError: + return None + + try: + return CandidateOperation( + op=op, + source_span=sentence, + matched_verb=located_verb, + matched_value_token=count_token, + matched_unit_token=counted_noun, + matched_actor_token=subject_role, + ) + except ValueError: + return None + + +def _locate_token(sentence: str, target_lc: str) -> str | None: + """Return the literal-surface form of ``target_lc`` (lowercased) in + ``sentence`` whitespace-tokenized, or ``None`` if absent. + + Used by the acquisition-verb path to extract the matched verb + surface for ``CandidateOperation.matched_verb``. Falls back to + ``None`` only when the matcher's recorded ``verb_token`` somehow + diverges from the sentence surface — the under-admit path. + """ + for raw in sentence.split(): + tok = raw.strip(".,;:!?\"'()[]{}").lower() + if tok == target_lc: + return tok + return None + + def _resolve_count_value(count_token: str, count_kind: str) -> int | None: """Map ``count_token`` to a numeric value. diff --git a/generate/recognizer_match.py b/generate/recognizer_match.py index cb6a7659..adec50e3 100644 --- a/generate/recognizer_match.py +++ b/generate/recognizer_match.py @@ -457,6 +457,32 @@ _POSSESSION_VERBS: Final[frozenset[str]] = frozenset({ "has", "have", "had", }) +# ADR-0170 W2 — acquisition verbs: surface verbs that grammatically place +# the actor as the *gainer* of the operand quantity, NOT as having the +# operand as an initial state. Per ADR-0131.G.1 these verbs route to +# CandidateOperation(add), not CandidateInitial — emitting them as +# initials would create branch disagreement with the regex parser's +# ADD_VERBS path. +# +# Each member is also a member of generate.math_roundtrip.ADD_VERBS so +# the downstream CandidateOperation post-init whitelist accepts the +# matched_verb token. +# +# DELIBERATELY EXCLUDED: +# - "gained / gains / gain": delta-of-attribute (weight, age) — admitting +# as add-operation risks wrong>0 on questions that ask total state +# - "donated / donates / donate": SUBTRACT verb (actor gives away) +# - "saved / saves / save": ambiguous (saved time vs saved up money) +# +# Widening this set is operator-reviewable per the wrong=0 hazard +# documented in feedback-wrong-zero-hazard-case-0050. +_ACQUISITION_VERBS: Final[frozenset[str]] = frozenset({ + "collected", "collects", "collect", + "received", "receives", "receive", + "bought", "buys", "buy", + "got", "gets", "get", +}) + # Pronoun subjects refused at extraction (ambiguous referent). The # extractor requires a concrete proper-noun subject the source span can # ground. @@ -566,7 +592,11 @@ def _try_extract_discrete_count_anchor( return None verb = m.group("verb").lower() - if verb not in _POSSESSION_VERBS: + if verb in _POSSESSION_VERBS: + anchor_kind = "possession" + elif verb in _ACQUISITION_VERBS: + anchor_kind = "acquisition" + else: return None count_token = m.group("count") @@ -608,6 +638,11 @@ def _try_extract_discrete_count_anchor( "count_token": count_token, "count_kind": count_kind, "counted_noun": canon, + # ADR-0170 W2 — anchor_kind discriminates the downstream + # injector path: "possession" → CandidateInitial (existing); + # "acquisition" → CandidateOperation(add) (new). + "anchor_kind": anchor_kind, + "verb_token": verb, } diff --git a/tests/test_adr_0163_d2_discrete_count_injection.py b/tests/test_adr_0163_d2_discrete_count_injection.py index 6803ffd7..2f9402af 100644 --- a/tests/test_adr_0163_d2_discrete_count_injection.py +++ b/tests/test_adr_0163_d2_discrete_count_injection.py @@ -73,12 +73,16 @@ def _ratified_registry(): class TestExtractionCorrectness: def test_basic_integer_count(self) -> None: a = _try_extract("Sam has 5 apples.") + # Post-W2 (ADR-0170): anchor carries anchor_kind discriminator + # and verb_token for the injector's dispatch + admissibility. assert a == { "kind": "discrete_count", "subject_role": "Sam", "count_token": "5", "count_kind": "integer", "counted_noun": "apples", + "anchor_kind": "possession", + "verb_token": "has", } def test_past_tense_had(self) -> None: @@ -162,11 +166,40 @@ class TestExtractionRefusal: # 'widgets' is not in the spec's observed_counted_nouns. assert _try_extract("Sam has 5 widgets.") is None - def test_non_possession_verb_refused(self) -> None: - # 'wants', 'collected', 'bought' — operation verbs, not state. + def test_non_possession_non_acquisition_verb_refused(self) -> None: + # Post-W2 (ADR-0170): possession verbs (has/have/had) AND + # acquisition verbs (collected/received/bought/got) extract + # successfully — the latter dispatched to CandidateOperation(add) + # in the injector. Verbs outside both sets still refuse. assert _try_extract("Michael wants 10 pounds.") is None - assert _try_extract("Nicole collected 400 paperclips.") is None - assert _try_extract("Sam bought 5 apples.") is None + # 'gained' is deliberately EXCLUDED from _ACQUISITION_VERBS + # (delta-of-attribute hazard); must still refuse. + assert _try_extract("Orlando gained 5 pounds.") is None + # 'donated' is a SUBTRACT verb (actor gives away); deferred + # until a separate W2.1 PR adds depletion-verb handling. + assert _try_extract("Alice donated 3 books.") is None + + def test_acquisition_verbs_extract_with_anchor_kind(self) -> None: + # Post-W2 (ADR-0170): acquisition verbs extract with + # anchor_kind='acquisition'. The injector then emits + # CandidateOperation(add) rather than CandidateInitial. + result = _try_extract("Nicole collected 400 paperclips.") + assert result is not None + assert result["anchor_kind"] == "acquisition" + assert result["verb_token"] == "collected" + + result_buy = _try_extract("Sam bought 5 apples.") + assert result_buy is not None + assert result_buy["anchor_kind"] == "acquisition" + assert result_buy["verb_token"] == "bought" + + def test_possession_verbs_extract_with_possession_kind(self) -> None: + # Pre-W2 behavior preserved: possession verbs extract with + # anchor_kind='possession'; injector emits CandidateInitial. + result = _try_extract("Sam has 5 apples.") + assert result is not None + assert result["anchor_kind"] == "possession" + assert result["verb_token"] == "has" def test_owns_outside_v1_whitelist(self) -> None: # v1 restricts to has/have/had to align with CandidateInitial's diff --git a/tests/test_adr_0170_w1_injector_type_widening.py b/tests/test_adr_0170_w1_injector_type_widening.py index 09696c17..e25dcbf0 100644 --- a/tests/test_adr_0170_w1_injector_type_widening.py +++ b/tests/test_adr_0170_w1_injector_type_widening.py @@ -72,24 +72,19 @@ def test_inject_from_match_return_type_is_widened(): # --------------------------------------------------------------------------- -def test_discrete_count_injector_still_emits_only_candidate_initial(): - """W1 is type-level only. The existing - ``inject_discrete_count_statement`` returns ``CandidateInitial`` — - not ``CandidateOperation`` — at runtime. This is the byte-identical - behavior guarantee for the W1 PR. +def test_discrete_count_injector_return_type_post_w2(): + """W1 was type-level only; W2 (ADR-0170 implementation) extends the + DCS injector to emit ``CandidateOperation(add)`` for acquisition + verbs alongside ``CandidateInitial`` for possession verbs. - Mechanically: pre-W1 the function returned - ``tuple[CandidateInitial, ...]``. Post-W1 it still does. Subsequent - PRs (W2 DCS-S1 acquisition, W3 currency, W4 multiplicative) widen - the per-injector emission shapes; W1 ships only the dispatcher - contract.""" + The function's return type is now the widened + ``tuple[InjectorEmission, ...]``. The pin verifies the contract is + consistent with the dispatcher's widened type — runtime emission + is verified separately by the W2 acquisition-verb tests.""" import inspect sig = inspect.signature(inject_discrete_count_statement) - # With ``from __future__ import annotations`` the return annotation - # is stored as a string. The W1 pin is that the existing DCS - # injector's *narrower* return type is unchanged — only the - # dispatcher (``inject_from_match``) widens. - assert sig.return_annotation == "tuple[CandidateInitial, ...]" + # Post-W2 the DCS injector itself emits the widened union type. + assert sig.return_annotation == "tuple[InjectorEmission, ...]" # --------------------------------------------------------------------------- diff --git a/tests/test_adr_0170_w2_dcs_acquisition_verbs.py b/tests/test_adr_0170_w2_dcs_acquisition_verbs.py new file mode 100644 index 00000000..c7aa2c2c --- /dev/null +++ b/tests/test_adr_0170_w2_dcs_acquisition_verbs.py @@ -0,0 +1,279 @@ +"""ADR-0170 W2 — DCS-S1 acquisition verbs: first ``CandidateOperation`` +emission from the recognizer-injector path. + +W2 proves the W1 contract widening with concrete real-emission code: +the DCS injector now dispatches on the matcher's recorded +``anchor_kind`` and emits ``CandidateOperation(add)`` for acquisition +verbs (collected / received / bought / got) instead of +``CandidateInitial``. + +This preserves ADR-0131.G.1's branch-disagreement discipline: +acquisition verbs route to operations, not initials, so the regex +parser's ADD_VERBS path and the injector's CandidateOperation path +emit compatible kinds for the same sentence (collapsed-tie OK). + +Hard invariants: + +- ``wrong == 0`` — verify against case 0050 hazard +- The acquisition path emits only ``CandidateOperation(add)``, + matching ADR-0131.G.1 +- Verbs deliberately excluded (gained, donated, saved) still refuse +""" + +from __future__ import annotations + +import pytest + +from evals.refusal_taxonomy.shape_categories import ShapeCategory +from generate.math_candidate_parser import CandidateInitial, CandidateOperation +from generate.math_problem_graph import Operation, Quantity +from generate.recognizer_anchor_inject import ( + _build_operation_from_discrete_count_acquisition, + inject_discrete_count_statement, + inject_from_match, +) +from generate.recognizer_match import ( + _ACQUISITION_VERBS, + _POSSESSION_VERBS, + _try_extract_discrete_count_anchor, + _padded_lower, +) +from generate.math_candidate_graph import _load_ratified_registry_or_empty + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + + +def _dcs_spec(): + reg = _load_ratified_registry_or_empty() + for r in reg: + if r.shape_category.value == "discrete_count_statement": + return r.canonical_pattern + raise RuntimeError("no ratified discrete_count_statement spec on main") + + +def _extract(stmt: str): + return _try_extract_discrete_count_anchor(stmt, _padded_lower(stmt), _dcs_spec()) + + +def _make_match(parsed_anchors): + from generate.recognizer_registry import RatifiedRecognizer + from generate.recognizer_match import RecognizerMatch + + rec = RatifiedRecognizer( + proposal_id="test-w2", + shape_category=ShapeCategory.DISCRETE_COUNT_STATEMENT, + canonical_pattern=dict(_dcs_spec()), + spec_digest="test-digest", + review_date="2026-05-27", + ratified_at_revision="test", + ) + return RecognizerMatch( + recognizer=rec, + category=ShapeCategory.DISCRETE_COUNT_STATEMENT, + outcome="admissible", + graph_intent="count", + parsed_anchors=parsed_anchors, + ) + + +# --------------------------------------------------------------------------- +# Verb-set membership pins +# --------------------------------------------------------------------------- + + +def test_acquisition_verbs_set_contains_expected_verbs(): + expected = {"collected", "collects", "collect", + "received", "receives", "receive", + "bought", "buys", "buy", + "got", "gets", "get"} + assert _ACQUISITION_VERBS == frozenset(expected) + + +def test_possession_verbs_set_unchanged(): + # Pre-W2 set preserved. + assert _POSSESSION_VERBS == frozenset({"has", "have", "had"}) + + +def test_acquisition_and_possession_sets_disjoint(): + assert _ACQUISITION_VERBS.isdisjoint(_POSSESSION_VERBS) + + +def test_acquisition_verbs_subset_of_add_verbs(): + # Every acquisition verb must be in ADD_VERBS so the downstream + # CandidateOperation post-init whitelist accepts the matched_verb + # token. + from generate.math_roundtrip import ADD_VERBS + assert _ACQUISITION_VERBS.issubset(ADD_VERBS) + + +# --------------------------------------------------------------------------- +# Extractor — anchor_kind discrimination +# --------------------------------------------------------------------------- + + +@pytest.mark.parametrize( + "verb,canonical", + [ + ("collected", "collected"), + ("received", "received"), + ("bought", "bought"), + ("got", "got"), + ], +) +def test_extractor_records_acquisition_anchor_kind(verb: str, canonical: str): + anchor = _extract(f"Nicole {verb} 400 paperclips.") + assert anchor is not None + assert anchor["anchor_kind"] == "acquisition" + assert anchor["verb_token"] == canonical + + +def test_extractor_records_possession_anchor_kind(): + anchor = _extract("Nicole has 400 paperclips.") + assert anchor is not None + assert anchor["anchor_kind"] == "possession" + assert anchor["verb_token"] == "has" + + +# --------------------------------------------------------------------------- +# Injector — emits CandidateOperation for acquisition +# --------------------------------------------------------------------------- + + +def test_acquisition_anchor_produces_candidate_operation_add(): + anchor = _extract("Nicole collected 400 paperclips.") + assert anchor is not None + match = _make_match((anchor,)) + out = inject_discrete_count_statement(match, "Nicole collected 400 paperclips.") + assert len(out) == 1 + cand = out[0] + assert isinstance(cand, CandidateOperation), ( + f"acquisition anchor must emit CandidateOperation, got {type(cand).__name__}" + ) + assert cand.op.kind == "add" + assert cand.op.actor == "Nicole" + assert cand.op.operand.value == 400 + assert cand.op.operand.unit == "paperclips" + assert cand.matched_verb == "collected" + + +def test_possession_anchor_still_produces_candidate_initial(): + """Pre-W2 behavior preserved: possession anchors still emit + CandidateInitial, not CandidateOperation.""" + anchor = _extract("Nicole has 400 paperclips.") + assert anchor is not None + match = _make_match((anchor,)) + out = inject_discrete_count_statement(match, "Nicole has 400 paperclips.") + assert len(out) == 1 + cand = out[0] + assert isinstance(cand, CandidateInitial) + assert cand.matched_anchor == "has" + + +@pytest.mark.parametrize("verb", ["collected", "received", "bought", "got"]) +def test_all_acquisition_verbs_emit_candidate_operation(verb: str): + anchor = _extract(f"Sam {verb} 5 apples.") + assert anchor is not None + match = _make_match((anchor,)) + out = inject_discrete_count_statement(match, f"Sam {verb} 5 apples.") + assert len(out) == 1 + cand = out[0] + assert isinstance(cand, CandidateOperation) + assert cand.op.kind == "add" + assert cand.matched_verb == verb + + +# --------------------------------------------------------------------------- +# Deliberate exclusions — verbs that must still refuse +# --------------------------------------------------------------------------- + + +def test_gained_still_refused_delta_of_attribute_hazard(): + """``gained`` is delta-of-attribute (weight, age), not acquisition; + admitting it as add-operation would risk wrong>0 on questions + that ask total state. Deliberately excluded from + _ACQUISITION_VERBS.""" + assert _extract("Orlando gained 5 pounds.") is None + + +def test_donated_still_refused_subtract_verb(): + """``donated`` is a SUBTRACT verb (actor gives away). Future W2.1 + PR may add depletion-verb handling; for now, refuses.""" + assert _extract("Alice donated 3 books.") is None + + +def test_saved_still_refused_ambiguous(): + """``saved`` is ambiguous between time/money/effort. Deliberately + excluded from _ACQUISITION_VERBS until disambiguation lands.""" + assert _extract("Bob saved 50 apples.") is None + + +# --------------------------------------------------------------------------- +# Case 0050 hazard pin — wrong=0 safety net +# --------------------------------------------------------------------------- + + +def test_case_0050_hazard_unaffected_by_w2(): + """Case gsm8k-train-sample-v1-0050 must remain refused at + sentence_index=0. The acquisition-verb extension does not affect + the case 0050 sentence ``Mark does a gig every other day for 2 + weeks.`` — ``does`` is not in _POSSESSION_VERBS or + _ACQUISITION_VERBS, so the DCS extractor refuses.""" + from generate.math_candidate_graph import parse_and_solve + case_text = ( + "Mark does a gig every other day for 2 weeks. " + "For each gig, he plays 3 songs. " + "2 of the songs are 5 minutes long and the last song is twice that long. " + "How many minutes did he play?" + ) + result = parse_and_solve(case_text) + assert not result.is_admitted, ( + f"case 0050 admitted post-W2: answer={result.answer!r} " + f"graph={result.selected_graph!r}" + ) + + +# --------------------------------------------------------------------------- +# Determinism + wrong=0 invariant +# --------------------------------------------------------------------------- + + +def test_w2_emission_deterministic_across_reruns(): + """Same anchor → byte-identical CandidateOperation. The new + acquisition path inherits the determinism contract.""" + anchor = _extract("Nicole collected 400 paperclips.") + match = _make_match((anchor,)) + out1 = inject_discrete_count_statement(match, "Nicole collected 400 paperclips.") + out2 = inject_discrete_count_statement(match, "Nicole collected 400 paperclips.") + assert out1 == out2 + + +def test_w2_admission_path_passes_roundtrip_admissible(): + """The injected CandidateOperation must pass + ``roundtrip_admissible`` — the existing wrong=0 safety net for + operations. This is the layer-3 check in ADR-0163.D.2's + five-layer net, now extended to the acquisition path.""" + from generate.math_roundtrip import roundtrip_admissible + anchor = _extract("Nicole collected 400 paperclips.") + match = _make_match((anchor,)) + out = inject_discrete_count_statement(match, "Nicole collected 400 paperclips.") + assert len(out) == 1 + assert roundtrip_admissible(out[0]) + + +# --------------------------------------------------------------------------- +# Dispatcher pin +# --------------------------------------------------------------------------- + + +def test_inject_from_match_dispatches_to_acquisition_path(): + """The W1 dispatcher routes through to the W2 acquisition path + via the type-widened return contract.""" + anchor = _extract("Sam bought 5 apples.") + match = _make_match((anchor,)) + out = inject_from_match(match, "Sam bought 5 apples.") + assert len(out) == 1 + assert isinstance(out[0], CandidateOperation) + assert out[0].op.kind == "add"