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
+149 -3
View File
@@ -70,6 +70,31 @@ REGISTERS = {
"analysis and moral reflection, occasional direct address to the reader, and "
"physical setting — Yorkshire weather, schoolrooms, Belgian pensionnats — "
"rendered with the feeling it carries"),
# ⭐ McCarthy is the first register in this map that names PUNCTUATION, and that is a
# GATE-DESIGN choice made before any McCarthy number existed, not a description choice.
# The eval harness drives the base (unadapted) control arm with this same prompt via
# `gen_beats_chat_yarros.py --system-from <pairs provenance>`, and `voice_distance.py` is
# Burrows's Delta over CHARACTER BIGRAMS. An adapter that learns only "emit no quotation
# marks" moves delta_cb a long way without having learned a sentence -- and on a corpus
# measuring 0.0 quote marks per 10k words against Hemingway's 838 that is the single
# cheapest available trick. Stating the tics here hands them to the control arm too, so
# the adapter earns no delta for them and the remaining gap is attributable to sentence
# structure, which is what the axis claims to measure. lv-mccarthy D1 pre-registered a
# punctuation-normalised SECONDARY read for exactly this risk; this closes it in the
# PRIMARY read as well. Cost, stated up front: the voice axis gets harder, and on an
# underpowered fixture that risks a Brontë-style marginal result. McCarthy's val split
# yields 269 in-band passages against Brontë's 44, which is the reason that trade is
# affordable here and was not there.
# ⚠ Deliberately NOT mentioned: the untranslated Spanish dialogue of the Border Trilogy.
# It is a property of 3 of the 6 works, not of the voice, and inviting an LLM to produce
# Spanish on a beat that has none is damage rather than register.
"mccarthy": ("Cormac McCarthy", "third-person past-tense narration held at the surface of "
"things, with no access to what anyone thinks; long sentences strung together "
"on `and`, set against clipped fragment paragraphs; dialogue carried WITHOUT "
"quotation marks, each speech its own paragraph, attributed plainly or not at "
"all; contractions written with no apostrophe — `dont`, `aint`, `wont`, "
"`didnt`; terrain, animals, weather and tools named concretely and "
"technically; violence and landscape rendered flatly and without comment"),
}
CONTEXT_BLOCK = """The passage is preceded by this, for reference only. Do NOT write a beat for it — it is
@@ -135,6 +160,8 @@ def post(path: str, payload: dict, key: str, timeout: int = 120) -> dict:
HEADING_MAX_WORDS = 6
# A dash-separated fragment list: the shape of a chapter argument, not of prose.
ARGUMENT_LIST = re.compile(r"\s[-\u2013\u2014]\s")
def drop_leading_heading(paras: list[str], enabled: bool) -> list[str]:
@@ -152,10 +179,112 @@ def drop_leading_heading(paras: list[str], enabled: bool) -> list[str]:
"""
if not enabled or not paras:
return paras
return paras[1:] if len(paras[0].split()) <= HEADING_MAX_WORDS else paras
if len(paras[0].split()) <= HEADING_MAX_WORDS:
paras = paras[1:]
# ⭐ AND THEN THE CHAPTER ARGUMENT, which is a heading that is 40 words long.
# Blood Meridian sets each chapter's argument as a dash-separated list of title-case
# fragments under the roman numeral -- `Desert castaways - The backtrack - A hideout -
# The wind takes a side - The judge returns` -- hard-wrapped across several short
# paragraphs. The <=6-word rule drops the `XXI` above it and leaves the argument, so a
# chapter-opening passage trains the carrier to emit a dash-separated summary before the
# prose. Same harm as the bare heading, a different shape.
# Measured: 131 paragraphs, ALL in blood-meridian; 0 in the other five McCarthy works,
# 0 in all ten Hemingway works and 0 in all four Brontë works, so this extension is a
# byte no-op on every pair set already shipped under this flag.
while paras and ARGUMENT_LIST.search(paras[0]) and len(paras[0].split()) <= 20:
paras = paras[1:]
return paras
def chunk(corpus: Path, lo: int, hi: int, split: str, drop_heading: bool = False) -> list[dict]:
# A line that ends a sentence, after detached terminal punctuation is closed up: this corpus
# carries 151 occurrences of `boxcutter .` in The Road, and a naive test reads those as
# mid-sentence and joins across a real paragraph break.
SENT_FINAL = re.compile(r'[.!?"\u201d]$')
DETACHED_PUNCT = re.compile(r"\s+([.,;:!?])")
def reflow_hard_wraps(paras: list[str], enabled: bool) -> tuple[list[str], int, int]:
"""Undo an extraction's HARD LINE WRAPPING inside a paragraph. Returns (paras, joined, split).
⚠ DEFECT 4 of this pair build, and unlike the first three it was MEASURED ON A SHIPPED
ADAPTER before it was fixed here. `lv-bronte` trained on a corpus whose four works are
100% hard-wrapped at ~68 characters (Gutenberg plain text), and the wrap transfers
straight through to the product:
arm intra-para breaks MID-SENTENCE per 1k chars
bronte base (control) 0 0 0.00
bronte ckpt475 (SHIPPED) 1,251 1,219 12.46
bronte ckpt925 1,134 1,087 11.79
hemingway base/ckpt*/all 58/0/0 0 0.00 <- 0% wrapped corpus
Both controls fire: the base arms emit none, so the carrier is not the source, and the
Hemingway arms emit none, so the instrument is not manufacturing signal. `score_beats.py`
does not look for this and passed Brontë's damage axis anyway (ran-on +0.15 / 0.400 floor),
so nothing downstream would have reported it.
McCarthy is the MIXED case, which is worse to learn than either pure one: The Road is
hard-wrapped (3,587 intra-paragraph newlines, 60.9 per 1k words) and the other five works
have exactly ZERO, so the corpus teaches the break as a coin flip. The transform is
therefore SELF-TARGETING -- a paragraph with no interior newline is returned untouched --
and needs no per-work special-casing.
⚠⚠ THE OBVIOUS FIX -- join every interior newline with a space -- CORRUPTS THE THING THIS
ADAPTER EXISTS TO LEARN. 46 paragraphs in The Road are two-speaker exchanges whose blank
line was lost, and McCarthy's dialogue is unmarked, so the speaker boundary IS the
paragraph boundary:
"And we're carrying the fire." || 'Yes.'
"I'm going to blow out the lamp." || 'Is that okay?'
'Take me with you, the boy said.' || 'He looked as if he was going to cry.'
Joining those puts two speakers in one paragraph and unmarked dialogue stops parsing.
⚠ A WRAP-WIDTH test cannot separate them either, and this was measured rather than
assumed: the wrap is POSITION-DEPENDENT -- first lines of a paragraph break at ~30
characters and later lines at ~78 -- so `We're not the first ones here.` (29) is
geometrically indistinguishable from a full line, and a max-line-length rule splits 347
paragraphs of which the majority are genuine wraps (`If you died I would want to die` ||
`too.`).
So the discriminator is PUNCTUATION, and the error budget is deliberately asymmetric:
- Li does NOT end sentence-final -> a wrap. JOIN with a space. 3,315 of 3,587 (92.4%),
and this is the ENTIRE defect class: a mid-sentence newline is the thing the Brontë
adapter learned to emit.
- Li DOES end sentence-final -> promote the newline to a paragraph BREAK. 272 cases.
Correct for all 46 dialogue exchanges; for some fragmentary narration it inserts a
paragraph break the book does not have.
That residual is the cheap error on purpose. A spurious break inside McCarthy narration is
invisible -- the page is already full of one-line fragment paragraphs -- while a merged
pair of speakers is a form error in the corpus's most distinctive feature. Splitting
where the book does not costs paragraphing; joining where the book does not costs the
voice.
"""
if not enabled:
return paras, 0, 0
out: list[str] = []
joined = promoted = 0
for para in paras:
lines = [x.strip() for x in para.split("\n") if x.strip()]
if len(lines) < 2:
out.append(para)
continue
buf = [lines[0]]
for nxt in lines[1:]:
if SENT_FINAL.search(DETACHED_PUNCT.sub(r"\1", buf[-1])):
out.append(" ".join(buf))
buf = [nxt]
promoted += 1
else:
buf.append(nxt)
joined += 1
out.append(" ".join(buf))
return out, joined, promoted
def chunk(corpus: Path, lo: int, hi: int, split: str, drop_heading: bool = False,
reflow: bool = False) -> list[dict]:
"""Accumulate consecutive paragraphs into product-band passages, per chapter.
Chapter-bounded so a passage never straddles a chapter break. The trailing buffer of
@@ -163,12 +292,16 @@ def chunk(corpus: Path, lo: int, hi: int, split: str, drop_heading: bool = False
teach the length the product is trying to hold.
"""
out = []
reflow_joined = reflow_split = 0
for f in sorted(corpus.glob("*.copy0.jsonl")):
for line in f.read_text(encoding="utf-8").splitlines():
d = json.loads(line)
if d.get("split") != split:
continue
paras = [p.strip() for p in re.split(r"\n\s*\n", d["text"]) if p.strip()]
paras, _j, _s = reflow_hard_wraps(paras, reflow)
reflow_joined += _j
reflow_split += _s
paras = drop_leading_heading(paras, drop_heading)
buf, n, prev = [], 0, None
for p in paras:
@@ -181,6 +314,9 @@ def chunk(corpus: Path, lo: int, hi: int, split: str, drop_heading: bool = False
else:
prev = None # oversized run dropped; context would be a lie
buf, n = [], 0
if reflow:
print(f"[reflow] {reflow_joined} wrapped lines rejoined, "
f"{reflow_split} interior newlines promoted to paragraph breaks", flush=True)
return out
@@ -316,6 +452,14 @@ def main() -> int:
ap.add_argument("--drop-leading-heading", action="store_true",
help="DEFECT 3 fix: drop a unit's opening block when it is a bare chapter "
"heading. OFF by default so the BabyYarros pair set stays byte-reproducible.")
ap.add_argument("--reflow-hard-wraps", action="store_true",
help="DEFECT 4 fix: rejoin lines an extraction hard-wrapped mid-sentence "
"inside a paragraph. MEASURED on the shipped lv-bronte adapter, which "
"emits mid-sentence line breaks at 12.5 per 1k chars against 0.00 for "
"its own base control. Self-targeting -- a no-op on any work whose "
"paragraphs hold no interior newline, which is 5 of 6 McCarthy works "
"and all 10 Hemingway works. OFF by default so the Yarros, Hemingway "
"and Brontë pair sets stay byte-reproducible.")
ap.add_argument("--control-in", default=None,
help="JSON of hand-written beats [{idx,beat}]; generate beats for the SAME "
"passages and print side by side. The positive control -- a beat "
@@ -328,7 +472,8 @@ def main() -> int:
author, register = REGISTERS[a.register]
sys_prompt = SYS_PASSAGE.format(author=author, register=register)
key = a.key or (Path(a.key_file).read_text().strip() if a.key_file else None)
passages = chunk(Path(a.corpus), a.lo, a.hi, a.split, a.drop_leading_heading)
passages = chunk(Path(a.corpus), a.lo, a.hi, a.split, a.drop_leading_heading,
a.reflow_hard_wraps)
print(f"[chunk] {len(passages)} passages in split={a.split}, band {a.lo}-{a.hi}", flush=True)
if not passages:
print("REFUSING: no passages -- wrong corpus dir or split", file=sys.stderr)
@@ -474,6 +619,7 @@ def main() -> int:
"system_prompt": sys_prompt,
"register": a.register or "yarros-frozen-one-paragraph",
"drop_leading_heading": a.drop_leading_heading,
"reflow_hard_wraps": a.reflow_hard_wraps,
}
Path(str(out_path) + ".provenance.json").write_text(json.dumps(prov, indent=2), encoding="utf-8")
print(f"[done] {kept} pairs -> {out_path}")