Skip to content

bam: remove BamRecord, some other prototype stuff - #45

Merged
killercup merged 20 commits into
mainfrom
bam-cleanup
May 4, 2026
Merged

bam: remove BamRecord, some other prototype stuff#45
killercup merged 20 commits into
mainfrom
bam-cleanup

Conversation

@killercup

Copy link
Copy Markdown
Member

No description provided.

killercup and others added 4 commits May 2, 2026 15:38
The read-path BamRecord struct had no production callers — every reader
in the crate decodes via RecordStore::push_raw straight into slabs. Its
only uses were as a round-trip oracle in tests and a separate fuzz
target that fuzzed a code path production never takes.

Drop the struct, decode helper, and accessors. Keep parse_header,
ParsedHeader, compute_end_pos_from_raw, read2, and read4 — those are
the genuinely-shared primitives still used by RecordStore::push_raw,
the pre-push CIGAR scan, and OwnedBamRecord::from_raw_bam.

Migrate the round-trip tests in owned_record.rs and writer.rs to push
into a RecordStore and assert against SlimRecord/store accessors —
which is what production runs anyway. Retarget the fuzz target the
same way.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The method had zero callers outside its own two tests — its docstring
already steered everyone to RecordStore::push_raw for bulk decode, and
no production caller in seqair or rastair2 used it for the "edit one
record" flow either. Keeping it meant maintaining a third copy of the
"parse 32-byte header → walk qname/cigar/seq/qual/aux" walk (after
RecordStore::push_raw and the now-deleted BamRecord::decode).

Drop the method, the OwnedRecordError::Decode variant it raised, and
the pos_from_bam_i32 helper that nothing else used. Drop the two
tests that exercised it as a to_bam_bytes round-trip oracle — the
writer's round-trip coverage already exercises that path through a
RecordStore.

After this, "decode raw BAM bytes" has exactly one entry point:
RecordStore::push_raw.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Previously two writer paths each packed the BAM record fixed header
inline: OwnedBamRecord::to_bam_bytes (the typed-record path) and
BamWriter::write_store_record_inner (the slab-resident path). Both
duplicated the bin_mq_nl / flag_nc bit-packing and the eight LE-byte
appends. A field added to one site without the other (e.g. the bin
recompute, or the n_cigar_op casts) was a bug class waiting to happen.

Extract `bam::record::encode_fixed_header` as the inverse of the
existing `parse_header`, taking a `FixedHeaderFields` struct in BAM
wire types (pos/next_pos already resolved to -1 for unmapped, and
l_read_name as u8 to encode the qname ≤ 254 invariant in the type).
Both call sites now construct the struct with their own validated
casts and call the shared encoder. The resulting BAM bytes are
unchanged — verified by the existing writer round-trip tests and the
broader suite.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Both write paths (write -> write_inner via OwnedBamRecord::to_bam_bytes,
and write_store_record -> write_store_record_inner via slab + the
shared encode_fixed_header) ended with the same dance: size-cap check,
pre-write index validation (mapped-without-reference rejection), BGZF
block-size + bytes write, then index dispatch with the placed-unmapped
vs mapped vs fully-unmapped fork.

Two copies of the index dispatch meant a fix in one site silently
diverged from the other — already happened once with the
beg.saturating_add(1) clamp.

Extract `finalize_record(ref_id, is_unmapped, beg, end_pos)` for the
shared tail. Each writer entry now: fill self.buf with record bytes,
compute (beg, end_pos), call finalize_record. Net -35 lines in writer.rs
and one place to fix any future change to size limits, index pushing,
or BGZF write order.

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

Copy link
Copy Markdown
Member Author

still not very clean. will have to make some examples and proof/refine the APIs

killercup and others added 16 commits May 3, 2026 10:22
4 examples rewritten, `SegmentOptions::default()` added

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Centralizes SAM CIGAR formatting so callers stop hand-rolling the
op-code-to-char match. Drops the inline fmt_cigar helper from the
realignment example.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Wraps the four-line MM/ML/seq/is_reverse extraction in a typed
constructor returning Result<Option<Self>, FromRecordError>: Ok(None)
when MM is absent, typed errors for wrong-type tags or malformed MM/ML.
Forgetting to thread `is_reverse` through `parse` silently produces
wrong methylation calls — the constructor pulls it from rec.flags
itself. Drops the manual extraction from examples/base_mods.rs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Drops the opaque `build_index: bool` and positional `level: i32` from
`BamWriter::from_path` / `from_path_with_level` / `from_stdout` /
`new_inner` in favor of a `BamWriterBuilder` reached via
`BamWriter::builder(path, header)` (file target) or
`BamWriter::build_over(writer, header)` (arbitrary `Write` sink).
Setters: `write_index(bool)` and `compression_level(i32)`. The header
is required positionally; everything else has a default. Header is
still written eagerly during `build()`. Spec rules and all in-tree
call sites (bench, example, tests, internal tests) updated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Re-attach the spec rules orphaned by the BamRecord -> SlimRecord
migration to their new code homes (BamFlags predicates, parse_header,
decode_bases_into, RecordStore::{seq_at,aux}, push_raw SIMD path,
matching/indel pre-compute fields). Rename remaining BamRecord prose
references to OwnedBamRecord across the BAM-record-builder/writer/flags
specs, point r[bam_writer.index_coproduction] at the new
BamWriterBuilder::write_index API, and replace the BamRecord mention in
r[perf.precompute_matches_indels] / base-quality field-usage with
SlimRecord. Drop the contradictory "no universally correct value"
paragraph from SegmentOptions::new now that Default exists, and note on
BaseModState::from_record that we reject MM-with-deltas records lacking
ML (stricter than SAM 1.6). Extract the duplicated push-raw test
scaffold into bam::test_util.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The &[u8] FromAuxValue impl previously accepted both Z and H tags,
returning the raw stored bytes for either. This silently misinterpreted
H tags: their wire encoding is ASCII hex digits, so a caller fetching
&[u8] would get the digits rather than the underlying byte array.

