"""D1b for lv-bronte — repair the Gutenberg source before anything reads it. Two defects, both measured on this corpus, both of which survive silently into a trained adapter if nothing removes them. 1. PUBLISHER BACK MATTER, 1,922 words, Shirley only. The final unit carries a T. Nelson & Sons printer's block, a `THE NELSON CLASSICS` catalogue advertising Ainsworth, Marryat, Verne, Kingsley and Dickens, and a Project Gutenberg transcriber's note listing punctuation corrections. None of it is Brontë. It is there for the structural reason the Hemingway run recorded: a splitter cuts on headings, and nothing follows the final one, so whatever the edition appends rides inside the last chapter. It is also where the entity `CHARLES` (12 occurrences) came from — another author's given name, in the training text. 2. GUTENBERG ITALIC MARKUP `_like this_`, 1,344 spans across three works (The Professor's edition has none). Two separate harms. It teaches the adapter to emit underscores as prose. And `_` is a WORD CHARACTER in regex, so the leak gate's `\\b(Name)\\b` scan cannot match inside `_Antigua_` — an entity wrapped in italics is invisible to the gate, which is the same failure shape as Yarros's possessive-only `Afendra`: never renamed AND never reported. Measured blast radius here is exactly one entity, but "one" is a fact about this corpus, not a property of the defect. Writes a repaired copy; never mutates the source tree. """ from __future__ import annotations import argparse, json, re, shutil from pathlib import Path #: The printed end-of-novel marker, alone on its line. END = re.compile(r"\nTHE END\.?\s*\n") #: Fingerprints that distinguish a real apparatus block from a stray phrase. APPARATUS = re.compile(r"NELSON|PUBLISHERS|CLASSICS|Transcriber|LIBRARY|Illustration", re.I) #: `_word_` / `_several words_`. A span MAY wrap across a single newline — 24 of #: them do here (`_sotto\nvoce_`, `_ignis\nfatuus_`), and a newline-free pattern #: silently leaves exactly those behind — but never across a blank line, which #: would let an unbalanced underscore swallow whole paragraphs. ITALIC = re.compile(r"_((?:[^_\n]|\n(?!\n)){1,200})_") def main() -> int: ap = argparse.ArgumentParser() ap.add_argument("corpus") ap.add_argument("--out", required=True) a = ap.parse_args() src, dst = Path(a.corpus), Path(a.out) if dst.exists(): shutil.rmtree(dst) shutil.copytree(src, dst) man = json.loads((dst / "manifest.json").read_text()) print(f" {'work':<16}{'words in':>10}{'words out':>11}{'back matter':>13}{'italic spans':>14}") total_back = total_ital = 0 for w in man["works"]: p = dst / w["path"] rows = [json.loads(l) for l in p.read_text(encoding="utf-8").splitlines() if l.strip()] before = sum(len(r["text"].split()) for r in rows) # (1) back matter — only the LAST unit can carry it stripped = 0 last = rows[-1] m = END.search(last["text"]) if m and APPARATUS.search(last["text"][m.end():]): stripped = len(last["text"][m.end():].split()) last["text"] = last["text"][: m.start()].rstrip() + "\n" # (2) italic markup, every unit spans = 0 for r in rows: r["text"], n = ITALIC.subn(r"\1", r["text"]) spans += n after = sum(len(r["text"].split()) for r in rows) p.write_text("\n".join(json.dumps(r, ensure_ascii=False) for r in rows) + "\n", encoding="utf-8") w["words"] = after total_back += stripped total_ital += spans print(f" {w['slug']:<16}{before:>10}{after:>11}{stripped:>13}{spans:>14}") man["total_words"] = sum(w["words"] for w in man["works"]) man["normalisation"] = (man.get("normalisation", "") + " | back-matter stripped + Gutenberg italic markup removed").strip(" |") (dst / "manifest.json").write_text(json.dumps(man, ensure_ascii=False, indent=2), encoding="utf-8") # Read back what we claim to have done, rather than asserting it. full = "\n".join((dst / w["path"]).read_text(encoding="utf-8") for w in man["works"]) residue = {p: full.count(p) for p in ("CHARLES KINGSLEY", "CHARLES DICKENS", "NELSON", "Transcriber", "JULES VERNE")} ital_left = len(ITALIC.findall(full)) print(f"\n stripped {total_back} words of back matter · unwrapped {total_ital} italic spans") print(f" read-back: apparatus residue {residue} · italic spans remaining {ital_left}") if any(residue.values()) or ital_left: print("== REPAIR INCOMPLETE") return 1 print(f" total_words -> {man['total_words']}") return 0 if __name__ == "__main__": raise SystemExit(main())