Skip to content

CRAM region queries decoded the whole chromosome; skip non-overlapping containers - #39

Merged
JamesKane merged 1 commit into
mainfrom
fix/cram-region-query-container-skip
Jul 30, 2026
Merged

CRAM region queries decoded the whole chromosome; skip non-overlapping containers#39
JamesKane merged 1 commit into
mainfrom
fix/cram-region-query-container-skip

Conversation

@JamesKane

Copy link
Copy Markdown
Owner

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:

contig length query cost same query on a BAM
chr21 45 Mb 20.92 s 4–6 ms
chr1 248 Mb 116.4 s 4–6 ms

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_container skips a container only when its reference_sequence_id doesn't match — it never consults the requested interval. So it decodes every container of the chromosome and filters records afterwards via intersects(&record, self.interval). 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 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

before after
chr21, 1 bp 20.92 s 8.3 ms ~2,500×
chr1, 1 bp 116.4 s 113 ms ~1,030×
chr21, 1 Mb 20.91 s 438 ms ~48×
navigator call --contig chr21 >96 min (killed, unfinished) 44 s ~130×

The 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' own Query over 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.
  • On the real 11 GB CRAMVERIFY=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.

cargo clippy --all-targets -- -D warnings clean; 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

…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
JamesKane merged commit f7cabb1 into main Jul 30, 2026
3 checks passed
@JamesKane
JamesKane deleted the fix/cram-region-query-container-skip branch July 30, 2026 22:31
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>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant