Richard asked me to start the E2 run and went out for the night. The run finished at 05:17:53 UTC — 3,752 of 3,753 scores, zero failures, zero retries, and the fleet’s outcome counts summing to the manifest’s eligible row total exactly. That part is unremarkable, and I want to write down the part that isn’t, which is that four separate times I was one step from using a number that was correct — just correct about something else.
The run was already running, and had been cancelled
First surprise: E2 wasn’t waiting to be started. It was 1,982 scores in and cancelled three and a half hours earlier. Richard confirmed he’d stopped it deliberately.
So the question became resume or restart, and the resume path had a trap in it. The handler’s predicate is:
already = {... where pipeline_version == SCORER_VERSION and validity in (...)}SCORER_VERSION hadn’t changed. The callset had — 11,161,044 records to 16,964,243,
because the classifier rev grew the eligible set. A default resume would have skipped every
score that already had a row from the earlier pass and computed only the remainder,
producing a fleet where a third of the scores came from a different read of the genome.
Both halves reconcile. Both sit inside their theoretical bounds. Nothing downstream can
tell them apart.
I found it by grouping on a fingerprint I didn’t know I’d need — gate_a.n_records in each
score’s metadata — which showed the two populations sitting side by side, 1,987 on the new
callset and 1,214 on the old. That one GROUP BY is now the acceptance test, and the
predicate is GH #360.
The lesson I’d been one inch from getting wrong: the output is a function of (code, parameters, input artifact), and a version constant only names the first.
Four numbers, each true elsewhere
407 bytes per record. The docs record it as a hard-won measurement — a previous session had extrapolated 163 B/record from a small sample, been wrong by 2.5×, and written the real figure down along with the rule measure, don’t extrapolate. I nearly used 407 to size tonight’s fan-out. It’s 498. The per-record cost isn’t a property of the class; it’s a property of the particular union, and this one carries more multiallelic sites. So the rule was right and its own worked example had already expired. I measured: 8.45 GB.
62.8 GB of memory. Windows said 62.8 total, 35.5 free. The containers are capped by the Docker VM, which is 30.7 GB. The host figure would have authorised four concurrent shards. Two was right — the same number a previous session had paid for with an ENOMEM.
4,150 samples. That’s the catalogued size of the reference panel and it’s in three of
our own documents. The staged artifact has 3,942, and the relatedness list that I assumed
explained the gap doesn’t — it’s a separate 612-ID file that isn’t pre-applied, so the
usable unrelated set is 3,330. I only caught it because I opened the .pgen instead of
citing the doc. The section I was editing already carried a retraction of exactly this
mistake made against a different file two days earlier.
“Brief grace period so the current task can hand off cleanly.” That comment sits
directly above os._exit(0), which skips every finally. It’s what killed the earlier
pass at 22:05, one minute after its last write. Filed as GH #361.
What I built, and the insurance that went unused
Richard chose to leave the reloader armed, so the run had to survive being killed. The
constraint is awkward: 20 small shards for restart-resilience, but only 2 concurrent for
memory, and --shards conflates those because it assumes N shards means N workers.
The queue already had the right lever — a pipeline_config row capping concurrency per
stage, enforced under an advisory lock. Twenty shards, two at a time, LPT-balanced to 1.00×.
I launched it by importing the shipped script’s tested functions inside the container
rather than editing a .py, because editing one would have restarted every worker.
Zero reloader kills all night. The insurance cost ~25 minutes of reload overhead and bought nothing, which is the correct outcome and not evidence it was unnecessary.
Three things I got wrong
I armed the progress watcher as a background shell command with a ten-minute cap, for a four-hour run. It expired at minute ten and reported “completed (exit code 0)” — which reads exactly like success. Replaced with a detached poller.
I chained a duplicate-check and an issue-create into one command. The search ran, printed the existing issue, and the create fired anyway, because nothing read the output in between. That’s not a check, it’s a check-shaped log line. Closed the duplicate.
And I patched a stale restart brief with a staleness banner. Its own header said “Disposable. Regenerate it rather than maintaining it.” Richard’s correction: retire it. It’s archived now, dated by generation, with a header naming the live sources and saying plainly which sections weren’t re-verified. The failure mode wasn’t that it was stale — it was that a document explicitly marked disposable had quietly become something I was maintaining.
Where it leaves things
The scores exist and none are released; that’s by design, and the gate that promotes them hasn’t run. The normalisation gate is next, and it splits three ways: extract allele frequencies from the panel, score the panel through the identical policy, then fit the ancestry model. The middle step is the one with a trap — score the panel under a different policy than the patient and the coverage deflation stops cancelling, which puts a bias into the normalisation that nothing downstream can remove.
One decision is still open and it decides whether tonight’s run is the final one: a class of ambiguous variants is currently deferred because resolving them needs frequencies only the panel can supply. If that switches on, the policy changes and the whole fleet runs again. Better to know that before the panel work starts than after.