ADR-0164.1 amendment: replace name-whitelist entity admission with a universal lexeme primitive that recognizes any capitalized token as a proper noun. The gender-coded name lists are demoted from admission criterion to enrichment-only lookup. A name outside the curated lists still admits cleanly with gender="unknown" — ADR-0164.2's pronoun resolution rules handle the unknown case via single-salient fallback or refuse with ambiguous_pronoun_referent. Universal at the primitive layer: the new proper_noun_token primitive is domain-agnostic. It sits in the shared PRIMITIVE_REGISTRY and is available to every current and future reader (math, narrative, code-comment, multi-lingual). The math reader is its first consumer. Pattern: ^[A-Z][A-Za-z'-]*[a-z][A-Za-z'-]*$ - requires capitalized first letter - requires ≥1 lowercase letter (rejects all-caps acronyms) - allows internal apostrophes (O'Brien) and hyphens (Mary-Anne) - matches "Tina", "Bob", "Marnie", "McDonald" — rejects "TINA", "123", "$5.00" (those go to their own primitives) Sentence-initial lookup-first dispatch (lifecycle._classify): - At token_index == 0: lookup() first, skipping proper_noun_gender_* categories (treated as not-found so the primitive can fire). If lookup misses, primitive scan picks up novel names. Inverts the question from "is this a name?" to "is this a known common word?" - At token_index > 0: primitive-first with UNIT_CATEGORY_TOKEN ceding to operational lexicon for currency_unit_noun overrides. Lexicon rename (per-category source files): - proper_noun_entity_female.jsonl -> proper_noun_gender_female.jsonl - proper_noun_entity_male.jsonl -> proper_noun_gender_male.jsonl Compiled lexicon.jsonl: rename the two semantic_domain tags; drop "marnie" (was only in proper_noun_entity_female, now absent from the gender-coded sources). Net: 208 -> 207 entries. New manifest checksum: 1fb9b0d790258736267d528e8e8a2436ce88b9ce690805fe2813ba077861ba2a New helper gender_of_proper_noun(surface, lexicon) returns Literal["female","male","neuter","unknown"] — pure enrichment lookup, never gates admission. Measurement (reader_phase1_plus_proper_noun_delta.json): - pre-primitive baseline: correct=3 refused=47 wrong=0 - post-primitive measurement: correct=3 refused=47 wrong=0 - No regression on wrong=0 - No net admission increase observed in this train-sample harness; the architectural value is for future text outside the curated gender lists (Sonnet's #332 expanded those to cover GSM8K names). Tests: - test_lexeme_primitives.py: registry count 8 -> 9, proper_noun_token fires + variants (Bob, Marnie, McDonald, O'Brien, Mary-Anne), numeric/all-caps refusals, numeric-literal still wins overlap on "123" - test_reader_question_frame.py: 5 new tests for sentence-initial dispatch + unknown-gender pronoun resolution + novel-name admission via primitive (Zelda) - test_en_core_math_v1_pack.py: category counts updated; mutual-exclusion between gender_female and gender_male preserved; total 208 -> 207 - test_lexicon.py: category list + lookup assertion updated to renamed proper_noun_gender_female - test_proper_noun_primitive_universality.py: new test module asserting domain-agnostic property of the primitive Validation: - pack + lexicon + primitive tests: 147 passed - reader + universality tests: 22 passed - smoke lane: 67 passed Closes the engine_state question by leaving those files untracked (repo discipline: runtime artifacts never enter PRs). Refs ADR-0164.1 amendment, ADR-0164.2 §EntityRegistry, ADR-0165 §Legitimate uses (the new primitive passes the three-question test).
209 lines
6.7 KiB
Python
209 lines
6.7 KiB
Python
"""ADR-0164.1 — Lexeme primitive registry.
|
||
|
||
Nine seed primitives for the incremental comprehension reader's step-1
|
||
lexical scan (ADR-0164 §Decision §3, ADR-0165 §Legitimate uses).
|
||
|
||
Public API:
|
||
PRIMITIVE_REGISTRY — immutable sorted tuple[LexemePrimitive, ...]
|
||
scan(token) — first-hit match in priority order; None on miss
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import re
|
||
import types
|
||
from dataclasses import dataclass
|
||
from typing import Mapping
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Core types
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_ORDINAL_RANKS: dict[str, str] = {
|
||
"first": "1",
|
||
"second": "2",
|
||
"third": "3",
|
||
"fourth": "4",
|
||
"fifth": "5",
|
||
"sixth": "6",
|
||
"seventh": "7",
|
||
"eighth": "8",
|
||
"ninth": "9",
|
||
"tenth": "10",
|
||
}
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class LexemePrimitive:
|
||
name: str
|
||
pattern: re.Pattern[str]
|
||
emits: str
|
||
extracts: tuple[str, ...]
|
||
priority: int
|
||
provenance: str
|
||
|
||
|
||
@dataclass(frozen=True, slots=True)
|
||
class LexemeMatch:
|
||
primitive_name: str
|
||
emit_category: str
|
||
extracted_values: Mapping[str, str]
|
||
source_text: str
|
||
source_span: tuple[int, int]
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Per-primitive constant fields (not captured by regex groups)
|
||
# ---------------------------------------------------------------------------
|
||
|
||
_PRIMITIVE_CONSTANTS: dict[str, dict[str, str]] = {
|
||
"decimal-currency-literal": {"unit_class": "currency"},
|
||
"currency-literal": {"unit_class": "currency"},
|
||
"percentage-literal": {"unit_class": "ratio"},
|
||
"fraction-literal": {"unit_class": "fraction"},
|
||
"time-amount-literal": {"unit_class": "time"},
|
||
"numeric-literal": {"unit_class": "pending"},
|
||
"ordinal-literal": {},
|
||
"mass-noun-token": {"unit_class": "currency-mass"},
|
||
}
|
||
|
||
|
||
def _make_registry() -> tuple[LexemePrimitive, ...]:
|
||
entries: list[LexemePrimitive] = [
|
||
LexemePrimitive(
|
||
name="decimal-currency-literal",
|
||
pattern=re.compile(r"\$(?P<whole>\d+)\.(?P<cents>\d{2})\b"),
|
||
emits="QUANTITY",
|
||
extracts=("whole", "cents"),
|
||
priority=10,
|
||
provenance="ADR-0164.1",
|
||
),
|
||
LexemePrimitive(
|
||
name="currency-literal",
|
||
pattern=re.compile(r"\$(?P<value>\d+(?:\.\d+)?)\b"),
|
||
emits="QUANTITY",
|
||
extracts=("value",),
|
||
priority=20,
|
||
provenance="ADR-0164.1",
|
||
),
|
||
LexemePrimitive(
|
||
name="percentage-literal",
|
||
pattern=re.compile(r"(?P<value>\d+(?:\.\d+)?)[ ]?%"),
|
||
emits="QUANTITY",
|
||
extracts=("value",),
|
||
priority=30,
|
||
provenance="ADR-0164.1",
|
||
),
|
||
LexemePrimitive(
|
||
name="fraction-literal",
|
||
pattern=re.compile(r"(?P<numerator>\d+)[ ]?/[ ]?(?P<denominator>\d+)\b"),
|
||
emits="QUANTITY",
|
||
extracts=("numerator", "denominator"),
|
||
priority=40,
|
||
provenance="ADR-0164.1",
|
||
),
|
||
LexemePrimitive(
|
||
name="time-amount-literal",
|
||
pattern=re.compile(
|
||
r"(?P<value>\d+)[- ]?"
|
||
r"(?P<unit>hour|minute|day|week|month|year|second)s?\b",
|
||
re.IGNORECASE,
|
||
),
|
||
emits="QUANTITY",
|
||
extracts=("value", "unit"),
|
||
priority=50,
|
||
provenance="ADR-0164.1",
|
||
),
|
||
LexemePrimitive(
|
||
name="ordinal-literal",
|
||
pattern=re.compile(
|
||
r"(?P<rank>first|second|third|fourth|fifth|"
|
||
r"sixth|seventh|eighth|ninth|tenth)\b",
|
||
re.IGNORECASE,
|
||
),
|
||
emits="ORDINAL",
|
||
extracts=("rank",),
|
||
priority=60,
|
||
provenance="ADR-0164.1",
|
||
),
|
||
LexemePrimitive(
|
||
name="mass-noun-token",
|
||
pattern=re.compile(
|
||
r"(?P<lemma>money|profit|interest|income|savings|cost|amount|total)\b",
|
||
re.IGNORECASE,
|
||
),
|
||
emits="UNIT_CATEGORY_TOKEN",
|
||
extracts=("lemma",),
|
||
priority=70,
|
||
provenance="ADR-0164.1",
|
||
),
|
||
LexemePrimitive(
|
||
name="numeric-literal",
|
||
pattern=re.compile(r"(?P<value>\d+(?:\.\d+)?)\b"),
|
||
emits="QUANTITY",
|
||
extracts=("value",),
|
||
priority=100,
|
||
provenance="ADR-0164.1",
|
||
),
|
||
# ADR-0165 code-review test:
|
||
# 1) Matches one capitalized token shape (name-like orthographic material).
|
||
# 2) The class is closed by token-local capitalization/punctuation rules.
|
||
# 3) Novel sentence phrasings still admit because matching is token-local.
|
||
LexemePrimitive(
|
||
name="proper_noun_token",
|
||
pattern=re.compile(r"^[A-Z][A-Za-z'’\-]*[a-z][A-Za-z'’\-]*$"),
|
||
emits="proper_noun_token",
|
||
extracts=("surface",),
|
||
priority=90,
|
||
provenance="adr_0164_1_amendment_brief_8_2",
|
||
),
|
||
]
|
||
return tuple(sorted(entries, key=lambda p: (p.priority, p.name)))
|
||
|
||
|
||
PRIMITIVE_REGISTRY: tuple[LexemePrimitive, ...] = _make_registry()
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# scan — hot path: priority-ordered first-hit match
|
||
# ---------------------------------------------------------------------------
|
||
|
||
def scan(token: str) -> LexemeMatch | None:
|
||
"""Return the first matching LexemePrimitive result, or None.
|
||
|
||
Runs primitives in priority order (lower first). First non-empty match
|
||
wins. Does not advance past end-of-token; uses fullmatch semantics on
|
||
the trimmed token so cross-token patterns cannot fire.
|
||
|
||
Pure / deterministic / no I/O.
|
||
"""
|
||
if not token:
|
||
return None
|
||
|
||
for primitive in PRIMITIVE_REGISTRY:
|
||
m = primitive.pattern.search(token)
|
||
if m is None:
|
||
continue
|
||
|
||
start, end = m.span()
|
||
groups = m.groupdict()
|
||
|
||
# For ordinal-literal, replace the word with its integer rank.
|
||
if primitive.name == "ordinal-literal" and "rank" in groups:
|
||
groups["rank"] = _ORDINAL_RANKS.get(groups["rank"].lower(), groups["rank"])
|
||
|
||
# Merge captured groups with per-primitive constants, sort keys.
|
||
merged = {**groups, **_PRIMITIVE_CONSTANTS.get(primitive.name, {})}
|
||
ev: Mapping[str, str] = types.MappingProxyType(
|
||
{k: merged[k] for k in sorted(merged)}
|
||
)
|
||
|
||
return LexemeMatch(
|
||
primitive_name=primitive.name,
|
||
emit_category=primitive.emits,
|
||
extracted_values=ev,
|
||
source_text=token[start:end],
|
||
source_span=(start, end),
|
||
)
|
||
|
||
return None
|