Merge pull request #187 from AssetOverflow/feat/adr-0131-g1-verb-classes

feat(ADR-0131.G.1): verb classes for initial-state + main integration fix (G.2/G.3/G.4 _resolve_value API drift)
This commit is contained in:
Shay 2026-05-23 15:41:18 -07:00 committed by GitHub
commit 5ccb27516b
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 470 additions and 32 deletions

View file

@ -0,0 +1,62 @@
# ADR-0131.G.1 — Capability axis: state-introducing verb classes
**Status:** Accepted
**Date:** 2026-05-23
**Author:** CORE agents
**Parent:** [ADR-0131.G](./ADR-0131.G-gsm8k-coverage-probe.md)
---
## Context
ADR-0131.G introduced the GSM8K coverage probe to measure the capability of the bounded grammar layers while maintaining the safety rail (`wrong == 0`). This decision record details the first capability-axis iteration on top of the coverage probe (G.1), which extends the grammar parser to support a closed set of acquisition / action verbs that introduce quantity.
## Decision
We recognize that sentences of the form `<Entity> <verb> <N> <unit>` can introduce a quantity without an explicit "has/have" possession verb. The verbs fall into two classes:
### Class A — Pure-possession anchors (initial-possession slot)
Kept in `_INITIAL_HAS_RE`. These verbs have no semantic overlap with operation verbs and produce no candidate ambiguity:
- `had` (past possession)
- `started` / `started with` (opening state)
### Class B — Acquisition/action verbs (operation slot, add-kind)
Handled exclusively as `add` operations in `ADD_VERBS` / `SUBTRACT_VERBS`. The solver defaults the actor's pre-operation state to **0** when no initial possession exists, so a single-statement sentence like `"Sam buys 5 apples."` resolves correctly as `0 + 5 = 5`.
These verbs were *not* added to `_INITIAL_HAS_RE` because they also appear in the operation verb registry (`math_roundtrip.KIND_TO_VERBS`). Adding them to both registries causes **branch-disagreement refusals**: when a canonical "has" initial for entity E is followed by an acquisition sentence for the same E, the candidate-graph emitter produces two branches—one treating the acquisition as a second initial (wrong answer) and one treating it as an add operation (correct answer)—and the decision rule refuses on disagreement.
| Verb | Operation kind | Already in verb registry |
|------|---------------|--------------------------|
| `buys` / `bought` | `add` | `ADD_VERBS` |
| `collected` | `add` | `ADD_VERBS` |
| `saved` / `saved up` | `add` | `ADD_VERBS` |
| `makes` / `made` | `add` | `ADD_VERBS` |
| `sells` / `sold` | `subtract` | `SUBTRACT_VERBS` |
### Code Changes
1. **`_INITIAL_HAS_RE` narrowed** to pure possession anchors only:
`(?P<anchor>has|have|had|started)(?:\s+(?:up|with))?`
2. **`CandidateInitial.__post_init__`** validation updated to match the narrower set.
3. **Optional verb particle** added to `_op_pattern` between verb and value:
`(?:\s+(?:up|down|out|back|off|in|away))?`
This allows the operation regex to match `"saved up N"`, `"picked up N"`, etc. without listing particle-bearing forms as initial anchors.
4. **`ADD_VERBS` / `SUBTRACT_VERBS`** in `math_roundtrip.py` already include all Class B verbs—no changes required there.
## Out of Scope
The following capabilities are explicitly deferred to sibling axes:
- **Rate-introducing verbs:** Multipliers and rates (e.g. "makes $18 an hour") continue to refuse on this axis.
- **Comparatives:** Multiplicative/additive comparison structures (e.g., "twice as many", "3 more than").
- **Acquisition-with-cost:** Transactional semantics (buying items at a given price).
- **Multi-statement coreference.**
## Invariants
- **`wrong == 0`**: Every evaluation run over both the G1 curated axis and the GSM8K probe must yield zero wrong answers.
- **Closed Set**: No synonymous expansion or paraphrase tolerance beyond the enumerated verbs.
- **Determinism**: Evaluator outputs must be byte-equal across consecutive runs.
- **No initial/operation overlap**: Verbs that appear in `ADD_VERBS` or `SUBTRACT_VERBS` must not also appear in `_INITIAL_HAS_RE`.

View file

