Files
esh-pfi-infrastructure/scripts/mccarthy-corpus/GATE-PREREG.md
T
Vuong Hoang 0d80e493a8 fix(r49): the quote-mark counter was counting apostrophes, and I saw it fire before I saw the bug
voice_distance.py's quote class shipped this morning as "'‘’“”«»‹›‚„` -- with the
apostrophe characters in it. On a corpus whose defining tic is dont/aint/wont and
whose possessives are everywhere, that made it an apostrophe counter wearing a
quote-mark label.

                          as implemented    TRUE quotes    all apostrophes
  held-out McCarthy ref            121.1            0.0              121.1
  base-unadapted                   224.7           19.9              204.8
  held-out Hemingway ref          1112.6          694.7              351.7

The corrected column is the one the pre-registration names: 0.0 for McCarthy,
which is exactly what build_corpus_mccarthy.py ASSERTS, and 694.7 for Hemingway,
the documented ~838 scale the 100-per-10k trigger line was anchored to. The
as-implemented column matched neither.

ORDER OF EVENTS, because it is the material fact: the base arm finished first, so
the trigger became evaluable while the adapted arms were still generating. I
evaluated it, saw it FIRE at 224.7, and only then -- reading the reference row of
my own table against a corpus I knew asserts 0.0 -- found the bug. No delta_cb,
memorisation rate or damage number had been read at any point.

Fixing a detector to measure the quantity the frozen rule names is not moving the
rule, but the fix un-fires the trigger and no reader should have to take my word
about my motives. So GATE-PREREG.md AMENDMENT 2 makes the trigger MOOT instead of
adjudicating it: the normalised secondary read is load-bearing UNCONDITIONALLY for
this gate, whichever reading you accept, both columns reported. The fix therefore
has no effect on the verdict.

There is a better reason than the bug anyway: base's true quote density is 19.9
against the reference's 0.0, so it did not fully comply. A small residual cheap win
IS available to the adapter, and the normalised read is what prices it. A threshold
is a blunt instrument for a residual that size.

Apostrophes now get their own column and are never folded into quotes again.
Default path stays byte-identical to the shipped lv-hemingway artifact.

The durable lesson is the one this line keeps relearning in new places: I controlled
strip_punct (2500 -> 0) and the byte-identity of the default path, but never asked
the quote counter for a value whose answer I already knew. The corpus asserts 0.0.
That check cost one line and was available before the gate ever launched.
2026-09-21 15:19:33 -07:00

20 KiB
Raw Blame History

lv-mccarthy v2 gate — pre-registration

Written 2026-09-21, before a single generation existed. Everything below is fixed. The point of writing it down is that a threshold chosen after seeing the numbers is not a threshold. This is the third pre-registration in the line and it inherits the lv-hemingway design deliberately, so the two runs stay comparable; every place it departs is marked and argued.

Nothing here may be amended after the first arm is generated. If the design turns out to be wrong, the run is wrong and gets re-run — the rule does not move.


1. What is being gated

gx10:~/r49-runs/mccarthy-4b-pairs-3ep/ — Qwen3-4B-Instruct + instruction-pair LoRA on the renamed McCarthy corpus (584,684 words, 167 units, 6 works, 6 renamed copies). 3-epoch schedule, seed 4919, rank 32 / alpha 64, lr 1e-4, seq 1536, 1,380 steps in 2h30m47s, finished 2026-09-18 00:49 PT. train_loss 2.172, end-of-run eval_loss 2.4594.

The run outcome was unverified for three days and this gate is the first read of it. That is recorded here rather than quietly fixed, because "the run finished" and "the run did what was configured" are different claims and only the second one is worth anything.

1a. Provenance defects found on first read, and their disposition

found disposition
provenance.json "run": "r49-babyyarros-pairs-pilot" Cosmetic, not a mis-copy. The same literal appears in the yarros AND hemingway provenance — it is a hardcoded label in train_pairs_lora.py, not a stale field for this run. Fix the harness; it invalidates nothing.
harness_commit: "" Empty on all three runs. The training harness never recorded its own commit. No claim in this gate rests on it. Named here so it is not re-discovered as news.
pairs_sha256_16 does not match sha256sum of the pairs file Not a defect. It mismatches identically on yarros and hemingway too, so the harness computes it over the loaded records rather than the raw bytes. It is a consistent, per-corpus-unique cache key.
pairs recorded as the relative pairs/pairs-full.jsonl Resolved against the launch CWD ~/lv-mccarthy. Bound by count instead of by path: train_pairs 3673 and val_pairs_n 269 match lv-mccarthy/pairs/ exactly and match no other pair set on the box.

The run trained on McCarthy's pairs. That is established by the record counts and by system_prompt_source: "pairs provenance (mccarthy)", not by the run label.

2. Arms — three, and the third is here for a different reason than Hemingway's was

arm what it is role
base-unadapted ~/carriers/Qwen3-4B-Instruct, no adapter negative control for memorisation (never saw the corpus) and the voice baseline
ckpt900 epoch 1.958, eval_loss 2.38706 — the curve's minimum ship candidate
ckpt450 epoch 0.980, eval_loss 2.4063 prior test, see below

Measured on this run's own 56 eval points: median neighbour jitter 0.00393.

⚠ ckpt450 IS NOT TIED, and saying so is the point. On Hemingway the second arm was in the run because the loss curve could not separate it (+0.9× jitter). Here it can: ckpt450 sits +0.0193 = 4.9× jitter above the minimum. Three checkpoints are inside one jitter of the best — 875 (+0.4×), 850 (+1.1×), 925 (+1.5×) — and the choice of 900 among them is arbitrary and declared arbitrary; they are the same point on the curve. ckpt450 is not one of them.

So it is generated for a stated, falsifiable reason rather than as a coin-flip:

  • The prior it tests. On Brontë the earlier epoch-1 checkpoint won the tiebreak on the axes that resolve. On Hemingway the pair was tied and the question stayed open. McCarthy is the first run in the line whose loss curve says epoch 2 is genuinely better than epoch 1. That is a claim the axes can check, and checking it costs one arm.
  • The thing it would buy. Axis B (memorisation) carries more weight here than on any previous corpus — Brontë is public domain, Hemingway is not, and McCarthy is in copyright with a living estate. An earlier checkpoint is the cheap way to buy a lower verbatim-overlap rate. Whether it actually does is measured below, not assumed.

adapter/ (end of run, epoch 3.0, 2.4594) is NOT gated. At +18.4× jitter it is resolved by the loss curve alone — and note the curve does not drift there, it steps: 2.393 at step 925, 2.457 at step 950, and flat for the remaining 430 steps. The epoch-3 collapse reproduces the Hemingway finding on a second corpus.

⚠ The shipped adapter/ directory is the epoch-3 weights. Whatever ships is a checkpoint, not adapter/.

3. Fixture and seeds — fixed now

  • 60 beats, sampled by build_beat_fixture.py from ~/lv-mccarthy/pairs/pairs-val.jsonl (held-out split=val, 269 pairs), --seed 4919, spread across works round-robin.
  • --max-words 140, not the script's default 150. McCarthy's register asks for 90–140 words and score_beats.py scores the in-band rate at 90–140. A fixture built at 90–150 would put reference passages outside the band the product asks for and the scorer measures. 207 of 269 val pairs are in-band, across all six works (blood-meridian 51, all-the-pretty-horses 40, the-road 38, cities-of-the-plain 33, no-country-for-old-men 28, the-crossing 17).
  • 4 seeds: 1234 5678 9012 3456. Same as lv-bronte and lv-hemingway.
  • → 240 generations per arm, 720 total.

60 is carried over from Hemingway unchanged so the two floors are comparable; 207 in-band pairs make it available with room to spare (Hemingway had 173, Brontë 44).

4. The floor rule — inherited, unchanged

The noise floor for a candidate is the larger of the within-arm seed spreads of the TWO arms being compared: max(spread(candidate), spread(base-unadapted)).

Settled at lv-hemingway and not reopened. voice_distance.py prints both this and lv-bronte's all-arms floor and flags any case where the two rules disagree; the pairwise number is the verdict.

5. ⭐ THE VOICE AXIS — the open question, settled here before any number exists

McCarthy's corpus measures 0.0 quote marks per 10k words against Hemingway's 838, and voice_distance.py is Burrows's Delta over character bigrams. "Emit no quotation marks" is therefore the single cheapest available way to move this metric a long way without having learned a sentence. lv-mccarthy D1 flagged this and deferred it; it is settled now, in three parts.

5a. PRIMARY read — unchanged, and already hardened. The mccarthy register names the punctuation on purpose (build_sft_pairs.py REGISTERS, commit f3bf3ca), and eval-mccarthy.sh drives every arm including base-unadapted with that same system prompt via --system-from. The control is therefore told to drop quote marks and write dont/aint/wont. The cheap win is handed to both sides and the adapter earns no delta for it. This was decided when the register was written, before any McCarthy number existed, and is not reopened here. Stated cost: the voice axis is harder for this corpus than for Hemingway's, deliberately.

5b. SECONDARY read — pre-registered, REPORTED, never the verdict. voice_distance.py --secondary-normalised re-runs the entire analysis with all punctuation stripped from the reference and from every arm. A gap that survives is carried by words rather than by marks.

⚠ It is a conservative lower bound, not a better measurement: stripping terminal punctuation also strips sentence-length signal the adapter legitimately learned, and sentence length is a real McCarthy trait. It reads as "at least this much of the primary gap is not the punctuation trick." It cannot overturn the primary verdict.

5c. THE CONFOUND TRIGGER — the part that makes 5a falsifiable. 5a's whole argument is that the base control complies with the instruction. That is an assumption about a model's behaviour, and it is checkable. --punct-report prints per-arm quote-mark, contraction-apostrophe and dash density against the reference, and evaluates this line:

If base-unadapted quote density exceeds 100 marks per 10k words, the control did not take the win it was handed, part of the primary gap IS the punctuation trick, and the normalised secondary read (5b) is promoted to load-bearing for the verdict.

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.

Instrument controls, run before this was committed. The voice_distance.py refactor that added 5b and 5c reproduces the shipped lv-hemingway voice_distance.txt byte for byte on the default path (positive control: the edit did not move an existing record). strip_punct drops a quote-bearing sample's density from 2500.0 to 0.0 per 10k (negative control: the normalisation is not a no-op). On the Hemingway arms the secondary read still resolves a gap (ckpt1750 +0.410 at 7.3× floor) rather than flattening everything — so a null result from 5b on McCarthy would be a finding about McCarthy, not a blind detector.

6. The three axes, and what passes

axis instrument passes if
A. VOICE voice_distance.py --author McCarthy --punct-report --secondary-normalised delta_cb(base) − delta_cb(candidate) exceeds the pairwise floor on the PRIMARY read, unless 5c fires, in which case on the NORMALISED read
B. NOT COPIED memorization_check.py (longest verbatim n-gram vs the renamed copies, n=8) hit-rate and max at or near base-unadapted, which never saw the corpus. Positive control (corpus vs itself) must saturate, or the zeroes are blind
C. NO DAMAGE score_beats.py --metric-source raw in-band up on base beyond the metric's own floor; on-beat and ran-on not worse beyond it

7. The decision rule — frozen

  1. ckpt900 is the default ship candidate. The loss curve resolves it above ckpt450 at 4.9× jitter.
  2. ckpt450 displaces it only if BOTH hold: it is not resolvably worse on axis A (gap_450 ≥ gap_900 − pairwise_floor) AND it is strictly better on axis B by more than the base-to-candidate separation on that axis. Buying memorisation headroom is worth it; buying it by giving up the voice the adapter exists for is not.
  3. Axis C failure disqualifies that candidate outright, whatever axis A says.
  4. If both candidates fail axis A (gap inside the pairwise floor), the result is a negative reported with its sensitivity floor, and the ship-or-park call goes to the operator. The lv-bronte precedent — fail on A, clean on B and C, shipped anyway as additive and reversible — does not transfer automatically: Brontë is public domain and McCarthy is not, so axis B carries more weight in that conversation, not less.
  5. Nothing ships on the strength of the epoch-3 adapter/. It is not gated.

8. Pre-flight — already run, and clean

Both re-run at gate time rather than quoted from the 2026-09-17 session, because "it passed three days ago" is a claim about a tree that may have moved.

Leak gate (leak_gate.py --min-cap 8, report leak_gate_report.gate-time.json):

0 of 75 renameable · 0 of 37 sub-threshold · 0 separator-split survivors
positive control 108/108 surfaces found in the unrenamed source
negative control nonce absent · split-scan controls: planted `W olfenbarger` detected

Beat-contamination audit (audit_pairs_sourcenames.py --min-cap 5, mirroring the floor rename.py actually ran at), over all 3,942 built pairs:

BEAT     naming a removed surface: 0 of 3942 (0.00%)
RESPONSE naming a removed surface: 0 of 3942 (0.00%)
controls green: 108/108 found in source · nonce absent from both trees

⭐ Zero, where Hemingway carried 70 of 7,094 contaminated beats (0.96%). That is build_sft_pairs.py --source-entities, which was mandatory for this corpus's D4 and which Hemingway's pairs predate. The gate is unconfounded on both halves of the pair, not just the fixture half.

Sensitivity floor, stated as part of the pre-flight rather than as a footnote: a name appearing fewer than 8 times per work is never detected and a phrase recurring fewer than 5 times is never audited — and no phrase map exists for this corpus, so the phrase audit does not run at all. Yarros and Brontë both had one. A clean leak gate here is a narrower claim than a clean leak gate there.

9. Reproduce

# on pfi-gx10, ~/lv-mccarthy
./eval-mccarthy.sh           # three arms, 60 beats, 4 seeds, all three axes

AMENDMENT 1 — 2026-09-21, axis B's negative control

Written while the base arm was still generating and before a single McCarthy delta_cb, memorisation rate or damage number had been read. Recorded here rather than folded into the text above, so the order of events stays legible.

What was wrong with §6 axis B as frozen

§6 says axis B passes if hit-rate and max sit "at or near base-unadapted, which never saw the corpus". The lv-hemingway gate record already establishes that this control is defective, and I wrote the clause anyway:

memorization_check.py uses the base-unadapted arm as its negative control, but base writes 18,035 words of summary against the adapted arms' 27,413 of pastiche — text that does not imitate the register cannot collide with its n-grams, so a 0.00 there means "different register", not "did not memorise". — persistent-memory.d/2026-09-17-lv-hemingway-gate.md

A negative control that differs from the candidate in a way correlated with the metric is not a control. Base's zero is unearned, and an axis that passes by comparing against an unearned zero is not measuring anything.

There is a second, smaller fault in the same instrument: it builds its n-gram set from every record in the renamed copies, val included. The adapter never trained on val, so a collision with val text is counted as memorisation of training text when it is not. Brontë's and Hemingway's numbers carry this overcount too.

What is added

Two things, both strictly harder to pass. Neither can turn a failing candidate into a passing one; both can turn a passing one into a failing one.

  1. The correct negative control: the author himself. Held-out (val) text — real McCarthy no arm trained on, in the same register as the candidates by construction — chunked to the arms' own median generation length and scored against the training text. This is the innocent-sample rate. On Hemingway it was 0.01 against the adapter's 0.07, which refuted the comfortable "his plain register makes collisions inevitable" story rather than assuming it.
  2. A train-only n-gram set, so a collision with held-out text is not counted as memorisation of the training text. The inherited all-records number is printed alongside it, because Brontë's and Hemingway's records were written with it and a reader comparing runs needs both.

The amended axis B, and the amended tiebreak

  • Axis B passes if the candidate's hit-rate and max are at or near the held-out-author reference, not base. Base stays in the table as the register-mismatch marker it actually is. The positive control (train vs train) must still saturate or every zero in the table is blind.
  • §7 rule 2 is amended to match: ckpt450 displaces ckpt900 only if it is not resolvably worse on axis A and strictly better on axis B measured against the held-out-author reference.
  • An elevated rate is not by itself a no-ship. The lv-hemingway precedent is explicit that rate and exposure are different questions: it shipped at 7× the author's own rate because every matched run was read and every one was stock dialogue in the commonest words in English, max 9 words, no proper noun. The same reading is required here and is part of the axis, not a follow-up. ⚠ McCarthy is in copyright with a living estate, so a matched run carrying distinctive imagery or a proper noun is disqualifying in a way it was not for a rate number alone.

Why this is not threshold-shopping

The rule that must not move is the one that decides a result. This amendment moves the reference a candidate is compared against from a known-broken one to a known- correct one, in the direction that makes passing harder, on an argument published four days before this gate existed and independent of any McCarthy number — none of which had been read when this was written. The honest alternative was to run an axis whose control I already knew to be unearned.


AMENDMENT 2 — 2026-09-21, the confound trigger's own instrument was broken

Read the order of events before the numbers, because the order is the point. The base arm finished first, so §5c's trigger became evaluable while the two adapted arms were still generating. I evaluated it, saw it FIRE, and only then — reading the reference row of my own table — found that the detector was measuring the wrong thing. No delta_cb, memorisation rate or damage number had been read at any point.

The bug

voice_distance.py's quote-mark class shipped as "'‘’“”«»‹›‚„` — it included the apostrophe characters. So it was an apostrophe counter wearing a quote-mark label, on a corpus whose defining tic is dont/aint/wont and whose possessives are everywhere.

                          as implemented    TRUE quote marks     all apostrophes
