Files
rune/scripts/gen_lexicon_evolution_truth.py
T
Nick b704f87549 feat(lexicon_evolution): port to FULL Rust — dichotomy wave
Full name-preserving port of src/rune/lexicon_evolution.py to
src/rust/lexicon_evolution (RUST verdict, #2479 dichotomy tranche — 7/7 fns,
dichotomy audit match clean):
- content_words: regex-equivalent [a-z]{4,} scanner over the lowered text,
  stop/framing tables 1:1, (text or "") coercion
- distinctive_terms: stem-aware document frequency, df < max(total*0.05, 1.5)
- shared_distinctive: stem-matched share over raw existing values (None → "")
- coherence: conflict/consistent/novel lattice — negation is a SUBSTRING test
  on the lowered fact, the shared-term test is the padded " term " form
- grounded: conjunctive stem hits with the min_distinct counter
- _stem: suffix-rule table quirk-for-quirk (migrate→migrat vs migration→migr;
  retroactive→retroactiv vs retroactively→retroactive) + y→i fold
- promote_eligible: strength gate → empty block → distinctive share → no
  signal → grounded/parked, with the oracle's f-string reasons byte-for-byte
  (py_num_display: ints stay ints, integral floats keep .0)

FFI doc-envelope law ({ok, value|err}), no le_free_string export (siblings
share the process; callers free via capability_scoring cs_free_string).
rune/le_loader.py mirrors broker_loader.py; facade delegation with
RUNE_LE_CORE=python pin, _py_ variants kept verbatim as the load-bearing
fallback. Set-valued fns cross the FFI SORTED; the facade restores set types.

Parity: 76 frozen truth cases (every public fn, every branch) frozen via
scripts/gen_lexicon_evolution_truth.py against the live oracle with
RUNE_LE_CORE=python pinned; battery replays VERBATIM 7/7; live replay
facade 54/54 + kernel 76/76; clippy 0; test_lexicon_contract 15/15 both
modes (kernel active + pinned fallback); test_governance_quartet 15/15;
pre-existing failures in test_constitutional_adversarial (7F) and
test_property_invariants (collection) proven unrelated via stash-dance.
2026-10-12 02:08:12 +01:00

620 lines
23 KiB
Python

