core/generate/structure_mapping/text_extract.py
Shay e5643454d9 feat(trackb): S1–S4 symbolic SME, selector, pure-S1 coverage gain
Increment 2 for ADR-0252 Track B. Extends Increment 1's structure-mapping
slice with pure-family canonicals S2–S4, overlapping-waves selector, and a
structure-mapping-owned pure-S1 text extract that recovers four real
holdout cases the serving reader misses (0148, 0228, 0234, 0441).

Bar results (command-backed via scripts/measure_trackb_inc2.py):
- generalization ratio S1 = 9.0 (9 holdout cases / 1 template)
- coverage gain organ 5 → trackb 9, wrong=0
- selector routes S1/S2/S3; refuses empty; surface≠structure

Off-serving: no organ retirement, serving reader untouched. S2–S4 holdout
ratios are 0 (parser frontier). S3/S4 emit refuses at multi-register scope
without weakening the three-gate wrong=0 path.

[Verification]: Smoke suite passed locally (~132s, 176 passed);
trackb unit tests 35 passed; measure_trackb_inc2 all modes green.
2026-07-19 19:49:07 -07:00

267 lines
9.6 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.
"""
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,
}
@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 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``
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``
P3 — day/entity pair with ordinal:
``first day was twice ... second day. If <n> ... second day, how many ... two days``
"""
if not isinstance(text, str) or not text.strip():
return TextExtractResult(None, "empty_text")
t = " ".join(text.split())
# --- P3: people-counting / day1 = k × day2, day2 seeded (case 0148 family)
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".{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 = _factor_from_token(m.group("kword"))
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),
)
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"
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<n>\d+(?:\.\d+)?)"
r".{0,80}?"
r"(?:altogether|in total|combined|total)",
t,
flags=re.IGNORECASE | re.DOTALL,
)
if m:
k = _factor_from_token(m.group("kword"))
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),
)
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"
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".{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)",
t,
flags=re.IGNORECASE | re.DOTALL,
)
if m:
k = _factor_from_token(m.group("kword"))
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),
)
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"
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<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)",
t,
flags=re.IGNORECASE | re.DOTALL,
)
if m:
k = _factor_from_token(m.group("kword"))
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),
)
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)