Files
esh-pfi-infrastructure/scripts/r49-corpus/leak_gate.py
T
vh c55966433f fix(lv-mccarthy): the leak gate passed with five protagonist names still in every copy
`leak_gate.py` scans `\b(Surface)\b`. Any character inserted inside a name defeats
that pattern outright, so a mangled occurrence is unrenameable by rename.py AND
unreportable by the gate. lv-mccarthy's 2026-09-17 tree passed at "0 of 75
renameable and 0 of 37 sub-threshold" while carrying 13 occurrences of Bell,
Chigurh, Moss, Toadvine and Glanton in all six copies:

    B ell  C higurh  M oss  T oadvine    a small-caps drop cap kept as its own token
    Toad-vine  Glan-ton                  a print line-break hyphen kept by the extractor

Every visible occurrence HAD been renamed, which is what made the residue invisible
to a spot-read. Fixed at three levels, all three of which must stay:

  build_corpus_mccarthy.py rules 4 and 5 repair the source text — 32 split initials
  with a lowercase remainder, 5 hyphen-split names, each with an expected count so a
  master change fails the build. Rule 4's letter class is consonants only: `I` opens
  1,966 paragraphs, `A` 143 and `Y` 32 (Spanish `y`); folding any would corrupt 2,141
  lines to fix 32.

  leak_gate.py gains a separator-tolerant pass with its own positive and negative
  controls, and it FAILS the gate. Validated against the pre-fix tree: reports all
  five surfaces, exits 1. Its fragment filter is what makes it usable — a naive scan
  returns 18 false positives on Hemingway (`God damn`, `I run`) against 3 real ones;
  requiring one fragment to be a non-word of the corpus cleared all 18 and kept all 3.

  The whole D1→D3 chain is reproduced byte-identically before and after, so the fix
  is the only delta: 6 works, the entity map, the final map and all 36 copy files.

Cross-checked on the shipped corpora: lv-bronte is clean of this class, lv-hemingway
carries 3 (`Primi tivo`, `Pasionar ia`, `Chi cote`) and is live on fv-ml1.

Also in build_sft_pairs.py, both needed before lv-mccarthy's pairs:

  DEFECT 4, hard-wrap reflow. Measured on the SHIPPED lv-bronte adapter, which emits
  mid-sentence line breaks at 12.46 per 1k chars against 0.00 for its own base control
  and 0.00 for every Hemingway arm. McCarthy is the mixed case — The Road is wrapped,
  the other five works are not — so the corpus teaches the break as a coin flip. The
  obvious fix (join every interior newline) corrupts 46 two-speaker exchanges whose
  blank line was lost, and unmarked dialogue is the one thing this adapter exists to
  learn; the rule splits on sentence-final punctuation instead and takes the cheaper
  error. Self-targeting and off by default, so every shipped pair set is unchanged.

  A `mccarthy` register, which names the punctuation deliberately: the eval drives the
  base control arm with this same prompt, so tics left out of it are a surface trick
  only the adapter can perform, and delta_cb is a character-bigram measure.

  drop_leading_heading now also consumes Blood Meridian's dash-separated chapter
  arguments — 131 paragraphs, 0 in every other work of all three corpora.

And a RUNBOOK, because the D1→D3 session recorded nothing and the chain had to be
recovered by rebuilding candidates and matching sha256 against the artifacts on disk.
2026-09-17 11:37:33 -07:00

