feat(lv-mccarthy): pre-register the v2 gate before any arm is generated

Freezes the lv-mccarthy gate design while no McCarthy generation exists, per the
lv-hemingway precedent: a threshold chosen after seeing the numbers is not a
threshold. Three arms (base-unadapted, ckpt900, ckpt450), 60 beats, 4 seeds.

Settles the voice-axis question D1 deferred. McCarthy's corpus measures 0.0 quote
marks per 10k against Hemingway's 838, so "emit no quotation marks" is the cheapest
way to move a char-bigram Delta without learning a sentence. Three parts:

  PRIMARY    unchanged -- the mccarthy register names the punctuation and
             --system-from drives the base control with the same prompt, so the
             cheap win is handed to both sides.
  SECONDARY  voice_distance.py --secondary-normalised re-runs the whole analysis
             with punctuation stripped from the reference and every arm. A
             conservative lower bound; reported, never the verdict.
  TRIGGER    voice_distance.py --punct-report evaluates a pre-registered line --
             base-unadapted quote density above 100 per 10k means the control did
             not take the win it was handed, and the normalised read is promoted
             to load-bearing. 100 is the order-of-magnitude line between this
             corpus's 0.0 and Hemingway's 838, chosen now rather than after the
             table prints.

ckpt450 is NOT tied with the minimum (+4.9x the 0.00393 median neighbour jitter)
and the pre-registration says so. It is generated to test a stated prior and to
price the memorisation headroom an earlier checkpoint buys on an in-copyright
author, with a decision rule that says exactly what result would let it displace
ckpt900.

Instrument controls, run before this landed:
  - the voice_distance.py refactor reproduces the shipped lv-hemingway
    voice_distance.txt BYTE FOR BYTE on the default path
  - strip_punct drops a quote-bearing sample from 2500.0 to 0.0 marks per 10k
  - the secondary read still resolves a gap on the Hemingway arms (+0.410 at
    7.3x floor), so a null on McCarthy would be a finding, not a blind detector

Pre-flight re-run at gate time rather than quoted from 2026-09-17:
  - leak gate: 0 of 75 renameable, 0 of 37 sub-threshold, 0 separator-split,
    four controls green
  - beat-contamination audit: 0 of 3942 beats AND 0 of 3942 responses, against
    Hemingway's 70 of 7094 -- build_sft_pairs.py --source-entities earned its
    mandatory flag

