feat(adr-0179-ex2): bare-decimal grounding in the shared round-trip primitive

EX-2 — the ONE shared-primitive (serving-path) touch of extraction richness.
_value_grounds already grounds the symbol form $N.NN (currency) and N/M (fraction);
a decimal written WITHOUT a symbol ('0.75') is never a single token (the tokenizer
splits on '.') so it failed to ground — refusing correct products like 0003
(48*24*0.75=864). Now a bare decimal 'N.M' grounds when both digit-runs appear,
symmetric with the $N.NN and N/M branches. Only returns True on a match; non-matching
decimals fall through (refuse).

wrong=0 (the load-bearing obligation, this being a shared/serving-path change):
- serving 3/47/0 BYTE-IDENTICAL (verified).
- round-trip + candidate-graph tests 51/51.
- lane-SHA gate (pinned eval lanes unchanged) — verified before merge.

Payoff: 0003 unblocked in sealed practice (search_chain -> 864 = gold; +1 flip).
Decimal cases that now ground but mis-compose become sealed eliminations (learning
signal); serving untouched. 6 EX-2 tests (decimal grounds/refuses, integer
unchanged, serving byte-identical, 0003 resolves).
This commit is contained in:
Shay 2026-05-28 17:43:12 -07:00
parent 16fbc600e6
commit 939fa56671
2 changed files with 76 additions and 0 deletions

View file

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

View file

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