core/generate/structure_mapping/text_extract.py
Shay 2544fda6f6 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
2026-07-19 20:04:27 -07:00

328 lines
11 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""Structure-mapping-owned pure-family text → MathProblemGraph extractors.
Off-serving only. Used when the live ``parse_and_solve`` reader refuses a
problem that is still a pure S1/S2/S3/S4 surface form. Does **not** modify
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
import re
from dataclasses import dataclass
from generate.math_problem_graph import (
Comparison,
InitialPossession,
MathProblemGraph,
Operation,
Quantity,
Unknown,
)
# Word → factor for multiplicative language.
_WORD_FACTORS: dict[str, float] = {
"twice": 2.0,
"double": 2.0,
"two": 2.0,
"three": 3.0,
"triple": 3.0,
"thrice": 3.0,
"four": 4.0,
"five": 5.0,
"six": 6.0,
"seven": 7.0,
"eight": 8.0,
"nine": 9.0,
"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:
graph: MathProblemGraph | None
reason: str | None
pattern_id: str | None = None
def _factor_from_token(tok: str) -> float | None:
t = tok.strip().lower()
if t in _WORD_FACTORS:
return _WORD_FACTORS[t]
try:
v = float(t)
except ValueError:
return 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 and
pass the pure-family surface gate):
P2 — scaled entity seeded (inverted):
``<B> wrote|has <k> times as many <unit> as <A>. If <B> ... <n> ... altogether``
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|"
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}?"
r"(?:how many|total).{0,40}?(?:two days|both days|in total)",
t,
flags=re.IGNORECASE | re.DOTALL,
)
if m:
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:
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
# REQUIRE "times as many/much" or word-factor "twice as many" — never bare "N more".
m = re.search(
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)",
t,
flags=re.IGNORECASE | re.DOTALL,
)
if m:
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").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 form: first/second + "twice|N times as many UNIT as"
m = re.search(
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|how many)",
t,
flags=re.IGNORECASE | re.DOTALL,
)
if m:
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():
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")
# 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|"
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|how many students)",
t,
flags=re.IGNORECASE | re.DOTALL,
)
if m:
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:
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")
return TextExtractResult(None, "no_pure_s1_pattern")
def extract_role_graph_from_text(text: str) -> TextExtractResult:
"""Best-effort SM-owned extract. Currently pure-S1 patterns only."""
return extract_pure_s1(text)