Also records three provenance defects found on first read of the run and their
disposition: the hardcoded "r49-babyyarros-pairs-pilot" run label (cosmetic, same
literal on all three runs), the empty harness_commit (all three runs), and a
pairs_sha256_16 that is not a sha256sum of the file (consistent across runs, so a
cache key rather than a fault). The run is bound to McCarthy's pairs by record
count, not by the label.
This commit is contained in:
Vuong Hoang
2026-09-21 14:39:10 -07:00
parent e52def115c
commit 9c8a4e959c
4 changed files with 538 additions and 35 deletions
+160 -35
View File
@@ -19,12 +19,65 @@ absolute-band claim. The A-vs-A floor below is the only thing that makes a betwe
gap meaningful — half-vs-half of the held-out reference gives the distance the metric
returns for two samples of the SAME author, so a between-arm gap smaller than that floor
is not a finding.
⭐ TWO OPT-IN READS ADDED FOR lv-mccarthy (2026-09-21), both pre-registered in
`scripts/mccarthy-corpus/GATE-PREREG.md` before any McCarthy generation existed. Neither
runs by default and neither changes a byte of the default output, because the Brontë,
Yarros and Hemingway records were written by the default path and must stay reproducible.
--secondary-normalised Re-runs the WHOLE analysis with punctuation stripped from the
reference and from every arm. McCarthy's corpus measures 0.0
quote marks per 10k words against Hemingway's 838, so "emit no
quotation marks" is the single cheapest way to move a
char-bigram distance without having learned a sentence. This
read is a deliberately CONSERVATIVE LOWER BOUND: it also strips
terminal punctuation, and therefore strips sentence-length
signal the adapter legitimately learned. It is reported, it is
never the verdict.
--punct-report Per-arm punctuation density against the reference. This is the
check that says whether the PRIMARY read is confounded at all:
the eval harness hands the base control the same register
prompt, tics included, so if base COMPLIES its quote density
sits near the corpus's and the adapter earns no delta for the
cheap win. If base IGNORES the instruction, the primary gap is
partly punctuation and the caller must say so. The trigger is
pre-registered, not chosen here: see PUNCT_CONFOUND_PER_10K.
"""
from __future__ import annotations
import argparse, json, re, sys, statistics as st
from collections import Counter
from pathlib import Path
# ⚠ PRE-REGISTERED, in GATE-PREREG.md, before any McCarthy arm was generated. If the base
# control's quote-mark density exceeds this, the control did NOT take the punctuation win
# it was instructed to take, the primary delta_cb gap is partly that win, and the
# normalised secondary read is promoted to load-bearing. The corpus measures 0.0 per 10k
# and Hemingway's measures 838; 100 is the order-of-magnitude line between them.
PUNCT_CONFOUND_PER_10K = 100.0
QUOTE_CHARS = "\"'‘’“”«»‹›‚„`"
_PUNCT_RE = re.compile(r"[^\w\s]|_", re.UNICODE)
_QUOTE_RE = re.compile("[" + re.escape(QUOTE_CHARS) + "]")
# a contraction apostrophe is one sitting BETWEEN letters -- `dont` vs `don't` is the tic
# the register names, and a possessive or a quote mark is not the same measurement.
_CONTRACTION_APOS_RE = re.compile(r"(?<=[A-Za-z])['’](?=[A-Za-z])")
_DASH_RE = re.compile("[—–]|--")
def identity(text: str) -> str:
return text
def strip_punct(text: str) -> str:
"""Remove every punctuation mark, keeping letters, digits and word boundaries.
Deliberately blunt. The point is not to isolate one tic but to remove the entire
punctuation channel, so that whatever gap survives is carried by words and their
morphology alone. Underscore is stripped explicitly because `\\w` keeps it.
"""
return re.sub(r"\s+", " ", _PUNCT_RE.sub(" ", text)).strip()
def bigrams(text: str) -> Counter:
t = re.sub(r"\s+", " ", text.lower())
@@ -42,32 +95,56 @@ def delta(arm_text: str, ref_prof: dict, mu: dict, sd: dict, keys: list[str]) ->
return st.mean(abs((ap[k] - mu[k]) / sd[k] - (ref_prof[k] - mu[k]) / sd[k]) for k in keys)
def main() -> int:
# ⚠ --author IS REQUIRED, and that is the fix for a defect this script shipped with.
# The reference label was hardcoded "Yarros". Run against Brontë it printed
# "reference: held-out Yarros" over Brontë's numbers, and that output is now sitting
# in a committed artifact saying the wrong author. A default would have kept the
# silent-wrong-label failure and only moved it; naming the author is one word at the
# call site and the label can no longer disagree with the data.
ap = argparse.ArgumentParser()
ap.add_argument("corpus", help="renamed corpus dir containing copies/ with split=val records")
ap.add_argument("evaldir", help="dir of voice.<arm>.jsonl; the control arm's name must "
"contain the substring `unadapted`")
ap.add_argument("--author", required=True,
help="reference author label, e.g. Hemingway. Required: see above.")
a = ap.parse_args()
corpus = Path(a.corpus)
evaldir = Path(a.evaldir)
author = a.author
# reference = held-out val text
val = []
for p in sorted((corpus / "copies").glob("*.jsonl")):
for l in p.read_text(encoding="utf-8").splitlines():
r = json.loads(l)
if r.get("split") == "val":
val.append(r["text"])
# dedup identical val chapters across copies (renaming aside, the same chapter recurs)
ref_text = "\n".join(dict.fromkeys(val))
def load_arms(evaldir: Path) -> list[tuple[str, list[dict]]]:
out = []
for f in sorted(evaldir.glob("voice.*.jsonl")):
recs = [json.loads(l) for l in f.read_text(encoding="utf-8").splitlines() if l.strip()]
out.append((f.stem.replace("voice.", ""), recs))
return out
def density(text: str, pattern: re.Pattern) -> float:
w = len(text.split()) or 1
return len(pattern.findall(text)) * 10000.0 / w
def punct_report(ref_text: str, arms: list[tuple[str, list[dict]]]) -> None:
"""Did the base control take the punctuation win the register prompt handed it?"""
print("\n PUNCTUATION DENSITY per 10k words -- the confound check, not an axis")
print(" (the eval harness drives EVERY arm with the same register prompt, tics included;")
print(" a compliant base control earns the adapter no delta_cb for them)")
print(f" {'arm':22s} {'quote-marks':>12s} {'contraction-apos':>18s} {'dashes':>9s}")
rows = [("held-out reference", ref_text)]
rows += [(a, "\n".join(r["continuation"] for r in recs)) for a, recs in arms]
base_q = None
for name, txt in rows:
q = density(txt, _QUOTE_RE)
print(f" {name:22s} {q:12.1f} {density(txt, _CONTRACTION_APOS_RE):18.1f} "
f"{density(txt, _DASH_RE):9.1f}")
if "unadapted" in name:
base_q = q
if base_q is None:
print(" ⚠ no arm name contains `unadapted` -- the control was not identified, so the")
print(" pre-registered confound trigger CANNOT be evaluated. This is not a pass.")
return
if base_q > PUNCT_CONFOUND_PER_10K:
print(f"\n ⚠⚠ CONFOUND TRIGGERED: base control quote density {base_q:.1f} > "
f"{PUNCT_CONFOUND_PER_10K:.0f} per 10k.")
print(" The control's output is punctuation-rich. Where the register prompt NAMES the")
print(" punctuation (lv-mccarthy does; lv-hemingway and lv-bronte do not), that means")
print(" the control did not take the win it was handed, so part of the primary")
print(" delta_cb gap is that win rather than sentence structure, and per the")
print(" pre-registration the NORMALISED secondary read becomes load-bearing. Where the")
print(" register does NOT name it, this is a description of the corpus, not a defect.")
else:
print(f"\n [PASS] base control quote density {base_q:.1f} <= "
f"{PUNCT_CONFOUND_PER_10K:.0f} per 10k: the control complied with the register,")
print(" so the punctuation win is handed to both sides and the primary read stands.")
def analyse(ref_text_raw: str, arms: list[tuple[str, list[dict]]], author: str,
transform=identity) -> None:
ref_text = transform(ref_text_raw)
# feature set: the most frequent bigrams in the reference (stable, high-signal)
keys = [k for k, _ in bigrams(ref_text).most_common(400)]
# mu/sd across the val text split into chunks, for z-scoring
@@ -88,14 +165,9 @@ def main() -> int:
print(f"same-author target (held-out {author} vs itself): delta_cb = {same_author:.3f}")
print(f" -> the floor of what any arm could reach; lower is more {author}-like, this is the best possible\n")
def arm_texts(f):
return [json.loads(l) for l in f.read_text(encoding="utf-8").splitlines()]
rows = []
for f in sorted(evaldir.glob("voice.*.jsonl")):
arm = f.stem.replace("voice.", "")
recs = arm_texts(f)
allt = "\n".join(r["continuation"] for r in recs)
for arm, recs in arms:
allt = transform("\n".join(r["continuation"] for r in recs))
d = delta(allt, ref_prof, mu, sd, keys)
# within-arm sampling spread = the REAL noise floor for a between-arm gap:
# split by seed and score each subset; the range is this metric's variance
@@ -103,7 +175,8 @@ def main() -> int:
by_seed = {}
for r in recs:
by_seed.setdefault(r["seed"], []).append(r["continuation"])
seed_ds = [delta("\n".join(v), ref_prof, mu, sd, keys) for v in by_seed.values() if len(v) > 2]
seed_ds = [delta(transform("\n".join(v)), ref_prof, mu, sd, keys)
for v in by_seed.values() if len(v) > 2]
spread = (max(seed_ds) - min(seed_ds)) if len(seed_ds) > 1 else float("nan")
rows.append((arm, d, len(allt.split()), seed_ds, spread))
@@ -131,7 +204,7 @@ def main() -> int:
if base_row is None:
print(f"\n all-arms noise floor (largest within-arm seed spread): {noise_all:.3f}")
print(" ⚠ no arm name contains `unadapted` -- no control identified, no verdict\n")
return 0
return
base_arm, base = base_row
print(f"\n all-arms noise floor (largest within-arm seed spread, lv-bronte's rule): {noise_all:.3f}")
print(f" PAIRWISE floor is the verdict: max(spread(candidate), spread({base_arm}) = "
@@ -160,6 +233,58 @@ def main() -> int:
print(f" ⚠ RELATIVE reading on one harness: {nseeds if len(nseeds) > 1 else nseeds[0]} "
f"seed group(s) per arm, scored against this corpus's own held-out split. "
f"It is not an absolute-band claim and corroborates nothing on its own.")
def main() -> int:
# ⚠ --author IS REQUIRED, and that is the fix for a defect this script shipped with.
# The reference label was hardcoded "Yarros". Run against Brontë it printed
# "reference: held-out Yarros" over Brontë's numbers, and that output is now sitting
# in a committed artifact saying the wrong author. A default would have kept the
# silent-wrong-label failure and only moved it; naming the author is one word at the
# call site and the label can no longer disagree with the data.
ap = argparse.ArgumentParser()
ap.add_argument("corpus", help="renamed corpus dir containing copies/ with split=val records")
ap.add_argument("evaldir", help="dir of voice.<arm>.jsonl; the control arm's name must "
"contain the substring `unadapted`")
ap.add_argument("--author", required=True,
help="reference author label, e.g. Hemingway. Required: see above.")
ap.add_argument("--secondary-normalised", action="store_true",
help="ALSO run the whole analysis with punctuation stripped from the "
"reference and every arm. A conservative LOWER BOUND on the voice "
"gain, reported alongside; it never overturns the primary verdict.")
ap.add_argument("--punct-report", action="store_true",
help="ALSO print per-arm punctuation density vs the reference, and "
"evaluate the pre-registered base-control confound trigger.")
a = ap.parse_args()
corpus = Path(a.corpus)
evaldir = Path(a.evaldir)
author = a.author
# reference = held-out val text
val = []
for p in sorted((corpus / "copies").glob("*.jsonl")):
for l in p.read_text(encoding="utf-8").splitlines():
r = json.loads(l)
if r.get("split") == "val":
val.append(r["text"])
# dedup identical val chapters across copies (renaming aside, the same chapter recurs)
ref_text = "\n".join(dict.fromkeys(val))
arms = load_arms(evaldir)
analyse(ref_text, arms, author, identity)
if a.punct_report:
punct_report(ref_text, arms)
if a.secondary_normalised:
print("\n" + "=" * 78)
print("SECONDARY READ -- PUNCTUATION STRIPPED. Pre-registered, REPORTED, NOT THE VERDICT.")
print("Every punctuation mark is removed from the reference and from every arm, so a")
print("gap that survives here is carried by words rather than by marks. It is a LOWER")
print("BOUND and not a better measurement: stripping terminal punctuation also strips")
print("sentence-length signal the adapter legitimately learned. Read it as `at least")
print("this much of the primary gap is not the punctuation trick`.")
print("=" * 78 + "\n")
analyse(ref_text, arms, author, strip_punct)
return 0