diff --git a/generate/math_roundtrip.py b/generate/math_roundtrip.py index 5ec361f3..0c22c4e7 100644 --- a/generate/math_roundtrip.py +++ b/generate/math_roundtrip.py @@ -359,6 +359,15 @@ def _value_grounds(value_token: str, haystack_tokens: frozenset[str]) -> bool: m = re.fullmatch(r"(\d+)/(\d+)", value_token) if m is not None: return m.group(1) in haystack_tokens and m.group(2) in haystack_tokens + # ADR-0179 EX-2 — bare decimal "N.M" (the currency branch above handles the + # symbol form $N.NN; a decimal written without a symbol, e.g. "0.75", is never + # a single token because the word-boundary tokenizer splits on ".", so it + # grounds exactly when both digit-runs appear as tokens — symmetric with the + # $N.NN and N/M widenings. Only returns True on a match; non-matching decimals + # fall through to the existing paths (which ultimately refuse). + if "." in value_token and re.fullmatch(r"\d+\.\d+", value_token) is not None: + if all(part in haystack_tokens for part in value_token.split(".")): + return True if "-" in value_token and not value_token[0].isdigit(): try: from language_packs.numerics_loader import parse_compound_cardinal diff --git a/tests/test_adr_0179_ex2_decimal_grounding.py b/tests/test_adr_0179_ex2_decimal_grounding.py new file mode 100644 index 00000000..e53006ec --- /dev/null +++ b/tests/test_adr_0179_ex2_decimal_grounding.py @@ -0,0 +1,67 @@ +"""ADR-0179 EX-2 — bare-decimal grounding in the shared round-trip primitive. + +`_value_grounds` already grounds the symbol form `$N.NN` (currency) and `N/M` +(fraction); a decimal written without a symbol (`0.75`) was never a single token +(the tokenizer splits on `.`) and so failed to ground — refusing correct products +like 0003 (`48×24×0.75 = 864`). EX-2 grounds a bare decimal when both digit-runs +appear, symmetric with those branches. + +This is the ONE shared-primitive (serving-path) change in extraction richness, so +the load-bearing test is wrong=0: serving stays 3/47/0 byte-identical. +""" + +from __future__ import annotations + +import json +from pathlib import Path + +from generate.math_roundtrip import _tokens, _value_grounds + + +class TestBareDecimalGrounding: + def test_decimal_grounds_when_digit_runs_present(self) -> None: + toks = _tokens("They sell for $0.75 each.") # -> tokens include "0","75" + assert _value_grounds("0.75", toks) is True + + def test_decimal_refuses_when_a_run_absent(self) -> None: + toks = _tokens("They sell for 80 cents.") # no "0"/"75" + assert _value_grounds("0.75", toks) is False + + def test_plain_decimal_in_text_grounds(self) -> None: + toks = _tokens("It moved 2.5 meters.") # "2","5" present + assert _value_grounds("2.5", toks) is True + + def test_integer_grounding_unchanged(self) -> None: + toks = _tokens("He has 48 boxes.") + assert _value_grounds("48", toks) is True + assert _value_grounds("49", toks) is False + + +class TestWrongZeroPreserved: + def test_serving_byte_identical(self) -> None: + # the load-bearing obligation: the shared-primitive change must not shift + # the serving contract. + from evals.gsm8k_math.train_sample.v1.runner import ( + _CASES_PATH, + _load_cases, + build_report, + ) + + counts = build_report(_load_cases(_CASES_PATH))["counts"] + assert counts == {"correct": 3, "wrong": 0, "refused": 47} + + +class TestUnblocksDecimalProduct: + def test_0003_class_product_now_resolves(self) -> None: + # 48 boxes x 24 each x $0.75 each = 864 — previously refused (0.75 ungrounded) + from generate.derivation.multistep import search_chain + + case = next( + json.loads(line) + for line in Path( + "evals/gsm8k_math/train_sample/v1/cases.jsonl" + ).read_text().splitlines() + if "student council" in line + ) + res = search_chain(case["question"]) + assert res is not None and res.answer == float(case["answer_numeric"])