core/docs/decisions/ADR-0127-units-pack-and-units-aware-parser.md
Shay 6e7766f66e docs(ADR-0127+0128): exhaustive units pack + sibling numerics pack
ADR-0127 expanded from minimum-viable to EXHAUSTIVE:
- 13 dimensions (7 base + 6 derived via dimension algebra)
- ~150 unit lemmas covering imperial+metric for length/time/mass/
  volume/money/temperature/area, with full irregular-plural set
- Multi-word structural rules (square <length>, <length>/<time>)
- ~25 container nouns, ~6 rate connectors, ~15 symbol/affixes
- Conversion graph EXHAUSTIVE within each dimension (~80 edges)
- Temperature edges carry affine offset field (F/C/K)
- Ratification adds: exhaustive-coverage gate, NIST/ISO provenance,
  dimension-algebra closure

ADR-0128 NEW: en_numerics_v1 sibling pack
- Exhaustive cardinals (0..20 + tens + magnitudes) + compound rule
- Exhaustive ordinals (1st..31st covers calendar/position)
- Fractions (named + symbol + slash-form + mixed numbers)
- Multipliers (double/triple/twice/half + N-times rule)
- Quantifiers with semantic_type (indefinite -> refusal preserves
  wrong==0)
- Comparison anchors migrated from math_roundtrip.py hard-coded
  tables (ratifiability)
- Number-format regexes (digits/decimals/percentages/fractions/
  mixed) with test corpus
- Cross-pack consistency check with en_units_v1 fraction symbols

Joint exit gate: train sample re-run with both packs mounted.
If passed -> sealed holdout. If missed -> real Path-B trigger
(full deterministic design failed).
2026-05-23 06:48:22 -07:00

409 lines
19 KiB
Markdown
Raw Permalink 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.

