From 4fbc992a77ef78fe68662cffa56469dfeedad640 Mon Sep 17 00:00:00 2001 From: Shay Date: Sat, 18 Jul 2026 22:24:38 -0700 Subject: [PATCH] =?UTF-8?q?feat(reader-arc):=20mass-noun=20compare=20frame?= =?UTF-8?q?=20(0148=20core=20piece)=20=E2=80=94=20WIP,=20verified=20extrac?= =?UTF-8?q?tion=20+=20fail-closed?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Increment 2, target 0148. _COMPARE_MASSNOUN_RE + wiring in _compare_multiplicative_candidates: 'the [total] number of counted on was twice the total number counted on ' -> A = factor x B. Verified: extracts 0148's statement correctly (actor='the first day', ref='the second day', factor=2.0, forward); fail-closed (additive 'N more than' -> 0 cands; off-shape -> 0 cands; possessive frame unaffected). Regression-safe: 76 comparative/compare/q-diff tests pass, tune wrong=0 PARSED=3. NOT yet converting 0148: discovered parse_and_solve parses these day-entity sentences via a DIFFERENT reader path (produces title-case 'First Day'/'Second Day' entities) — both extract_operation_candidates + extract_initial_candidates return [] for the possessive compare + seed, yet the full reader solves rung-1. Next: locate that path, wire the mass-noun frame into it with matching entity canonicalization; then passive-seed + prepositional-lead; then the 3-part proof. --- generate/math_candidate_parser.py | 38 +++++++++++++++++++++++++++++++ 1 file changed, 38 insertions(+) diff --git a/generate/math_candidate_parser.py b/generate/math_candidate_parser.py index 856b6814..4acdf01e 100644 --- a/generate/math_candidate_parser.py +++ b/generate/math_candidate_parser.py @@ -1268,6 +1268,23 @@ _ANCHOR_TO_FACTOR: Final[dict[str, tuple[float, str]]] = { "third": (1.0 / 3.0, "fraction"), } +# ADR-0250 increment 2 (case-first, target 0148) — mass-noun compare frame: +# "the [total] number of on was the [total] +# number [] on " → A = × B. +# The entities are mass-noun/temporal phrases ("the first day" / "the second +# day"); the anchor is the SAME closed factor set as the possessive frames, so +# no new factor/direction driver. Fail-closed: `$`-anchored, single clause, and +# the anchor must be a known multiplier — an additive "N more … than" surface +# has no anchor here and cannot match (that hazard belongs to compare_additive, +# out of scope). Narrow by design; refuses anything off-shape. +_COMPARE_MASSNOUN_RE: Final[re.Pattern[str]] = re.compile( + r"^\s*the\s+(?:total\s+)?number\s+of\s+(?P\w+)\s+\w+\s+on\s+" + r"(?Pthe\s+\w+(?:\s+\w+)?)\s+was\s+(?:a\s+)?" + r"(?Ptwice|thrice|half|quarter|third)\s+the\s+(?:total\s+)?number\s+" + r"(?:\w+\s+)?on\s+(?Pthe\s+\w+(?:\s+\w+)?)\s*\.?\s*$", + flags=re.IGNORECASE, +) + def _direction_to_anchor(direction_raw: str) -> tuple[str, str]: """Map surface direction word → (canonical Comparison.direction, @@ -1437,6 +1454,27 @@ def _compare_multiplicative_candidates(sentence: str) -> list[CandidateOperation s = sentence.strip() out: list[CandidateOperation] = [] + # ADR-0250 increment 2 — mass-noun frame ("the number of counted on + # was twice the total number counted on "). Checked first because its + # anchor set overlaps the possessive frames; fail-closed by the narrow regex. + m = _COMPARE_MASSNOUN_RE.match(s) + if m is not None: + anchor = m.group("anchor").lower() + factor, direction = _ANCHOR_TO_FACTOR[anchor] + cand = _build_compare_multiplicative( + actor_raw=m.group("actor"), + factor=factor, + matched_verb=anchor, + matched_value_token=anchor, + unit_raw=m.group("unit"), + reference_raw=m.group("reference"), + source=sentence, + direction=direction, + ) + if cand is not None: + out.append(cand) + return out + m = _COMPARE_MULT_ANCHOR_RE.match(s) if m is not None: anchor = m.group("anchor").lower()