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