diff --git a/docs/decisions/ADR-0127-units-pack-and-units-aware-parser.md b/docs/decisions/ADR-0127-units-pack-and-units-aware-parser.md index 6574d8e7..69336847 100644 --- a/docs/decisions/ADR-0127-units-pack-and-units-aware-parser.md +++ b/docs/decisions/ADR-0127-units-pack-and-units-aware-parser.md @@ -67,43 +67,95 @@ obligations.** **No new exit gates beyond ADR-0126's** (`correct ≥ 10/50, wrong == 0` on the train sample, this time with units pack in scope). -### Pack content (`en_units_v1`) +### Pack content (`en_units_v1`) — EXHAUSTIVE SCOPE Structure mirrors `en_arithmetic_v1`: `lexicon.jsonl` + -`manifest.json` + `glosses.jsonl` + a self-sealing -`.mastery_report.json` ratification artifact. +`manifest.json` + `glosses.jsonl` + `conversions.jsonl` + a +self-sealing `.mastery_report.json` ratification artifact. **Dimension classes** (entry_id prefix `en-units-dim-`): -| Dimension | Notes | -|-----------|-------| -| `count` | Discrete countable nouns (apples, kids, boxes, books) | -| `length` | feet, inches, miles, yards, meters, centimeters | -| `time` | hours, minutes, days, weeks, months, years | -| `mass` | pounds, ounces, kilograms, grams | -| `money` | dollars, cents (anchored by `$` / `¢` symbols) | -| `temperature` | degrees, Fahrenheit, Celsius | -| `volume` | gallons, quarts, cups, liters | +| Dimension | Canonical unit | Notes | +|-----------|---------------|-------| +| `count` | (dimensionless) | The no-unit dimension | +| `length` | foot | imperial + metric families | +| `time` | minute | seconds through millennia | +| `mass` | pound | imperial + metric | +| `money` | cent | with currency-symbol attachments | +| `temperature` | °F | affine scales (F/C/K/R) | +| `volume` | cup | cooking + liquid + dry | +| `area` | sq ft | **derived** = length × length | +| `speed` | mph | **derived** = length / time | +| `density` | lb/cu ft | **derived** = mass / volume | +| `frequency` | Hz | **derived** = 1 / time | +| `wage` | $/hour | **derived** = money / time | +| `unit_price` | $/item | **derived** = money / count | -**Unit lemmas** (entry_id prefix `en-units-unit-`): +Derived dimensions are encoded *compositionally* via a +`dimension_algebra.jsonl` table. The parser recognizes +"miles per hour" structurally as `length-unit + per + +time-unit`; it does NOT need a `mph` primitive. Reduces unit +combinatorics from O(unit²) to O(unit + connector). -Each unit binds to exactly one dimension. Carries `singular`, -`plural`, and `symbol` (where applicable: `$`, `°`). Initial pack -targets ≤ 60 lemmas — bounded by GSM8K-frequent vocabulary, not -NIST-complete coverage. +**Unit lemmas** (entry_id prefix `en-units-unit-`) — exhaustive +for grade-school math word problems (~150 entries): -**Container nouns** (entry_id prefix `en-units-container-`): +| Dimension | Lemmas | +|-----------|--------| +| length | inch/in, foot/ft, yard/yd, mile/mi, millimeter/mm, centimeter/cm, meter/m, kilometer/km | +| time | second/sec/s, minute/min, hour/hr/h, day, week, month, year/yr, decade, century, millennium | +| mass | ounce/oz, pound/lb, ton, gram/g, kilogram/kg, milligram/mg, metric_ton/tonne | +| volume | teaspoon/tsp, tablespoon/tbsp, cup, pint/pt, quart/qt, gallon/gal, milliliter/mL, liter/L | +| money | cent/¢, dollar/$, plus optional foreign: euro/€, pound_sterling/£, yen/¥, peso | +| temperature | degree/°, Fahrenheit/°F, Celsius/°C, Kelvin/K | +| area | sq_inch, sq_foot, sq_yard, sq_mile, sq_meter, sq_kilometer, acre, hectare | +| count | (open class — head-noun grammar; pack does not enumerate, but provides count-dimension stamp for any unmatched noun in countable position) | -Closed set: `box`, `bag`, `pack`, `basket`, `batch`, `dozen`, -`group`, `set`, `case`, `pair`, `pile`, `stack`. Each declares -its canonical syntax (` of `) and an -optional `default_size` (only `dozen=12`, `pair=2`, others null). +Each unit carries `singular`, `plural`, `symbol`, `dimension`, +`is_canonical_for_dimension` (bool). Plural forms include the +irregulars: foot/feet, child/children (count), person/people +(count), mouse/mice (count), goose/geese (count), +fish/fish (count), sheep/sheep (count), deer/deer (count), +woman/women (count), man/men (count), leaf/leaves, +knife/knives, life/lives. -**Rate connectors** (entry_id prefix `en-units-rate-`): +**Multi-word units** (entry_id prefix `en-units-multiword-`): -Closed set: `per`, `an`, `every`, `each`. Each declares the -template ` ` it -participates in. +Structural composition rules (not lexicon enumeration): + +- `square ` → area (sq_ft, sq_meter, sq_mile, …) +- `cubic ` → volume_derived +- ` per ` → speed +- ` per ` → unit_price or wage +- ` per ` → density + +The pack ships the *patterns*, not every concrete combination — +the parser composes them at recognition time. + +**Container nouns** (entry_id prefix `en-units-container-`) — +closed set (~25): + +`box`, `bag`, `pack`, `basket`, `batch`, `dozen`, `group`, +`set`, `case`, `pair`, `pile`, `stack`, `bunch`, `bundle`, +`carton`, `crate`, `jar`, `can`, `bottle`, `cup` (when +container, not volume-unit — context-disambiguated), `plate`, +`bowl`, `tray`, `sack`, `container`, `gross` (=144). + +Each declares syntax ` of ` and +optional `default_size` (only `dozen=12`, `pair=2`, `gross=144`, +others null). + +**Rate connectors** (entry_id prefix `en-units-rate-`) — closed +set (~6): + +`per`, `an`, `every`, `each`, `by`, `in` (as in "every 3 days"). +Each declares the dimension-algebra template it participates in. + +**Symbol/affix table** (entry_id prefix `en-units-symbol-`): + +`$`, `¢`, `°`, `°F`, `°C`, `%`, `½`, `¼`, `¾`, `⅓`, `⅔`, `⅛`, +`⅜`, `⅝`, `⅞`. Each maps to its lexical lemma. Cross-linked to +`en_numerics_v1` (sibling pack, ADR-0128) for fraction symbols. **Substance qualifiers** (entry_id prefix `en-units-substance-`): @@ -128,11 +180,17 @@ where `to_unit = ratio × from_unit`. The pack ratifies the {"edge_id":"en-units-conv-006","from":"hours","to":"days","ratio":0.04166666667,"dimension":"time"} ``` +**Temperature edges carry an affine `offset` field** because +temperature conversion is not pure multiplication: +`°F = 1.8 × °C + 32`. Schema: `{"from","to","ratio","offset", +"dimension"}`. Multiplicative-only edges have `offset=0`. + The graph is **the conversion table as data**, not as code. The -solver consults it; the parser doesn't need to. Initial coverage -target: complete edges for `length`, `time`, `money`, `mass`, -`volume` between GSM8K-frequent unit pairs — bounded by NIST SI -tables (closed set), not free expansion. +solver consults it; the parser doesn't need to. Coverage target +is EXHAUSTIVE within each dimension's lemma set (~80 edges +total: every pair of in-pack units in the same dimension must be +reachable). Bounded by NIST SI tables (closed set); ratification +proves completeness — see invariants below. ### Why conversions matter @@ -177,10 +235,22 @@ just lexicon well-formedness: `minutes` for time, `dollars` for money, etc.). All canonicalization routes through the canonical unit; this bounds the shortest-path computation to O(1) lookups. +5. **Exhaustive coverage gate** — every dimension's lemma set + must produce a fully connected conversion subgraph. Adding + a unit lemma without conversion edges (or with edges that + don't connect to the canonical) is rejected at ratification. + This prevents the inventory from silently fragmenting. +6. **NIST/ISO provenance** — every conversion ratio cites a + source via `provenance_id` (NIST SP 811 for SI, ISO 4217 for + currency, etc.). Unsourced ratios are rejected. +7. **Dimension algebra closure** — every derived-dimension entry + (speed, density, wage, area) has both decomposed forms + present (e.g., for `speed`: at least one length-unit and one + time-unit are present in their respective dimensions). These invariants live in `tests/test_adr_0127_pack_ratification.py` and run at every pack-change PR — the conversion graph cannot -ship broken. +ship broken or incomplete. ### Parser integration diff --git a/docs/decisions/ADR-0128-numerics-pack.md b/docs/decisions/ADR-0128-numerics-pack.md new file mode 100644 index 00000000..e0dba302 --- /dev/null +++ b/docs/decisions/ADR-0128-numerics-pack.md @@ -0,0 +1,338 @@ +# ADR-0128 — `en_numerics_v1` Pack + +**Status:** Proposed (scope-only; sibling to ADR-0127) +**Date:** 2026-05-23 +**Author:** CORE agents + reviewers +**Depends on:** +- ADR-0126 (candidate-graph parser + round-trip filter) +- ADR-0127 (`en_units_v1` — units pack, cross-referenced for + shared symbol/affix table) +**Supersedes:** none +**Sibling:** ADR-0127. Both packs are jointly required for the +units-aware candidate-graph parser to produce a fair empirical +result on the GSM8K-math lane. Either pack failing ratification +independently blocks the joint exit gate. + +--- + +## Context + +ADR-0127 introduces `en_units_v1` (dimensions, units, conversions, +containers, rate connectors). That pack handles the *physical / +economic* substrate. It does NOT handle the *linguistic forms* a +quantity can take in English math word problems: + +- Cardinal number words ("seventeen", "two hundred and fifty") +- Ordinal words ("third", "twentieth") +- Fractions as words ("two-thirds", "a quarter") and as symbols + (`½`, `¾`) +- Multiplier words ("twice", "triple", "half") +- Quantifiers ("all", "some", "each", "every", "many", "few") +- Comparison anchors ("more", "fewer", "less", "additional") +- Numeric format strings ("1,000", "1.5", "1/2", "75%") + +Today these are scattered: +- `WORD_NUMBERS` table hard-coded in `generate/math_roundtrip.py` + (one through twelve only) +- `_COMPARE_VERB` / comparison anchors hard-coded in + `generate/math_parser.py` +- Fraction handling absent +- Percentage handling absent +- Multi-digit number-word parsing ("two hundred") absent + +The GSM8K train sample shows the cost: e.g., "Half of the kids +are going to soccer camp" (refused — `half` not handled), +"3/4 of its temperature" (refused — `3/4` not recognized as +fractional form). These are unrecognized *quantity forms*, not +unrecognized *units*; they sit on a different lexical axis from +ADR-0127's substrate. + +A units pack without a numerics pack solves "5 feet + 8 inches" +but still refuses "half a foot." Both packs are needed for the +architecture to get a fair empirical reading on the train sample. + +--- + +## Decision + +Add a `language_packs/data/en_numerics_v1/` ratified semantic pack +that exhaustively encodes the English linguistic forms of +quantities. Parser changes are minimal — most pack content +replaces today's scattered hard-coded tables with ratified +lookups. + +### Pack content (`en_numerics_v1`) — EXHAUSTIVE SCOPE + +Structure mirrors `en_arithmetic_v1` / `en_units_v1`: +`lexicon.jsonl` + `manifest.json` + `glosses.jsonl` + +`.mastery_report.json`. + +**Cardinal number words** (entry_id prefix `en-num-card-`): + +EXHAUSTIVE for grade-school range: + +- `zero` through `twenty` (21 entries) +- Tens: `thirty`, `forty`, `fifty`, `sixty`, `seventy`, `eighty`, + `ninety` (7 entries) +- Compound rule: `-` (e.g., "twenty-one", + "ninety-nine") — structural composition, not enumerated +- `hundred`, `thousand`, `million`, `billion` (4 entries) +- Compound rule: ` hundred [and ]`, ` thousand + [ ]` — structural composition + +Each entry carries `surface`, `numeric_value` (int), `morphology` +(`cardinal`). + +**Ordinal number words** (entry_id prefix `en-num-ord-`): + +EXHAUSTIVE for grade-school range (1st–31st covers most +calendar / position references in word problems): + +- `first` through `tenth` (10 entries — irregular morphology) +- Suffix rule: `th` for 11–31 (eleventh, twelfth, + thirteenth, …, thirty-first) — structural composition with + spelling-irregularity table (`fifth` not `fiveth`, `eighth` not + `eightth`, `twelfth` not `twelveth`, etc.) +- `twentieth`, `thirtieth`, `hundredth`, `thousandth` (4 entries) + +Each entry carries `surface`, `position` (int), `morphology` +(`ordinal`). + +**Fraction words** (entry_id prefix `en-num-frac-`): + +- Named fractions: `half` (½), `third` (⅓), `quarter` (¼), + `fifth` (⅕), `sixth` (⅙), `seventh` (⅐), `eighth` (⅛), + `ninth` (⅑), `tenth` (⅒), `sixteenth` (¹/₁₆) — ~10 entries +- Compound rule: `/` (e.g., + "two-thirds", "three-quarters") — structural composition +- Article-bound: `a half`, `a quarter`, `a third` resolve to + the same numeric value as the bare form +- **Symbol cross-link** to `en_units_v1` symbol table for `½`, + `¼`, `¾`, `⅓`, `⅔`, `⅛`, `⅜`, `⅝`, `⅞` + +Each entry carries `surface`, `numerator` (int), `denominator` +(int), `decimal_value` (float — `1/3 = 0.333…` etc.), +`morphology` (`fraction`). + +**Multiplier words** (entry_id prefix `en-num-mult-`) — closed +set: + +`double` (×2), `triple` (×3), `quadruple` (×4), `quintuple` (×5), +`twice` (×2), `thrice` (×3), `half` (×0.5 — also a fraction +word; both entries cross-reference). Plus structural rule +`N times` for arbitrary integer multipliers. + +**Quantifiers** (entry_id prefix `en-num-quant-`) — closed set: + +`all`, `none`, `some`, `both`, `each`, `every`, `many`, `few`, +`several`, `most`, `any`, `no`, `single`. + +Each declares its `semantic_type`: `total`, `empty`, `partial`, +`paired`, `distributive`, `indefinite`. The parser uses this to +decide whether the quantifier yields a determinate value +(`both = 2`, `single = 1`) or is undeterminate and triggers +refusal (`some`, `many`, `few` — refuse rather than guess; this +preserves `wrong == 0`). + +**Comparison anchors** (entry_id prefix `en-num-compare-`): + +Migrated from `generate/math_roundtrip.py`'s hard-coded +`COMPARE_ADDITIVE_ANCHORS` / `COMPARE_MULTIPLICATIVE_ANCHORS`. + +- Additive: `more`, `fewer`, `less`, `additional`, `extra`, + `missing`, `remaining` +- Multiplicative: `twice`, `thrice`, `times`, `half`, `double`, + `triple`, `quadruple`, `third`, `quarter` + +Each cross-references its multiplier or fraction entry where +applicable (avoiding duplicate truth). + +**Number formats** (entry_id prefix `en-num-format-`) — +structural rules, not enumeration: + +- Digit groups: `1,000` (US thousand separator), `10,000`, + `1,000,000` — `(\d{1,3})(?:,\d{3})+` → strip commas, parse as int +- Decimals: `1.5`, `3.14`, `0.25` — `\d+\.\d+` → parse as float +- Slash-fractions: `1/2`, `3/4`, `7/8` — `(\d+)/(\d+)` → + parse as `Fraction` +- Mixed numbers: `1 1/2`, `2 3/4` — `(\d+) (\d+)/(\d+)` → + `whole + numerator/denominator` +- Percentages: `75%`, `1.5%` — `\d+(?:\.\d+)?%` → divide by 100 +- Negative numbers: `-3`, `-0.5` — leading minus (rarely in + grade-school but cheap to include) + +Each format rule declares its regex pattern, `parser_function` +(by name), and `output_type` (`int`, `float`, `Fraction`). + +### Cross-references with `en_units_v1` + +- Fraction symbols `½`, `¼`, `¾`, etc. appear in both packs. + Single source of truth lives in `en_numerics_v1`; + `en_units_v1` symbol-affix table contains a *reference* entry + pointing to the numerics pack via `cross_pack_id`. Ratification + cross-pack consistency check (see ADR-0127 ratification + invariants and below) verifies the references resolve. +- Percentage (`%`) cross-references between packs because it's + both a numeric format and a dimensionless modifier. +- Multipliers (`double`, `twice`) cross-reference because they + also appear as comparison anchors in `en_units_v1`'s parser- + consumed register. + +### Ratification invariants (`en_numerics_v1`-specific) + +1. **Cardinal exhaustiveness** — every English cardinal 0..20, + every "tens" form, every magnitude word + (hundred/thousand/million) present. +2. **Ordinal exhaustiveness** — every English ordinal 1st..31st + present (covers month days + most grade-school position + references). +3. **Fraction exhaustiveness** — every named fraction 1/2 through + 1/10 present + the irregular set (sixteenth, thirty-second). +4. **Cross-pack consistency** — every fraction-symbol entry in + `en_units_v1` resolves to a fraction entry in + `en_numerics_v1`. Verified by joint pack-mount ratification. +5. **Quantifier semantic-type completeness** — every quantifier + lemma carries a `semantic_type` from the closed set + `{total, empty, partial, paired, distributive, indefinite}`. +6. **Format regex test corpus** — each format rule has a + minimum of 10 positive + 10 negative test strings in + `tests/test_adr_0128_numeric_formats.py`. Format ambiguity + (e.g., `1.000` could be `1` or `1000`) is *refused* per + `wrong == 0`, not guessed. + +### Parser integration + +1. **`generate/math_roundtrip.py`** — replace hard-coded + `WORD_NUMBERS` and `COMPARE_*_ANCHORS` tables with calls into + `en_numerics_v1` loader. Behavior preserved (current entries + are a subset); future extensions land in the pack, not the + source. + +2. **`generate/math_candidate_parser.py`** — new value-token + normalization helper `normalize_value_token(token) -> Quantity + | Fraction | None` that consults `en_numerics_v1` format + rules. Handles "two-thirds" → `Fraction(2,3)`, `75%` → `0.75`, + `1,500` → `1500`, etc. + +3. **Round-trip filter (`roundtrip_admissible`)** — the existing + `_value_grounds` helper is extended to ground word-forms via + the numerics pack loader (current hard-coded `WORD_NUMBERS` + capped at "twelve" widens to the full cardinal table). + +4. **Quantifier-driven refusal** — when a candidate's value-token + resolves to an `indefinite` quantifier (`some`, `many`, `few`), + the parser emits NO candidate for that sentence. Empty list → + refusal at decision rule. Preserves `wrong == 0`. + +### What ADR-0128 explicitly does NOT do + +- Does NOT replace `en_units_v1`. The packs are siblings. +- Does NOT introduce new operation kinds. ADR-0126 + 0122 cover + the operation grammar; this pack supplies value-token forms. +- Does NOT model number-system alternates (Roman numerals, + Chinese numerals, etc.) — out of scope for English math + problems. +- Does NOT model implicit numbers ("a dozen" / "a few") as + determinate values — `a dozen = 12` is encoded via the + container's `default_size` (ADR-0127's container entry, not + here); `a few` is `indefinite` and triggers refusal. +- Does NOT add new exit gates. Joint exit criterion with + ADR-0127 (re-run train sample with both packs mounted). + +--- + +## Invariants preserved / added + +| Invariant | Preserved or added | How | +|-----------|--------------------|-----| +| `wrong == 0` | Preserved | Indefinite quantifiers trigger refusal; format ambiguity triggers refusal | +| `trace_hash` byte-equality | Preserved | Pack lookup is deterministic | +| Pack-binding (ADR-0114a #10) | Reinforced | Value-token resolution cites `en-num-*` entry_ids in `SolutionTrace.provenance` | +| Round-trip admissibility | Strengthened | Word-form numbers ground via pack lookup, not regex enumeration | +| Replay equivalence | Preserved | Same pack version → same lookup result | +| `versor_condition(F) < 1e-6` | Untouched | No runtime field changes | +| Manifest checksum SHA-256 of bytes-on-disk | Required | Mastery report self-seals | + +## Exit criterion + +**Joint with ADR-0127** — both packs ratified, both mounted, +re-run train sample: + +``` +correct >= 10 / 50 on evals/gsm8k_math/train_sample/v1/cases.jsonl +wrong == 0 +``` + +**If passed:** run sealed holdout once, freeze in ADR-0127/0128 +joint results. + +**If missed:** **Path-B trigger** — full deterministic design +(candidate-graph + units + numerics) failed to move GSM8K. +Demote benchmark, re-target math expert promotion. + +--- + +## Alternatives considered + +### A. Fold numerics into `en_units_v1` as one big pack. +Rejected per ADR-0127 discussion: domain-distinct lexicons, +risk-isolation, future i18n composability. + +### B. Keep hard-coded tables in `math_roundtrip.py` / `math_parser.py`. +Rejected — violates pack-binding (ADR-0114a #10); future +language packs (Spanish, German) would have to duplicate the +hard-coded tables; ratification is impossible. + +### C. Use a third-party number-parsing library (`word2number`, +`num2words`, etc.). Rejected — same opacity / non-auditability +critique as ADR-0127 alt B. + +### D. Ship only cardinals + fractions; defer quantifiers / +ordinals / formats. Rejected — quantifiers (`half`, `some`) are +*specifically* what the GSM8K train sample refuses on. Partial +pack delivers partial empirical signal — same trap ADR-0127 +addresses re: scope mismatch. + +--- + +## Implementation plan (proposed sub-phases) + +| Phase | Module | Description | +|-------|--------|-------------| +| 0128.1 | `language_packs/data/en_numerics_v1/` | Pack content (lexicon + manifest + glosses + mastery report) | +| 0128.2 | `language_packs/loader.py` | `lookup_cardinal`, `lookup_ordinal`, `lookup_fraction`, `lookup_quantifier`, `match_number_format` | +| 0128.3 | `generate/math_roundtrip.py` | Replace hard-coded `WORD_NUMBERS` + `COMPARE_*_ANCHORS` with pack-backed lookups | +| 0128.4 | `generate/math_candidate_parser.py` | `normalize_value_token` helper; quantifier-driven refusal | +| 0128.5 | `tests/test_adr_0128_*.py` | Pack ratification (exhaustiveness gates) + parser integration + format regex corpus | +| 0128.6 | Joint with ADR-0127.7 | Re-run train sample with both packs mounted | + +Regression gates: +- `core test --suite smoke -q` +- `core test --suite math -q` +- `core test --suite packs -q` +- ADR-0126 P3+P4 tests +- ADR-0127 pack ratification + +## PR checklist (when proposing for merge) + +``` +What capability did this add? + → Exhaustive English linguistic-form ontology for quantities; + sibling substrate to en_units_v1 (ADR-0127). +What invariant proves the field remains valid? + → Wrong==0 preserved via indefinite-quantifier refusal + + format-ambiguity refusal; cardinal/ordinal/fraction + exhaustiveness ratification gates. +Which CLI suite/eval proves the lane? + → smoke + math + packs + joint train_sample re-run with + ADR-0127. +Did this avoid hidden normalization, stochastic fallback, +approximate recall, unreviewed mutation? + → Yes. Pack lookup is deterministic; lexicon is bounded and + ratified; no learned tokenization; no fuzzy matching. +If it touches user input, what trust boundary was enforced? + → Pack file paths validated via safe_pack_id (ADR-0051). + Format regexes carry test corpora documenting accepted vs + rejected inputs; user input is matched against ratified + patterns only. +```