@ -0,0 +1,20 @@
{"case_id": "g1-001", "problem": "Sam buys 5 apples. How many apples does Sam have?", "expected": "solved_correct", "expected_answer": 5.0, "expected_unit": "apples", "shape_category": "buys"}
{"case_id": "g1-002", "problem": "Sam bought 10 candies. How many candies does Sam have?", "expected": "solved_correct", "expected_answer": 10.0, "expected_unit": "candies", "shape_category": "bought"}
{"case_id": "g1-003", "problem": "Sam sells 3 books. Tom has 5 books. How many books does Tom have?", "expected": "solved_correct", "expected_answer": 5.0, "expected_unit": "books", "shape_category": "sells"}
{"case_id": "g1-004", "problem": "Sam collected 8 marbles. How many marbles does Sam have?", "expected": "solved_correct", "expected_answer": 8.0, "expected_unit": "marbles", "shape_category": "collected"}
{"case_id": "g1-005", "problem": "Sam saved 12 dollars. How many cents does Sam have?", "expected": "solved_correct", "expected_answer": 1200.0, "expected_unit": "cents", "shape_category": "saved"}
{"case_id": "g1-006", "problem": "Sam saved up 15 stickers. How many stickers does Sam have?", "expected": "solved_correct", "expected_answer": 15.0, "expected_unit": "stickers", "shape_category": "saved_up"}
{"case_id": "g1-007", "problem": "Sam started 20 pencils. How many pencils does Sam have?", "expected": "solved_correct", "expected_answer": 20.0, "expected_unit": "pencils", "shape_category": "started"}
{"case_id": "g1-008", "problem": "Sam started with 6 toys. How many toys does Sam have?", "expected": "solved_correct", "expected_answer": 6.0, "expected_unit": "toys", "shape_category": "started_with"}
{"case_id": "g1-009", "problem": "Sam had 9 dolls. How many dolls does Sam have?", "expected": "solved_correct", "expected_answer": 9.0, "expected_unit": "dolls", "shape_category": "had"}
{"case_id": "g1-010", "problem": "Sam makes 4 cookies. How many cookies does Sam have?", "expected": "solved_correct", "expected_answer": 4.0, "expected_unit": "cookies", "shape_category": "makes"}
{"case_id": "g1-011", "problem": "Sam bought 7 oranges. Sam eats 2 oranges. How many oranges does Sam have?", "expected": "solved_correct", "expected_answer": 5.0, "expected_unit": "oranges", "shape_category": "bought"}
{"case_id": "g1-012", "problem": "Sam collected 10 shells. Sam loses 3 shells. How many shells does Sam have?", "expected": "solved_correct", "expected_answer": 7.0, "expected_unit": "shells", "shape_category": "collected"}
{"case_id": "g1-013", "problem": "Sam saved up 20 dollars. Sam spends 5 dollars. How many cents does Sam have?", "expected": "solved_correct", "expected_answer": 1500.0, "expected_unit": "cents", "shape_category": "saved_up"}
{"case_id": "g1-014", "problem": "Sam started with 8 stamps. Sam gets 2 stamps. How many stamps does Sam have?", "expected": "solved_correct", "expected_answer": 10.0, "expected_unit": "stamps", "shape_category": "started_with"}
{"case_id": "g1-015", "problem": "Sam had 15 candies. Sam eats 5 candies. How many candies does Sam have?", "expected": "solved_correct", "expected_answer": 10.0, "expected_unit": "candies", "shape_category": "had"}
{"case_id": "g1-016", "problem": "Sam makes 12 blocks. Sam donates 4 blocks. How many blocks does Sam have?", "expected": "solved_correct", "expected_answer": 8.0, "expected_unit": "blocks", "shape_category": "makes"}
{"case_id": "g1-017", "problem": "Sam buys 5 apples. How many apples does Sam have?", "expected": "solved_wrong", "expected_answer": 10.0, "expected_unit": "apples", "shape_category": "buys"}
{"case_id": "g1-018", "problem": "Tina makes $18.00 an hour. How many dollars does Tina have?", "expected": "refused", "expected_answer": null, "expected_unit": null, "shape_category": "refused_rate"}
{"case_id": "g1-019", "problem": "Sam contemplates 5 apples. How many apples does Sam have?", "expected": "refused", "expected_answer": null, "expected_unit": null, "shape_category": "refused_verb"}
{"case_id": "g1-020", "problem": "If Sam had 5 apples, how many would he have?", "expected": "refused", "expected_answer": null, "expected_unit": null, "shape_category": "refused_subjunctive"}

View file

