From c13d7e14c4a9d3addb17757ff1432cd904781f0b Mon Sep 17 00:00:00 2001 From: Shay Date: Sat, 23 May 2026 07:41:50 -0700 Subject: [PATCH] feat(ADR-0127/0128 integration): pack-aware parser + Path-B trigger evidence MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integrates en_units_v1 (#164) + en_numerics_v1 (#163) into the ADR-0126 candidate-graph parser. Loader merge (re-exports from numerics_loader.py give single import path), pack-aware unit canonicalization (handles irregular plurals like feet/children via lookup_unit), indefinite-quantifier refusal (ADR-0128.4 — 'some'/'many' emit no candidates, preserving wrong==0), and widened initial-possession shapes: - has N [of ] (ADR-0127 substance qualifier) - There are N [in ] (implicit-subject shape) Plus: pack-backed cardinal grounding in math_roundtrip._value_grounds (widens word-number coverage from hard-coded 0-12 to full numerics pack cardinal table + compound rule). Op-pattern trailing prep alternation gains of/for/with for substance qualifiers. REGRESSION: 1050/1050 tests green across math + ADR-0126 + ADR-0127 ratification + ADR-0128 ratification + runner. EMPIRICAL RESULT (the Path-B trigger ADR-0126/0127/0128 named): correct = 0/50 wrong = 0/50 refused = 50/50 on evals/gsm8k_math/train_sample/v1/cases.jsonl Per ADR-0127's exit criterion (correct >= 10/50, wrong == 0): **MISSED** — the full deterministic design (candidate-graph topology + units pack + numerics pack + pack-aware parser) does not move the GSM8K-math lane. This is the real Path-B trigger. WHAT WORKS (synthetic verification, 6/6 cases solve end-to-end): - 'Jan has 5 apples. Jan buys 3 apples. ...' -> 8 - 'Sam has 10 feet of rope. Sam uses 3 feet of rope. ...' -> 7 - 'There are 5 kids in camp. ...' -> 5 - 'Sam has 10 children. Sam loses 2 children. ...' -> 8 - (money + time-dimension variants pass) WHY GSM8K STAYS AT ZERO: real GSM8K problems carry compound linguistic structure (pronouns across statements, possessives, subordinate clauses, multi-word entities, multi-step inference) that no amount of pack vocabulary addresses. Per-sentence parse rate improved measurably on simple shapes; joint problem-level pass rate stayed at zero because every real problem contains at least one sentence the parser still cannot handle. Full results + Path-B recommendation in docs/decisions/ADR-0127-0128-RESULTS.md. The substrate (architecture + packs) stays load-bearing in main; the math expert promotion path retargets to a benchmark where exact recall and determinism are the discriminators (proposed ADR-0131). --- docs/decisions/ADR-0127-0128-RESULTS.md | 244 +++++++++++++++++++ evals/gsm8k_math/train_sample/v1/report.json | 4 +- generate/math_candidate_parser.py | 176 ++++++++++--- generate/math_roundtrip.py | 36 ++- language_packs/loader.py | 65 +++++ 5 files changed, 481 insertions(+), 44 deletions(-) create mode 100644 docs/decisions/ADR-0127-0128-RESULTS.md diff --git a/docs/decisions/ADR-0127-0128-RESULTS.md b/docs/decisions/ADR-0127-0128-RESULTS.md new file mode 100644 index 00000000..3fa3ff32 --- /dev/null +++ b/docs/decisions/ADR-0127-0128-RESULTS.md @@ -0,0 +1,244 @@ +# ADR-0127 + ADR-0128 Results — Path-B Triggered + +**Status:** Empirical result; load-bearing for the GSM8K-math arc decision +**Date:** 2026-05-23 +**Author:** CORE agents + reviewers +**Depends on:** ADR-0126 (architecture), ADR-0127 (units pack), +ADR-0128 (numerics pack), ADR-0114a (10 anti-overfitting obligations), +ADR-0119 (+ all 8 sub-phases), ADR-0120 (expert promotion contract), +ADR-0121 (math expert promotion deferred) + +--- + +## TL;DR + +The ADR-0126 → 0127 → 0128 arc shipped the full deterministic +design that the parser-by-rule + units-substrate hypothesis +required. The empirical result on the GSM8K train sample is: + +``` +correct = 0 / 50 +wrong = 0 / 50 (wrong == 0 preserved) +refused = 50 / 50 +``` + +Per ADR-0127's exit criterion and the Path-A vs Path-B decision +documented in ADR-0126: **the deterministic parser-by-rule +architecture, with full units + numerics substrate, does not move +the GSM8K-math lane.** This is the **real Path-B trigger.** The +math expert promotion path retargets to a benchmark where exact +recall and determinism are the discriminators — see +"Recommendation" below. + +--- + +## What was shipped + +| Layer | Module / Pack | What | +|-------|---------------|------| +| Architecture | `generate/math_roundtrip.py` | Round-trip admissibility primitive (26 tests, ADR-0126 P1) | +| Architecture | `generate/math_candidate_parser.py` | Candidate-emitting sentence parser (17 tests, ADR-0126 P2) | +| Architecture | `generate/math_candidate_graph.py` | Branch enumeration + decision rule (22 tests, ADR-0126 P3) | +| Architecture | `evals/gsm8k_math/runner.py::_score_one_candidate_graph` | Runner wiring (9 tests, ADR-0126 P4) | +| Substrate | `language_packs/data/en_units_v1/` | 284 lemmas, 401 conversion edges, NIST/ISO provenance (Gemini, PR #164) | +| Substrate | `language_packs/data/en_numerics_v1/` | 130 lemmas across cardinals/ordinals/fractions/multipliers/quantifiers/comparison-anchors/format-rules (Opus #2, PR #163) | +| Loader | `language_packs/loader.py` re-exports | Single import path for both packs (ADR-0127/0128 deferred coordination) | +| Integration | `generate/math_candidate_parser.py::_canonicalize_unit` | Pack-aware unit canonicalization — handles irregular plurals (feet, children, etc.) via pack lookup | +| Integration | `generate/math_candidate_parser.py::extract_initial_candidates` | Widened to ` has N [of ]` + `There are N [in ]` shapes | +| Integration | `generate/math_candidate_parser.py::_is_indefinite_quantifier` | ADR-0128.4 quantifier-driven refusal (`some`, `many`, etc. → no candidate emitted; preserves wrong == 0) | +| Integration | `generate/math_candidate_parser.py` op-pattern trailing prep | Added `of` / `for` / `with` to the discardable preposition tail (ADR-0127 substance qualifier) | +| Integration | `generate/math_roundtrip.py::_value_grounds` | Pack-backed cardinal lookup (widens word-number coverage from hard-coded 0-12 to full numerics pack) | +| Integration | `evals/gsm8k_math/train_sample/v1/runner.py` | Swapped `_score_one` → `_score_one_candidate_graph` | + +## What measurably works (synthetic verification) + +The candidate-graph architecture + pack substrate solves the +*kinds* of problems it was designed to solve. Six tailored +synthetic cases verify end-to-end: + +| Case | Result | +|------|--------| +| `Jan has 5 apples. Jan buys 3 apples. How many apples does Jan have?` | ✓ 8 | +| `Sam has 10 feet of rope. Sam uses 3 feet of rope. How many feet does Sam have?` | ✓ 7 (non-count unit; substance qualifier) | +| `There are 5 kids in camp. How many kids do they have?` | ✓ 5 (implicit-subject shape) | +| `Sam has 10 dollars. Sam spends 3 dollars. How many dollars does Sam have?` | ✓ 7 (money) | +| `Sam has 5 hours. Sam uses 2 hours. How many hours does Sam have?` | ✓ 3 (time-dimension unit) | +| `Sam has 10 children. Sam loses 2 children. How many children does Sam have?` | ✓ 8 (irregular plural via pack) | + +**1050/1050** existing test regression suites green across math, +ADR-0126, ADR-0127 pack ratification, ADR-0128 pack ratification, +and runner. Zero regressions from the integration work. + +## What did not move (empirical reality) + +The 50-case GSM8K train sample stays at 0 correct / 0 wrong / 50 +refused. Inspection of refusal causes shows that real GSM8K +problems carry compound linguistic structure that no pack adds +on its own: + +| Refusal class (from baseline categorization) | Train sample share | Pack-addressable? | +|---|---|---| +| OTHER_SHAPE (subordinate clauses, multi-word entities, possessives, pronouns across statements) | 27 / 50 | **No** — these need parser grammar work, not packs | +| NON_COUNT_UNIT (`feet`, `hours`, etc.) | 8 / 50 | **Partial** — pack helps single-statement, but problems still chain across multiple statements that other gaps refuse | +| MONEY (`$N`) | 5 / 50 | **Partial** — same multi-statement compound issue | +| RATE (`per`, `each`) | 5 / 50 | Partial | +| INDEFINITE_QUANT (`some`, `few`) | 3 / 50 | Yes — pack refuses cleanly, but refusal isn't correct | +| CONTAINER_OF | 1 / 50 | Partial | +| THERE_ARE | 1 / 50 | Yes (now parses), but other statements in same problem still refuse | + +The structural problem: a 3-5-sentence GSM8K problem refuses if +*any* sentence has no admissible candidate. P(problem passes) = +P(sentence passes)^N. The pack work raised per-sentence parse +rate measurably on simple shapes, but the *joint* pass rate +stayed at zero because every real problem contains at least one +sentence the parser still can't handle. + +## Why this is the Path-B trigger ADR-0126 named + +ADR-0126's exit criterion documented two outcomes: + +> **If passed:** if 50-case train sample shows correct ≥ 10/50 +> with wrong == 0, the architecture is validated; run the sealed +> holdout. +> +> **If missed:** if 50-case train sample shows correct < 10/50, +> the parser-by-rule architecture (in any topology) is the wrong +> abstraction for GSM8K coverage. ADR-0126 itself is deferred and +> the work pivots to benchmark re-selection. + +ADR-0127's exit criterion sharpened this: re-run train sample +with units pack mounted; if still missed, the failure is *real* +(architecture + substrate both insufficient). + +ADR-0128 added the numerics pack to the same exit criterion. + +All three packs are mounted, the architecture is in place, the +substrate is exhaustive (284 unit lemmas + 130 numeric lemmas + +401 conversion edges), and the result is 0/50. + +**This is the moment the architectural arc was designed to +surface a decision.** The deterministic design is correct, +load-bearing, and complete; it does not produce GSM8K coverage +because GSM8K's linguistic distribution is not parseable by any +deterministic rule set at the rate the substrate enables. The +27/50 OTHER_SHAPE refusals are the empirical evidence that the +gap is *grammar coverage of paraphrase variance*, not any +specific missing pack lemma. + +## What `wrong == 0` actually bought us + +Despite 0/50 correct, the wrong-zero discipline produced a +sound, replay-deterministic, audit-trail-complete pipeline that: + +- Refuses honestly on every case it cannot handle +- Carries pack-grounded provenance on every emitted operation +- Round-trip-verifies every parsed slot against source tokens +- Passes every adversarial gate +- Composes with the existing teaching subsystem's reviewed- + correction discipline + +This is genuinely useful infrastructure. The verdict is "wrong +benchmark for this architecture's strengths," not "the +architecture is bad." + +## Recommendation: Path B + +Three sub-decisions: + +### 1. Demote GSM8K-math lane from the math expert promotion contract. + +GSM8K is retained as a stress test and as a "we honestly refuse +on this distribution" demonstration, but **`correct_rate` on +GSM8K is removed from the ADR-0120 expert-promotion gate** for +the `mathematics_logic` domain. + +### 2. Re-target the math expert promotion to a benchmark where exact-recall + determinism are discriminators. + +Candidates: + +- **MATH symbolic subset** — symbolic-equivalence problems where + exact algebraic recall is the right primitive +- **CORE-native teaching-corpus eval** — problems sourced from + ratified teaching chains, where the parser's grammar exactly + matches the corpus's surface forms by construction (no + paraphrase-variance gap) +- **A curated word-problem set** with bounded grammar (e.g., + Khan Academy style problems pre-filtered to single-sentence + arithmetic shapes) + +A separate ADR (proposed: ADR-0131) scopes the re-targeting +decision and exit criteria. + +### 3. ADR-0126 / 0127 / 0128 substrate stays in main as load-bearing infrastructure. + +The candidate-graph topology, the round-trip admissibility +primitive, the units pack, the numerics pack, and the +deterministic pack-aware parser are useful for: + +- Any future deterministic word-problem benchmark +- The teaching corpus's own evaluation lane (where grammar match + is by-construction) +- Future cross-language packs (`es_units_v1`, `es_numerics_v1`) + that reuse the architecture +- Operator interaction surfaces where deterministic refusal + + honest provenance matter more than raw coverage + +**Do not revert.** This work proved a hypothesis correctly even +though the hypothesis didn't pan out for GSM8K. + +## What this does NOT recommend + +- Does **not** recommend abandoning the deterministic engine + philosophy. The architecture works; the benchmark choice was + the error. +- Does **not** recommend pulling in LLM-assisted parsing or + any opaque component. The contract integrity is intact and + worth preserving. +- Does **not** recommend more parser regex expansion within the + current architecture for GSM8K. Four previous ADRs in that + shape produced 0 lift; this one (the architectural pivot) did + the same. The treadmill has been independently characterized + twice now. + +## Composition with deferred backlog (ADR-0129 / ADR-0130) + +The deferred teaching-loop ADRs (`spaced-correction-replay`, +`pre-articulation-calibration`) become more interesting once Path +B lands a new benchmark, because: + +- A benchmark where the parser handles by-construction grammar + cleanly will produce a stable correction-store population — + the precondition ADR-0129 named for un-deferral. +- A new benchmark's per-version calibration cohorts give + ADR-0130 a real signal to measure. + +These remain deferred under the new path, but their un-deferral +exit criteria become reachable. + +## PR checklist + +``` +What capability did this add? + → The integration layer that wires en_units_v1 + en_numerics_v1 + into the ADR-0126 candidate-graph parser. Pack-aware unit + canonicalization, indefinite-quantifier refusal, substance- + qualifier handling, There-are initial shape, pack-backed + cardinal grounding. Also the Path-B trigger evidence. +What invariant proves the field remains valid? + → wrong == 0 preserved on train sample (0/50 wrong). Trace + determinism preserved. Round-trip admissibility extended + with pack-typed unit grounding. +Which CLI suite/eval proves the lane? + → smoke + math + packs + train_sample_runner. All 1050 tests + green; train sample re-runs deterministic 0/0/50. +Did this avoid hidden normalization, stochastic fallback, +approximate recall, unreviewed mutation? + → Yes. Pack lookups are deterministic. Indefinite quantifier + refusal is a deliberate hard-no, not a probabilistic + threshold. No LLM fallback added. +If it touches user input, what trust boundary was enforced? + → No new user-input surfaces. Pack loaders already validate + pack_id via safe_pack_id (ADR-0051). Train sample is + unsealed by design (drawn from GSM8K train split, not + holdout). +``` diff --git a/evals/gsm8k_math/train_sample/v1/report.json b/evals/gsm8k_math/train_sample/v1/report.json index e5f140c3..04a8c5b2 100644 --- a/evals/gsm8k_math/train_sample/v1/report.json +++ b/evals/gsm8k_math/train_sample/v1/report.json @@ -18,7 +18,7 @@ }, { "case_id": "gsm8k-train-sample-v1-0002", - "reason": "candidate_graph: no admissible candidate for statement: 'Jan buys 1000 feet of cable.'", + "reason": "candidate_graph: no admissible candidate for statement: 'She splits it up into 25-foot sections.'", "verdict": "refused" }, { @@ -213,7 +213,7 @@ }, { "case_id": "gsm8k-train-sample-v1-0041", - "reason": "candidate_graph: no admissible candidate for statement: 'Troy bakes 2 pans of brownies, cut into 16 pieces per pan.'", + "reason": "candidate_graph: no admissible candidate for statement: 'The guests eat all of 1 pan, and 75% of the 2nd pan.'", "verdict": "refused" }, { diff --git a/generate/math_candidate_parser.py b/generate/math_candidate_parser.py index e077afac..f2835ca0 100644 --- a/generate/math_candidate_parser.py +++ b/generate/math_candidate_parser.py @@ -75,9 +75,11 @@ class CandidateInitial: matched_entity_token: str def __post_init__(self) -> None: - if self.matched_anchor.lower() not in ("has", "have"): + # ADR-0127 widens the anchor set to include 'there are/were/is/was' + # for the implicit-subject initial-possession shape. + if self.matched_anchor.lower() not in ("has", "have", "are", "were", "is", "was"): raise ValueError( - f"CandidateInitial.matched_anchor must be has/have; " + f"CandidateInitial.matched_anchor must be has/have/are/were/is/was; " f"got {self.matched_anchor!r}" ) @@ -120,7 +122,28 @@ _INITIAL_HAS_RE: Final[re.Pattern[str]] = re.compile( rf"^(?P{_ENTITY})\s+" rf"(?Phas|have)\s+" rf"(?P{_VALUE})\s+" - r"(?P\w+)\s*\.?$" + r"(?P\w+)" + # ADR-0127 substance qualifier: "Sam has 5 feet of rope" — the + # 'of ' tail is grammatically real but arithmetically inert. + r"(?:\s+of\s+.+)?" + r"\s*\.?$" +) + +# ADR-0127 "There are/were N [in ]" initial-possession shape. +# The implicit-subject anchor 'there are' is the only initial-possession +# shape that doesn't name an entity in the source; we treat the +# place phrase (when present) as the entity and treat the unit as the +# count noun. When no place is named, the entity is the unit itself +# (collective). Indefinite quantifiers ('some', 'few', 'many') in the +# value slot are refused upstream by extract_initial_candidates via +# the quantifier-driven refusal helper (ADR-0128.4). +_INITIAL_THERE_ARE_RE: Final[re.Pattern[str]] = re.compile( + r"^There\s+(?Pare|were|is|was)\s+" + rf"(?P{_VALUE})\s+" + r"(?P\w+)" + r"(?:\s+in\s+(?P[A-Za-z]\w*(?:\s+\w+)?))?" + r"\s*\.?$", + flags=re.IGNORECASE, ) @@ -139,36 +162,93 @@ def _resolve_value(value_token: str) -> int: return WORD_NUMBERS[value_token.lower()] +def _is_indefinite_quantifier(token: str) -> bool: + """ADR-0128.4 — quantifier-driven refusal helper. + + Returns True when ``token`` resolves (via en_numerics_v1 lookup) to + an indefinite quantifier (``some``, ``many``, ``few``, ``several``, + etc.). Indefinite quantifiers in value-slot positions are refused + rather than guessed — preserves wrong == 0. + """ + try: + from language_packs.loader import lookup_quantifier + entry = lookup_quantifier(token.lower()) + if entry is not None and entry.semantic_type == "indefinite": + return True + except Exception: + pass + return False + + def extract_initial_candidates(sentence: str) -> list[CandidateInitial]: """Return all admissible initial-possession candidates for ``sentence``. - Currently emits at most one candidate (the single canonical shape - " has "). Returns an empty list if no shape matches. + Recognized shapes: + 1. " has [of ]" — canonical. + 2. "There are [in ]" — implicit-subject shape. + + ADR-0128.4: if the value slot resolves to an indefinite quantifier + (`some kids`, `many things`), no candidate is emitted (refusal + preserves wrong == 0). """ s = sentence.strip().rstrip(".") + out: list[CandidateInitial] = [] + m = _INITIAL_HAS_RE.match(s) - if not m: - return [] - entity = _normalize_entity(m.group("entity")) - value = _resolve_value(m.group("value")) - unit_raw = m.group("unit") - # Canonicalize: lowercase + ensure plural (matching math_parser._canonical_unit). - unit = unit_raw.lower() - if not unit.endswith("s"): - unit = unit + "s" - return [ - CandidateInitial( - initial=InitialPossession( - entity=entity, - quantity=Quantity(value=value, unit=unit), - ), - source_span=sentence, - matched_anchor=m.group("anchor"), - matched_value_token=m.group("value"), - matched_unit_token=unit_raw, - matched_entity_token=m.group("entity"), - ) - ] + if m is not None: + value_raw = m.group("value") + if not _is_indefinite_quantifier(value_raw): + entity = _normalize_entity(m.group("entity")) + value = _resolve_value(value_raw) + unit_raw = m.group("unit") + unit = _canonicalize_unit(unit_raw) + out.append( + CandidateInitial( + initial=InitialPossession( + entity=entity, + quantity=Quantity(value=value, unit=unit), + ), + source_span=sentence, + matched_anchor=m.group("anchor"), + matched_value_token=value_raw, + matched_unit_token=unit_raw, + matched_entity_token=m.group("entity"), + ) + ) + + m2 = _INITIAL_THERE_ARE_RE.match(s) + if m2 is not None: + value_raw = m2.group("value") + if not _is_indefinite_quantifier(value_raw): + unit_raw = m2.group("unit") + unit = _canonicalize_unit(unit_raw) + value = _resolve_value(value_raw) + place = m2.group("place") + # When a 'in ' phrase is present, treat the place as + # the implicit entity. Otherwise use the unit's plural as + # the collective entity name (deterministic, derivable from + # the source: "There are 5 kids" -> entity='kids'). + if place is not None: + entity = _normalize_entity(place) + entity_token = place + else: + entity = unit + entity_token = unit_raw + out.append( + CandidateInitial( + initial=InitialPossession( + entity=entity, + quantity=Quantity(value=value, unit=unit), + ), + source_span=sentence, + matched_anchor=m2.group("anchor"), + matched_value_token=value_raw, + matched_unit_token=unit_raw, + matched_entity_token=entity_token, + ) + ) + + return out # --------------------------------------------------------------------------- @@ -200,13 +280,16 @@ def _op_pattern(verbs_pattern: str, *, requires_target: bool) -> re.Pattern[str] if requires_target: target_part = r"\s+to\s+(?P[A-Z]\w+)" trailing_prep = ( - r"(?:\s+(?:on|from|at|in|onto|into|under|over)\s+.+)?" + r"(?:\s+(?:on|from|at|in|onto|into|under|over|of|for|with)\s+.+)?" ) else: target_part = "" - # Note: 'to' is included in the discardable preposition set. + # 'to' is included in the discardable preposition set. + # 'of' is included for ADR-0127 substance qualifiers ("1000 feet + # of cable") — the substance NP is grammatically real but + # arithmetically inert; the unit slot carries the dimensional info. trailing_prep = ( - r"(?:\s+(?:on|from|at|in|onto|into|under|over|to)\s+.+)?" + r"(?:\s+(?:on|from|at|in|onto|into|under|over|to|of|for|with)\s+.+)?" ) return re.compile( r"^" @@ -228,6 +311,27 @@ _SUBTRACT_OP_RE: Final[re.Pattern[str]] = _op_pattern(_SUBTRACT_VERBS_PATTERN, r _TRANSFER_OP_RE: Final[re.Pattern[str]] = _op_pattern(_TRANSFER_VERBS_PATTERN, requires_target=True) +def _canonicalize_unit(unit_raw: str) -> str: + """Canonicalize a unit surface token to its plural form. + + ADR-0127 integration: consult en_units_v1 first. If the token is a + pack-recognized unit, use the pack's canonical plural form (handles + irregular plurals like feet/feet, children, mice, etc. correctly). + Otherwise fall back to the legacy '+s' rule for count nouns. + """ + lowered = unit_raw.lower() + try: + from language_packs.loader import lookup_unit + entry = lookup_unit(lowered) + if entry is not None: + return entry.plural.lower() + except Exception: + pass + if not lowered.endswith("s"): + return lowered + "s" + return lowered + + def _build_op_candidate( m: re.Match[str], kind: str, source: str ) -> CandidateOperation | None: @@ -237,9 +341,7 @@ def _build_op_candidate( unit_raw = m.group("unit") if unit_raw is None: return None - unit = unit_raw.lower() - if not unit.endswith("s"): - unit = unit + "s" + unit = _canonicalize_unit(unit_raw) subject = _normalize_entity(m.group("subject")) verb = m.group("verb").lower() value = _resolve_value(m.group("value")) @@ -324,9 +426,7 @@ def extract_question_candidates(sentence: str) -> list[CandidateUnknown]: m = _Q_TOTAL_RE.match(s) if m is not None: unit_raw = m.group("unit") - unit = unit_raw.lower() - if not unit.endswith("s"): - unit = unit + "s" + unit = _canonicalize_unit(unit_raw) out.append( CandidateUnknown( unknown=Unknown(entity=None, unit=unit), @@ -340,9 +440,7 @@ def extract_question_candidates(sentence: str) -> list[CandidateUnknown]: m = _Q_ENTITY_RE.match(s) if m is not None: unit_raw = m.group("unit") - unit = unit_raw.lower() - if not unit.endswith("s"): - unit = unit + "s" + unit = _canonicalize_unit(unit_raw) entity = _normalize_entity(m.group("entity")) out.append( CandidateUnknown( diff --git a/generate/math_roundtrip.py b/generate/math_roundtrip.py index efb7e518..4a2076d3 100644 --- a/generate/math_roundtrip.py +++ b/generate/math_roundtrip.py @@ -275,16 +275,37 @@ def _token_in(needle: str, haystack_tokens: frozenset[str]) -> bool: def _value_grounds(value_token: str, haystack_tokens: frozenset[str]) -> bool: """A numeric value grounds if its surface token appears, OR if the token is a digit-string and any equivalent word-form appears, OR if it's a - word-form and the digit appears.""" + word-form and the digit appears. + + ADR-0128 integration: en_numerics_v1's cardinal table is consulted in + addition to the legacy hard-coded WORD_NUMBERS, widening coverage from + 1-12 to the full pack cardinal range (0-1000+ plus compound rule). The + hard-coded WORD_NUMBERS remains as a fast path and as a fallback if + the pack is unavailable; the pack adds, never replaces. + """ if _token_in(value_token, haystack_tokens): return True - # word -> digit equivalent lowered = value_token.lower() + + # Pack-backed cardinal lookup (ADR-0128). Soft import — if the pack + # isn't mounted (e.g., in legacy test environments) we silently fall + # through to the hard-coded table. + try: + from language_packs.loader import lookup_cardinal + entry = lookup_cardinal(lowered) + if entry is not None: + digit = str(entry.numeric_value) + if digit in haystack_tokens: + return True + except Exception: + pass # fall through to hard-coded path + + # word -> digit equivalent (legacy) if lowered in WORD_NUMBERS: digit = str(WORD_NUMBERS[lowered]) if digit in haystack_tokens: return True - # digit -> any word with that integer value + # digit -> any word with that integer value (legacy) try: n = int(value_token) except ValueError: @@ -292,6 +313,15 @@ def _value_grounds(value_token: str, haystack_tokens: frozenset[str]) -> bool: for word, w_val in WORD_NUMBERS.items(): if w_val == n and word in haystack_tokens: return True + # Pack-backed reverse lookup: digit -> cardinal surface in haystack + try: + from language_packs.loader import lookup_cardinal + for tok in haystack_tokens: + entry = lookup_cardinal(tok) + if entry is not None and entry.numeric_value == n: + return True + except Exception: + pass return False diff --git a/language_packs/loader.py b/language_packs/loader.py index ffca3e92..c157cde1 100644 --- a/language_packs/loader.py +++ b/language_packs/loader.py @@ -288,3 +288,68 @@ def canonical_unit_for(dimension: str) -> str: if unit: return unit.singular return dim.canonical_unit + + +# --------------------------------------------------------------------------- +# ADR-0128 numerics-pack re-exports (deferred coordination from ADR-0128 brief) +# +# en_numerics_v1's loader functions live in language_packs/numerics_loader.py +# (per the brief's concurrency clause that allowed parallel development). +# Re-exporting them here gives callers a single import path +# (`from language_packs.loader import lookup_cardinal`) while keeping the +# numerics implementation in its own domain-cohesive module. +# --------------------------------------------------------------------------- + +from language_packs.numerics_loader import ( # noqa: E402 + CardinalEntry, + ComparisonAnchorEntry, + FractionEntry, + MultiplierEntry, + NumberFormatEntry, + OrdinalEntry, + ParsedNumber, + QuantifierEntry, + lookup_cardinal, + lookup_comparison_anchor, + lookup_comparison_anchors, + lookup_fraction, + lookup_multiplier, + lookup_ordinal, + lookup_quantifier, + match_number_format, + number_format_entries, + parse_compound_cardinal, +) + +__all__ = [ + # ADR-0127 units pack + "UnitEntry", + "ContainerEntry", + "DimensionEntry", + "ConversionEdge", + "ConversionGraph", + "lookup_unit", + "lookup_container", + "lookup_dimension", + "get_conversion_graph", + "canonical_unit_for", + # ADR-0128 numerics pack (re-exported from numerics_loader) + "CardinalEntry", + "OrdinalEntry", + "FractionEntry", + "MultiplierEntry", + "QuantifierEntry", + "ComparisonAnchorEntry", + "NumberFormatEntry", + "ParsedNumber", + "lookup_cardinal", + "lookup_ordinal", + "lookup_fraction", + "lookup_quantifier", + "lookup_multiplier", + "lookup_comparison_anchor", + "lookup_comparison_anchors", + "number_format_entries", + "match_number_format", + "parse_compound_cardinal", +]