CRAM region queries decoded the whole chromosome; skip non-overlapping containers - #39
Merged
Merged
Conversation
…g containers
Every CRAM region query cost the same as reading the entire contig, no matter
how small the region. Measured on a 30x 1kGP CRAM, a ONE-BASE query:
chr21 (45 Mb) 20.92 s chr1 (248 Mb) 116.4 s
same query on a BAM: 4-6 ms
Cost tracked contig length exactly (248/45 = 5.5x length, 116/21 = 5.5x time)
and was completely independent of region size -- a 1 bp and a 1 Mb query on
chr21 both took 20.91 s.
CAUSE. noodles' `Query::read_next_container` skips a container only when its
`reference_sequence_id` does not match; it never consults the requested
interval, so it decodes every container of the chromosome and filters records
afterwards. Our own `for_each` had replicated the same
reference-sequence-only filter. The `.crai` already carries `alignment_start`
and `alignment_span` per container -- the information needed to skip was
present and unused. On HG00096, chr21 holds 1,140 containers and a point query
needs exactly 1.
FIX. `cram_container_offsets` selects containers by interval overlap, shared by
both read paths. `query` no longer delegates to noodles: it decodes only the
containers that can overlap, lazily, one at a time so an early-stopping caller
does not pay for the rest. A container the index cannot place
(`alignment_start` = None) is KEPT -- skipping is only ever done on positive
evidence a container lies outside the interval, because a wrongly skipped
container is reads silently missing from a variant call.
RESULT, same file, same machine:
chr21 1 bp 20.92 s -> 8.3 ms (~2,500x)
chr1 1 bp 116.4 s -> 113 ms (~1,030x)
chr21 1 Mb 20.91 s -> 438 ms (~48x)
`navigator call --contig chr21` >96 min -> 44 s (~130x)
The last line is the one that matters: the whole-chromosome cost was paid once
per caller chunk AND once per realignment candidate, which is why a single
chromosome ran over an hour and a half without finishing.
CORRECTNESS. Faster-but-lossy would present as a faster caller rather than a
broken one, so equivalence is tested, not assumed:
- `cram_query_matches_noodles_query` runs our query and noodles' own Query over
the checked-in fixture and compares every field of every record.
- `container_offsets_select_only_overlapping_containers` pins the selection
boundaries (first/last base of a container overlap, one past either end does
not, unbounded intervals keep everything, other references are never
selected, unplaceable containers are kept) -- the fixture is a single
container and cannot catch an off-by-one here.
- On the real 11 GB CRAM, `VERIFY=1 cram_query_probe` compares against noodles
record for record: 305 records over 1 kb and 47,898 over 200 kb, byte
identical on name, position, flags and sequence.
Why this went unnoticed: the ground-truth subject's alignment is a BAM, and its
whole genome calls in ~5 minutes. Every CRAM in the workspace -- including all
3,216 1kGP alignments -- has been paying the whole-contig cost. Found while
trying to validate archaic segment calls against the hmmix 1000G callset, which
needs those CRAMs.
Adds `cram_query_probe`, the harness these numbers come from, as the regression
tool: it attributes cost per phase (open, first query, warm query, bulk region)
so a future slowdown names the part responsible.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
JamesKane
added a commit
that referenced
this pull request
Jul 31, 2026
* CRAM region queries decoded the whole chromosome; skip non-overlapping containers
Every CRAM region query cost the same as reading the entire contig, no matter
how small the region. Measured on a 30x 1kGP CRAM, a ONE-BASE query:
chr21 (45 Mb) 20.92 s chr1 (248 Mb) 116.4 s
same query on a BAM: 4-6 ms
Cost tracked contig length exactly (248/45 = 5.5x length, 116/21 = 5.5x time)
and was completely independent of region size -- a 1 bp and a 1 Mb query on
chr21 both took 20.91 s.
CAUSE. noodles' `Query::read_next_container` skips a container only when its
`reference_sequence_id` does not match; it never consults the requested
interval, so it decodes every container of the chromosome and filters records
afterwards. Our own `for_each` had replicated the same
reference-sequence-only filter. The `.crai` already carries `alignment_start`
and `alignment_span` per container -- the information needed to skip was
present and unused. On HG00096, chr21 holds 1,140 containers and a point query
needs exactly 1.
FIX. `cram_container_offsets` selects containers by interval overlap, shared by
both read paths. `query` no longer delegates to noodles: it decodes only the
containers that can overlap, lazily, one at a time so an early-stopping caller
does not pay for the rest. A container the index cannot place
(`alignment_start` = None) is KEPT -- skipping is only ever done on positive
evidence a container lies outside the interval, because a wrongly skipped
container is reads silently missing from a variant call.
RESULT, same file, same machine:
chr21 1 bp 20.92 s -> 8.3 ms (~2,500x)
chr1 1 bp 116.4 s -> 113 ms (~1,030x)
chr21 1 Mb 20.91 s -> 438 ms (~48x)
`navigator call --contig chr21` >96 min -> 44 s (~130x)
The last line is the one that matters: the whole-chromosome cost was paid once
per caller chunk AND once per realignment candidate, which is why a single
chromosome ran over an hour and a half without finishing.
CORRECTNESS. Faster-but-lossy would present as a faster caller rather than a
broken one, so equivalence is tested, not assumed:
- `cram_query_matches_noodles_query` runs our query and noodles' own Query over
the checked-in fixture and compares every field of every record.
- `container_offsets_select_only_overlapping_containers` pins the selection
boundaries (first/last base of a container overlap, one past either end does
not, unbounded intervals keep everything, other references are never
selected, unplaceable containers are kept) -- the fixture is a single
container and cannot catch an off-by-one here.
- On the real 11 GB CRAM, `VERIFY=1 cram_query_probe` compares against noodles
record for record: 305 records over 1 kb and 47,898 over 200 kb, byte
identical on name, position, flags and sequence.
Why this went unnoticed: the ground-truth subject's alignment is a BAM, and its
whole genome calls in ~5 minutes. Every CRAM in the workspace -- including all
3,216 1kGP alignments -- has been paying the whole-contig cost. Found while
trying to validate archaic segment calls against the hmmix 1000G callset, which
needs those CRAMs.
Adds `cram_query_probe`, the harness these numbers come from, as the regression
tool: it attributes cost per phase (open, first query, warm query, bulk region)
so a future slowdown names the part responsible.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
* Gate Tier B archaic segments off: no per-individual signal
Tier B shipped in alpha.14 on the strength of one number -- its total archaic
extent landed at 1.01x the hmmix European mean. Validating it against hmmix's
own calls FOR THE SAME INDIVIDUALS shows that number is all there is.
The workspace turned out to hold CHM13 CRAMs for 2,307 of the 2,309 people in
hmmix's published callset, so the comparison is per-person rather than against
a cohort distribution. (Reaching them needed the CRAM region-query fix in #39:
chr21+22 per sample went from ~3.5 hours to ~90 seconds.)
LOCATIONS -- BELOW CHANCE. HG00096, chr21+22, hmmix lifted hg38->CHM13:
ours 2.294 Mb / 112 segments hmmix 2.333 Mb / 48 tracts
base overlap 0.050 Mb -> sensitivity 2.1%, precision 1.5%
null (our own segment lengths placed at random in the same span): 5.0%,
p95 9.4%
Every alternative explanation was tested and rejected: overlap-vs-shift is flat
across +/-2 Mb with no peak (not a coordinate error, and lifted fragment lengths
sum to the hg38 input exactly); 70.7% of the truth lies inside our callable
territory, and sensitivity restricted to reachable truth is still 3.0% (not
callability); unioning haplotypes reproduces hmmix's published 2.09 Mb EUR mean
exactly (not haplotype handling); overlap re-derived brute force (not the
harness -- though one real harness bug WAS found: CrossMap splits tracts at
median 2 bp gaps, inflating 48 tracts to 423).
AMOUNTS -- NO CORRELATION. n=20 Europeans, randomly drawn, truth spanning
1.19-2.97 Mb:
Pearson r = -0.018 (permutation p = 0.94)
Spearman = -0.020 (p = 0.94)
mean ratio ours/theirs = 0.923
SD: truth 0.496 Mb, ours 0.312 Mb -> our spread is 0.63x the truth's
The two individuals with the LEAST archaic ancestry drew our two highest calls
(1.71x, 1.90x); the two with the most drew among our lowest (0.54x, 0.63x).
So the caller reproduces the cohort average and nothing about the person --
which is exactly what three fitted parameters were tuned to do. The design
recorded that result honestly as "a calibration check, not a validation"; this
is what the validation found.
THE GATE covers compute, read-back and display, per M3's rule (which the
original ship did not follow -- see the design's Deviations section):
- `call_archaic_segments_for_subject` returns an ERROR, not an empty result.
Every caller asked for a computation, and silently returning zero segments
would read as "you have no archaic ancestry" -- a much worse claim than "we
are not reporting this".
- `cached_archaic_segments` is gated too: rows written before the gate are
still in workspaces, and a read gate is the difference between withholding a
result and merely declining to recompute one.
- The UI card STATES that it is withheld and why, rather than disappearing. A
section that silently vanishes between releases reads as a bug; a stated
withholding is a finding about the data.
- No publish/export path exists for Tier B (M4 never started), so there is
nothing further to cover.
TIER A IS NOT AFFECTED and is not gated by this: the marker count is direct
dosage over a fixed panel with no HMM and no fitted thresholds, checked against
the per-site archaic rate on the intersection with real 23andMe v5 chip
content. It remains what the Simple-mode card and the Advanced count report.
Adds `scripts/archaic-validation/` -- the harness that produced these numbers,
since the design now makes re-running it the condition for re-enabling, and it
cannot be a gate if it only exists in a scratch directory. Its README records
the three traps that produced wrong answers along the way (span-vs-fragment
reassembly, the 2 bp lift gaps, and summing haplotypes instead of unioning).
Also adds `archaic_callable_dump`, which answered "could we even have found
them".
Re-enabling needs a method change, not a threshold sweep: Skov 2020 matches a
segment's whole haplotype against each archaic genome relative to a background
expectation, where ours tests private-variant density against pre-classified
sites.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Every CRAM region query cost the same as reading the entire contig, no matter how small the region.
Measured on a 30× 1kGP CRAM, a one-base query:
Cost tracked contig length exactly (248/45 = 5.5× length, 116/21 = 5.5× time) and was completely independent of region size — a 1 bp and a 1 Mb query on chr21 both took 20.91 s.
Cause
noodles'
Query::read_next_containerskips a container only when itsreference_sequence_iddoesn't match — it never consults the requested interval. So it decodes every container of the chromosome and filters records afterwards viaintersects(&record, self.interval). Our ownfor_eachhad replicated the same reference-sequence-only filter.The
.craialready carriesalignment_startandalignment_spanper container. The information needed to skip was present and unused. On HG00096, chr21 holds 1,140 containers and a point query needs exactly 1.Fix
cram_container_offsetsselects containers by interval overlap, shared by both read paths.queryno longer delegates to noodles — it decodes only containers that can overlap, lazily, one at a time, so an early-stopping caller doesn't pay for the rest.A container the index cannot place (
alignment_start=None) is kept. Skipping is only ever done on positive evidence that a container lies outside the interval, because a wrongly skipped container is reads silently missing from a variant call.Result
navigator call --contig chr21The last row is the one that matters: the whole-chromosome cost was paid once per caller chunk and once per realignment candidate, which is why a single chromosome ran 96 minutes without finishing.
Correctness
Faster-but-lossy would present as a faster caller rather than a broken one, so equivalence is tested rather than assumed:
cram_query_matches_noodles_query— runs our query and noodles' ownQueryover the checked-in fixture across three regions, comparing every field of every record.container_offsets_select_only_overlapping_containers— pins the selection boundaries: first/last base of a container overlaps, one past either end does not, unbounded intervals keep everything, other references are never selected, unplaceable containers are kept. The fixture is a single container and cannot catch an off-by-one here.VERIFY=1 cram_query_probecompares against noodles record for record: 305 records over 1 kb and 47,898 over 200 kb, byte-identical on name, position, flags and sequence.cargo clippy --all-targets -- -D warningsclean; 30 test suites pass.Notes
Ruled out before finding the cause, in case they come up again: not the NAS (204 MB/s, and the process sat at 98% CPU — a local copy behaves identically), not the reference FASTA (45 MB chr21-only reference: 20.93 s vs 21.00 s with the full 3.1 GB), not CRAM 3.1 decode recursion (these are 3.0), not chunk count.
Why it went unnoticed: the ground-truth subject's alignment is a BAM, whose whole genome calls in ~5 minutes. Every CRAM in the workspace — including all 3,216 1kGP alignments — has been paying the whole-contig cost.
Found while trying to validate archaic segment calls against the hmmix 1000G callset, which needs those CRAMs. This unblocks that work: chr21+22 per sample goes from ~3.5 hours to ~90 seconds.
Adds
cram_query_probe, the harness these numbers come from, as the regression tool — it attributes cost per phase (open, first query, warm query, bulk region) so a future slowdown names the part responsible.🤖 Generated with Claude Code