fix(cram): read CRAM files that use Huffman coding for the bases series (#429) - #431
fix(cram): read CRAM files that use Huffman coding for the bases series (#429)#431mwiewior wants to merge 2 commits into
Conversation
Points datafusion-bio-formats at the revision whose noodles fork has been rebased onto upstream master (biodatageeks/datafusion-bio-formats#225). Reading a CRAM whose bases series uses Huffman coding aborted the query with `not yet implemented`, raised from noodles' Byte::decode_take. It affected both scan_cram/read_cram and pb.depth, which was the entry point in the original report. Upstream implemented the missing decode path (zaeleus/noodles#393); rebasing picks it up. Also fixes reading a CRAM whose .crai holds more than one record, which failed with "invalid digit found in string" — the index reader reused its line buffer without clearing it, concatenating each record onto the previous one. All noodles crates now resolve to a single revision. Two divergent fork revisions used to be pinned at once, so duplicate copies of noodles-core, noodles-csi and noodles-bgzf were built. Adds tests/test_io_cram_huffman.py with a 2.5 KB fixture. Five of its six tests fail on master with the reported panic. The sixth pins the CRAI behaviour documented below, and passes either way by design. Documents that an indexed CRAM scan silently omits unmapped reads: a CRAI index cannot address the unmapped tail, so scanning a CRAM with a .crai beside it returns only placed reads, while the same file without one returns all of them. BAM is unaffected. This is pre-existing behaviour, not a change in this PR, but it is a quiet way to lose records and was undocumented. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Picks up biodatageeks/datafusion-bio-formats#225, which teaches the CRAM reader to read the unplaced, unmapped records at the end of a file when a CRAI is present. A whole-file scan_cram/read_cram now returns the same records with or without an index. Previously the indexed path dropped the unmapped tail silently: a file with 300 mapped and 200 unmapped reads gave 300 rows with a .crai beside it and 500 without one. Region queries are unchanged, since they ask for placed reads by definition. BAM was never affected. This was not a limitation of the format, as the docs in the previous commit claimed. A CRAI describes the unmapped slice with a reference sequence ID of -1, and noodles exposes Reader::query_unmapped to seek to it; our reader simply never asked. The docs are corrected accordingly, and the section is renamed from "CRAM index (CRAI) limitations" to "Unmapped reads and indexed scans" since it now documents behaviour rather than a caveat. TestCRAMIndexUnmappedReads is replaced by TestCRAMIndexedUnmappedReads, which asserts the indexed and sequential scans agree, that unmapped reads surface with a null chrom, that no record is duplicated, and that a region query still excludes the tail. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
|
Updated: the CRAI limitation this PR originally documented is fixed, not documented, and the pin now points at datafusion-bio-formats I need to correct something I got wrong in the first version of this PR. I wrote that a CRAI "cannot address the unmapped tail" and documented the dropped reads as a property of the format. That is not true. Dumping the index of the test fixture: The unmapped slice is right there, and noodles has exposed Behaviour now: a whole-file # sample.cram: 300 mapped + 200 unmapped
pb.scan_cram("sample.cram").collect().height # 500, indexed or not
pb.scan_cram("sample.cram").filter(pl.col("chrom").is_null()).collect() # the 200
pb.scan_cram("sample.cram").filter(pl.col("chrom") == "chr1").collect() # 150, no tailThe docs section is renamed from "CRAM index (CRAI) limitations" to "Unmapped reads and indexed scans", since it now describes behaviour rather than a caveat, and carries a short note for anyone on an older version who worked around it by deleting their index. The capability-matrix footnote is gone.
Full suite: 1076 passed, 3 skipped against the lockfile-pinned environment. Still draft, for the same reason as before: the |
|
@codex pls review |
|
@claude pls review |
|
Claude finished @mwiewior's task in 2m 23s —— View job Code Review
This is a well-structured PR that fixes a real bug, closes a silent data-loss gap in the indexed CRAM path, and documents the corrected behaviour. The test design is notably good. A few observations below. Hard BlockerRevision pin in Tests (
|
|
Codex Review: Didn't find any major issues. 👍 Reviewed commit: ℹ️ About Codex in GitHubYour team has set up Codex to review pull requests in this repo. Reviews are triggered when you
If Codex has suggestions, it will comment; otherwise it will react with 👍. Codex can also answer questions or update the PR. Try commenting "@codex address that feedback". |
Fixes #429.
What was broken
Reading a CRAM whose bases series uses Huffman coding aborted the query with
not yet implemented, raised fromnoodles'sByte::decode_take. It affectedscan_cram/read_cramandpb.depth, which is what @J-Wall was calling in #429.Only the single-byte
Byte::decodepath handled Huffman; the bulkdecode_takepath used for the bases series did not. Upstream implemented it in zaeleus/noodles#393, so the fix is to stop being 727 commits behind: ournoodlesfork has been rebased onto upstream master, anddatafusion-bio-formatsrepointed at it.What's in the rebase
Two of our patches were dropped as obsolete — upstream fixed the CRAM container landmark calculation itself, and migrated xz2 → lzma-rust2. Everything still needed was replayed, three patches by hand against changed upstream APIs. Details in biodatageeks/datafusion-bio-formats#225.
The rebase also surfaced a second bug, found because this repo's CRAM tests started failing:
crai::io::reader::read_index()reused its line buffer without clearing it, concatenating each record onto the previous one, so any CRAI with more than one record failed withinvalid digit found in string. Fixed at the source with regression tests; it deserves an upstream PR too.All
noodlescrates now resolve to a single revision. Two divergent fork revisions used to be pinned at once, so cargo was building duplicate copies ofnoodles-core,noodles-csiandnoodles-bgzf.Tests
tests/test_io_cram_huffman.py, with a 2.5 KB fixture. Five of its six tests fail onmasterwith the reported panic — I checked, because a regression test that passes before the fix is worthless. The sixth pins the CRAI behaviour below and passes either way by design.Getting a fixture that reproduces the bug took some care, and the test module documents why: the unmapped reads are required (the bases series is only read for unmapped records), and the fixture must ship without a
.crai(an indexed scan skips unmapped reads and never reaches the Huffman block).Full suite: 1074 passed, 3 skipped against the lockfile-pinned environment, versus 1068 on
master— the six new tests, no regressions.Documentation — CRAI limitations
While building the fixture I found something worth writing down, so
docs/features/reading.mdgains a CRAM index (CRAI) limitations section:A CRAI records only placed alignment slices and, unlike BAI/CSI, cannot address the unmapped tail. So a whole-file scan of an indexed CRAM silently drops unmapped reads. BAM is not affected — an indexed
scan_bamreturns them, which I verified rather than assumed. Region queries are unaffected, since they ask for placed reads by definition.This is pre-existing behaviour, not something this PR changes, but it is a quiet way to lose records and was undocumented.
Not covered here
pb.depthreaches CRAM through the pileup crate rather than the table provider. Thetest_depth_on_huffman_cramtest above covers it end to end from Python, but a Rust-level test belongs indatafusion-bio-functions.