core/scripts/generate_en_numerics_v1.py
Shay 452e3bb9f5 feat(packs): ADR-0128.1+0128.2 — en_numerics_v1 + loader
Exhaustive English linguistic-form ontology for quantities:
cardinals (0..20 + tens + magnitudes + compound rule), ordinals
(1st..31st + decade/magnitude forms), named fractions (1/2..1/10
+ sixteenth/thirty-second) + symbol forms (½ ¼ ¾ ⅓ ⅔ ⅛ ⅜ ⅝ ⅞),
multipliers (double/triple/twice/half), quantifiers with
semantic_type (indefinite triggers refusal at parse time —
preserves wrong==0), comparison anchors migrated for
ratifiability, number-format regexes with positive/negative
corpora.

Loader API in language_packs/numerics_loader.py (sibling module
to be merged into main loader after Gemini's ADR-0127 loader
lands, to avoid concurrent merge conflict).

Ratification invariants gated: cardinal/ordinal/fraction
exhaustiveness, quantifier semantic-type closed set, format-regex
test corpora (10+ positive/negative per format, ambiguity
refused), manifest checksums = SHA-256 of bytes-on-disk,
self-sealing mastery report.

Cross-references en_units_v1 (Gemini ADR-0127): fraction symbols
authoritative here; en_units_v1 symbol-affix table will point to
these entries.

No parser changes (deferred to 0128.3-0128.6). No train-sample
re-run (joint exit gate with ADR-0127 runs after both packs land).

Total: 130 lexicon entries across 7 kinds.
Lanes: smoke 67/0/0, packs 6/0/0, ADR-0128 suite 243/0/0.
2026-05-23 07:02:09 -07:00

405 lines
15 KiB
Python

