core/generate/derivation/extract.py
Shay 65d857e72a
feat(adr-0179): integrate EX-1/EX-4/EX-5 extraction richness (sealed lane) (#455)
Reconciles ChatGPT's four independently-branched extraction PRs (#451/#452/
#453/#454) into one coherent generate/derivation/extract.py. They each rewrote
the same file + same new test off main, so they conflicted pairwise and needed
integration, not a merge.

Integrated (span-tracked, most-specific-pass-first so numbers are never double
counted):
- EX-1 word-numbers (#452): reuses WORD_NUMBERS; tens-one hyphen compounds;
  factor-bearing half/third/quarter excluded.
- EX-4 list-unit inheritance (#451): bare numeric list with one trailing unit.
- EX-5 sentence-final numbers (#454): bare final number with empty unit.

Deferred: EX-3 multi-word units (#453). Its greedy lowercase span reads
"6 apples and 4 apples" as unit "apples and", regressing GB-2's
test_same_unit_list_sums, and still can't recover real multi-word units from
0024-class text ("jumping jacks on"). Needs a tighter rule; see
docs/handoff/AUDIT-ADR-0179-EX-RECONCILE.md.

Verification (sealed lane only; chat/ does not import this module):
- Serving frozen: lane-SHA 8/8 match, generate_claims --check OK -> 3/47/0
  byte-identical, wrong=0 held.
- Sealed practice improved 4/2/44 -> 4/1/45: case 0025 flips wrong->refused.
  EX-1 reads "three", so completeness sees a quantity the 6x50 chain omits and
  refuses the spurious 300 (gold 1200) instead of committing it.
- No new test failures (3 pre-existing on main).

Also fixes stale test drift from EX-2 (#447): TestDecimalGroundingGapIsDeferred
asserted decimals still refuse, but #447 made $0.75-class resolve to 864.
Renamed to TestDecimalGroundingResolves and updated to assert the flip.

Honest scope note: EX-4 does NOT unblock real case 0024 (its PR test used a
fabricated bare-list paraphrase). TestRealCase0024StillBlocked pins the true
boundary.
2026-05-29 08:43:03 -07:00

171 lines
7.4 KiB
Python

"""ADR-0175 Phase 3b / ADR-0179 — lexeme-level quantity extraction.
Pulls ``(value, unit, source_token)`` triples from a problem using conservative
orthographic patterns. Per ADR-0165 these are *lexeme* patterns ("what this
piece looks like: a number, a unit word") — never grammar templates ("how words
combine to mean X"). The *combining* is the search's job (search.py / compose.py)
gated by self-verification, which is refuse-preferring; over-extraction here can
only cost *refusals*, never a wrong answer.
ADR-0179 enrichments integrated here (sealed lane only — ``chat/`` does not import
this module, so none of this can move the serving ``3/47/0``):
* **EX-1 — word-numbers.** ``"three apples"`` → ``3.0``, including tens-one
hyphen compounds (``"twenty-four"`` → ``24.0``). Reuses the canonical
``WORD_NUMBERS`` table from :mod:`generate.math_roundtrip` (single number
vocabulary). Factor-bearing forms (``half``/``third``/``quarter``) are excluded
— they read as divisors, not counts.
* **EX-4 — list-unit inheritance.** In a bare numeric list with the unit stated
once at the end (``"20, 36, 40 and 50 push-ups"``) the trailing unit attaches
to every number in the list. Still orthographic: a run of number tokens joined
by comma/``and`` delimiters, then one unit token. Whether the resulting
quantities may compose is the gate's decision, not the extractor's.
* **EX-5 — sentence-final numbers.** A number with no following unit word (end of
sentence/text or before terminal punctuation) extracts with an empty unit so it
stays available to the completeness check without inventing a unit lexeme.
EX-3 (multi-word units) is deliberately **not** integrated: the greedy lowercase
unit span regresses GB-2's same-unit detection (``"6 apples and 4 apples"`` →
unit ``"apples and"``) and does not cleanly recover real multi-word units from
0024-class text (``"20 jumping jacks on Monday"`` → ``"jumping jacks on"``). See
``docs/handoff/AUDIT-ADR-0179-EX-RECONCILE.md``.
"""
from __future__ import annotations
import re
from typing import Final
from generate.derivation.model import Quantity
from generate.math_roundtrip import WORD_NUMBERS
# Number (int or decimal) immediately followed by a single unit word. Lexeme-level.
_QTY_RE: Final[re.Pattern[str]] = re.compile(
r"(?<![\w.])(\d+(?:\.\d+)?)\s+([a-zA-Z]+)"
)
# EX-4: a same-unit numeric list with the unit stated once at the end, e.g.
# ``20, 36, 40 and 50 push-ups``. A run of number tokens separated by comma/and
# delimiters, followed by one (optionally hyphenated) unit token.
_LIST_WITH_TRAILING_UNIT_RE: Final[re.Pattern[str]] = re.compile(
r"(?<![\w.])"
r"((?:\d+(?:\.\d+)?\s*(?:,\s*|\s+and\s+)){1,}\d+(?:\.\d+)?)"
r"\s+([a-zA-Z]+(?:-[a-zA-Z]+)*)"
)
_NUMBER_RE: Final[re.Pattern[str]] = re.compile(r"\d+(?:\.\d+)?")
# EX-1: word-number (optionally a tens-one hyphen compound) followed by a unit
# word. Built from the round-trip table; factor-bearing forms excluded.
_WORD_NUMBER_ALT: Final[str] = "|".join(
re.escape(word)
for word in sorted(WORD_NUMBERS, key=len, reverse=True)
if word not in {"half", "third", "quarter"}
)
_WORD_QTY_RE: Final[re.Pattern[str]] = re.compile(
rf"(?i)\b({_WORD_NUMBER_ALT})(?:-({_WORD_NUMBER_ALT}))?\s+([a-zA-Z]+)\b"
)
# EX-5: a number with no following unit, at end of sentence/text.
_FINAL_NUMBER_RE: Final[re.Pattern[str]] = re.compile(
r"(?<![\w.])(\d+(?:\.\d+)?)(?=\s*(?:[.?!]|$))"
)
def _quantity(value_token: str, unit: str) -> Quantity | None:
"""Build a quantity from an already-matched numeric token."""
try:
value = float(value_token)
except ValueError: # pragma: no cover - regex guarantees numeric
return None
return Quantity(value=value, unit=unit.lower(), source_token=value_token)
def _resolve_word_number(first: str, second: str | None) -> float | None:
"""Resolve a conservative word-number token from ``WORD_NUMBERS``.
A bare word resolves directly. A hyphen compound resolves only as a tens-one
form (``twenty-four``, ``ninety-nine``); anything else returns ``None`` so the
extractor stays conservative rather than guessing a composition rule.
"""
first_value = WORD_NUMBERS.get(first.lower())
if first_value is None:
return None
if second is None:
return float(first_value)
second_value = WORD_NUMBERS.get(second.lower())
if second_value is None:
return None
if first_value < 20 or first_value >= 100 or not 0 < second_value < 10:
return None
return float(first_value + second_value)
def _claimed(pos: int, spans: list[tuple[int, int]]) -> bool:
"""Whether a numeric token at ``pos`` was already claimed by an earlier pass."""
return any(start <= pos < end for start, end in spans)
def extract_quantities(problem_text: str) -> tuple[Quantity, ...]:
"""Extract ``(value, unit, source_token)`` quantities in left-to-right order.
Deterministic. ``source_token`` is the surface number string (used by the
self-verification gate to prove the value is grounded in the text). Units are
lowercased; the value's surface token is preserved verbatim.
Passes run most-specific first and claim the digit spans they consume so later
passes never double-count a number:
1. EX-4 same-unit list (claims every number in the list);
2. digit + single unit word (skips numbers a list already claimed);
3. EX-1 word-number + unit word (alphabetic, disjoint from digit spans);
4. EX-5 sentence-final bare number (skips any already-claimed digit).
"""
found: list[tuple[int, Quantity]] = []
claimed: list[tuple[int, int]] = []
# 1. EX-4 — list with one trailing unit; the unit inherits to every number.
for match in _LIST_WITH_TRAILING_UNIT_RE.finditer(problem_text):
unit = match.group(2).lower()
for num in _NUMBER_RE.finditer(match.group(1)):
pos = match.start(1) + num.start()
quantity = _quantity(num.group(0), unit)
if quantity is not None:
found.append((pos, quantity))
claimed.append((pos, pos + len(num.group(0))))
# 2. digit + single unit word — the original base pattern.
for match in _QTY_RE.finditer(problem_text):
if _claimed(match.start(1), claimed):
continue
quantity = _quantity(match.group(1), match.group(2))
if quantity is not None:
found.append((match.start(1), quantity))
claimed.append(match.span(1))
# 3. EX-1 — word-numbers (and tens-one hyphen compounds) with a unit word.
for match in _WORD_QTY_RE.finditer(problem_text):
value = _resolve_word_number(match.group(1), match.group(2))
if value is None:
continue
source_token = (
match.group(1)
if match.group(2) is None
else match.group(0).rsplit(maxsplit=1)[0]
)
found.append(
(
match.start(1),
Quantity(value=value, unit=match.group(3).lower(), source_token=source_token),
)
)
# 4. EX-5 — sentence-final bare numbers (empty unit).
for match in _FINAL_NUMBER_RE.finditer(problem_text):
if _claimed(match.start(1), claimed):
continue
quantity = _quantity(match.group(1), "")
if quantity is not None:
found.append((match.start(1), quantity))
found.sort(key=lambda item: item[0])
return tuple(quantity for _, quantity in found)