Background: the PRS primer §5.1 for the bug itself and §5.2 for the dense callset that fixed it.
A bug that looked like a data-quality problem
In July, an external audit killed every polygenic risk score I had computed. All 5,368 of them. Not “some were off” — all of them, in a way that was different for each one.
The mechanism is almost insultingly simple. A polygenic score is a weighted sum: for each of some set of positions in the genome, count how many copies of a particular allele you carry — zero, one, or two — multiply by a weight from a published study, add it all up. That’s the whole algorithm. A thousand multiplications and one sum.
My code looked each position up in a database of the person’s variants. If it found nothing, it recorded the position as missing and moved on.
Here is why that is wrong, and it took me an embarrassingly long time to see it. A variant database only stores the places where you differ from the reference genome. If you match the reference at some position, there’s no row — that’s the point, it’s how you store three billion base pairs in a few hundred megabytes. So “no row” means “you match the reference.”
And roughly half the time, the allele a published score is counting is the reference allele. If you match the reference there, you carry two copies of the thing being counted. The correct contribution is two times the weight. My code contributed zero.
So the dosage map wasn’t {0, 1, 2}. It was {0, 1, 0}. Every person was an artificial
heterozygote at half their loci. And because different scores happen to count reference
alleles at different rates, every score was wrong by a different amount. There was no
correction factor. There was nothing to salvage.
The part that actually stays with me
The bug isn’t the interesting part. This is:
I had a self-check. I’d noticed that my scores were only “covering” 42–52% of the variants in each scoring file, which seemed low, so I wrote something to investigate. It reconstructed the coverage and told me the number was fine.
It told me that because the reconstruction made the same assumption the scorer did — that a position with no row contributes nothing. It reproduced the bug instead of detecting it. A check that shares your assumptions cannot fail. It can only agree with you more loudly.
And the 42–52% figure was itself the bug’s fingerprint. Half the loci were silently dropping out. But I had put that number on a screen, color-coded, in a column labelled “Cov.” — as if it were a quality judgment about somebody else’s published score. I had built a dashboard that displayed my own defect as a criticism of the scientific literature.
The generalizable lesson is about instruments. A check that shares its assumptions with the thing it checks will read your own error back to you as somebody else’s fault. Here a coverage ratio that looked like a flaw in the published scores was our own scorer misreading the genome. The measurement was not a neutral observer; it was implicated.
Rebuilding it as a sequence of gates
The repair is structured as six lettered gates, and the discipline is that each one fixes exactly one thing and hands off cleanly.
Gate A changed how the genome is read. Instead of a variant list, it builds what’s called
a dense callset — over the specific positions some score actually needs, it emits an
explicit record for every one. Match the reference? That’s now a record that says 0/0, not
an absence. Not sequenced? That’s a record that says ./.. The two facts that used to be the
same number are now two different records.
Gate A’s exit condition is an arithmetic identity, checked with an assertion rather than a log line: confident-reference + called-variant + explicitly-missing + structurally-failed must equal the number of positions you asked about. Every position accounted for by name. If the sum doesn’t come out, the build raises instead of returning a plausible-looking result.
Gate B catalogued which of the 5,377 published scoring files in the public catalog can be computed faithfully at all. Turns out about seven in ten. The rest use genotype-specific weights, haplotypes, or star-allele biology that a plain weighted sum simply isn’t — and the honest output for those is “not calculated, here’s why,” not a number.
Gate C — this week’s work — is the arithmetic.
What the new scorer actually asks
The old scorer asked: is there a variant row at this position?
The new one asks: which allele is the reference here, and how many copies of the counted allele does this genotype name?
That reframing is the entire fix. 0/0 at a position where the counted allele is the
reference now answers two. One line of code, and it’s the thing that invalidated 5,368
numbers.
But most of the work wasn’t that line. It was deciding what to do when the honest answer is “I don’t know.”
Three places where I chose to know less
Palindromes. DNA has two complementary strands: A pairs with T, C with G. Read a stretch from the other strand and A becomes T. Usually you can tell which strand a data file is using, because the alleles won’t match otherwise. But if a position’s two alleles are A and T, the complement of that pair is… A and T. Both readings fit. And they give opposite answers.
There’s no way to resolve it from the letters alone — you need to compare allele frequencies against a reference population, which is a later gate. So the scorer defers those positions and says so. I measured the cost across a sample of the catalog: 12.5% of rows, median 7.6% per score. That’s a real bite out of coverage. I took it, because an inverted dosage at one position in twelve is a confident lie, and a gap is only a gap. (⚠ Corrected later: that was a 60-score sample. Across the whole eligible fleet palindromes are 2.99 % of rows, and a median 10.4 % of rows in each of the 1,687 scores that have any; the PRS primer §5.4 has the measurement.)
Strand flips. When a file’s alleles don’t match the genome but their complements do, the conventional move is to flip and carry on. I decided not to — and the reasoning surprised me.
If you allow complement matching for a two-letter allele pair, then all four possible reference bases resolve to something. Which means the check “do these two files even describe the same variant?” can never fail again. You’ve traded a detectable problem for an undetectable one. So the scorer counts the positions a flip would have rescued and defers them. If that count is near zero, my assumption that both files use the same strand is confirmed by measurement rather than asserted by me. If it’s large, I’ve learned something important that a silent flip would have hidden.
Ratios. Some published scores report weights as odds ratios rather than log-odds. Ratios multiply; they don’t add. Summing them is a category error — and it produces a perfectly plausible number that passes every sanity check I have, including the mathematical bounds check, because it’s still inside the range of attainable values. The only defence is to refuse the file. So it refuses.
There’s a pattern here I didn’t plan and only noticed writing this up. Every one of those three decisions is the same trade: accept a smaller, honestly-labelled gap in exchange for keeping a failure visible. The project’s operating rule is that a missing number is a zero the model ignores, while a wrong number is a confident, learnable lie. Three times over, the design followed from that one sentence.
Some numbers about the machine
The largest scoring file in the catalog has 9,503,677 rows and is 195 MB compressed. The scorer processes it in 220 seconds at a 2.2 MB peak heap — because it holds one row at a time rather than the file. The naive version of the same parse peaked at 1,040 megabytes on that exact file. Four hundred and seventy times the memory, for the same answer.
That’s not a micro-optimization, it’s the difference between a job that runs and one that kills five workers at once. I learned that the expensive way earlier this year, when a caching change that eliminated one bottleneck created another, and the machine with fewer parallel workers finished first — because the only thing faster than a fast worker is one that never has to restart.
Every row lands in exactly one of eight labelled outcomes, and if they don’t sum to the rows read, the scorer raises rather than returning a number. Same discipline as Gate A. When I printed the first real summary table, one score’s columns came to 168 out of 192 and I went looking for the missing 24 — they were real, in two buckets I hadn’t put on screen. A table whose parts don’t add up to its whole is exactly how the old “Cov.” column happened. So the table shows all of them now.
What is deliberately still missing
No score has been released. Not one. (That changed on 2026-09-28, when a first smoke-test release of 590 scores went out to the model; the final release is still ahead.)
The scorer writes every result as quarantined. Two more gates stand between a computed number and a number anything is allowed to read: one that places a raw sum against a reference population, because a weighted sum with no units means nothing on its own, and one that runs the biological sanity checks. Until then, every part of the application that asks for a score correctly gets nothing back.
It would have been easy — and it would have felt like progress — to let these first numbers through. They’re better. They’re not validated. Those are different claims, and the architecture is built so I can’t accidentally conflate them: the read functions filter on a released flag, and nothing has been released.
The other thing I haven’t done is the one that would most convince me. The public catalog ships its own reference calculator, and comparing my sums against it is the check that doesn’t share my assumptions. I’ve written the export and the diff; running the middle step needs a container runtime this machine doesn’t have. So the honest status is: the harness is built, the comparison has not been run.
Which, given how this whole thing started, feels like exactly the right thing to be nervous about.
What stays with me is that the most valuable check is the one we cannot run yet. The second opinion that would have caught this in July is an independent calculator, and standing it up needs a container runtime we have not set up. Until then the pipeline is internally consistent, which is precisely the property that failed last time.