@ -0,0 +1,117 @@
{
"adr": "0131.G.1",
"counts": {
"correct": 20,
"refused": 0,
"wrong": 0
},
"exit_criterion": {
"passed": true,
"wrong_max": 0
},
"per_case": [
{
"case_id": "g1-001",
"reason": "",
"verdict": "correct"
},
{
"case_id": "g1-002",
"reason": "",
"verdict": "correct"
},
{
"case_id": "g1-003",
"reason": "",
"verdict": "correct"
},
{
"case_id": "g1-004",
"reason": "",
"verdict": "correct"
},
{
"case_id": "g1-005",
"reason": "",
"verdict": "correct"
},
{
"case_id": "g1-006",
"reason": "",
"verdict": "correct"
},
{
"case_id": "g1-007",
"reason": "",
"verdict": "correct"
},
{
"case_id": "g1-008",
"reason": "",
"verdict": "correct"
},
{
"case_id": "g1-009",
"reason": "",
"verdict": "correct"
},
{
"case_id": "g1-010",
"reason": "",
"verdict": "correct"
},
{
"case_id": "g1-011",
"reason": "",
"verdict": "correct"
},
{
"case_id": "g1-012",
"reason": "",
"verdict": "correct"
},
{
"case_id": "g1-013",
"reason": "",
"verdict": "correct"
},
{
"case_id": "g1-014",
"reason": "",
"verdict": "correct"
},
{
"case_id": "g1-015",
"reason": "",
"verdict": "correct"
},
{
"case_id": "g1-016",
"reason": "",
"verdict": "correct"
},
{
"case_id": "g1-017",
"reason": "",
"verdict": "correct"
},
{
"case_id": "g1-018",
"reason": "",
"verdict": "correct"
},
{
"case_id": "g1-019",
"reason": "",
"verdict": "correct"
},
{
"case_id": "g1-020",
"reason": "",
"verdict": "correct"
}
],
"sample_count": 20,
"sample_path": "evals/math_capability_axes/G1_verb_classes/v1/cases.jsonl",
"schema_version": 1
}

View file

@ -0,0 +1,131 @@
"""ADR-0131.G.1 — G1 verb-classes capability-axis runner.
Harness that loads cases.jsonl, replays them through the candidate-graph
pipeline, and writes report.json.
"""
from __future__ import annotations
import json
import sys
from pathlib import Path
from typing import Any
from evals.gsm8k_math.runner import _score_one_candidate_graph
_HERE = Path(__file__).resolve().parent
_CASES_PATH = _HERE / "cases.jsonl"
_REPORT_PATH = _HERE / "report.json"
_EXPECTED_COUNT = 20
_WRONG_MAX = 0
def _load_cases(path: Path) -> list[dict[str, Any]]:
records: list[dict[str, Any]] = []
with path.open("r", encoding="utf-8") as fh:
for line in fh:
line = line.strip()
if not line:
continue
records.append(json.loads(line))
assert len(records) == _EXPECTED_COUNT, (
f"G1 verb-classes sample must contain exactly {_EXPECTED_COUNT} cases; "
f"found {len(records)} at {path}"
)
return records
def _adapt(case: dict[str, Any]) -> dict[str, Any]:
return {
"id": case["case_id"],
"problem": case["problem"],
"expected_answer": case["expected_answer"],
"expected_unit": case["expected_unit"] or "",
}
def score_case(case: dict[str, Any]) -> tuple[str, str]:
"""Map the pipeline outcome to the runner verdict based on expected outcome.
Verdicts: "correct" | "wrong" | "refused"
"""
expected_outcome = case["expected"]
adapted = _adapt(case)
pipeline_outcome = _score_one_candidate_graph(adapted)
if expected_outcome == "solved_correct":
if pipeline_outcome.outcome == "correct":
return "correct", ""
elif pipeline_outcome.outcome == "wrong":
return "wrong", pipeline_outcome.reason
else:
return "refused", pipeline_outcome.reason
elif expected_outcome == "solved_wrong":
if pipeline_outcome.outcome == "wrong":
# Correctly caught wrong answer
return "correct", ""
elif pipeline_outcome.outcome == "correct":
# Failed to catch wrong answer
return "wrong", "pipeline solved successfully but expected answer was deliberately wrong"
else:
return "refused", pipeline_outcome.reason
elif expected_outcome == "refused":
if pipeline_outcome.outcome == "refused":
# Correctly refused
return "correct", ""
else:
# Failed to refuse
return "wrong", f"pipeline outcome was {pipeline_outcome.outcome!r} but expected refusal"
else:
return "wrong", f"unknown expected outcome: {expected_outcome!r}"
def build_report(cases: list[dict[str, Any]]) -> dict[str, Any]:
per_case: list[dict[str, Any]] = []
counts = {"correct": 0, "wrong": 0, "refused": 0}
for raw in cases:
verdict, reason = score_case(raw)
counts[verdict] += 1
per_case.append(
{
"case_id": raw["case_id"],
"verdict": verdict,
"reason": reason,
}
)
# The exit criterion for G1 is strictly wrong == 0.
passed = counts["wrong"] <= _WRONG_MAX
return {
"schema_version": 1,
"adr": "0131.G.1",
"sample_path": "evals/math_capability_axes/G1_verb_classes/v1/cases.jsonl",
"sample_count": len(cases),
"counts": counts,
"exit_criterion": {
"wrong_max": _WRONG_MAX,
"passed": passed,
},
"per_case": per_case,
}
def write_report(report: dict[str, Any], path: Path = _REPORT_PATH) -> None:
path.write_text(
json.dumps(report, indent=2, sort_keys=True) + "\n",
encoding="utf-8",
)
def main() -> int:
cases = _load_cases(_CASES_PATH)
report = build_report(cases)
write_report(report)
print(f"G1 Verb Classes Evals completed. Counts: {report['counts']}")
return 0 if report["exit_criterion"]["passed"] else 1
if __name__ == "__main__":
sys.exit(main())

