core/generate/comprehension/lexeme_primitives.py
Shay b3dbde94b4
feat(comprehension/8.2): universal proper_noun_token primitive (#333)
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).
2026-05-26 22:16:34 -07:00

209 lines
6.7 KiB
Python
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 — 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