core/docs/decisions/ADR-0164.1-lexical-primitive-scope.md
Shay 3b8f441ae0
docs(ADR-0164.1): lexical primitive set scope (#318)
Closes ADR-0164 §Open question #1. Enumerates the 8-primitive seed
registry for en_core_math_v1 (decimal-currency, currency, percentage,
fraction, time-amount, numeric, ordinal, mass-noun-token), fixes the
record schema (name/pattern/emits/extracted_fields/provenance/priority),
documents pairwise overlap precedence with rationale, and records 4
rejected temptations (rate phrases, compound entities, question stems,
compound numerics) so the ADR-0165 grammar/lexeme boundary doesn't get
relitigated by future authors.
2026-05-26 19:27:30 -07:00

413 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-0164.1 — Lexical Primitive Set Scope (seed registry for `en_core_math_v1`)
**Status:** Proposed
**Date:** 2026-05-26
**Author:** Shay
**Anchor:** [[thesis-decoding-not-generating]]
**Parent:** [ADR-0164 — Incremental Comprehension Reader](./ADR-0164-incremental-comprehension-reader.md)
**Companion:** [ADR-0165 — Regex Scope Rule](./ADR-0165-regex-scope-rule.md)
**Resolves:** ADR-0164 §Open question #1 ("Lexical primitive set scope")
---
## Context — why this sub-ADR exists
ADR-0164 specifies the reader and leaves the *exact bootstrap primitive set*
open (§Open question #1). ADR-0165 specifies the *rule* that bounds what may
become a primitive (lexeme-level, closed orthographic shape) but does not
enumerate the set.
This ADR closes the gap. It enumerates the seed primitive registry that
ships with the ADR-0164 Phase 1 PR, fixes the registry record schema,
documents overlap precedence (the only place primitives can interact at
recognition time), and records the temptations that are explicitly **not**
admitted, so future authors don't relitigate them.
The reader's Phase 1 acceptance gate (see ADR-0164 §Phasing) depends on
this set being closed before scan-time. Adding a primitive after Phase 1
follows the population corridor in ADR-0165 §Population (contemplation →
proposal → review).
---
## Decision
The seed lexeme-primitive registry for `en_core_math_v1` contains the
**eight** primitives below. Each is a closed orthographic shape per the
ADR-0165 three-question test (§Code-review test). Each carries the schema
fields in §Registry record schema. Overlap precedence between primitives
is fixed by §Overlap precedence and is the only behavior that may differ
between "primitive A fires" and "primitive A fires given primitive B
already matched the same span."
Population beyond this seed set rides the ADR-0165 corridor and is
out of scope for this ADR.
---
## Registry record schema
Each primitive is a frozen record with the following fields. Field order
is canonical (used by the canonical-bytes serialization that feeds
`trace_hash` per CLAUDE.md §Runtime Surface Contract).
| Field | Type | Meaning |
|---|---|---|
| `name` | kebab-case string, unique | Registry key. Stable across rounds. Forms the trace-evidence label when the primitive fires. |
| `pattern` | regex source string (Python `re` flavor) | The orthographic recognizer. Must be anchorable to a single token or contiguous token-class run (ADR-0165 §Rule). No `.*` across word combinations. No `\s+VERB\s+` constructions. |
| `emits` | enum: `QUANTITY`, `ORDINAL`, `UNIT_CATEGORY_TOKEN` | The reader category emitted on match. The category is what the reader's composition rules consume; the surface form is discarded after extraction. |
| `extracted_fields` | typed tuple `(name: type, ...)` | The structured payload produced. Types are `int`, `Decimal`, `str` from a closed enum, or `tuple[int, int]` for compound shapes (e.g. fractions). Reader composition is allowed to read these fields and only these fields. |
| `provenance` | string `"ADR-<id>"` or `"teaching-ratified <ISO date> #<queue-id>"` | Audit trail. Seed primitives in this ADR are stamped `"ADR-0164.1"`. New primitives stamped by the HITL queue at acceptance time (ADR-0161). |
| `priority` | integer ≥ 0 (lower wins) | Tiebreaker when two primitives match the same span. The overlap-precedence table (§Overlap precedence) fixes seed priorities; new primitives declare priority at proposal time and the operator ratifies it. |
The registry is a tuple of records, ordered by `priority` ascending then
`name` ascending (stable ordering for replay equivalence).
---
## Seed primitive set (n = 8)
Each entry below is a populated record. Patterns are shown as Python `re`
source; the runtime compiles them with `re.IGNORECASE` unless otherwise
noted, anchored to a single token / contiguous span (the reader is
responsible for token-boundary alignment; the regex itself does not
consume surrounding whitespace).
### 1. `decimal-currency-literal`
```yaml
name: decimal-currency-literal
pattern: \$(\d+)\.(\d{2})\b
emits: QUANTITY
extracted_fields: (whole: int, cents: int, unit_class: str = "currency")
provenance: ADR-0164.1
priority: 10
```
Rationale: `$18.00`, `$1.50`. The two-decimal-place currency form is its
own shape because (a) the cents field is structurally significant
(rounding semantics), (b) it must beat both `currency-literal` and
`numeric-literal` on the same span. Closed orthographic class:
"dollar-sign, integer, dot, exactly two digits."
### 2. `currency-literal`
```yaml
name: currency-literal
pattern: \$(\d+(?:\.\d+)?)\b
emits: QUANTITY
extracted_fields: (value: Decimal, unit_class: str = "currency")
provenance: ADR-0164.1
priority: 20
```
Rationale: `$18`, `$1.5`, `$1000`. Currency notation with arbitrary
(non-cents-shaped) decimal. Beats `numeric-literal` because the `$`
prefix carries the unit. Does *not* cover `$1.5M`, `$18/hour`,
`USD 18` — see §Rejected temptations.
### 3. `percentage-literal`
```yaml
name: percentage-literal
pattern: (\d+(?:\.\d+)?)\s?%
emits: QUANTITY
extracted_fields: (value: Decimal, unit_class: str = "ratio")
provenance: ADR-0164.1
priority: 30
```
Rationale: `25%`, `7.5 %`. The `%` glyph is a closed unit suffix.
Single optional space allowed because percent signs frequently appear
detached (`7 %`). The space inside the pattern is a single character,
not a `\s+` across structure — the run is still one orthographic shape.
### 4. `fraction-literal`
```yaml
name: fraction-literal
pattern: (\d+)\s?/\s?(\d+)\b
emits: QUANTITY
extracted_fields: (numerator: int, denominator: int, unit_class: str = "fraction")
provenance: ADR-0164.1
priority: 40
```
Rationale: `1/2`, `3 / 4`. The slash-separated integer pair is a
genuine closed shape. Optional single space on either side of the
slash. Reader composes "1/2 of <X>" through composition rules, not
through extending this pattern.
### 5. `time-amount-literal`
```yaml
name: time-amount-literal
pattern: (\d+)[-\s]?(hour|minute|day|week|month|year|second)s?\b
emits: QUANTITY
extracted_fields: (value: int, unit: str, unit_class: str = "time")
provenance: ADR-0164.1
priority: 50
```
Rationale: `3 hours`, `30-minute`, `2 days`, `1 week`. The
"number-plus-time-unit-noun" form is the canonical closed time-amount
shape. Unit set is a closed enum; the reader treats `hour`/`hours`/
`hour-` uniformly via the singular-form extraction. Does *not* cover
`an hour` (article + bare unit, that's grammar — see §Rejected
temptations), and does *not* cover `3 hours per week` (rate phrase,
grammar).
### 6. `numeric-literal`
```yaml
name: numeric-literal
pattern: \d+(?:\.\d+)?\b
emits: QUANTITY
extracted_fields: (value: Decimal, unit_class: str = "pending")
provenance: ADR-0164.1
priority: 100
```
Rationale: `18`, `1.5`, `12`. The bare number. `unit_class=pending`
signals to the reader that a unit attachment is expected from a
downstream token (a `count_unit_noun`, `currency_unit_noun`,
`time_unit_noun`, etc., from the operational lexicon — ADR-0164
§Operational lexicon). Highest numeric `priority` ensures all other
numeric-bearing primitives win the overlap.
### 7. `ordinal-literal`
```yaml
name: ordinal-literal
pattern: (first|second|third|fourth|fifth|sixth|seventh|eighth|ninth|tenth)\b
emits: ORDINAL
extracted_fields: (rank: int)
provenance: ADR-0164.1
priority: 60
```
Rationale: `first`, `second`, `third`. Closed list of English ordinal
spellings 110. Extending the set to `eleventh``twentieth` or to
numeric ordinals (`1st`, `2nd`) is a teaching-corridor decision, not a
silent extension of this ADR. Extracted `rank` is the integer the
ordinal denotes.
### 8. `mass-noun-token`
```yaml
name: mass-noun-token
pattern: (money|profit|interest|income|savings|cost|amount|total)\b
emits: UNIT_CATEGORY_TOKEN
extracted_fields: (lemma: str, unit_class: str = "currency-mass")
provenance: ADR-0164.1
priority: 70
```
Rationale: ports `_MASS_NOUNS` from `generate/math_candidate_parser.py`
into a primitive form. The set is closed (8 lemmas) and orthographic;
each lemma is a single token recognized by spelling. The reader
composes "how much <mass-noun>" via composition rules over
`question_open` + `question_continuous_qty` + `UNIT_CATEGORY_TOKEN`,
not via extending this pattern across the question stem.
> Boundary note: this primitive is at the edge of the rule. A
> `mass-noun-token` is a closed set of single tokens, which satisfies
> ADR-0165's "contiguous token-class run." It is included as a
> primitive (rather than as an operational-lexicon entry under
> ADR-0164) because the reader's question-frame composition needs a
> structured category at scan time, not a lexicon hit during the
> composition pass. If Phase 1 measurement shows this is better
> modeled as a lexicon category, supersede this entry.
---
## Overlap precedence
A token span can be matched by more than one primitive. The reader runs
primitives in `priority` order (lower first), commits the first match
that consumes a non-empty span, and skips the consumed span for
subsequent primitives. The pairwise table below is the *normative*
record of every overlap relevant to the seed set. Each row is a
deliberate decision; review of a new primitive must extend this table.
| Span shape | Candidate primitives | Winner | Rationale |
|---|---|---|---|
| `$18.00` | `decimal-currency-literal`, `currency-literal`, `numeric-literal` | `decimal-currency-literal` | Two-decimal cents form is structurally distinct (rounding). Currency-literal would lose the cents semantics; numeric-literal would lose the `$`. Lowest priority (10) wins. |
| `$18` | `currency-literal`, `numeric-literal` | `currency-literal` | The `$` prefix carries the unit class. Numeric-literal alone would emit `unit_class=pending` and force the reader to recover the currency unit from context, which is exactly the kind of grammar inference the comprehension layer is meant to do *with* typed evidence, not despite it. |
| `25%` | `percentage-literal`, `numeric-literal` | `percentage-literal` | The `%` suffix carries the unit class (ratio). Same rationale as currency. |
| `3 hours` | `time-amount-literal`, `numeric-literal` (matching `3`) | `time-amount-literal` | The time-unit noun is part of the closed shape; allowing `numeric-literal` to consume `3` first would orphan `hours` and force the reader to compose a time quantity through the lexicon — bypassing a typed primitive that already exists. The span-commit rule (longest valid match at lowest priority among ties) handles this: time-amount's pattern consumes the full `3 hours` span before numeric-literal's anchor matches. |
| `1/2` | `fraction-literal`, `numeric-literal` (matching `1`, then `2`) | `fraction-literal` | The slash makes the two integers a structured pair, not two independent quantities. |
| `first` | `ordinal-literal`, (nothing else) | `ordinal-literal` | Listed for completeness; no overlap, but the priority is set above `numeric-literal` so future `ordinal-numeric-literal` (`1st`, `2nd`) — if and when ratified — fits cleanly into the same slot. |
| `money` | `mass-noun-token`, (operational-lexicon `currency_unit_noun` per ADR-0164) | `mass-noun-token` | Lexical-primitive scan runs before lexicon lookup (ADR-0164 §Deterministic reader, step 1 → step 2). The primitive emits a typed `UNIT_CATEGORY_TOKEN` carrying `unit_class=currency-mass`, which the lexicon entry would also produce — but the primitive's emission is what the question-frame composition expects. |
The precedence is **fixed**; a primitive proposal that would change an
existing row in this table requires explicit supersession of this ADR,
not a silent priority bump.
---
## Rejected temptations
The patterns below are *not* primitives and must not be added as
primitives. Each is a grammar template per ADR-0165 §Code-review test
and belongs in the reader's composition rules over the operational
lexicon.
### Rejected #1 — Rate phrase `$18/hour`, `$18 per hour`
A naive primitive might be `\$(\d+(?:\.\d+)?)\s?/\s?(hour|day|week)`.
- **What does it match?** A composition of three role-distinct
elements: a currency amount, a connector (`/` or the word `per`),
and a time unit. Not "one piece of orthographic material" but "a
way three pieces of material combine to mean *rate*."
- **Closed-set test:** No. The connector class is open (`/`, `per`,
`an`, `each`, `every`, `for each`, …). The denominator class is
open (any unit noun, not just time). Recognizing rate via regex
enumerates surface forms.
- **Novel-phrasing test:** Refuses `$18 every hour`, `$18 for each
hour worked`, `eighteen dollars an hour`. The refusal is brittle
on the same underlying meaning — the diagnostic of a grammar
template.
Correct home: reader composition rule `[QUANTITY{currency}] +
[distributive_modifier|"/"|"per"] + [time_unit_noun]``RATE`
operation. Currency and time-amount primitives feed the composition;
the rate semantics emerge there.
### Rejected #2 — Compound entity `her three friends`, `Tina and Marion`
A naive primitive might be
`(she|he|her|his|their)\s+(\d+|two|three|four|five|several|a few)\s+(friends|sisters|brothers|cousins)`.
- **What does it match?** A possessive determiner, a count, and a
relational noun — three role-distinct elements with an open
combinatorial product. Pure grammar.
- **Closed-set test:** No. Possessive determiners are a small but
context-dependent set; counts include numeric literals *and*
spelled-out numerals *and* "a few" / "several" / "many" hedges;
relational nouns are open-ended (`friends`, `co-workers`,
`neighbors`, `kids`, `students`, …).
- **Novel-phrasing test:** Refuses `the three friends she invited`,
`Tina, Marion, and Jen`, `each of her friends`. Every novel
reference shape refuses on the same underlying meaning — group
entity binding.
Correct home: cross-sentence `ProblemReadingState` (ADR-0164 §Open
question #4) plus reader composition rules over `entity_pronoun`,
`numeric-literal`, and a `relational_noun` lexicon category. Group
binding is the reader's job, not the primitive layer's.
### Rejected #3 — Question stem `How much money will she earn`
A naive primitive might be `How\s+much\s+(money|profit|...)\s+(will
|did|does)\s+(she|he|they|it)`.
- **What does it match?** A five-role grammar template: question
opener + continuous-quantity word + mass-noun + auxiliary +
pronoun. This is the canonical example of what ADR-0164 deprecates
and ADR-0165 §Forbidden uses cites verbatim.
- **Closed-set test:** No on every role except `much` and `she/he/
they/it`. The mass-noun set is reviewable but the rest of the
composition opens onto open lexical classes (auxiliaries, modal
verbs, perfect/progressive constructions, embedded clauses).
- **Novel-phrasing test:** Refuses `How much will she have earned
by Friday`, `How much money does Tina end up with`, `How much
did it cost him in total`. These are the exact 34/47 refusals
ADR-0164 §Context documents.
Correct home: the reader's question-frame composition rules,
consuming primitive emissions (`UNIT_CATEGORY_TOKEN` from
`mass-noun-token`) plus operational-lexicon categories
(`question_open`, `question_continuous_qty`, `entity_pronoun`,
`accumulation_verb` from ADR-0164 §Operational lexicon).
### Rejected #4 — Compound numeric `1,000`, `1.5M`, `1.5 million`
A naive primitive might be `\d{1,3}(,\d{3})+(\.\d+)?` plus a
sibling `\d+(\.\d+)?[KMB]\b`.
- **What does it match?** A composition between digit groups and
scaling tokens. Comma grouping is one shape; magnitude suffixes
are another; spelled-out scale words are a third.
- **Closed-set test:** Borderline. The comma-grouped form alone
(`1,000`, `12,345,678`) is a closed orthographic shape and *could*
be ratified as a primitive (`grouped-numeric-literal`) through the
ADR-0165 corridor when GSM8K evidence demands it. The `1.5M` and
`1.5 million` forms are compositions and must not be folded into
the same primitive.
- **Why rejected from the seed:** No GSM8K `train_sample/v1` case
observed in the ADR-0164 §Context evidence requires it. Adding it
speculatively violates ADR-0114a (no surface-form additions without
evidence) and the cleanup-as-you-find discipline (don't add what
you don't need).
Correct home: defer to teaching-corridor ratification once a refusal
on a comma-grouped numeric is observed. The `M`/`million` scaling
case is composition (numeric + magnitude lexicon entry), not a
primitive.
---
## Consequences
### Positive
1. **Phase 1 acceptance has a concrete scan-time vocabulary.** Eight
primitives, fully specified, ship with the Phase 1 PR. The reader's
step-1 (lexical primitive scan) is now implementable.
2. **Overlap behavior is recorded, not discovered.** Every overlap a
reviewer might worry about (`$18.00`, `3 hours`, `1/2`, `25%`) has
an explicit winner with rationale.
3. **The "where do I draw the line?" question is settled with
examples.** Future authors who want to add a primitive for a rate
phrase, compound entity, or question stem have §Rejected
temptations as the rejection precedent — no relitigation.
### Negative / tradeoffs
1. **Seed set is small.** Eight primitives won't cover every shape
GSM8K throws. That's the point — population is the corridor's job
(ADR-0165 §Population). Reader refusals on unknown token shapes
are evidence that flows back into the queue.
2. **`mass-noun-token` is at the rule boundary.** Listed honestly in
§Seed primitive set with an explicit supersede-if path. Phase 1
measurement will settle whether it belongs here or in the
operational lexicon.
3. **Overlap precedence table will grow.** Every new primitive must
extend the table. This is the cost of fixed precedence, and it's
cheaper than the alternative (silent runtime tiebreaking).
---
## Acceptance criteria for this sub-ADR
This ADR moves to **Accepted** when:
1. The seed registry above is materialized in
`language_packs/data/en_core_math_v1/lexical_primitives.json` (or
the equivalent loader format settled in the Phase 1 PR), one
record per entry, fields populated verbatim.
2. The manifest checksum hashes the bytes written to disk (CLAUDE.md
§Semantic Pack Discipline).
3. The overlap-precedence table has a pinned regression test —
given the seed registry, the eight overlap rows resolve to the
declared winner on synthetic minimum-pair inputs.
4. ADR-0164 §Open question #1 is checked off in that ADR's
open-questions list when ADR-0164 next ships an update.
---
## Cross-references
- **Parent:** ADR-0164 — Incremental Comprehension Reader
- **Companion (the rule):** ADR-0165 — Regex Scope Rule
- **Anti-overfitting doctrine:** ADR-0114a
- **Pack discipline:** CLAUDE.md §Semantic Pack Discipline
- **Population corridor:** ADR-0150 (contemplation), ADR-0152
(learning-arc proof), ADR-0155 (CI contemplation runner), ADR-0161
(HITL async queue)
- **Anchor:** `[[thesis-decoding-not-generating]]` — the primitive
set is a decoder's recognizer table. It enumerates the closed
orthographic shapes the reader can pick up *as such*; it does not
enumerate sentences.