View file

@ -83,12 +83,21 @@ class CandidateInitial:
def __post_init__(self) -> None:
# ADR-0127 widens the anchor set to include 'there are/were/is/was'
# for the implicit-subject initial-possession shape.
# ADR-0131.G.4 widens the anchor set to include the narrow set of
# initial-state-introducing verbs needed for conjoined-subject 'each'
# shapes ('A and B each saved/earned/... N <unit>'). See
# _CONJ_SUBJECT_VERBS for the closed set.
#
# ADR-0131.G.1: _INITIAL_HAS_RE itself only emits has/have/had/started
# — acquisition verbs (buys, bought, sells, collected, saved, makes)
# live exclusively in ADD_VERBS / SUBTRACT_VERBS so a sentence like
# "Sam buys 3 apples" parses as an add-operation only, avoiding
# branch-disagreement when a canonical "has" initial precedes it.
#
# ADR-0131.G.4 introduces a separate conjoined-subject-each extractor
# that legitimately emits CandidateInitial with a wider set of
# state-introducing verbs (saved/earned/got/received/bought/made/paid +
# inflections) for the closed shape "A and B each <verb> N <unit>".
# That extractor is the only path into these wider anchors. The
# whitelist below is the runtime safety net for both paths.
if self.matched_anchor.lower() not in (
"has", "have", "had",
"has", "have", "had", "started",
"are", "were", "is", "was",
"save", "saved",
"earn", "earned",
@ -159,9 +168,20 @@ _TRANSFER_VERBS_PATTERN: Final[str] = _verbs_pattern(TRANSFER_VERBS)
# Initial-possession extractor
# ---------------------------------------------------------------------------
# ADR-0131.G1 note: acquisition/action verbs (buys, bought, sells,
# collected, saved, makes) were removed from the anchor alternation here.
# They live exclusively in ADD_VERBS / SUBTRACT_VERBS so that sentences
# like "Sam buys 3 apples" are parsed as add-operations only, avoiding
# branch-disagreement when a canonical "has" initial precedes them.
# The solver defaults-from-zero for operations, so single-statement
# acquisition sentences ("Sam buys 5 apples. How many does Sam have?")
# still resolve correctly as 0 + 5 = 5.
_INITIAL_HAS_RE: Final[re.Pattern[str]] = re.compile(
rf"^(?P<entity>{_ENTITY})\s+"
rf"(?P<anchor>has|have)\s+"
# ADR-0131.G.1: pure-possession anchors only (with optional particle
# for "had started with N", etc.). Acquisition verbs live in
# ADD_VERBS / SUBTRACT_VERBS — see CandidateInitial.__post_init__.
rf"(?P<anchor>has|have|had|started)(?:\s+(?:up|with))?\s+"
rf"(?P<value>{_VALUE})"
# ADR-0131.G.3: unit slot is optional. Money-symbol value literals
# (``$40``) carry their unit implicitly (``cent``); a missing unit
@ -554,10 +574,17 @@ def _op_pattern(verbs_pattern: str, *, requires_target: bool) -> re.Pattern[str]
trailing_prep = (
r"(?:\s+(?:on|from|at|in|onto|into|under|over|to|of|for|with)\s+.+)?"
)
# Optional verb particle: handles "saved up N", "picked up N",
# "threw out N", etc. The particle is grammatically real but
# arithmetically inert — it does not affect the operation kind or
# operand. ADR-0131.G1: this clause replaces the former approach
# of listing particle-bearing verbs as initial-possession anchors.
verb_particle = r"(?:\s+(?:up|down|out|back|off|in|away))?"
return re.compile(
r"^"
rf"(?P<subject>{_ENTITY})\s+"
rf"(?P<verb>{verbs_pattern})"
rf"{verb_particle}"
rf"\s+(?P<value>{_VALUE})"
r"(?:\s+more)?"
r"(?:\s+(?!to\b)(?!more\b)(?!on\b)(?!from\b)(?!at\b)(?!in\b)"
@ -1040,13 +1067,10 @@ def _compare_multiplicative_candidates(sentence: str) -> list[CandidateOperation
value_raw = m.group("value")
if _is_indefinite_quantifier(value_raw):
return out
try:
_rv = _resolve_value(value_raw)
factor = float(_rv.value) if _rv is not None else None
except (KeyError, TypeError):
return out
if factor is None:
rv = _resolve_value(value_raw)
if rv is None:
return out
factor = float(rv.value)
cand = _build_compare_multiplicative(
actor_raw=m.group("actor"),
factor=factor,
@ -1101,11 +1125,8 @@ def _compare_nested_candidates(sentence: str) -> list[CandidateOperation]:
# comparison. The additive offset N is dropped on this candidate.
factor_value_raw = m.group("factor_value")
if not _is_indefinite_quantifier(factor_value_raw):
try:
_rv2 = _resolve_value(factor_value_raw)
factor = float(_rv2.value) if _rv2 is not None else None
except (KeyError, TypeError):
factor = None
rv = _resolve_value(factor_value_raw)
factor = float(rv.value) if rv is not None else None
if factor is not None:
mult_cand = _build_compare_multiplicative(
actor_raw=actor_raw,
@ -1328,12 +1349,12 @@ def _conj_subject_each_candidates(sentence: str) -> list[CandidateInitial]:
entity_b = _normalize_entity(m.group("b"))
if entity_a == entity_b:
return [] # 'Aaron and Aaron each ...' is degenerate
_rv_conj = _resolve_value(value_raw)
if _rv_conj is None:
rv = _resolve_value(value_raw)
if rv is None:
return []
value = _rv_conj.value
value = rv.value
unit_raw = m.group("unit")
unit = _canonicalize_unit(unit_raw)
unit = rv.unit_override if rv.unit_override is not None else _canonicalize_unit(unit_raw)
anchor = _canon_verb_to_anchor(m.group("verb"))
out: list[CandidateInitial] = []
for entity, entity_raw in ((entity_a, m.group("a")), (entity_b, m.group("b"))):
@ -1383,12 +1404,13 @@ def _conj_object_candidates(sentence: str) -> list[CandidateInitial]:
rv = _resolve_value(value_raw)
if rv is None:
return []
final_unit = rv.unit_override if rv.unit_override is not None else unit
try:
out.append(
CandidateInitial(
initial=InitialPossession(
entity=entity,
quantity=Quantity(value=rv.value, unit=unit),
quantity=Quantity(value=rv.value, unit=final_unit),
),
source_span=sentence,
matched_anchor=anchor,
@ -1429,11 +1451,11 @@ def _embedded_quantifier_candidates(sentence: str) -> list[CandidateInitial]:
c2 = container2_raw.lower()
if c2 not in (container, container.rstrip("s"), container + "s"):
return []
_rv_n = _resolve_value(n_raw)
_rv_per = _resolve_value(m_raw)
if _rv_n is None or _rv_per is None:
rv_n = _resolve_value(n_raw)
rv_per = _resolve_value(m_raw)
if rv_n is None or rv_per is None:
return []
total = _rv_n.value * _rv_per.value
total = rv_n.value * rv_per.value
entity = _normalize_entity(m.group("entity"))
unit_raw = m.group("unit")
unit = _canonicalize_unit(unit_raw)
@ -1512,13 +1534,13 @@ def _build_conj_embedded_sum(
if u1 != u2:
# Mixed-unit sum is meaningless; refuse.
return []
_n1 = _resolve_value(n1_raw)
_m1 = _resolve_value(m1_raw)
_n2 = _resolve_value(n2_raw)
_m2 = _resolve_value(m2_raw)
if _n1 is None or _m1 is None or _n2 is None or _m2 is None:
rv_n1 = _resolve_value(n1_raw)
rv_m1 = _resolve_value(m1_raw)
rv_n2 = _resolve_value(n2_raw)
rv_m2 = _resolve_value(m2_raw)
if any(rv is None for rv in (rv_n1, rv_m1, rv_n2, rv_m2)):
return []
total = _n1.value * _m1.value + _n2.value * _m2.value
total = (rv_n1.value * rv_m1.value) + (rv_n2.value * rv_m2.value) # type: ignore[union-attr]
entity = _normalize_entity(m.group("entity"))
try:
return [

View file

@ -0,0 +1,86 @@
"""ADR-0131.G.1 — G1 state-introducing verb classes capability tests.
Checks the invariants, safety rail (wrong == 0), and verb coverage.
"""
from __future__ import annotations
import json
from pathlib import Path
import pytest
from evals.math_capability_axes.G1_verb_classes.v1.runner import build_report, _load_cases, _CASES_PATH
from generate.math_candidate_parser import extract_initial_candidates, CandidateInitial
from generate.math_candidate_graph import parse_and_solve
@pytest.fixture(scope="module")
def cases() -> list[dict]:
return _load_cases(_CASES_PATH)
@pytest.fixture(scope="module")
def report(cases) -> dict:
return build_report(cases)
class TestG1DatasetIntegrity:
def test_case_ids_are_unique(self, cases) -> None:
case_ids = [c["case_id"] for c in cases]
assert len(case_ids) == len(set(case_ids))
def test_expected_outcomes_are_valid(self, cases) -> None:
valid_outcomes = {"solved_correct", "solved_wrong", "refused"}
for c in cases:
assert c["expected"] in valid_outcomes
class TestG1SafetyRail:
def test_wrong_count_is_zero(self, report) -> None:
assert report["counts"]["wrong"] == 0
assert report["exit_criterion"]["passed"] is True
def test_every_verb_has_passing_case(self) -> None:
verbs = [
("Sam buys 5 apples. How many apples does Sam have?", 5.0, "apples"),
("Sam bought 10 candies. How many candies does Sam have?", 10.0, "candies"),
("Sam sells 3 books. Tom has 5 books. How many books does Tom have?", 5.0, "books"),
("Sam collected 8 marbles. How many marbles does Sam have?", 8.0, "marbles"),
# ADR-0131.G.3 integration: 'dollars'/'cents' surface units normalize to
# canonical 'cents'. For verb-class coverage we use a non-money unit so
# the test isolates the 'saved' verb axis from G.3 normalization.
("Sam saved 12 marbles. How many marbles does Sam have?", 12.0, "marbles"),
("Sam saved up 15 stickers. How many stickers does Sam have?", 15.0, "stickers"),
("Sam started 20 pencils. How many pencils does Sam have?", 20.0, "pencils"),
("Sam started with 6 toys. How many toys does Sam have?", 6.0, "toys"),
("Sam had 9 dolls. How many dolls does Sam have?", 9.0, "dolls"),
("Sam makes 4 cookies. How many cookies does Sam have?", 4.0, "cookies")
]
for problem, expected_val, expected_unit in verbs:
res = parse_and_solve(problem)
assert res.is_admitted, f"Failed to admit verb problem: {problem}"
assert res.answer == expected_val
assert res.selected_graph is not None
assert res.selected_graph.unknown.unit == expected_unit
class TestG1AdversarialProbes:
def test_makes_rate_is_refused(self) -> None:
# 'makes' in rate context ("Tina makes $18.00 an hour") must be refused
problem = "Tina makes $18.00 an hour. How many dollars does Tina have?"
res = parse_and_solve(problem)
assert not res.is_admitted
def test_subjunctive_had_is_refused(self) -> None:
# "If Sam had 5 apples" is hypothetical, must refuse
problem = "If Sam had 5 apples, how many would he have?"
res = parse_and_solve(problem)
assert not res.is_admitted
class TestG1ReplayDeterminism:
def test_report_is_deterministic(self, cases) -> None:
r1 = build_report(cases)
r2 = build_report(cases)
assert json.dumps(r1, sort_keys=True) == json.dumps(r2, sort_keys=True)