held-out McCarthy ref            121.1                 0.0               121.1
base-unadapted                   224.7                19.9               204.8
held-out Hemingway ref          1112.6               694.7               351.7

The corrected column is the one the pre-registration names: 0.0 for this corpus, which is exactly what build_corpus_mccarthy.py asserts, and 694.7 for Hemingway's val split, which is the documented ~838 scale the 100-per-10k line was anchored to. The as-implemented column matched neither, so it was never measuring the quantity §5c specified.

The two readings, and why I am not choosing between them

as implemented (apostrophe-inclusive)   base 224.7 > 100   ->  TRIGGERED
as specified   (quote marks only)       base  19.9 < 100   ->  does NOT trigger

Fixing a detector so it measures the quantity the frozen rule names is not moving the rule. But I saw the firing value first and the fix un-fires it, which is precisely the shape of threshold-shopping, and no reader should have to take my word about my own motives.

So the trigger is made moot instead of adjudicated:

For this gate the NORMALISED SECONDARY READ (§5b) is load-bearing UNCONDITIONALLY, whichever reading of §5c you accept. Both quote-mark columns are reported. The bug fix therefore has no effect on the verdict, and the conservative branch is taken by default.

There is an argument for that independent of the bug, and it is the better reason: base's true quote density is 19.9 against the reference's 0.0, so it did not fully comply. A small residual cheap win is genuinely available to the adapter, and the normalised read is what prices it. A trigger threshold is a blunt instrument for a residual this size; running the conservative read always is simply better than deciding when to.

What changed in the code

_QUOTE_RE is quote marks only. Apostrophes get their own column and are never folded back in. --punct-report now prints quote-marks, all-apostrophes, contraction-apostrophes and dashes, so a future reader can see the thing that fooled this one.

The durable lesson

This is the same failure the r49 line keeps finding, in a new place: a detector validated only against the present case cannot tell a wrong measurement from a right one. I controlled strip_punct (2500 → 0, it works) and the byte-identity of the default path (it matches), but I never asked the quote counter for a value I already knew the answer to. The corpus asserts 0.0. Reading 121.1 against a known 0.0 is what exposed it — and that check cost one line and was available before the gate ever launched.