Restrict the &[u8] impl to Z, and add a HexBytes wrapper with its own
FromAuxValue impl plus a decode() method that materializes the byte
array. H tags now require an explicit fetch as HexBytes; Z tags do not
coerce into HexBytes. Searched the workspace — no production caller was
relying on H acceptance via &[u8].

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The builder types are part of the writer's public API (BamWriter::builder
and BamWriter::build_over both return one), but were only reachable as
seqair::bam::writer::BamWriterBuilder. Surface them at seqair::bam so
they line up with BamWriter and BamWriteError.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
When build_over(writer, header).write_index(true).build() is called,
there is no sidecar location for a .bai file and BAI virtual offsets
only make sense against a seekable BAM file — building an IndexBuilder
would just waste work and hand the caller something that points into a
stream they have already streamed away.

Accept write_index(true) on the ToWriter path for API uniformity, but
drop the request: log info! and skip IndexBuilder construction. The
ToPath path is unchanged.

Migrate the existing index tests that used build_over(&mut Vec) to
tempfile-backed BamWriter::builder(path) targets, and add a new
write_index_on_writer_target_is_soft_noop test that pins down the new
behavior.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
BamWriter::builder(path, header) and BamWriter::build_over(writer, header)
hung off the writer type itself even though both returned a builder, not
a writer. Move them to BamWriterBuilder::to_path and
BamWriterBuilder::to_writer so the configure-then-build surface lives in
one place: BamWriterBuilder is the configuration type, BamWriter is what
.build() produces.

Update every test/bench/example caller and the bam_writer.create_*
spec rules.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
IndexedBamReader's pre-filter unconditionally dropped placed-unmapped
reads (flag 0x4 with a valid tid) before they reached the customize
layer. Downstream tools that want htslib-`view`-equivalent semantics
(e.g. perbase `only-depth -x`) had no way to see them and worked around
it by injecting `-F 4` at the call site.

Add a builder-style `keep_unmapped(bool)` setter (and `keeps_unmapped()`
inspector). Default `false` preserves htslib-pileup-correct behavior;
`true` lets `filter_raw` / `filter` decide. `fork()` propagates the
flag.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Replaces the `for idx in 0..store.len() as u32 { let rec = store.record(idx); ... }`
boilerplate with `for (idx, rec) in store.iter() { ... }`. Yielded `idx`
matches the `u32` returned by `push_raw`/`push_fields` so callers can still
reach the variable-length slabs via `store.cigar(idx)` etc., or via the
`SlimRecord::cigar(&store)` getters.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
The yielded `u32` was redundant — `SlimRecord` already carries
`cigar(&store)` / `qname(&store)` / `seq(&store)` / `qual(&store)` /
`aux(&store)` getters that cover every shared-reference access pattern,
and the only operations that need the index are mutable (`set_alignment`,
`extra_mut`, …) which can't run during iteration anyway. Callers that
genuinely need a position can use `records().enumerate()`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Measured ~8× wall-clock speedup on this repo (58.9s → 7.0s) because
nextest parallelizes test binaries instead of running them serially.
Repo already ships a nextest config at .config/nextest.toml.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Mapped records with SEQ=* (seq_len=0) were silently dropped from every
pileup column because the qual slab lookup returned None. htslib's pileup
keeps them — bam_seqi reads beyond the empty SEQ buffer, the 0xFF qual
sentinel decodes to N, and the alignment counts toward depth(). Any BAM
with secondary alignments lacking sequence (a common SAM convention) saw
a different coverage profile under seqair than htslib; perbase #107 had
to ignore an empty-SEQ parity test waiting for this fix.

Match/Insertion ops on a seq_len=0 record now emit Base::Unknown +
BaseQuality::UNAVAILABLE, matching htslib's depth() and match_depth().

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@killercup
killercup merged commit 9eb0f30 into main May 4, 2026
4 checks passed
@killercup
killercup deleted the bam-cleanup branch May 4, 2026 12:26
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

1 participant