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.
This commit is contained in:
Shay 2026-05-27 12:07:54 -07:00 committed by GitHub
parent eeeec80127
commit b190f3b6c5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 502 additions and 34 deletions

View file

@ -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": <str>,
"count_token": <str>,
"count_kind": <"integer"|"word">,
"counted_noun": <str>,
"verb_token": <str>, # 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.

View file

@ -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,
}

View file

@ -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

View file

@ -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, ...]"
# ---------------------------------------------------------------------------

View file

@ -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"