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.
This commit is contained in:
vh
2026-09-17 11:37:33 -07:00
parent 4dce0d0a43
commit c55966433f
5 changed files with 452 additions and 10 deletions
+109 -5
View File
@@ -27,6 +27,65 @@ 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())
@@ -79,6 +138,14 @@ def main() -> int:
"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()
@@ -158,6 +225,37 @@ def main() -> int:
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),
@@ -168,15 +266,21 @@ def main() -> int:
"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):
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:
print(f"\n== GATE FAILED: {len(surv_renameable) + len(surv_sub)} source entities and "
f"{len(surviving_phrases)} phrases survive"); return 1
print("\n== GATE PASSED: 0 source entities and 0 audited phrases survive in any copy")
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.")