Files
esh-pfi-infrastructure/scripts/mccarthy-corpus/GATE-PREREG.md
T
Vuong Hoang 43a8d7409e fix(lv-mccarthy): the axis C I froze is UNSATISFIABLE, so it never adjudicated anything
Arithmetic, not a re-reading of results. Criterion 1 needs the candidate's in-band
rate to beat base's 0.89 by more than the 0.200 floor -- so it needs in_band >
1.09, and a rate cannot exceed 1. A PERFECT adapter, every one of 240 generations
inside the 90-140 band, scores +0.11 and fails by 0.09.

So "both candidates failed axis C" carries zero information about either candidate.
That verdict was determined before a single generation existed. It is my error.

The cause is specific: section 6 transcribed score_beats.py's v1 criteria, which the
operator AMENDED on 2026-09-15 -- three weeks before this corpus was built -- for
exactly this reason. Its own docstring: "NOT carried into v2: in-band (unresolvable
-- base maxes it) and on-beat."

The governing rule reverts to the operator's v2: axis C is ran-on only. That is the
rule they ratified and the rule lv-hemingway shipped under. Reverting a
transcription error to its source is not threshold-shopping; the source is older
than the data.

It does not rescue ckpt450 either. Under the corrected axis C its ran-on is +0.19
against a 0.200 floor -- a pass by 0.01, inside the noise the floor exists to
describe, and not a pass worth shipping on an author in copyright with a living
estate.

AMENDMENT 3 therefore gates two more arms on the SAME frozen fixture, seeds and
rule -- ckpt300 (epoch 0.65) and ckpt225 (epoch 0.49), both on disk because
--save-total-limit 60 kept all 56 checkpoints -- since the damage grows
monotonically with epoch and the voice may arrive before it does.

And it sets the ship bar BEFORE those arms exist: voice clearing the pairwise floor
on both reads, memorisation at or near the held-out author's 0.12 with every match
read, and ran-on delta AT MOST 0.100 -- half the measured floor, not merely inside
it, because a rate that clears a noise threshold by 5% of that threshold has not
been shown to clear it. If nothing clears all three, lv-mccarthy is parked pending
a retrain, and that is the report rather than a softer bar.
2026-09-21 16:34:01 -07:00

423 lines
23 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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
```bash
# 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.
---
## AMENDMENT 3 — 2026-09-21, §6 axis C is UNSATISFIABLE and therefore void
**This is arithmetic, not a re-reading of results.** Criterion 1 of §6 axis C, as frozen,
requires the candidate's in-band rate to exceed the base control's by more than the metric's
own floor. On this run:
```
needs in_band(candidate) - 0.89 > 0.200
i.e. in_band(candidate) > 1.09
ceiling in_band(candidate) <= 1.00 a rate cannot exceed 1
```
**A PERFECT adapter — every one of 240 generations inside the 90–140 band — scores +0.11
against a floor of 0.200 and fails by 0.09.** No artifact can pass it. A criterion that no
possible candidate can satisfy does not adjudicate anything, so "both candidates failed axis
C" carries **zero information about either candidate**; that verdict was determined before a
single generation existed.
This is my error and it has a specific cause: §6 transcribed `score_beats.py`'s **v1**
criteria, which the operator **amended on 2026-09-15** — *before this corpus was built* —
precisely because they gate on metrics the unadapted carrier already maxes:
> *"The defect in v1: it gated on metrics the UNADAPTED carrier already maxes. … Those three
> axes can only detect DAMAGE that training does. … NOT carried into v2: in-band
> (unresolvable — base maxes it) and on-beat."* — `score_beats.py`, operator-authorised
> amendment, 2026-09-15
### The governing rule reverts to the operator's own v2
**Axis C is `ran-on` only**: the candidate's ran-on rate must not exceed the base control's
by more than the metric's floor. That is the rule the operator ratified, the rule lv-hemingway
shipped under, and it predates this gate by three weeks. Reverting a transcription error to its
source is not threshold-shopping — the source is older than the data.
⚠ **But it does not rescue ckpt450 either, and the bar for that is set HERE, before the
numbers that could clear it exist.** Under the corrected axis C, ckpt450's ran-on is +0.19
against a floor of 0.200 — a pass by **0.01**, which is inside the noise the floor exists to
describe. A marginal pass is not a pass worth shipping on an author in copyright with a living
estate.
### AMENDMENT 3's added arms and its added bar
The damage grows monotonically with epoch (base 1% over-band → ckpt450 20% → ckpt900 28%), and
`--save-total-limit 60` kept all 56 checkpoints. So two more arms are gated, **on the same
frozen fixture, the same four seeds and the same rule** — adding candidates does not move a bar:
| arm | epoch | why |
|---|---|---|
| `ckpt300` | 0.652 | the damage may not have arrived yet; the voice may have |
| `ckpt225` | 0.489 | the same question half an epoch earlier |
**A candidate ships from this gate only if ALL of the following hold. Fixed now, before either
arm is generated:**
1. **Voice** — gap over base exceeds the pairwise floor on the PRIMARY read **and** on the
normalised read (§5b, load-bearing per AMENDMENT 2).
2. **Memorisation** — hit-rate and max at or near the **held-out-author reference** (0.12 /
max 12), per AMENDMENT 1, with every matched run read.
3. **Damage** — `ran-on` delta over base **at most 0.100**, i.e. **half the measured floor**.
Not "inside the floor": half of it. This is the bright line ckpt450's +0.19 fails, chosen
because a rate that clears a noise threshold by 5% of that threshold has not been shown to
clear it at all.
If no candidate clears all three, lv-mccarthy is **parked pending a retrain**, and that is the
report — not a softer bar.