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:
@@ -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.")
|
||||
|
||||
Reference in New Issue
Block a user