19 KiB
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 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:
{"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:
- 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. - 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.
- 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. - Canonical unit per dimension — each dimension declares
one canonical unit (
feetfor length,secondsorminutesfor time,dollarsfor money, etc.). All canonicalization routes through the canonical unit; this bounds the shortest-path computation to O(1) lookups. - 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.
- 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. - 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):
-
extract_initial_candidateswidens to recognize three additional shapes when pack consultation confirms dimensional typing:<Entity> has N <pack-unit>(withof <substance>tail discarded)<Entity> has N <pack-container> of <content>There are N <pack-unit> [of <substance>]
-
New
extract_rate_declaration_candidatesrecognizes<Entity> makes $N <rate-connector> <pack-time-unit>as anapply_ratecandidate (ADR-0122 shape; the pack supplies the dimensional check that today's narrow regex misses). -
Round-trip filter gains a dimensional check. The existing
roundtrip_admissibleadds an optionalrequire_pack_typed_unitparameter (default False to preserve P1 behavior). When True,matched_unit_tokenmust resolve to a known unit lemma inen_units_v1. This is the wrong-answer firewall for unit hallucination: a parser that fires onSam buys 3 contemplationswould now fail becausecontemplationsis not pack-typed. -
Solver gains a dimensional-canonicalization helper. New module
generate/math_unit_conversion.pyexportscanonicalize_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 joinsSolutionTrace.stepsso 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 | packs/data/en_units_v1/ |
Pack content: lexicon (dimensions + units + containers + rate connectors) + conversions.jsonl + manifest + glosses + mastery report |
| 0127.2 | 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 -qcore test --suite math -q(existing 714/714 + ADR-0126 74/74)core test --suite packs -q(newen_units_v1ratification 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.