Skip to content

fix(cbq): correct silent N to A corruption on CBQ decode (#94)#95

Closed
BenjaminDEMAILLE wants to merge 3 commits into
ArcInstitute:mainfrom
BenjaminDEMAILLE:fix/cbq-n-decode-issue-94
Closed

fix(cbq): correct silent N to A corruption on CBQ decode (#94)#95
BenjaminDEMAILLE wants to merge 3 commits into
ArcInstitute:mainfrom
BenjaminDEMAILLE:fix/cbq-n-decode-issue-94

Conversation

@BenjaminDEMAILLE

Copy link
Copy Markdown

Fixes #94.

Problem

A .cbq written with N bases does not round-trip: on decode every N comes back as A. In debug builds this panics inside sucds (elias_fano/iter.rs:23, debug_assert_ne!(num_ones, 0)); in the shipped release config it is silent, returning a real A where the read had a no-call N. For methylation/variant consumers this is a silent correctness violation.

Root cause

CBQ stores N as a 2-bit placeholder A plus a sucds EliasFano index of the N-positions, backfilled on decode. In the serial Reader path, ColumnarBlock::decompress_columns() did:

self.ef_bytes.resize(self.len_nef, 0);              // pre-fills len_nef ZERO bytes
copy_decode(self.z_npos.as_slice(), &mut self.ef_bytes)?;  // io::Write for Vec<u8> APPENDS

copy_decode's destination is a &mut Vec<u8>, whose io::Write impl appends. So ef_bytes became [0u8; len_nef] ++ [real EF bytes], and EliasFano::deserialize_from read the leading zeros, producing an empty index (num_ones() == 0). backfill_npos() then panicked in debug or silently no-oped in release, leaving the placeholder As.

This is not a sucds bug: its serialize/deserialize are symmetric. The other columns are fine because numeric columns decode into cast_slice_mut(&mut vec) (an in-place &mut [u8]), and headers/qual append into an already-cleared buffer with no resize. Only ef_bytes did both resize(_, 0) and append.

The mmap / process_parallel path (decompress_from_bytes, using dctx.decompress) writes at offset 0 via zstd-safe's WriteBuf, so it was already correct.

Fix

Clear ef_bytes before copy_decode so it holds exactly the decompressed bytes, mirroring the headers/qual columns. Also reset len_nef in ColumnarBlock::clear() for consistent block reuse.

No on-disk format change: z_npos and len_nef are written correctly and already read back correctly by the mmap path, so FILE_VERSION is unchanged and existing files (N-free or not) are unaffected.

Tests

Existing CBQ tests were ACGT-only, which is why this shipped. Added N-nucleotide round-trip coverage (leading / interior / trailing / all-N, single and multi-block, with and without quality+headers) over both the serial Reader path and the parallel MmapReader/process_parallel path. Verified these new tests fail (serial) on the pre-fix code and pass after; full suite green in debug and release; the exact issue repro prints NACGTACGT, NNNNNNNN, ACGTNACGT unchanged in both profiles.

🤖 Generated with Claude Code

BenjaminDEMAILLE and others added 2 commits July 1, 2026 09:41
CBQ stores N bases as a 2-bit placeholder A plus an Elias-Fano index of
the N-positions that is backfilled on decode. In the serial Reader path,
decompress_columns pre-filled ef_bytes with `resize(len_nef, 0)` and then
called copy_decode, whose io::Write for Vec<u8> appends. The serialized
EliasFano bytes therefore landed after len_nef zero bytes, so
deserialize_from read the leading zeros and produced an empty index
(num_ones == 0). backfill_npos then panicked in debug (sucds
debug_assert) or silently no-oped in release, leaving every N decoded as A.

Clear ef_bytes before copy_decode so the buffer holds exactly the
decompressed bytes, mirroring the headers/qual columns. Also reset
len_nef in ColumnarBlock::clear() for consistent block reuse. The mmap
process_parallel path (dctx.decompress writes at offset 0) was already
correct, and the on-disk bytes are unchanged, so no format bump is needed.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
)

Existing CBQ tests were ACGT-only, which is why the N-decode bug shipped.
Add coverage for N at leading, interior, trailing, and all-N positions,
across single and multiple blocks, with and without quality plus headers,
over both the serial Reader path and the parallel MmapReader /
process_parallel path.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

@gemini-code-assist gemini-code-assist Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request fixes a bug (issue #94) where N-nucleotides were silently dropped during decoding. Instead of resizing self.ef_bytes with zeros (which caused subsequent decodes to append and fail to reconstruct the Elias-Fano index), the code now clears self.ef_bytes before decoding. Additionally, self.len_nef is now properly reset when clearing the block, and comprehensive serial and parallel round-trip tests have been added. The reviewer suggested a performance improvement to pre-allocate capacity for self.ef_bytes using reserve after clearing it, avoiding unnecessary reallocations during decompression.

Comment thread src/cbq/core/block.rs
)

Address PR review: pre-size ef_bytes to the known decompressed length
(len_nef) after clearing it, so copy_decode's appends avoid incremental
reallocations. Capacity-only change; decode semantics are unchanged.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@noamteyssier

Copy link
Copy Markdown
Collaborator

done with #96

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.

CBQ mis-decodes N-containing records (silent N→A in release, panic in debug)

2 participants