#!/usr/bin/env python3
"""gen_lexicon_evolution_truth.py — freeze/replay parity truth for
lexicon_evolution (dichotomy wave, #2479 tranche).
Freeze mode (default): exercises EVERY public function of the LIVE Python
oracle (src/rune/lexicon_evolution.py) across every branch and records
(kernel_input, expected_kernel_output) pairs into
src/rust/lexicon_evolution/src/frozen_le_truth.json.
Replay mode (--replay): re-runs the SAME scenarios with the Rust core
delegation ACTIVE (RUNE_LE_CORE unpinned) and asserts both kernel-level
(loader direct) and facade-level (oracle functions delegate) parity
against the frozen file.
Poisoned-truth law: freeze pins RUNE_LE_CORE=python BEFORE any import.
Mutation law: case inputs are deep-copied BEFORE they reach the oracle —
the frozen input must be the pre-call input, not a post-call mutation.
Set law: the oracle's set-valued functions (distinctive_terms,
shared_distinctive) are frozen and compared as SORTED lists — Python set
iteration order is not stable across processes; the facade restores set
types on its public surface.
Kernel-only group: `_stem` is private to the oracle (no facade symbol);
its truth group is replayed kernel-side only (the Python _stem body is
frozen verbatim and covered by tests/test_lexicon_contract.py).
"""
import copy
import json
import os
import sys
from datetime import datetime, timezone
REPO = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
TRUTH_PATH = os.path.join(REPO, "src", "rust", "lexicon_evolution", "src", "frozen_le_truth.json")
REPLAY = "--replay" in sys.argv
if not REPLAY:
os.environ["RUNE_LE_CORE"] = "python" # poisoned-truth law (freeze side)
else:
os.environ.pop("RUNE_LE_CORE", None) # live-parity law (facade must delegate)
sys.path.insert(0, os.path.join(REPO, "src"))
sys.path.insert(0, os.path.join(REPO, "src", "rune"))
import lexicon_evolution as oracle_mod # noqa: E402
if REPLAY:
from rune import le_loader # noqa: E402 (kernel-level replay side)
if not le_loader.available():
print("REPLAY ABORT: kernel not built —", le_loader.LIB_PATH)
sys.exit(2)
class Case:
def __init__(self, name, fn):
self.name = name
self.fn = fn
class _Unset:
pass
_UNSET = _Unset()
def _input(text=_UNSET, word=_UNSET, fact=_UNSET, proposal=_UNSET,
corpus_texts=_UNSET, existing_values=_UNSET, source_texts=_UNSET,
min_distinct=_UNSET, min_strength=_UNSET, evidence_strength=_UNSET):
payload = {}
if text is not _UNSET:
payload["text"] = text
if word is not _UNSET:
payload["word"] = word
if fact is not _UNSET:
payload["fact"] = fact
if proposal is not _UNSET:
payload["proposal"] = proposal
if corpus_texts is not _UNSET:
payload["corpus_texts"] = list(corpus_texts)
if existing_values is not _UNSET:
payload["existing_values"] = list(existing_values)
if source_texts is not _UNSET:
payload["source_texts"] = list(source_texts)
if min_distinct is not _UNSET and min_distinct is not None:
payload["min_distinct"] = min_distinct
if min_strength is not _UNSET and min_strength is not None:
payload["min_strength"] = min_strength
if evidence_strength is not _UNSET:
payload["evidence_strength"] = evidence_strength
return payload
def sorted_or(x):
return sorted(x) if isinstance(x, (set, frozenset)) else x
def facade_call(group, inp):
"""Run the LIVE facade (delegates to the Rust core when built)."""
if group == "content_words":
return oracle_mod.content_words(inp["text"])
if group == "distinctive_terms":
return oracle_mod.distinctive_terms(inp["fact"], inp["corpus_texts"])
if group == "shared_distinctive":
return oracle_mod.shared_distinctive(
inp["fact"], inp["existing_values"], inp["corpus_texts"])
if group == "coherence":
return oracle_mod.coherence(
inp["fact"], inp["existing_values"], inp["corpus_texts"])
if group == "grounded":
if "min_distinct" in inp:
return oracle_mod.grounded(inp["fact"], inp["source_texts"],
min_distinct=inp["min_distinct"])
return oracle_mod.grounded(inp["fact"], inp["source_texts"])
if group == "promote_eligible":
return oracle_mod.promote_eligible(
inp["proposal"], inp["existing"], inp["evidence_strength"],
min_strength=inp.get("min_strength", 3),
corpus_texts=inp["corpus_texts"])
raise KeyError(group)
def kernel_call(group, inp):
"""Run the Rust kernel directly through the ctypes loader."""
return getattr(le_loader, group)(**copy.deepcopy(inp))
# ── content_words ────────────────────────────────────────────────────────────
def cw_case(rec, name, text):
inp = _input(text=text)
got = oracle_mod.content_words(copy.deepcopy(text))
rec(name, inp, got, {"words": got})
def c_cw_basic(rec):
cw_case(rec, "cw-basic-stop-framing-filtered",
"The quick brown fox jumps over the lazy dog")
def c_cw_none(rec):
cw_case(rec, "cw-none-coerced-empty", None)
def c_cw_zero(rec):
# falsy non-string → (text or "") → ""
cw_case(rec, "cw-zero-falsy-coerced-empty", 0)
def c_cw_empty(rec):
cw_case(rec, "cw-empty-string", "")
def c_cw_uppercase(rec):
cw_case(rec, "cw-uppercase-folded", "PREFERS Oatmilk AND Celesta")
def c_cw_short(rec):
cw_case(rec, "cw-short-words-dropped", "a an the cat dog ox")
def c_cw_framing(rec):
cw_case(rec, "cw-framing-words-dropped",
"work always way thing value identity persona")
def c_cw_punct(rec):
cw_case(rec, "cw-punctuation-splits", "co-ffee! 1234 espresso's brew.")
def c_cw_mixed(rec):
cw_case(rec, "cw-mixed-stop-framing", "she will make the work better each day")
# ── distinctive_terms ────────────────────────────────────────────────────────
def dt_case(rec, name, fact, corpus):
inp = _input(fact=fact, corpus_texts=corpus)
got = oracle_mod.distinctive_terms(copy.deepcopy(fact), copy.deepcopy(corpus))
rec(name, inp, got, {"terms": sorted(got)})
def c_dt_empty_fact(rec):
dt_case(rec, "dt-empty-fact", "", ["some text here"])
def c_dt_all_stop(rec):
dt_case(rec, "dt-fact-all-stop-framing", "the that this work",
["some text here"])
def c_dt_rare(rec):
dt_case(rec, "dt-rare-terms-distinctive", "she drinks oatmilk daily",
["oatmilk is life", "coffee today", "tea later"])
def c_dt_common(rec):
dt_case(rec, "dt-common-term-not-distinctive", "physics of black holes",
["physics paper one", "physics paper two", "more physics here",
"unrelated text"])
def _filler_corpus(target_word, doc_ids, n=40):
corpus = []
for i in range(n):
if i in doc_ids:
corpus.append(f"the {target_word} session notes")
else:
corpus.append("filler text here")
return corpus
def c_dt_boundary_not_distinctive(rec):
# df 2 at total 40 → threshold max(2.0, 1.5) = 2.0 → 2 < 2 is False
dt_case(rec, "dt-boundary-df2-not-distinctive",
"celesta zephyr quantum", _filler_corpus("celesta", {0, 1}))
def c_dt_boundary_distinctive(rec):
# df 1 at total 40 → 1 < 2.0 → distinctive
dt_case(rec, "dt-boundary-df1-distinctive",
"celesta zephyr", _filler_corpus("celesta", {0}))
def c_dt_boundary_df3(rec):
dt_case(rec, "dt-boundary-df3-not-distinctive",
"celesta zephyr quantum", _filler_corpus("quantum", {0, 1, 2}))
def c_dt_stem_df(rec):
# migrate/migration share the stem "migr" → df counts both docs
dt_case(rec, "dt-stem-aware-document-frequency", "migrate north soon",
["the migration was huge", "another migration happened", "calm day"])
def c_dt_empty_corpus(rec):
dt_case(rec, "dt-empty-corpus-all-distinctive", "oatmilk ritual", [])
def c_dt_case_fold(rec):
dt_case(rec, "dt-case-folding", "OATMILK Rituals", ["oatmilk bar"])
# ── shared_distinctive ───────────────────────────────────────────────────────
def sd_case(rec, name, fact, existing, corpus):
inp = _input(fact=fact, existing_values=existing, corpus_texts=corpus)
got = oracle_mod.shared_distinctive(
copy.deepcopy(fact), copy.deepcopy(existing), copy.deepcopy(corpus))
rec(name, inp, got, {"shared": sorted(got)})
def c_sd_basic(rec):
sd_case(rec, "sd-shared-distinctive-term", "she never jokes about oatmilk",
["i love oatmilk a lot"], ["oatmilk is life", "tea later"])
def c_sd_none_shared(rec):
sd_case(rec, "sd-nothing-shared", "celesta practice daily",
["i love oatmilk"], ["oatmilk is life", "tea later"])
def c_sd_distinct_empty(rec):
# framing-only fact → distinctive_terms empty → short-circuit
sd_case(rec, "sd-distinct-empty-short-circuit", "work always thing",
["whatever corpus text"], ["whatever corpus text"])
def c_sd_stem_shared(rec):
sd_case(rec, "sd-stem-aware-shared", "migration policy debate",
["we migrated north"], ["migration news", "calm"])
def c_sd_none_value(rec):
sd_case(rec, "sd-none-existing-value-filtered", "she never jokes about oatmilk",
[None, "i love oatmilk"], ["oatmilk is life", "tea later"])
def c_sd_empty_existing(rec):
sd_case(rec, "sd-empty-existing", "oatmilk mornings", [], ["oatmilk is life"])
# ── coherence ────────────────────────────────────────────────────────────────
def co_case(rec, name, fact, existing, corpus=None):
inp = _input(fact=fact, existing_values=existing,
corpus_texts=[] if corpus is None else corpus)
if corpus is None:
got = oracle_mod.coherence(copy.deepcopy(fact), copy.deepcopy(existing))
else:
got = oracle_mod.coherence(copy.deepcopy(fact), copy.deepcopy(existing),
copy.deepcopy(corpus))
rec(name, inp, got, {"verdict": got})
def c_co_empty_existing(rec):
co_case(rec, "co-empty-existing-novel", "oatmilk mornings", [])
def c_co_blank_existing(rec):
co_case(rec, "co-blank-existing-filtered-novel", "oatmilk mornings",
["", " ", None])
def c_co_conflict(rec):
co_case(rec, "co-negation-on-shared-distinctive-conflict",
"she never jokes about oatmilk", ["i love oatmilk"],
["oatmilk bar", "tea"])
def c_co_negation_off_shared(rec):
# negation present but the shared term is not a standalone word in the
# fact ("oatmilk-lover" has no trailing space) → consistent
co_case(rec, "co-negation-not-on-shared-term-consistent",
"oatmilk-lover never smiles", ["i love oatmilk"],
["oatmilk bar", "tea"])
def c_co_consistent(rec):
co_case(rec, "co-shared-distinctive-consistent",
"i enjoy oatmilk mornings", ["i love oatmilk"],
["oatmilk bar", "tea"])
def c_co_novel(rec):
co_case(rec, "co-nothing-shared-novel", "celesta practice daily",
["i love oatmilk"], ["oatmilk bar", "tea"])
def c_co_corpus_kills_shared(rec):
# corpus override drives oatmilk df over the threshold → shared empty → novel
co_case(rec, "co-corpus-override-kills-shared-novel",
"she never jokes about oatmilk", ["i love oatmilk"],
["oatmilk"] * 30)
def c_co_default_corpus(rec):
# corpus_texts omitted → corpus = the (filtered) existing values
co_case(rec, "co-default-corpus-is-values", "i enjoy oatmilk mornings",
["i love oatmilk"])
# ── grounded ─────────────────────────────────────────────────────────────────
def gr_case(rec, name, fact, sources, min_distinct=None):
inp = _input(fact=fact, source_texts=sources, min_distinct=min_distinct)
if min_distinct is None:
got = oracle_mod.grounded(copy.deepcopy(fact), copy.deepcopy(sources))
else:
got = oracle_mod.grounded(copy.deepcopy(fact), copy.deepcopy(sources),
min_distinct=min_distinct)
rec(name, inp, got, {"grounded": got})
def c_gr_no_srcs(rec):
gr_case(rec, "gr-empty-sources-false", "she drinks oatmilk daily", [])
def c_gr_blank_srcs(rec):
gr_case(rec, "gr-blank-sources-false", "she drinks oatmilk daily", ["", " "])
def c_gr_no_fact_terms(rec):
gr_case(rec, "gr-fact-no-content-words-false", "the work thing",
["oatmilk is life"])
def c_gr_direct(rec):
gr_case(rec, "gr-direct-word-hit", "she drinks oatmilk daily",
["oatmilk is life"])
def c_gr_stem(rec):
gr_case(rec, "gr-stem-aware-hit", "migrated north",
["the migration of songbirds"])
def c_gr_miss(rec):
gr_case(rec, "gr-no-stem-overlap-false", "quantum flux patterns",
["oatmilk life"])
def c_gr_min2_one_hit(rec):
gr_case(rec, "gr-min-distinct-2-one-hit-false", "she drinks oatmilk with celesta",
["oatmilk life"], min_distinct=2)
def c_gr_min2_two_hits(rec):
gr_case(rec, "gr-min-distinct-2-two-hits-true", "she drinks oatmilk with celesta",
["oatmilk and celesta life"], min_distinct=2)
def c_gr_case(rec):
gr_case(rec, "gr-source-case-folded", "she drinks oatmilk daily",
["OATMILK IS LIFE"])
# ── _stem (kernel-only: private in the oracle) ───────────────────────────────
STEM_WORDS = [
"migration", "migrated", "migrating", "migrate", "migrates",
"retroactively", "retroactive", "honesty", "ability", "abilities",
"running", "kindness", "cats", "dogs", "hope", "oatmilk", "rituals",
"nation", "national", "carefully", "careful", "berries",
]
def st_case(rec, name, word):
inp = _input(word=word)
got = oracle_mod._stem(copy.deepcopy(word))
rec(name, inp, got, {"stem": got})
def c_stem_table(rec):
for w in STEM_WORDS:
st_case(rec, f"stem-{w}", w)
# ── promote_eligible ─────────────────────────────────────────────────────────
def pe_case(rec, name, proposal, existing, strength, min_strength=None, corpus=()):
inp = _input(proposal=proposal, existing_values=existing,
evidence_strength=strength, min_strength=min_strength,
corpus_texts=corpus)
# the oracle/loader param is `existing` (the promote signature)
inp["existing"] = inp.pop("existing_values")
if min_strength is None:
got = oracle_mod.promote_eligible(copy.deepcopy(proposal),
copy.deepcopy(existing), strength)
else:
got = oracle_mod.promote_eligible(
copy.deepcopy(proposal), copy.deepcopy(existing), strength,
min_strength=min_strength, corpus_texts=copy.deepcopy(corpus))
rec(name, inp, got, {"eligible": got["eligible"], "reason": got["reason"]})
def c_pe_below_strength(rec):
pe_case(rec, "pe-strength-gate-first", "oatmilk mornings", ["i love oatmilk"],
2, min_strength=3, corpus=["oatmilk bar"])
def c_pe_float_strength(rec):
pe_case(rec, "pe-float-strength-message", "oatmilk mornings", ["i love oatmilk"],
2.5, min_strength=3, corpus=["oatmilk bar"])
def c_pe_empty_block(rec):
pe_case(rec, "pe-empty-block-first-principle", "oatmilk mornings", [], 3,
min_strength=3)
def c_pe_blank_block(rec):
pe_case(rec, "pe-blank-block-filtered-first-principle", "oatmilk mornings",
["", " "], 3, min_strength=3)
def c_pe_shared(rec):
pe_case(rec, "pe-shared-distinctive-eligible", "i genuinely love oatmilk rituals",
["i love oatmilk"], 3, min_strength=3,
corpus=["oatmilk bar", "tea later"])
def c_pe_shared_multi(rec):
pe_case(rec, "pe-shared-multiple-sorted-top3", "oatmilk celesta mornings",
["oatmilk and celesta"], 3, min_strength=3,
corpus=["oatmilk bar", "celesta hall"])
def c_pe_no_terms(rec):
pe_case(rec, "pe-framing-only-no-lexical-signal", "work always thing way",
["i love oatmilk"], 3, min_strength=3, corpus=["oatmilk bar"])
def c_pe_novel_grounded(rec):
pe_case(rec, "pe-novel-axis-grounded-eligible", "celesta practice daily",
["i love oatmilk"], 3, min_strength=3,
corpus=["she plays celesta nightly"])
def c_pe_novel_parked(rec):
pe_case(rec, "pe-novel-axis-not-grounded-parked", "celesta practice daily",
["i love oatmilk"], 3, min_strength=3, corpus=["oatmilk bar only"])
def c_pe_min_strength_one(rec):
pe_case(rec, "pe-min-strength-1-passes-gate", "oatmilk mornings", [], 1,
min_strength=1)
def c_pe_corpus_override_grounded(rec):
# corpus override pushes oatmilk df over the threshold → shared empty →
# novel axis, but the proposal still grounds in the overridden corpus
pe_case(rec, "pe-corpus-override-novel-grounded", "oatmilk rituals",
["i love oatmilk"], 3, min_strength=3,
corpus=["oatmilk here", "oatmilk there", "oatmilk everywhere"])
def c_pe_default_corpus(rec):
# corpus_texts omitted → corpus = existing values
pe_case(rec, "pe-default-corpus-is-existing", "oatmilk rituals",
["i love oatmilk"], 3, min_strength=3)
# ── driver ───────────────────────────────────────────────────────────────────
CONTENT_WORDS = [c_cw_basic, c_cw_none, c_cw_zero, c_cw_empty, c_cw_uppercase,
c_cw_short, c_cw_framing, c_cw_punct, c_cw_mixed]
DISTINCTIVE = [c_dt_empty_fact, c_dt_all_stop, c_dt_rare, c_dt_common,
c_dt_boundary_not_distinctive, c_dt_boundary_distinctive,
c_dt_boundary_df3, c_dt_stem_df, c_dt_empty_corpus, c_dt_case_fold]
SHARED = [c_sd_basic, c_sd_none_shared, c_sd_distinct_empty, c_sd_stem_shared,
c_sd_none_value, c_sd_empty_existing]
COHERENCE = [c_co_empty_existing, c_co_blank_existing, c_co_conflict,
c_co_negation_off_shared, c_co_consistent, c_co_novel,
c_co_corpus_kills_shared, c_co_default_corpus]
GROUNDED = [c_gr_no_srcs, c_gr_blank_srcs, c_gr_no_fact_terms, c_gr_direct,
c_gr_stem, c_gr_miss, c_gr_min2_one_hit, c_gr_min2_two_hits,
c_gr_case]
STEM = [c_stem_table]
PROMOTE = [c_pe_below_strength, c_pe_float_strength, c_pe_empty_block,
c_pe_blank_block, c_pe_shared, c_pe_shared_multi, c_pe_no_terms,
c_pe_novel_grounded, c_pe_novel_parked, c_pe_min_strength_one,
c_pe_corpus_override_grounded, c_pe_default_corpus]
# group → (cases, facade_replayable, kernel_symbol, unwrap of expected)
GROUPS = {
"content_words": (CONTENT_WORDS, True, "le_content_words",
lambda w: w["words"]),
"distinctive_terms": (DISTINCTIVE, True, "le_distinctive_terms",
lambda w: w["terms"]),
"shared_distinctive": (SHARED, True, "le_shared_distinctive",
lambda w: w["shared"]),
"coherence": (COHERENCE, True, "le_coherence", lambda w: w["verdict"]),
"grounded": (GROUNDED, True, "le_grounded", lambda w: w["grounded"]),
"stem": (STEM, False, "le_stem", lambda w: w["stem"]),
"promote_eligible": (PROMOTE, True, "le_promote_eligible", lambda w: w),
}
def canon(x) -> str:
"""Content canonicalization (key-order independent)."""
return json.dumps(json.loads(json.dumps(x, default=str)), sort_keys=True)
def main():
frozen = None
if REPLAY:
with open(TRUTH_PATH) as f:
frozen = json.load(f)["cases"]
groups = {}
for group, (cases, facade_replayable, _sym, _unwrap) in GROUPS.items():
recs = []
def rec(name, inp, oracle_ret, expected, _r=recs):
entry = {"name": name, "input": inp,
"return": sorted_or(oracle_ret) if group in
("distinctive_terms", "shared_distinctive")
else oracle_ret,
"expected": expected}
if REPLAY and facade_replayable:
old = next(c for c in frozen[group] if c["name"] == name)
for key in ("input", "return", "expected"):
got, want = entry[key], old[key]
if canon(got) != canon(want):
print(f"FACADE DIVERGE [{group}] {name} {key}")
print(" got: ", json.dumps(got, sort_keys=True, default=str)[:600])
print(" want:", json.dumps(want, sort_keys=True, default=str)[:600])
recs.append({"name": name, "diverged": key})
return
_r.append(entry)
for c in cases:
c(rec)
groups[group] = recs
total = sum(len(v) for v in groups.values())
if REPLAY:
facade_groups = [g for g, (_, r, _, _) in GROUPS.items() if r]
facade_total = sum(len(groups[g]) for g in facade_groups)
facade_failures = sum(1 for g in facade_groups for c in groups[g]
if "diverged" in c)
failures = 0
checked = 0
for group, cases in frozen.items():
_sym = GROUPS[group][2]
unwrap = GROUPS[group][3]
for case in cases:
checked += 1
got = kernel_call(group, case["input"])
want = unwrap(case["expected"])
if canon(got) != canon(want):
failures += 1
print(f"KERNEL DIVERGE [{group}] {case['name']}")
print(" got: ", json.dumps(got, sort_keys=True, default=str)[:600])
print(" want:", json.dumps(want, sort_keys=True, default=str)[:600])
print(f"replay: facade {facade_total - facade_failures}/{facade_total}, "
f"kernel {checked - failures}/{checked}")
return 1 if (failures or facade_failures) else 0
doc = {
"frozen": datetime.now(timezone.utc).isoformat(),
"oracle": "src/rune/lexicon_evolution.py",
"pin": "RUNE_LE_CORE=python",
"note": "Frozen via scripts/gen_lexicon_evolution_truth.py against the "
"live Python oracle. Battery embeds this VERBATIM. Group "
"`stem` is kernel-only (_stem is private in the oracle).",
"cases": groups,
}
os.makedirs(os.path.dirname(TRUTH_PATH), exist_ok=True)
with open(TRUTH_PATH, "w") as f:
json.dump(doc, f, indent=1)
f.write("\n")
print(f"frozen {total} cases → {os.path.relpath(TRUTH_PATH, REPO)}")
return 0
if __name__ == "__main__":
sys.exit(main())