# ADR-0127 — `en_units_v1` Pack + Units-Aware Candidate Extractors
**Status:** Proposed (scope-only; implementation follow-up to ADR-0126)
**Date:** 2026-05-23
**Author:** CORE agents + reviewers
**Depends on:**
- ADR-0115 / 0116 / 0117 / 0118 (parser / solver / verifier / realizer)
- ADR-0122 (Rate operand)
- ADR-0126 (candidate-graph parser + round-trip filter)
**Supersedes:** none
**Blocks:** the Path-B decision for the GSM8K-math lane (the real
empirical answer can't be obtained until the parser consumes a
units pack).
---
## Context
ADR-0126's first empirical run produced **0 / 50 correct, 0 wrong,
50 refused** on the GSM8K train-sample inner-loop gate. Inspection
of the refusal reasons revealed a pattern: every refusal happens at
the *first statement* of each problem, and every refused first
statement shares the same shape — **the parser fails on the
unit-of-measurement construction, not on the operation grammar**.
Representative train-sample first statements (all refused):
| Case | First statement | What's structurally not recognized |
|------|----------------|-----------------------------------|
| 1 | `Tina makes $18.00 an hour.` | Money + time = rate dimension |
| 2 | `Jan buys 1000 feet of cable.` | Length unit + substance qualifier |
| 3 | `... bookstore donated 48 boxes of erasers.` | Container + count-content |
| 5 | `In one hour, Addison mountain's temperature ...` | Temperature dimension + fractional ratio |
The shared failure mode is not "unknown verb" or "unknown sentence
shape." It is "the parser has no ontology of units." The current
parser asks "is `feet` a valid noun?" via `_canonical_unit` which
just pluralizes. The right question is "what *dimension* is `feet`,
and does `of cable` modify it as a substance qualifier?"
This is structurally different from the per-axis grammar treadmill
that produced four zero-lift ADRs (0122 / 0123 / 0123a / 0123b).
Vocabulary expansion is unbounded and adversarial; **units of
measurement form a finite, externally well-defined ontology**
(NIST SI tables for physical units; closed sets for currency, time,
English container nouns). A units pack is *semantic substrate the
parser consults*, not more grammar regex.
ADR-0126's candidate-graph topology is built to consume exactly
this kind of substrate — the round-trip filter already does
token-level grounding; adding a "matched_unit_token must resolve
to a known dimension in the pack" check is a natural extension.
Without ADR-0127 the ADR-0126 empirical result is uninformative:
0/50 reflects P2's deliberate minimum-viable scope, not the
architecture's capacity. The architecture's real verdict lives
behind the units pack.
---
## Decision
Add a `language_packs/data/en_units_v1/` ratified semantic pack and
extend the ADR-0126 candidate parser to consult it during candidate
emission. **No new operation kinds.** **No new ADR-0114a
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`) — EXHAUSTIVE SCOPE
Structure mirrors `en_arithmetic_v1`: `lexicon.jsonl` +
`manifest.json` + `glosses.jsonl` + `conversions.jsonl` + a
self-sealing `.mastery_report.json` ratification artifact.
**Dimension classes** (entry_id prefix `en-units-dim-`):
| 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 |
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).
**Unit lemmas** (entry_id prefix `en-units-unit-`) — exhaustive
for grade-school math word problems (~150 entries):
| 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) |
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.
**Multi-word units** (entry_id prefix `en-units-multiword-`):
Structural composition rules (not lexicon enumeration):
- `square <length-unit>` → area (sq_ft, sq_meter, sq_mile, …)
- `cubic <length-unit>` → volume_derived
- `<length-unit> per <time-unit>` → speed
- `<money-unit> per <count-noun-or-unit>` → unit_price or wage
- `<mass-unit> per <volume-unit>` → 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 `<count> <container> of <content>` 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-`):
Pattern `N <unit> of <substance>` (e.g., "1000 feet of cable").
The pack does NOT enumerate substances — that's open class. It
encodes the *structural rule* that any measure-dimension unit
admits an `of <NP>` substance tail, which the round-trip filter
treats as a discarded grounded modifier.
**Unit-conversion graph** (`conversions.jsonl`, separate file):
Each line declares a directed edge `(from_unit, to_unit, ratio)`
where `to_unit = ratio × from_unit`. The pack ratifies the
*connected* weighted graph per dimension. Examples:
```jsonl
{"edge_id":"en-units-conv-001","from":"inches","to":"feet","ratio":0.0833333333,"dimension":"length"}
{"edge_id":"en-units-conv-002","from":"feet","to":"inches","ratio":12,"dimension":"length"}
{"edge_id":"en-units-conv-003","from":"cents","to":"dollars","ratio":0.01,"dimension":"money"}
{"edge_id":"en-units-conv-004","from":"dollars","to":"cents","ratio":100,"dimension":"money"}
{"edge_id":"en-units-conv-005","from":"minutes","to":"hours","ratio":0.01666666667,"dimension":"time"}
{"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. 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
Without conversions, the pack solves only single-unit arithmetic
(`5 apples + 3 apples`). With conversions, it solves
within-dimension mixed-unit arithmetic — which is the majority
of real word problems:
| Without conversions | With conversions |
|--------------------|------------------|
| `5 feet + 8 inches = ?` (refused) | canonicalize to feet (or inches), add, emit |
| `$2 + 75¢ = ?` (refused) | canonicalize to cents (or dollars), add, emit |
| `2 hours 30 minutes` (refused) | canonicalize to minutes (or hours), normalize |
Solver-side responsibility (delegated to ADR-0127.5 below):
when an operation's operand has a unit in the same dimension as
the actor's last quantity but a different unit, the solver
canonicalizes via shortest path in the conversion graph before
performing arithmetic. The canonical_bytes of `SolutionTrace`
records which edges fired, preserving determinism and
replay-equality.
### Graph ratification invariants (`en_units_v1`-specific)
The pack ratification process validates *graph correctness*, not
just lexicon well-formedness:
1. **Round-trip identity** — for every edge `(A, B, r)`, there
must exist an edge `(B, A, 1/r)` with `|round_trip_error| <
1e-9`. Asymmetric tables are rejected at ratification.
2. **Per-dimension connectivity** — within each dimension, the
subgraph induced by that dimension's units must be connected
(every unit reachable from every other). Isolated unit
lemmas are rejected.
3. **Path consistency** — for any two units A and C in the same
dimension, all shortest paths from A to C must yield the
same product of ratios within `1e-9`. Inconsistent paths
(e.g., 12 in/ft × 3 ft/yd ≠ 1 yd/36 in) are rejected at
ratification, not at runtime.
4. **Canonical unit per dimension** — each dimension declares
one canonical unit (`feet` for length, `seconds` or
`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 or incomplete.
### Parser integration
Three load-bearing changes to `generate/math_candidate_parser.py`
(no changes to legacy `math_parser.py`):
1. **`extract_initial_candidates` widens** to recognize three
additional shapes when pack consultation confirms dimensional
typing:
- `<Entity> has N <pack-unit>` (with `of <substance>` tail
discarded)
- `<Entity> has N <pack-container> of <content>`
- `There are N <pack-unit> [of <substance>]`
2. **New `extract_rate_declaration_candidates`** recognizes
`<Entity> makes $N <rate-connector> <pack-time-unit>` as an
`apply_rate` candidate (ADR-0122 shape; the pack supplies the
dimensional check that today's narrow regex misses).
3. **Round-trip filter gains a dimensional check.** The existing
`roundtrip_admissible` adds an optional `require_pack_typed_unit`
parameter (default False to preserve P1 behavior). When True,
`matched_unit_token` must resolve to a known unit lemma in
`en_units_v1`. This is the wrong-answer firewall for unit
hallucination: a parser that fires on `Sam buys 3 contemplations`
would now fail because `contemplations` is not pack-typed.
4. **Solver gains a dimensional-canonicalization helper.** New
module `generate/math_unit_conversion.py` exports
`canonicalize_to_dimension_canonical(quantity, conversion_graph)
-> (quantity_in_canonical_unit, [edges_fired])`. Operations whose
operand unit differs from the actor's tracked unit (but shares
dimension) get canonicalized before arithmetic. The fired-edges
list joins `SolutionTrace.steps` so replay reproduces the
conversion path byte-equal. Mixed-dimension operands
(`apples + dollars`) remain a SolveError — units type the
arithmetic.
### What ADR-0127 explicitly does NOT do
- Does NOT model fractional-of-prior-quantity phrasing
(`3/4 of its temperature`). That's a separate compositional
pattern, not a unit-ontology question.
- Does NOT model multi-step rate compositions
(`overtime = base + 1/2 base`). That's solver-level.
- Does NOT add new operation kinds. ADR-0126 + 0122 cover the
needed shapes (add, subtract, transfer, multiply, divide,
apply_rate, compare_*). The pack just helps the parser
*recognize* the operand inputs.
- Does NOT replace `_canonical_unit`. The pack lookup is
*additive* — when the pack confirms a token, dimensional
routing kicks in; when not, the legacy plural-canonicalization
fallback remains.
- Does NOT touch the sealed holdout. Re-runs against the
unsealed train sample only.
---
## Invariants preserved / added
| Invariant | Preserved or added | How |
|-----------|--------------------|-----|
| `wrong == 0` | Preserved | Pack-typed unit check is a *stricter* gate, never a looser one |
| `trace_hash` byte-equality | Preserved | Pack consultation is deterministic; same pack version → same lookup result |
| Pack-binding (ADR-0114a #10) | Reinforced | The parser now explicitly cites pack entry_ids in `SolutionTrace.provenance` for unit-typed candidates |
| Round-trip admissibility | Strengthened | When `require_pack_typed_unit=True`, hallucinated units fail to ground |
| `versor_condition(F) < 1e-6` | Untouched | No runtime field changes |
| Manifest checksum SHA-256 of bytes-on-disk | Required | Mastery report self-seals like `en_arithmetic_v1` |
## Exit criterion
**Same gate as ADR-0126 but with `en_units_v1` mounted:**
```
correct >= 10 / 50 on evals/gsm8k_math/train_sample/v1/cases.jsonl
wrong == 0
```
**If passed:** run sealed holdout *once* and freeze the number in
ADR-0127-results. Architecture + units substrate jointly validated.
**If missed (Path-B trigger, now real):** the deterministic
parser-by-rule approach + units ontology + candidate-graph
topology is the *full* design we believed was right, and it still
doesn't move GSM8K. That is the empirical signal to demote GSM8K
and re-target the math expert promotion to a benchmark where
exact-recall and determinism are the discriminators.
---
## Alternatives considered
### A. Skip the units pack, just expand parser regex.
Rejected — this IS the per-axis grammar treadmill (4 zero-lift
ADRs). Adding patterns like `<Entity> has N \w+ of \w+` without
dimensional typing produces wrong candidates the round-trip
filter can't catch (any noun would ground; nothing rejects "5
contemplations of cable").
### B. Pull units from a third-party library (Pint, etc.).
Rejected — violates "no opaque dependencies in the runtime path."
A curated pack of ≤ 60 lemmas is auditable, version-pinnable,
ratifiable, and inspectable; an external library is none of those.
### C. Defer units, ship ADR-0126 with the honest 0/50 + open
question. Considered. Discarded because 0126's empirical result
is genuinely uninformative without the substrate it was designed
to consume. Shipping the architecture with a misleading "0 lift"
headline invites the wrong conclusion (Path B prematurely).
### D. Build the units pack but DON'T integrate it; ship as
inventory. Rejected — the pack must be load-bearing per CLAUDE.md
"prefer compact, curated packs" plus the project's general stance
against decoration without integration.
---
## Implementation plan (proposed sub-phases)
| Phase | Module | Description |
|-------|--------|-------------|
| 0127.1 | `language_packs/data/en_units_v1/` | Pack content: lexicon (dimensions + units + containers + rate connectors) + `conversions.jsonl` + manifest + glosses + mastery report |
| 0127.2 | `language_packs/loader.py` (or sibling) | Pack loader API: `lookup_unit(token) -> UnitEntry \| None`; `get_conversion_graph(dimension) -> ConversionGraph` |
| 0127.3 | `generate/math_roundtrip.py` | Optional `require_pack_typed_unit` parameter on `roundtrip_admissible` |
| 0127.4 | `generate/math_candidate_parser.py` | Three new initial-possession shapes + rate-declaration extractor, consulting the pack loader |
| 0127.5 | `generate/math_unit_conversion.py` (new) | `canonicalize_to_dimension_canonical(quantity, graph)`; shortest-path lookup + edges-fired provenance for `SolutionTrace.steps` |
| 0127.6 | `generate/math_solver.py` | Wire dimensional canonicalization into add/subtract/transfer/compare arithmetic; mixed-dimension operands → SolveError |
| 0127.7 | `evals/gsm8k_math/train_sample/v1/runner.py` | Re-run with units pack engaged; new `report.json` |
| 0127.8 | `tests/test_adr_0127_*.py` | Pack ratification (round-trip identity + connectivity + path consistency + canonical-unit-per-dimension) + parser integration + solver canonicalization + train-sample lift gate |
Regression gates (must remain green at every phase):
- `core test --suite smoke -q`
- `core test --suite math -q` (existing 714/714 + ADR-0126 74/74)
- `core test --suite packs -q` (new `en_units_v1` ratification entries)
- ADR-0126 P3+P4 tests (the candidate-graph machinery is unchanged)
## PR checklist (when proposing for merge)
```
What capability did this add?
→ Pack-typed unit recognition for the candidate-graph parser; the
semantic substrate ADR-0126 was designed to consume.
What invariant proves the field remains valid?
→ Pack-typed unit check (new); wrong == 0 (preserved); manifest
checksum SHA-256 of bytes-on-disk (required).
Which CLI suite/eval proves the lane?
→ smoke + math + packs + train_sample_runner (this is the
re-measurement that decides Path B or not).
Did this avoid hidden normalization, stochastic fallback,
approximate recall, unreviewed mutation?
→ Yes. Pack lookup is deterministic; unit ontology is bounded
and curated; no learned scoring; ratified pack only.
If it touches user input, what trust boundary was enforced?
→ Pack file paths are validated via the existing safe_pack_id
sanitiser (ADR-0051). Pack content is mastery-report-sealed.
```