"""Deterministic generator for the en_numerics_v1 ratified semantic pack.
Writes lexicon.jsonl, glosses.jsonl, manifest.json, and a self-sealing
.mastery_report.json under language_packs/data/en_numerics_v1/.
Re-running yields byte-identical output. SHA-256 checksums hash the
bytes actually written to disk (CLAUDE.md rule).
"""
from __future__ import annotations
import hashlib
import json
from pathlib import Path
PACK_ID = "en_numerics_v1"
PROVENANCE = "adr-0128:operator_seed:2026-05-23"
PACK_DIR = Path(__file__).resolve().parent.parent / "language_packs" / "data" / PACK_ID
def jline(d: dict) -> str:
return json.dumps(d, ensure_ascii=False, sort_keys=False, separators=(",", ":"))
# ---------------------------------------------------------------------------
# Cardinals: 0..20, tens (30..90), magnitudes (hundred/thousand/million/billion)
# ---------------------------------------------------------------------------
CARDINAL_UNITS = [
"zero", "one", "two", "three", "four", "five", "six", "seven", "eight",
"nine", "ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen",
"sixteen", "seventeen", "eighteen", "nineteen", "twenty",
]
CARDINAL_TENS = [
("thirty", 30), ("forty", 40), ("fifty", 50), ("sixty", 60),
("seventy", 70), ("eighty", 80), ("ninety", 90),
]
CARDINAL_MAGNITUDES = [
("hundred", 100), ("thousand", 1000),
("million", 1_000_000), ("billion", 1_000_000_000),
]
# Ordinals — exhaustive 1st..31st + decade/magnitude forms
ORDINALS = [
("first", 1), ("second", 2), ("third", 3), ("fourth", 4), ("fifth", 5),
("sixth", 6), ("seventh", 7), ("eighth", 8), ("ninth", 9), ("tenth", 10),
("eleventh", 11), ("twelfth", 12), ("thirteenth", 13), ("fourteenth", 14),
("fifteenth", 15), ("sixteenth", 16), ("seventeenth", 17),
("eighteenth", 18), ("nineteenth", 19), ("twentieth", 20),
("twenty-first", 21), ("twenty-second", 22), ("twenty-third", 23),
("twenty-fourth", 24), ("twenty-fifth", 25), ("twenty-sixth", 26),
("twenty-seventh", 27), ("twenty-eighth", 28), ("twenty-ninth", 29),
("thirtieth", 30), ("thirty-first", 31),
("fortieth", 40), ("fiftieth", 50), ("sixtieth", 60), ("seventieth", 70),
("eightieth", 80), ("ninetieth", 90),
("hundredth", 100), ("thousandth", 1000), ("millionth", 1_000_000),
]
# Fractions — named 1/2..1/10 + sixteenth + thirty-second + symbol forms
FRACTIONS_NAMED = [
("half", 1, 2, "½"),
("third", 1, 3, ""),
("quarter", 1, 4, "¼"),
("fifth", 1, 5, ""),
("sixth", 1, 6, ""),
("seventh", 1, 7, ""),
("eighth", 1, 8, ""),
("ninth", 1, 9, ""),
("tenth", 1, 10, ""),
("sixteenth", 1, 16, None),
("thirty-second", 1, 32, None),
]
# Additional symbol-only fraction entries (no English-word form)
FRACTION_SYMBOL_ONLY = [
("", 2, 3, "two-thirds"),
("¾", 3, 4, "three-quarters"),
("", 3, 8, "three-eighths"),
("", 5, 8, "five-eighths"),
("", 7, 8, "seven-eighths"),
]
MULTIPLIERS = [
("double", 2.0), ("triple", 3.0), ("quadruple", 4.0),
("quintuple", 5.0), ("twice", 2.0), ("thrice", 3.0), ("half", 0.5),
]
QUANTIFIERS = [
("all", "total", None),
("none", "empty", 0),
("some", "indefinite", None),
("both", "paired", 2),
("each", "distributive", None),
("every", "distributive", None),
("many", "indefinite", None),
("few", "indefinite", None),
("several", "indefinite", None),
("most", "partial", None),
("any", "indefinite", None),
("no", "empty", 0),
("single", "total", 1),
]
COMPARE_ADDITIVE = [
"more", "fewer", "less", "additional", "extra", "missing", "remaining",
]
COMPARE_MULTIPLICATIVE = [
"twice", "thrice", "times", "half", "double", "triple",
"quadruple", "third", "quarter",
]
NUMBER_FORMATS = [
{
"format_id": "thousand_separated",
"regex": r"^\d{1,3}(?:,\d{3})+$",
"parser_function": "parse_thousand_separated",
"output_type": "int",
},
{
"format_id": "decimal",
"regex": r"^-?\d+\.\d+$",
"parser_function": "parse_decimal",
"output_type": "float",
},
{
"format_id": "slash_fraction",
"regex": r"^-?\d+/\d+$",
"parser_function": "parse_slash_fraction",
"output_type": "Fraction",
},
{
"format_id": "mixed_number",
"regex": r"^-?\d+ \d+/\d+$",
"parser_function": "parse_mixed_number",
"output_type": "Fraction",
},
{
"format_id": "percentage",
"regex": r"^-?\d+(?:\.\d+)?%$",
"parser_function": "parse_percentage",
"output_type": "float",
},
{
"format_id": "signed_integer",
"regex": r"^-?\d+$",
"parser_function": "parse_signed_integer",
"output_type": "int",
},
]
def make_entry(entry_id: str, surface: str, *, pos: str, domains, tags, extra):
e = {
"entry_id": entry_id,
"surface": surface,
"lemma": surface,
"language": "en",
"pos": pos,
"semantic_domains": list(domains),
"morphology_tags": list(tags),
"provenance_ids": [PROVENANCE],
}
e.update(extra)
return e
def build_lexicon():
rows = []
glosses = []
# Cardinals — units 0..20
for i, w in enumerate(CARDINAL_UNITS):
eid = f"en-num-card-{i:03d}"
rows.append(make_entry(
eid, w, pos="NUM",
domains=["numerics.cardinal", "mathematics.value.integer"],
tags=["cardinal", "unit" if i < 20 else "decade-anchor"],
extra={"numeric_value": i, "morphology": "cardinal"},
))
glosses.append({"entry_id": eid, "gloss": f"Cardinal number word for the integer {i}."})
# Cardinals — tens 30..90
for j, (w, val) in enumerate(CARDINAL_TENS, start=21):
eid = f"en-num-card-{j:03d}"
rows.append(make_entry(
eid, w, pos="NUM",
domains=["numerics.cardinal", "mathematics.value.integer"],
tags=["cardinal", "decade-anchor"],
extra={"numeric_value": val, "morphology": "cardinal"},
))
glosses.append({"entry_id": eid, "gloss": f"Cardinal tens word for the integer {val}."})
# Cardinals — magnitudes
for k, (w, val) in enumerate(CARDINAL_MAGNITUDES, start=28):
eid = f"en-num-card-{k:03d}"
rows.append(make_entry(
eid, w, pos="NUM",
domains=["numerics.cardinal", "mathematics.value.magnitude"],
tags=["cardinal", "magnitude"],
extra={"numeric_value": val, "morphology": "cardinal-magnitude"},
))
glosses.append({"entry_id": eid, "gloss": f"Cardinal magnitude word for the integer {val}."})
# Ordinals
for n, (w, pos_val) in enumerate(ORDINALS, start=1):
eid = f"en-num-ord-{n:03d}"
rows.append(make_entry(
eid, w, pos="ADJ",
domains=["numerics.ordinal", "mathematics.value.position"],
tags=["ordinal"],
extra={"position": pos_val, "morphology": "ordinal"},
))
glosses.append({"entry_id": eid, "gloss": f"Ordinal number word for position {pos_val}."})
# Fractions — named
for m, (w, num, den, sym) in enumerate(FRACTIONS_NAMED, start=1):
eid = f"en-num-frac-{m:03d}"
extra = {
"numerator": num,
"denominator": den,
"decimal_value": num / den,
"morphology": "fraction",
}
if sym:
extra["symbol"] = sym
rows.append(make_entry(
eid, w, pos="NOUN",
domains=["numerics.fraction", "mathematics.value.rational"],
tags=["fraction", "named"],
extra=extra,
))
glosses.append({"entry_id": eid, "gloss": f"Named fraction word for {num}/{den}."})
# Fractions — symbol-only forms (canonical home; en_units_v1 cross-references)
for m, (sym, num, den, name) in enumerate(FRACTION_SYMBOL_ONLY,
start=len(FRACTIONS_NAMED) + 1):
eid = f"en-num-frac-{m:03d}"
rows.append(make_entry(
eid, sym, pos="SYM",
domains=["numerics.fraction", "mathematics.value.rational"],
tags=["fraction", "symbol"],
extra={
"numerator": num,
"denominator": den,
"decimal_value": num / den,
"morphology": "fraction-symbol",
"spelled_form": name,
},
))
glosses.append({"entry_id": eid, "gloss": f"Unicode fraction symbol for {num}/{den}."})
# Multipliers
for i, (w, factor) in enumerate(MULTIPLIERS, start=1):
eid = f"en-num-mult-{i:03d}"
rows.append(make_entry(
eid, w, pos="ADJ" if w not in {"twice", "thrice"} else "ADV",
domains=["numerics.multiplier", "mathematics.value.scalar"],
tags=["multiplier"],
extra={"factor": factor, "morphology": "multiplier"},
))
glosses.append({"entry_id": eid, "gloss": f"Multiplier word scaling a quantity by {factor}."})
# Quantifiers
for i, (w, sem_type, det_val) in enumerate(QUANTIFIERS, start=1):
eid = f"en-num-quant-{i:03d}"
extra = {"semantic_type": sem_type, "morphology": "quantifier"}
if det_val is not None:
extra["determinate_value"] = det_val
rows.append(make_entry(
eid, w, pos="DET",
domains=["numerics.quantifier", "mathematics.value.quantifier"],
tags=["quantifier", sem_type],
extra=extra,
))
glosses.append({"entry_id": eid,
"gloss": f"Quantifier ({sem_type}). "
f"{'Determinate value ' + str(det_val) + '.' if det_val is not None else 'No determinate value; indefinite quantifiers trigger refusal.'}"})
# Comparison anchors — additive
for i, w in enumerate(COMPARE_ADDITIVE, start=1):
eid = f"en-num-compare-add-{i:03d}"
rows.append(make_entry(
eid, w, pos="ADJ",
domains=["numerics.compare.additive"],
tags=["comparison-anchor", "additive"],
extra={"anchor_class": "additive", "morphology": "comparison-anchor"},
))
glosses.append({"entry_id": eid,
"gloss": f"Additive comparison anchor '{w}'."})
# Comparison anchors — multiplicative
for i, w in enumerate(COMPARE_MULTIPLICATIVE, start=1):
eid = f"en-num-compare-mul-{i:03d}"
rows.append(make_entry(
eid, w, pos="ADJ",
domains=["numerics.compare.multiplicative"],
tags=["comparison-anchor", "multiplicative"],
extra={"anchor_class": "multiplicative", "morphology": "comparison-anchor"},
))
glosses.append({"entry_id": eid,
"gloss": f"Multiplicative comparison anchor '{w}'."})
# Number formats (declarative rule entries)
for i, fmt in enumerate(NUMBER_FORMATS, start=1):
eid = f"en-num-format-{i:03d}"
rows.append(make_entry(
eid, fmt["format_id"], pos="X",
domains=["numerics.format"],
tags=["number-format"],
extra={
"format_id": fmt["format_id"],
"regex": fmt["regex"],
"parser_function": fmt["parser_function"],
"output_type": fmt["output_type"],
"morphology": "number-format",
},
))
glosses.append({"entry_id": eid,
"gloss": f"Number-format rule '{fmt['format_id']}' "
f"(regex emits {fmt['output_type']})."})
return rows, glosses
def sha256_bytes(data: bytes) -> str:
return hashlib.sha256(data).hexdigest()
def main():
PACK_DIR.mkdir(parents=True, exist_ok=True)
rows, glosses = build_lexicon()
lex_text = "\n".join(jline(r) for r in rows) + "\n"
gloss_text = "\n".join(jline(g) for g in glosses) + "\n"
lex_path = PACK_DIR / "lexicon.jsonl"
gloss_path = PACK_DIR / "glosses.jsonl"
lex_path.write_text(lex_text, encoding="utf-8")
gloss_path.write_text(gloss_text, encoding="utf-8")
lex_checksum = sha256_bytes(lex_path.read_bytes())
gloss_checksum = sha256_bytes(gloss_path.read_bytes())
manifest = {
"pack_id": PACK_ID,
"language": "en",
"role": "operational_base",
"script": "Latin",
"normalization_policy": "unitize_versor",
"source_manifest": f"{PACK_ID}.lexicon.jsonl",
"determinism_class": "D0",
"checksum": lex_checksum,
"version": "1.0.0",
"gate_engaged": True,
"oov_policy": "tagged_fallback",
"glosses_checksum": gloss_checksum,
"definitional_layer": False,
"provenance": PROVENANCE,
}
manifest_path = PACK_DIR / "manifest.json"
manifest_text = json.dumps(manifest, indent=2, ensure_ascii=False) + "\n"
manifest_path.write_text(manifest_text, encoding="utf-8")
# Self-sealing mastery report: hashes the bytes of the three pack files
# (lexicon, glosses, manifest) actually written to disk. Recomputing
# those checksums from disk and matching them seals the report.
counts = {
"cardinal": sum(1 for r in rows if r["entry_id"].startswith("en-num-card-")),
"ordinal": sum(1 for r in rows if r["entry_id"].startswith("en-num-ord-")),
"fraction": sum(1 for r in rows if r["entry_id"].startswith("en-num-frac-")),
"multiplier": sum(1 for r in rows if r["entry_id"].startswith("en-num-mult-")),
"quantifier": sum(1 for r in rows if r["entry_id"].startswith("en-num-quant-")),
"comparison_anchor": sum(1 for r in rows if r["entry_id"].startswith("en-num-compare-")),
"number_format": sum(1 for r in rows if r["entry_id"].startswith("en-num-format-")),
}
report = {
"pack_id": PACK_ID,
"version": "1.0.0",
"provenance": PROVENANCE,
"lexicon_sha256": lex_checksum,
"glosses_sha256": gloss_checksum,
"manifest_sha256": sha256_bytes(manifest_path.read_bytes()),
"entry_counts": counts,
"total_entries": len(rows),
"ratified_at": "2026-05-23",
"ratification_invariants": [
"cardinal_exhaustive_0_to_20",
"cardinal_tens_complete",
"cardinal_magnitudes_complete",
"ordinal_exhaustive_1_to_31",
"fraction_named_1_2_through_1_10",
"fraction_irregular_sixteenth_thirty_second",
"quantifier_semantic_type_closed_set",
"number_format_regex_corpus_gated",
],
}
report_path = PACK_DIR / f"{PACK_ID}.mastery_report.json"
report_path.write_text(json.dumps(report, indent=2, ensure_ascii=False) + "\n",
encoding="utf-8")
print(f"wrote {len(rows)} lexicon entries to {PACK_DIR}")
print(f"lexicon sha256 = {lex_checksum}")
print(f"glosses sha256 = {gloss_checksum}")
print(f"entry counts: {counts}")
if __name__ == "__main__":
main()