fix(trackb): pure-S1 extract refuse multi-clause overmatch (0361 wrong=0)

The inverted-seed P2 regex treated optional "times" as optional, so
additive "8 more" on multi-entity zoo case 0361 extracted as pure S1 and
the three-gate corridor emitted 1.125 vs gold 114.0 (wrong≠0).

- Require explicit multiplicative surface (times as many / twice the …)
- Fail-closed purity gate: multi-clause markers, multi-numeric, multi-mult
- Regression tests for 0361 and bare "N more"
- measure --mode full-holdout-wrong0 over all 500 holdout cases

[Verification]: full-holdout emit_ok=9 wrong=0 refused=491; unit 37 passed
This commit is contained in:
Shay 2026-07-19 20:04:27 -07:00
parent e5643454d9
commit 2544fda6f6
4 changed files with 292 additions and 131 deletions

View file

@ -123,6 +123,20 @@ SUMMARY right-reason: correct=9 wrong=0 refused=0
SUMMARY surface-variant: ok=True
```
### 3g. Full holdout wrong=0 gate (all 500 — not curated subset)
Regression: a loose inverted-seed regex matched multi-entity zoo case **0361**
(`John has 8 more pandas…`) as pure S1 and the corridor emitted `1.125` vs
gold `114.0`. Fixed by pure-family surface gates (require explicit multiplicative
language; refuse bare `N more` / multi-clause / multi-numeric surfaces).
```text
$ uv run python scripts/measure_trackb_inc2.py --mode full-holdout-wrong0
=== full holdout_dev/v1 wrong=0 gate (all 500 cases) ===
SUMMARY full-holdout: emit_ok=9 wrong=0 refused=491 n=500
PASS: wrong=0 absolute on full holdout_dev/v1
```
---
## 4. Discipline checks
@ -138,8 +152,9 @@ SUMMARY surface-variant: ok=True
1. **S2S4 holdout ratio = 0** until the serving reader (or a reviewed SM extract family) produces pure transfer/rate/additive graphs on holdout. Do not manufacture synthetic holdout clones for ratio.
2. **S3/S4 solve emit** blocked by multi-register scope (`apply_rate`, `compare_additive` not Tier-2a). Gates not weakened — refuse.
3. **SM extract is narrow** (regex pure-S1 templates only). It is not a general parser; expanding it is a deliberate future increment.
3. **SM extract is narrow** (regex pure-S1 templates only, with fail-closed purity gates). It is not a general parser; expanding it is a deliberate future increment. Overmatch risk is gated by `mode full-holdout-wrong0` on all 500 cases.
4. **Organ retirement** deferred; coverage-gain path met bar 2 without serving risk.
5. **0361 pure-S1 overmatch** (fixed in follow-up commit): optional `times` in P2 regex treated additive `8 more` as multiplicative; purity gate + required `times as many` closed it. Full-holdout scan is now mandatory before claiming wrong=0.
---

View file

@ -6,6 +6,14 @@ the serving reader or organ dispatch.
Deterministic regex extractors no LLM, no gold labels. Refuse is the
default; only narrow pure-family patterns emit a graph.
Pure-family surface gates (fail-closed wrong=0 absolute):
- Exactly one multiplicative compare phrase (``times as many/much``,
``twice|double|triple|thrice as``, ``times the number/size of``).
- Never match bare additive language (``N more``, ``N fewer``) as a factor
that was the 0361 overmatch that emitted a certified wrong answer.
- Refuse multi-clause / multi-relation / fraction / percent surfaces.
- At most two numeric seeds and two named roles for inverted/day templates.
"""
from __future__ import annotations
@ -39,6 +47,57 @@ _WORD_FACTORS: dict[str, float] = {
"ten": 10.0,
}
# Multiplicative compare surface (must be present for pure S1 extract).
# Intentionally does NOT match bare "N more" / "N fewer" (additive).
_MULT_COMPARE = re.compile(
r"(?:"
r"twice\s+as\s+(?:many|much)|"
r"twice\s+the\s+(?:total\s+)?(?:number|amount|size)|"
r"double\s+(?:what|the)|"
r"triple\s+(?:what|the)|"
r"thrice\s+as\s+(?:many|much)|"
r"(?:two|three|four|five|six|seven|eight|nine|ten|\d+)\s+times\s+"
r"(?:as\s+(?:many|much)|the\s+(?:total\s+)?(?:number|size|amount)|more\s+than)|"
r"times\s+(?:as\s+(?:many|much)|the\s+(?:total\s+)?(?:number|size|amount)\s+of)"
r")",
re.IGNORECASE,
)
# Additive / multi-step markers that disqualify pure-S1 extract.
_IMPURE_MARKERS = re.compile(
r"(?:"
r"\bfewer\b|"
r"\bless than\b|"
r"\bmore than\b|" # additive "more than" without being part of "times more than"
r"\b\d+\s+more\b|"
r"\b\d+\s+fewer\b|"
r"\b\d+\s+less\b|"
r"\b1/\d+\b|"
r"\bhalf as\b|"
r"\ba third\b|"
r"\bpercent\b|"
r"%|"
r"\band half\b|"
r"\bbut\b|"
r"\bthen\b|"
r"\bafter\b|"
r"\bbought\b|"
r"\bsold\b|"
r"\bgave\b|"
r"\bgives\b|"
r"\blost\b|"
r"\beach of\b|"
r"\bevery\b"
r")",
re.IGNORECASE,
)
# "times more than" is multiplicative; strip those before impure "more than" check.
_TIMES_MORE_THAN = re.compile(
r"(?:twice|thrice|\d+|two|three|four|five|six|seven|eight|nine|ten)\s+times\s+more\s+than",
re.IGNORECASE,
)
@dataclass(frozen=True, slots=True)
class TextExtractResult:
@ -58,33 +117,94 @@ def _factor_from_token(tok: str) -> float | None:
return v if v > 0 else None
def _purity_gate(text: str) -> str | None:
"""Return a refuse reason if text is not a pure single-relation S1 surface."""
# Count multiplicative compare phrases — pure S1 has exactly one.
mults = list(_MULT_COMPARE.finditer(text))
if len(mults) == 0:
return "no_multiplicative_compare"
if len(mults) > 1:
return "multi_multiplicative_compare"
# Impure markers after masking legitimate "times more than".
masked = _TIMES_MORE_THAN.sub("TIMES_MORE_THAN", text)
# Also mask the single mult compare span so "more" inside "times as many"
# is not confused — the mult phrase itself is clean.
for m in mults:
masked = masked[: m.start()] + ("X" * (m.end() - m.start())) + masked[m.end() :]
if _IMPURE_MARKERS.search(masked):
return "impure_multi_clause_markers"
# Too many independent numeric seeds → multi-step (pure S1: 1 seed + factor
# word, or 1 seed with numeric factor already in mult phrase).
nums = re.findall(r"\b\d+(?:\.\d+)?\b", text)
if len(nums) > 2:
return "too_many_numeric_seeds"
return None
def _s1_graph(
*,
a: str,
b: str,
k: float,
a_value: float,
unit: str,
) -> MathProblemGraph:
return MathProblemGraph(
entities=(a, b),
initial_state=(
InitialPossession(entity=a, quantity=Quantity(value=a_value, unit=unit)),
),
operations=(
Operation(
actor=b,
kind="compare_multiplicative",
operand=Comparison(
reference_actor=a,
delta=None,
factor=k,
direction="times",
),
),
),
unknown=Unknown(entity=None, unit=unit),
)
def extract_pure_s1(text: str) -> TextExtractResult:
"""Extract pure S1 graphs from narrow surface templates.
Patterns (all require a total/altogether/combined/two-days query):
P1 reference seeded:
``<B> has|scored|taught <k> times (as many|the number of) ... as <A>.
If <A> has <n> ... total|altogether``
or ``<A> has <n>. <B> has <k> times as many ... total``
Patterns (all require a total/altogether/combined/two-days query and
pass the pure-family surface gate):
P2 scaled entity seeded (inverted):
``<B> has|wrote <k> times as many ... as <A>. If <B> ... <n> ... altogether``
or ``<B> has <k> times as many as <A>. If <B> has <n>, how many ... combined``
``<B> wrote|has <k> times as many <unit> as <A>. If <B> ... <n> ... altogether``
P3 day/entity pair with ordinal:
``first day was twice ... second day. If <n> ... second day, how many ... two days``
P2b warehouse / first-second form with ``twice|N times as many ... as``
P2c deaf/blind population form
P3 day pair: first day = k × second day; second seeded; total two days
"""
if not isinstance(text, str) or not text.strip():
return TextExtractResult(None, "empty_text")
t = " ".join(text.split())
refuse = _purity_gate(t)
if refuse is not None:
return TextExtractResult(None, f"not_pure_s1:{refuse}")
# --- P3: people-counting / day1 = k × day2, day2 seeded (case 0148 family)
# Requires explicit "times" when kword is numeric; "twice/double/triple/thrice"
# are inherently multiplicative.
m = re.search(
r"(?:the\s+)?(?:number of\s+\w+\s+counted on\s+)?the\s+first\s+day\s+was\s+"
r"(?P<kword>twice|double|triple|thrice|\d+|two|three|four|five|six|seven|eight|nine|ten)"
r"(?:\s+times)?\s+(?:the\s+)?(?:total\s+)?(?:number\s+)?(?:counted\s+)?on\s+the\s+second\s+day"
r"(?P<kword>twice|double|triple|thrice|"
r"(?:two|three|four|five|six|seven|eight|nine|ten|\d+)\s+times)"
r"\s+(?:the\s+)?(?:total\s+)?(?:number\s+)?(?:counted\s+)?on\s+the\s+second\s+day"
r".{0,80}?"
r"(?P<n>\d+(?:\.\d+)?)\s+\w+\s+(?:were\s+)?counted\s+on\s+the\s+second\s+day"
r".{0,40}?"
@ -93,169 +213,110 @@ def extract_pure_s1(text: str) -> TextExtractResult:
flags=re.IGNORECASE | re.DOTALL,
)
if m:
k = _factor_from_token(m.group("kword"))
k_raw = m.group("kword")
# Strip trailing "times" for numeric/word factors
k_tok = re.sub(r"\s+times$", "", k_raw.strip(), flags=re.IGNORECASE)
k = _factor_from_token(k_tok)
n = float(m.group("n"))
if k is not None:
a, b, unit = "second day", "first day", "people"
graph = MathProblemGraph(
entities=(a, b),
initial_state=(
InitialPossession(
entity=a, quantity=Quantity(value=n, unit=unit)
),
),
operations=(
Operation(
actor=b,
kind="compare_multiplicative",
operand=Comparison(
reference_actor=a,
delta=None,
factor=k,
direction="times",
),
),
),
unknown=Unknown(entity=None, unit=unit),
graph = _s1_graph(
a="second day", b="first day", k=k, a_value=n, unit="people"
)
return TextExtractResult(graph, None, pattern_id="s1_p3_day_pair")
# --- P2: inverted seed — B = k×A, B has n, total A+B
# "Zig wrote four times as many books as Flo. If Zig wrote 60 books, how many ... altogether"
# REQUIRE "times as many/much" or word-factor "twice as many" — never bare "N more".
m = re.search(
r"(?P<b>[A-Z][A-Za-z]+(?:\s+[A-Z][A-Za-z]+)*)\s+"
r"(?:wrote|has|have|scored|taught|caught|produced|bought)\s+"
r"(?P<kword>twice|double|triple|thrice|\d+|two|three|four|five|six|seven|eight|nine|ten)"
r"(?:\s+times)?\s+(?:as many|as much|more|the number of)\s+"
r"(?P<unit>\w+)?\s*(?:as|than)\s+"
r"(?P<a>[A-Z][A-Za-z]+(?:\s+[A-Z][A-Za-z]+)*)"
r".{0,60}?"
r"(?:if\s+)?(?P=b)\s+(?:wrote|has|have|scored|taught|caught|produced|bought)\s+"
r"(?P<b>[A-Z][a-z]+)\s+"
r"(?:wrote|has|have|scored|taught|caught|produced)\s+"
r"(?P<kword>twice|double|triple|thrice|"
r"(?:two|three|four|five|six|seven|eight|nine|ten|\d+)\s+times)\s+"
r"(?:as many|as much)\s+"
r"(?P<unit>\w+)\s+as\s+"
r"(?P<a>[A-Z][a-z]+)"
r"\.\s+"
r"(?:If\s+)?(?P=b)\s+(?:wrote|has|have|scored|taught|caught|produced)\s+"
r"(?P<n>\d+(?:\.\d+)?)"
r".{0,80}?"
r"(?:altogether|in total|combined|total)",
r"(?:altogether|in total|combined)",
t,
flags=re.IGNORECASE | re.DOTALL,
)
if m:
k = _factor_from_token(m.group("kword"))
k_tok = re.sub(
r"\s+times$", "", m.group("kword").strip(), flags=re.IGNORECASE
)
k = _factor_from_token(k_tok)
n = float(m.group("n"))
a = m.group("a").strip()
b = m.group("b").strip()
unit = (m.group("unit") or "units").lower()
if k is not None and a.lower() != b.lower():
a_value = n / k
graph = MathProblemGraph(
entities=(a, b),
initial_state=(
InitialPossession(
entity=a, quantity=Quantity(value=a_value, unit=unit)
),
),
operations=(
Operation(
actor=b,
kind="compare_multiplicative",
operand=Comparison(
reference_actor=a,
delta=None,
factor=k,
direction="times",
),
),
),
unknown=Unknown(entity=None, unit=unit),
)
unit = m.group("unit").lower()
# Entity names must be single proper nouns (reject "he does lions").
if (
k is not None
and a.lower() != b.lower()
and a[0].isupper()
and b[0].isupper()
and " " not in a
and " " not in b
):
graph = _s1_graph(a=a, b=b, k=k, a_value=n / k, unit=unit)
return TextExtractResult(graph, None, pattern_id="s1_p2_b_seeded")
# Warehouse / population form:
# "The first warehouse has twice as many boxes as the second warehouse.
# If the first warehouse has 400 boxes, how many boxes ... combined"
# Warehouse form: first/second + "twice|N times as many UNIT as"
m = re.search(
r"(?:the\s+)?(?P<b>first\s+\w+|deaf\s+\w+\s+population|[A-Z][A-Za-z]+(?:\s+[A-Z][A-Za-z]+)*)\s+"
r"has\s+"
r"(?P<kword>twice|double|triple|thrice|\d+|two|three|four|five|six|seven|eight|nine|ten)"
r"(?:\s+times)?\s+(?:as many|as much)\s+(?P<unit>\w+)\s+as\s+"
r"(?:the\s+)?(?P<a>second\s+\w+|blind\s+\w+\s+population|[A-Z][A-Za-z]+(?:\s+[A-Z][A-Za-z]+)*)"
r"(?:the\s+)?(?P<b>first\s+\w+)\s+has\s+"
r"(?P<kword>twice|double|triple|thrice|"
r"(?:two|three|four|five|six|seven|eight|nine|ten|\d+)\s+times)\s+"
r"as many\s+(?P<unit>\w+)\s+as\s+"
r"(?:the\s+)?(?P<a>second\s+\w+)"
r".{0,80}?"
r"(?:if\s+)?(?:the\s+)?(?P=b)\s+has\s+(?P<n>\d+(?:\.\d+)?)"
r".{0,80}?"
r"(?:combined|altogether|in total|total|how many)",
r"(?:combined|altogether|in total|how many)",
t,
flags=re.IGNORECASE | re.DOTALL,
)
if m:
k = _factor_from_token(m.group("kword"))
k_tok = re.sub(
r"\s+times$", "", m.group("kword").strip(), flags=re.IGNORECASE
)
k = _factor_from_token(k_tok)
n = float(m.group("n"))
a = re.sub(r"\s+", " ", m.group("a")).strip()
b = re.sub(r"\s+", " ", m.group("b")).strip()
unit = m.group("unit").lower()
if k is not None and a.lower() != b.lower():
a_value = n / k
graph = MathProblemGraph(
entities=(a, b),
initial_state=(
InitialPossession(
entity=a, quantity=Quantity(value=a_value, unit=unit)
),
),
operations=(
Operation(
actor=b,
kind="compare_multiplicative",
operand=Comparison(
reference_actor=a,
delta=None,
factor=k,
direction="times",
),
),
),
unknown=Unknown(entity=None, unit=unit),
)
graph = _s1_graph(a=a, b=b, k=k, a_value=n / k, unit=unit)
return TextExtractResult(graph, None, pattern_id="s1_p2_b_seeded_has")
# Special: "deaf student population three times the size of blind student
# population. If the number of deaf students is 180, how many students
# are there altogether"
# Deaf/blind population form (fixed surface; still purity-gated).
m = re.search(
r"(?P<b>deaf\s+student\s+population)\s+"
r"(?P<kword>twice|double|triple|thrice|\d+|two|three|four|five|six|seven|eight|nine|ten)"
r"(?:\s+times)?\s+(?:the size of|as many as)\s+"
r"(?P<kword>twice|double|triple|thrice|"
r"(?:two|three|four|five|six|seven|eight|nine|ten|\d+)\s+times)\s+"
r"(?:the size of|as many as)\s+"
r"(?P<a>blind\s+student\s+population)"
r".{0,80}?"
r"(?:number of\s+deaf\s+students\s+is|deaf\s+students\s+is)\s+(?P<n>\d+(?:\.\d+)?)"
r".{0,60}?"
r"(?:altogether|in total|total|how many students)",
r"(?:altogether|in total|how many students)",
t,
flags=re.IGNORECASE | re.DOTALL,
)
if m:
k = _factor_from_token(m.group("kword"))
k_tok = re.sub(
r"\s+times$", "", m.group("kword").strip(), flags=re.IGNORECASE
)
k = _factor_from_token(k_tok)
n = float(m.group("n"))
if k is not None:
a, b, unit = "blind students", "deaf students", "students"
a_value = n / k
graph = MathProblemGraph(
entities=(a, b),
initial_state=(
InitialPossession(
entity=a, quantity=Quantity(value=a_value, unit=unit)
),
),
operations=(
Operation(
actor=b,
kind="compare_multiplicative",
operand=Comparison(
reference_actor=a,
delta=None,
factor=k,
direction="times",
),
),
),
unknown=Unknown(entity=None, unit=unit),
graph = _s1_graph(
a="blind students",
b="deaf students",
k=k,
a_value=n / k,
unit="students",
)
return TextExtractResult(graph, None, pattern_id="s1_p2_deaf_blind")

View file

@ -410,6 +410,50 @@ def mode_right_reason() -> None:
print(f"SUMMARY s2_public_sample: ok={s2_ok} scanned_transferish={s2_n}")
def mode_full_holdout_wrong0() -> None:
"""Absolute wrong=0 gate over **all** holdout_dev/v1 cases (not curated subset).
Any pipeline emit whose answer disagrees with gold is a hard failure of the
wrong=0 bar. Overmatching SM extract that certifies garbage is the hazard
this mode is designed to catch (regression: 0361).
"""
print("=== full holdout_dev/v1 wrong=0 gate (all 500 cases) ===")
cases = _load_cases()
emit_ok = 0
wrong: list[tuple[str, float | None, float, str | None, str | None]] = []
refused = 0
for cid, row in cases.items():
gold = float(row["expected_answer"])
out, trace, _ = run_structure_mapping_pipeline(row["problem"])
if not out.emitted or out.answer is None:
refused += 1
continue
if abs(float(out.answer) - gold) <= 1e-6 * max(1.0, abs(gold)):
emit_ok += 1
else:
wrong.append(
(
cid,
out.answer,
gold,
trace.graph_source,
trace.extract_pattern,
)
)
print(
f"SUMMARY full-holdout: emit_ok={emit_ok} wrong={len(wrong)} "
f"refused={refused} n={len(cases)}"
)
for cid, ans, gold, src, pat in wrong:
print(
f" WRONG {cid} emit={ans} gold={gold} src={src} extract={pat}"
)
if wrong:
print("FAIL: wrong≠0 on holdout_dev/v1 — pure-family extract overmatch")
else:
print("PASS: wrong=0 absolute on full holdout_dev/v1")
def mode_all() -> None:
mode_ratio()
print()
@ -420,6 +464,8 @@ def mode_all() -> None:
mode_parser_frontier()
print()
mode_right_reason()
print()
mode_full_holdout_wrong0()
def main(argv: list[str] | None = None) -> int:
@ -432,6 +478,7 @@ def main(argv: list[str] | None = None) -> int:
"selector",
"parser-frontier",
"right-reason",
"full-holdout-wrong0",
"all",
),
default="all",
@ -443,6 +490,7 @@ def main(argv: list[str] | None = None) -> int:
"selector": mode_selector,
"parser-frontier": mode_parser_frontier,
"right-reason": mode_right_reason,
"full-holdout-wrong0": mode_full_holdout_wrong0,
"all": mode_all,
}[args.mode]()
return 0

View file

@ -274,6 +274,43 @@ def test_surface_variant_inverted_s1():
assert trace.graph_source == "sm_extract"
def test_extract_refuses_multi_entity_zoo_0361():
"""Regression: 0361 overmatch must refuse, never emit wrong certified answer.
Prior loose P2 regex matched 'John has 8 more pandas than he does lions'
as pure S1 and the corridor emitted 1.125 vs gold 114.0 (wrong0).
"""
text = (
"John wants to start a zoo. He has 15 snakes. He has twice as many "
"monkeys as he does snakes. He has 5 fewer lions than he does monkeys. "
"John has 8 more pandas than he does lions. John has 1/3 as many dogs as "
"he does pandas. How many total animals does John have?"
)
from generate.structure_mapping.text_extract import extract_pure_s1
ext = extract_pure_s1(text)
assert ext.graph is None, f"must refuse extract, got {ext}"
assert ext.reason is not None
assert "not_pure_s1" in ext.reason or "no_pure" in ext.reason
out, trace, _ = run_structure_mapping_pipeline(text)
assert not out.emitted
assert out.answer is None
assert not trace.extract_used
def test_extract_refuses_bare_n_more_as_factor():
"""Bare additive 'N more' must never be parsed as multiplicative factor."""
from generate.structure_mapping.text_extract import extract_pure_s1
text = (
"John has 8 more pandas than he does lions. If John has 8 pandas, "
"how many pandas and lions do they have altogether?"
)
ext = extract_pure_s1(text)
assert ext.graph is None
def test_mapper_modules_do_not_import_scoring_labels():
root = Path("generate/structure_mapping")
for path in root.glob("*.py"):