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