ba8dac2c80
scripts/r49-corpus/{build_corpus,verify_corpus}.py; corpus staged at
gx10:~/r49-corpus/. Catalogue ids verified against gutenberg.org's own search
rather than recalled. Charlotte only -- the Bell poems are co-authored and the
Gaskell biography is a different hand, so neither belongs in a single-voice corpus.
Jane Eyre 1260 · Villette 9182 · Shirley 30486 · The Professor 1028
680,291 words · 142 chapters · 950,974 Qwen3 tokens (1.40 tok/word)
alphabet 75 letters, 23 non-ASCII · round-trip lossless · 0 byte-fallback
All 11 acceptance checks pass, including both tokenizer legs run against the pilot
carrier itself. With a real denominator the projections tighten: at 6 rename copies
x 3 epochs = 17.1M tokens, the 0.6B pilot is 1.98 h.
THE ALPHABET INVERTS THE YARROS RESULT. Brontë writes French constantly -- Villette
is set in a French-speaking city, Jane Eyre has Adèle, The Professor is set in
Brussels -- so the corpus carries é 432, è 237, à 93, ê 79, ô 48 plus œ and æ. F02
measured Yarros at 0.0002% non-ASCII and derived an ASCII-fold for the name pool.
Under F02's own subset rule the Brontë pool may keep FRENCH accents and must still
exclude the Czech/Latvian/Slovak/Hungarian marks that never appear here. The fold is
per-work, and this is the first corpus where deriving it changes the answer.
Typography was inconsistent across works and it was the transcriber, not the author:
Shirley uses straight quotes and `--` with zero em-dashes while Jane Eyre and
Villette use curly and em-dash. Normalised toward what the text means.
Three defects, each found by running something rather than reasoning about it:
`Produced by` matched Brontë's own prose four times, which is the adjective-"minor"
shape again and is fixed by anchoring boilerplate patterns to line start; asserting
open/close quote counts must be equal is wrong, because 19th-century multi-paragraph
speech legitimately runs a surplus of opens, so the real error signature is that no
paragraph may begin with a closing quote; and The Professor's table of contents puts
two chapter names per line, so a bare regex returns 38 headings for a 25-chapter
novel and a minimum-gap filter still leaks its tail -- the rule that works is that
the body's "CHAPTER I" is the last one in the file.
Records the operator's pilot ruling: trial on Qwen3-0.6B-Base first, move up only if
it produces something useful.
109 lines
5.3 KiB
Python
109 lines
5.3 KiB
Python
"""R49 D1 acceptance gate for a built corpus.
|
|
|
|
The design doc's D1 acceptance is "clean UTF-8, chapter-segmented, zero
|
|
boilerplate lines, stable tokenization". Each is checked here as something that
|
|
can actually go RED -- a gate that cannot fail is the third failure mode this
|
|
target has already recorded, and it is not repeated here.
|
|
|
|
python verify_corpus.py <corpus-dir> [--tokenizer PATH]
|
|
"""
|
|
from __future__ import annotations
|
|
import argparse, collections, json, re, sys, unicodedata
|
|
from pathlib import Path
|
|
|
|
#: ⚠ Anchored to line start, and that is not cosmetic. The first draft matched
|
|
#: `Produced by` anywhere and went RED on four hits that were all Charlotte
|
|
#: Bronte's own prose -- "a chilling effect produced by his steady announcement",
|
|
#: "how such a result was produced by such means". A hard rule on a phrase with a
|
|
#: common non-boilerplate sense manufactures failures; same shape as the drift
|
|
#: detector that fired on the adjective "minor" and stopped work three times.
|
|
#: Gutenberg credits always begin a line, so require that.
|
|
BOILER = [r"^.*PROJECT GUTENBERG.*$", r"^.*gutenberg\.org.*$", r"^\s*Produced by\b",
|
|
r"^\s*E-text prepared by\b", r"^\s*Transcribed from\b",
|
|
r"^\s*Distributed Proofread", r"^\*\*\*\s*(?:START|END) OF"]
|
|
|
|
ap = argparse.ArgumentParser()
|
|
ap.add_argument("corpus")
|
|
ap.add_argument("--tokenizer", default=None)
|
|
a = ap.parse_args()
|
|
root = Path(a.corpus)
|
|
man = json.loads((root / "manifest.json").read_text())
|
|
alpha = json.loads((root / "corpus_alphabet.json").read_text())
|
|
|
|
records = []
|
|
for w in man["works"]:
|
|
for line in (root / w["path"]).read_text(encoding="utf-8").splitlines():
|
|
records.append(json.loads(line))
|
|
text = "\n\n".join(r["text"] for r in records)
|
|
fails = []
|
|
|
|
def check(name, ok, detail=""):
|
|
print(f" [{'PASS' if ok else 'FAIL'}] {name}{(' -- ' + detail) if detail else ''}")
|
|
if not ok:
|
|
fails.append(name)
|
|
|
|
print(f"== {len(records)} chapters, {sum(r['words'] for r in records):,} words, {len(text):,} chars\n")
|
|
|
|
# 1. boilerplate
|
|
hits = {p: len(re.findall(p, text, re.I | re.M)) for p in BOILER}
|
|
bad = {p: n for p, n in hits.items() if n}
|
|
check("zero Gutenberg boilerplate", not bad, f"found {bad}" if bad else "7 patterns, 0 hits")
|
|
|
|
# 2. structure
|
|
per_work = collections.Counter(r["work"] for r in records)
|
|
seq_ok = all(
|
|
[r["chapter"] for r in records if r["work"] == w] == list(range(1, per_work[w] + 1))
|
|
for w in per_work)
|
|
check("chapters number 1..N with no gaps", seq_ok, ", ".join(f"{w}:{n}" for w, n in per_work.items()))
|
|
check("no empty chapters", all(r["words"] > 100 for r in records),
|
|
f"min {min(r['words'] for r in records)} words")
|
|
|
|
# 3. typography consistency AFTER normalisation -- the reason normalisation exists
|
|
counts = collections.Counter(text)
|
|
straight = counts['"'] + counts["'"]
|
|
dbl_hyphen = len(re.findall(r"(?<!-)--(?!-)", text))
|
|
check("no straight quotes survive", straight == 0, f'" {counts[chr(34)]}, \' {counts[chr(39)]}')
|
|
check("no `--` survives", dbl_hyphen == 0, f"{dbl_hyphen} occurrences")
|
|
#: An open/close COUNT mismatch is not an error here and asserting equality was
|
|
#: a bad gate. Nineteenth-century convention runs a speech across paragraphs by
|
|
#: opening each one and closing only the last, so every work carries a surplus of
|
|
#: opens -- measured +46 / +49 / +51 on the three works whose quotes were never
|
|
#: touched. The real error signature is a paragraph that BEGINS with a closing
|
|
#: quote, which convention never produces and a bad conversion does.
|
|
paras = [p.strip() for p in text.split("\n\n") if p.strip()]
|
|
lead_close = [p[:60] for p in paras if p.lstrip()[:1] == chr(0x201d)]
|
|
check("no paragraph opens with a closing quote", not lead_close,
|
|
f"{len(lead_close)} of {len(paras):,} paragraphs" + (f" e.g. {lead_close[0]!r}" if lead_close else ""))
|
|
surplus = counts[chr(0x201c)] - counts[chr(0x201d)]
|
|
print(f" open-quote surplus {surplus:+} of {counts[chr(0x201c)]:,} "
|
|
f"(multi-paragraph speech; expected, not a failure)")
|
|
|
|
# 4. alphabet is the real inventory
|
|
observed = {c for c in text if c.isalpha()}
|
|
check("alphabet matches the text exactly", observed == set(alpha["letters"]),
|
|
f"declared {len(alpha['letters'])}, observed {len(observed)}, "
|
|
f"diff {sorted(observed ^ set(alpha['letters']))}")
|
|
|
|
# 5. no control / exotic codepoints
|
|
weird = {c for c in text if unicodedata.category(c) in ("Cc", "Cf", "Co", "Cs") and c != "\n"}
|
|
check("no control or private-use codepoints", not weird, repr(sorted(weird)))
|
|
|
|
# 6. tokenizer stability -- F02's byte-fallback lesson, on the real carrier
|
|
if a.tokenizer:
|
|
from transformers import AutoTokenizer
|
|
tok = AutoTokenizer.from_pretrained(a.tokenizer)
|
|
sample = text[:400000]
|
|
ids = tok.encode(sample, add_special_tokens=False)
|
|
back = tok.decode(ids)
|
|
check("tokenizer round-trip is lossless", back == sample,
|
|
f"{len(ids):,} tokens from {len(sample):,} chars")
|
|
pieces = tok.convert_ids_to_tokens(ids)
|
|
fallback = [p for p in pieces if "�" in p]
|
|
check("no byte-fallback pieces", not fallback,
|
|
f"{len(fallback)} of {len(pieces):,} pieces")
|
|
total = len(tok.encode(text, add_special_tokens=False))
|
|
print(f"\n full corpus = {total:,} tokens ({total/sum(r['words'] for r in records):.2f} tok/word)")
|
|
|
|
print(f"\n== {'ALL CHECKS PASSED' if not fails else 'FAILED: ' + ', '.join(fails)}")
|
|
sys.exit(1 if fails else 0)
|