292 lines
16 KiB
Python
Raw 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.
"""R49 Stage D3 gate — do any of the author's own proper nouns survive the rename?
The rename exists so a voice adapter fits *prose style* and not the author's
characters and worldbuilding. That only holds if the renamed copies are actually
clean, and "actually clean" is a measurement, not a property of having run the
script. Brontë's run reached 0 of 203; BabyYarros opened at 86 of 232.
The gate is a whole-corpus scan, not a per-work one, and that distinction is
load-bearing. A name detected in `iron-flame` but below threshold in `fourth-wing`
is renamed in one copy and printed verbatim in the other, and a per-work gate
reports that as clean.
CONTROLS. A detector that only ever sees the renamed text cannot tell "absent"
from "blind", so this instrument runs both directions every time:
* POSITIVE -- the same scan over the UNRENAMED source. Every entity must be
found there. A miss means the matcher is broken and its zeroes are worthless.
* NEGATIVE -- a nonce string that appears in neither tree. A hit means the
matcher is manufacturing signal.
Exit code is the gate: 0 iff the controls pass AND no source entity survives.
"""
from __future__ import annotations
import argparse, json, re, sys
from collections import Counter, defaultdict
from pathlib import Path
NONCE = "Qxzvwolfram" # negative control: appears in no corpus
# ⚠⭐ THE SEPARATOR CLASS — a leak the `\b(Surface)\b` scan above structurally CANNOT see.
# Any character inserted inside a name defeats a word-boundary pattern outright, so a mangled
# occurrence is not merely unrepaired: it is unrenameable AND unreportable, and the gate prints
# a clean zero over it. Two mechanisms are already measured in this line:
#
# lv-bronte 2026-09-16 `_Antigua_` `_` is a word character, so \bAntigua\b cannot match
# inside it. Found by hand, not by this gate.
# lv-mccarthy 2026-09-17 `B ell` a small-caps drop cap survived as its own token
# `Toad-vine` a print line-break hyphen survived the extraction
#
# McCarthy's tree had already PASSED this gate at "0 of 75 renameable and 0 of 37
# sub-threshold" while carrying 13 occurrences of five protagonist names — Bell, Chigurh,
# Moss, Toadvine, Glanton — in all six copies. Every VISIBLE occurrence had been renamed
# correctly, which is exactly what makes the residue invisible to a spot-read.
#
# ⚠ A NAIVE separator-tolerant scan is dominated by FALSE POSITIVES, because it matches across
# genuine word boundaries. Measured on lv-hemingway: 21 raw hits, of which 18 are ordinary text
# (`God damn` for the surface `Goddamn` ×14, plus `I run`/`On an`/`Do me`/`Si le`) and 3 are
# real (`Primi tivo`, `Pasionar ia`, `Chi cote`). The discriminator that separates them without
# a dictionary: in a genuine split, at least one FRAGMENT is not a word — it occurs as a
# standalone token almost nowhere in the source. `God` and `damn` occur constantly; `Primi`,
# `tivo`, `ell`, `higurh` and `Toad` do not. That single test cleared all 18 and kept all 3.
SPLIT_SEP = r"[ _\-\u00ad\u2010\u2011]"
TOKEN = re.compile(r"[A-Za-z\u00c0-\u017f']+")
def split_scan(source: dict[str, str], copies: dict[str, str], surfaces: list[str],
frag_max: int, allow: set[str]) -> dict[str, dict]:
"""Surfaces surviving in the copies with ONE separator hiding them from the unigram scan.
Returns surface -> {"forms": {matched_string: hits}, "copies": n}. Exact matches are the
unigram pass's business and are excluded here so the two cannot double-report.
"""
src_tokens = Counter()
for t in source.values():
src_tokens.update(TOKEN.findall(t))
out: dict[str, dict] = {}
for s in surfaces:
if len(s) < 4 or not TOKEN.fullmatch(s):
continue
body = (SPLIT_SEP + "?").join(re.escape(c) for c in s)
pat = re.compile(r"(?<![A-Za-z])(" + body + r")(?![A-Za-z])")
forms: Counter = Counter()
seen_copies = set()
for name, text in copies.items():
for m in pat.finditer(text):
form = m.group(1)
if form == s or form in allow:
continue
frags = TOKEN.findall(form)
# A genuine split leaves a fragment that is not a word of this corpus.
if len(frags) < 2 or all(src_tokens[f] > frag_max for f in frags):
continue
forms[form] += 1
seen_copies.add(name)
if forms:
out[s] = {"forms": dict(forms), "copies": len(seen_copies)}
return out
def load_works(corpus: Path) -> dict[str, str]:
man = json.loads((corpus / "manifest.json").read_text())
out = {}
for w in man["works"]:
rows = [json.loads(l) for l in
(corpus / w["path"]).read_text(encoding="utf-8").splitlines() if l.strip()]
out[w["slug"]] = "\n\n".join(r["text"] for r in rows)
return out
def load_copies(renamed: Path) -> dict[str, str]:
out = {}
for p in sorted((renamed / "copies").glob("*.jsonl")):
rows = [json.loads(l) for l in p.read_text(encoding="utf-8").splitlines() if l.strip()]
out[p.name] = "\n\n".join(r["text"] for r in rows)
return out
def scan(texts: dict[str, str], surfaces: list[str]) -> dict[str, dict[str, int]]:
"""surface -> {text_name: hits}. One alternation pass per text, not one per name.
⚠ Longest-first alternation, so `Xaden Riorson` is consumed before `Xaden`
and a two-part name is not counted twice.
"""
if not surfaces:
return {}
pat = re.compile(r"\b(" + "|".join(re.escape(s) for s in
sorted(surfaces, key=len, reverse=True)) + r")\b")
hits: dict[str, dict[str, int]] = defaultdict(dict)
for name, text in texts.items():
local: dict[str, int] = defaultdict(int)
for m in pat.finditer(text):
local[m.group(1)] += 1
for s, n in local.items():
hits[s][name] = n
return hits
def main() -> int:
ap = argparse.ArgumentParser()
ap.add_argument("corpus", help="source corpus dir (manifest.json + works/)")
ap.add_argument("--entities", required=True)
ap.add_argument("--renamed", required=True, help="rename.py --out dir")
ap.add_argument("--min-cap", type=int, default=8,
help="rename.py's renameable threshold; entities below it are "
"reported separately because rename never touched them")
ap.add_argument("--phrase-map", default=None,
help="the JSON rename.py used; its `allow` list names the phrases judged "
"real-world or generic. Without it the phrase audit does not run.")
ap.add_argument("--phrase-min", type=int, default=5,
help="a capitalised 2-3gram must recur this often in the source to be audited")
ap.add_argument("--split-frag-max", type=int, default=3,
help="a separator-split candidate is reported only when one of its "
"fragments occurs as a standalone token no more than this often in "
"the source. Raising it reports more and flags more ordinary text; "
"0 turns the fragment filter off entirely.")
ap.add_argument("--no-split-scan", action="store_true",
help="skip the separator-split pass. It is ON by default because the pass "
"exists to catch a class that made this gate print a false zero.")
ap.add_argument("--report", default=None, help="write the full JSON breakdown here")
a = ap.parse_args()
corpus, renamed = Path(a.corpus), Path(a.renamed)
ents_all = json.loads(Path(a.entities).read_text())
source = load_works(corpus)
copies = load_copies(renamed)
if not copies:
print("== no copy files found -- nothing to gate"); return 1
# Mirror rename.py's own renameable predicate so the two cannot drift apart.
renameable, sub_threshold = {}, {}
for slug, w in ents_all.items():
for key, e in w["entities"].items():
surf = e.get("surface") or key
if "’" in key or "'" in key:
continue
(renameable if e["cap"] >= a.min_cap else sub_threshold).setdefault(surf, set()).add(slug)
surfaces = sorted(set(renameable) | set(sub_threshold))
print(f" {len(renameable)} renameable surfaces (cap >= {a.min_cap}) · "
f"{len(sub_threshold)} sub-threshold · {len(copies)} copy files")
# ---- controls --------------------------------------------------------
src_hits = scan(source, surfaces + [NONCE])
missing = [s for s in surfaces if s not in src_hits]
pos_ok = not missing
neg_ok = NONCE not in src_hits
print(f" [{'PASS' if pos_ok else 'FAIL'}] positive control: every surface found in the "
f"unrenamed source ({len(surfaces) - len(missing)}/{len(surfaces)})"
+ ("" if pos_ok else f" -- MISSING {missing[:10]}"))
print(f" [{'PASS' if neg_ok else 'FAIL'}] negative control: nonce `{NONCE}` absent from source")
# ---- the measurement -------------------------------------------------
copy_hits = scan(copies, surfaces + [NONCE])
neg_ok = neg_ok and NONCE not in copy_hits
surv_renameable = {s: copy_hits[s] for s in renameable if s in copy_hits}
surv_sub = {s: copy_hits[s] for s in sub_threshold if s in copy_hits}
print(f"\n SURVIVING renameable: {len(surv_renameable)} of {len(renameable)}")
for s, where in sorted(surv_renameable.items(), key=lambda kv: -sum(kv[1].values()))[:40]:
tot = sum(where.values())
print(f" {s:<18} {tot:>6} hits across {len(where)} copies "
f"(detected in: {','.join(sorted(renameable[s]))})")
if len(surv_renameable) > 40:
print(f" ... and {len(surv_renameable) - 40} more")
print(f"\n SURVIVING sub-threshold (cap < {a.min_cap}, rename never saw them): "
f"{len(surv_sub)} of {len(sub_threshold)}")
for s, where in sorted(surv_sub.items(), key=lambda kv: -sum(kv[1].values()))[:15]:
print(f" {s:<18} {sum(where.values()):>6} hits")
# ---- phrase audit ----------------------------------------------------
# ⚠ The unigram scan above cannot see `Riders Quadrant` or `Fourth Wing`:
# every component is an ordinary word the detector correctly refuses. This
# pass is what caught them AFTER the unigram gate read 0 of 314.
surviving_phrases = {}
if a.phrase_map:
pm = json.loads(Path(a.phrase_map).read_text())
allow = set(pm.get("allow", []))
PH = re.compile(r"\b([A-Z][a-z]{2,}(?: [A-Z][a-z]{2,}){1,2})\b")
src_ph = Counter()
for t in source.values():
src_ph.update(PH.findall(t))
cop_ph = Counter()
for t in copies.values():
cop_ph.update(PH.findall(t))
# A heading word cannot start a leak: `Chapter Twenty` is the book's own
# scaffolding, not the author's invention.
STRUCT = ("Chapter", "Prologue", "Epilogue", "Part", "Appendix", "Volume", "Book")
audited = {p for p, n in src_ph.items()
if n >= a.phrase_min and not p.startswith(STRUCT)} - allow
surviving_phrases = {p: {"source": src_ph[p], "copies": cop_ph[p]}
for p in audited if cop_ph[p] > 0}
print(f"\n PHRASE AUDIT: {len(audited)} capitalised 2-3grams recur >= {a.phrase_min} "
f"times in the source ({len(allow)} allow-listed as real-world/generic)")
print(f" SURVIVING phrases: {len(surviving_phrases)}")
for ph, w in sorted(surviving_phrases.items(), key=lambda kv: -kv[1]["source"])[:30]:
print(f" {ph:<34} source {w['source']:>4} copies {w['copies']:>5}")
# ---- separator-split audit --------------------------------------------
# ⚠ Its own controls, because a scan that only ever sees clean text cannot tell `absent`
# from `blind` -- the same argument that put the positive control on the unigram pass.
split_surv: dict[str, dict] = {}
split_ok = True
if not a.no_split_scan:
allow = set()
if a.phrase_map:
allow = set(json.loads(Path(a.phrase_map).read_text()).get("split_allow", []))
probe = next((s for s in sorted(surfaces, key=len, reverse=True)
if len(s) >= 4 and TOKEN.fullmatch(s)), None)
if probe:
# POSITIVE: the same surface with one separator inserted MUST be detected.
planted = {"__control__": f"the {probe[0]} {probe[1:]} rode on"}
pos = split_scan(source, planted, [probe], a.split_frag_max, set())
# NEGATIVE: the same split nonce, hunted in the REAL copies, must NOT be found.
# ⚠ The first version of this control planted the nonce in its own probe text and
# then asserted it was absent, so it failed by construction on every run. A control
# that cannot pass is not a control; it is an alarm wired to itself.
neg = split_scan(source, copies, [NONCE], a.split_frag_max, set())
split_ok = bool(pos) and not neg
print(f"\n [{'PASS' if split_ok else 'FAIL'}] split-scan controls: planted "
f"`{probe[0]} {probe[1:]}` {'detected' if pos else 'MISSED'}, split nonce "
f"{'absent' if not neg else 'FALSELY DETECTED'}")
split_surv = split_scan(source, copies, surfaces, a.split_frag_max, allow)
print(f" SEPARATOR-SPLIT survivors: {len(split_surv)} surfaces hidden from the "
f"unigram scan by a space, hyphen or underscore inside the name")
for s_, w in sorted(split_surv.items(), key=lambda kv: -sum(kv[1]["forms"].values())):
print(f" {s_:<18} {sum(w['forms'].values()):>5} hits in {w['copies']} copies "
f"as {', '.join(repr(k) for k in sorted(w['forms']))}")
if a.report:
Path(a.report).write_text(json.dumps({
"renameable_total": len(renameable), "sub_threshold_total": len(sub_threshold),
"controls": {"positive_pass": pos_ok, "negative_pass": neg_ok, "missing": missing},
"surviving_renameable": {s: {"hits": sum(w.values()), "copies": len(w),
"detected_in": sorted(renameable[s])}
for s, w in surv_renameable.items()},
"surviving_sub_threshold": {s: {"hits": sum(w.values()), "copies": len(w)}
for s, w in surv_sub.items()},
"surviving_phrases": surviving_phrases,
"split_scan_ran": not a.no_split_scan,
"split_scan_controls_pass": split_ok,
"split_frag_max": a.split_frag_max,
"surviving_separator_split": split_surv,
}, ensure_ascii=False, indent=2), encoding="utf-8")
print(f"\n wrote {a.report}")
if not (pos_ok and neg_ok and split_ok):
print("\n== CONTROLS FAILED -- this gate's verdict is not trustworthy"); return 2
if surv_renameable or surv_sub or surviving_phrases or split_surv:
print(f"\n== GATE FAILED: {len(surv_renameable) + len(surv_sub)} source entities, "
f"{len(surviving_phrases)} phrases and {len(split_surv)} separator-split "
f"surfaces survive"); return 1
print("\n== GATE PASSED: 0 source entities, 0 audited phrases and 0 separator-split "
"surfaces survive in any copy")
print(f" ⚠ sensitivity floor: a name appearing fewer than {a.min_cap} times per work is "
f"never detected, and a phrase recurring fewer than {a.phrase_min} times is never "
f"audited. Neither is renamed, and neither is reported here.")
return 0
if __name__ == "__main__":
sys.exit(main())