Skip to content

Commit 61143ec

Browse files
logannyeclaude
andcommitted
feat(genomics/index/view): ReferenceView — zero-copy reference access from the index
A borrowed ReferenceView over the persisted Reference2bit section (base_at + decode_window, on-demand 2-bit decode mirroring CompressedDNA::base_at, no full-reference Vec), via ReferenceIndex::reference_view(). Gated by base_at/decode_window == the original reference over an N-bearing multi-contig index. The shared reference-access foundation B4b (aligner DP window) and B4c (variants ref_base) consume. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent da3d1f6 commit 61143ec

4 files changed

Lines changed: 126 additions & 2 deletions

File tree

src/genomics/index/io.rs

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,13 @@ impl ReferenceIndex {
7373
&self.contigs,
7474
))
7575
}
76+
77+
/// Borrow a zero-copy view over the persisted 2-bit reference (`Reference2bit`).
78+
pub fn reference_view(
79+
&self,
80+
) -> Result<crate::genomics::index::view::ReferenceView<'_>, IndexIoError> {
81+
crate::genomics::index::view::ReferenceView::new(self.mmap.as_bytes(), &self.sections)
82+
}
7683
}
7784

7885
/// Writes a new index file.
@@ -835,6 +842,46 @@ mod tests {
835842
let _ = std::fs::remove_file(corrupt);
836843
}
837844

845+
#[test]
846+
fn reference_view_decodes_bytes_identical_to_in_ram() {
847+
let idx = sample_index();
848+
let path = temp_path("refview");
849+
IndexWriter::create(&path)
850+
.unwrap()
851+
.write_genome_index(&idx)
852+
.unwrap();
853+
let loaded = IndexReader::open(&path).unwrap();
854+
let rv = loaded.reference_view().unwrap();
855+
let reference = idx.reference();
856+
857+
assert_eq!(rv.len(), reference.len());
858+
assert!(!rv.is_empty());
859+
for i in 0..rv.len() {
860+
assert_eq!(rv.base_at(i), reference[i], "base_at mismatch @ {i}");
861+
}
862+
863+
let mut buf = Vec::new();
864+
let n = reference.len();
865+
for (s, e) in [(0usize, 10usize), (6, 14), (12, 22), (0, n), (n - 3, n + 5)] {
866+
rv.decode_window(s, e, &mut buf);
867+
assert_eq!(
868+
buf.as_slice(),
869+
&reference[s..e.min(n)],
870+
"decode_window {s}..{e}"
871+
);
872+
}
873+
let _ = std::fs::remove_file(path);
874+
}
875+
876+
#[test]
877+
fn reference_view_is_a_small_borrow() {
878+
assert!(
879+
std::mem::size_of::<crate::genomics::ReferenceView<'_>>() <= 64,
880+
"ReferenceView must be a small borrow, got {}",
881+
std::mem::size_of::<crate::genomics::ReferenceView<'_>>()
882+
);
883+
}
884+
838885
#[test]
839886
fn open_rejects_a_misaligned_block_offset() {
840887
// A block-record offset that is in-bounds but not 8-aligned must be rejected

src/genomics/index/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,4 +13,4 @@ mod view;
1313
pub use format::IndexHeader;
1414
pub use io::{IndexReader, IndexWriter, ReferenceIndex};
1515
pub use report::{estimate_build_working_set, render_plan_line, IndexBuildReport};
16-
pub use view::{FmIndexView, GenomeIndexView};
16+
pub use view::{FmIndexView, GenomeIndexView, ReferenceView};

src/genomics/index/view.rs

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -322,6 +322,83 @@ impl<'a> GenomeIndexView<'a> {
322322
}
323323
}
324324

325+
/// A borrowed, zero-copy view over the persisted 2-bit forward reference
326+
/// (`Reference2bit`). Decodes bases on demand from the mmap — no full-reference
327+
/// allocation — so consumers (the aligner DP window in B4b, variants' `ref_base`
328+
/// in B4c) read the reference from the `.idx` alone. Uses **global** (concatenated)
329+
/// coordinates; `(contig, pos)` mapping stays with `ContigSet`. The little-endian
330+
/// host requirement is the same as `FmIndexView` (checked in `new`).
331+
#[derive(Debug)]
332+
pub struct ReferenceView<'a> {
333+
len: usize,
334+
/// 2-bit packed bases, 32 per `u64` word (A/C/G/T = 0/1/2/3).
335+
data: &'a [u64],
336+
/// Ambiguity bits, one per base (a set bit marks `N`).
337+
amb: &'a [u64],
338+
}
339+
340+
impl<'a> ReferenceView<'a> {
341+
/// Parse + validate the `Reference2bit` section into a borrowed view.
342+
pub(crate) fn new(bytes: &'a [u8], sections: &[SectionEntry]) -> Result<Self, IndexIoError> {
343+
if cfg!(target_endian = "big") {
344+
return Err(IndexIoError::Invalid(
345+
"zero-copy reference view requires a little-endian host".to_string(),
346+
));
347+
}
348+
let section = section_bytes(bytes, sections, SectionKind::Reference2bit)?;
349+
let mut o = 0usize;
350+
let len = read_u64(section, &mut o)? as usize;
351+
let data_words = read_u64(section, &mut o)? as usize;
352+
let amb_words = read_u64(section, &mut o)? as usize;
353+
let data = as_u64_slice(slice_exact(section, &mut o, data_words.saturating_mul(8))?)?;
354+
let amb = as_u64_slice(slice_exact(section, &mut o, amb_words.saturating_mul(8))?)?;
355+
// The slices must cover `len` bases so `base_at` cannot index out of bounds.
356+
if data.len().saturating_mul(32) < len || amb.len().saturating_mul(64) < len {
357+
return Err(IndexIoError::Invalid(
358+
"Reference2bit section too small for the declared length".to_string(),
359+
));
360+
}
361+
Ok(Self { len, data, amb })
362+
}
363+
364+
/// Number of reference bases.
365+
pub fn len(&self) -> usize {
366+
self.len
367+
}
368+
369+
/// Whether the reference is empty.
370+
pub fn is_empty(&self) -> bool {
371+
self.len == 0
372+
}
373+
374+
/// The ASCII base at global position `global`. Mirrors `CompressedDNA::base_at`:
375+
/// a set ambiguity bit decodes to `N`, otherwise the 2-bit code maps to A/C/G/T.
376+
pub fn base_at(&self, global: usize) -> u8 {
377+
debug_assert!(global < self.len, "reference index out of range");
378+
if self.amb[global / 64] & (1u64 << (global % 64)) != 0 {
379+
return b'N';
380+
}
381+
let code = ((self.data[global / 32] >> ((global % 32) * 2)) & 0b11) as u8;
382+
match code {
383+
0 => b'A',
384+
1 => b'C',
385+
2 => b'G',
386+
_ => b'T',
387+
}
388+
}
389+
390+
/// Decode `[start, end.min(len))` into `out` (cleared first). `end` is clamped
391+
/// to `len`, so an over-range request never panics; `start <= end` is the
392+
/// caller's contract. Bounded by the window size — never the whole genome.
393+
pub fn decode_window(&self, start: usize, end: usize, out: &mut Vec<u8>) {
394+
out.clear();
395+
let end = end.min(self.len);
396+
for i in start..end {
397+
out.push(self.base_at(i));
398+
}
399+
}
400+
}
401+
325402
/// Validate that every block record (at the directory's offsets) lies fully
326403
/// within the `Blocks` section, using checked arithmetic so a corrupt word-count
327404
/// can never overflow into an in-bounds-but-wrong slice. Lets `block()` use

src/genomics/mod.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,7 +35,7 @@ pub use fm_index::{
3535
pub use genome_index::{GenomeIndex, GenomeIndexError, MAX_GENOME_LEN};
3636
pub use index::{
3737
estimate_build_working_set, render_plan_line, FmIndexView, GenomeIndexView, IndexBuildReport,
38-
IndexHeader, IndexReader, IndexWriter, ReferenceIndex,
38+
IndexHeader, IndexReader, IndexWriter, ReferenceIndex, ReferenceView,
3939
};
4040
pub use io::create_bam_writer;
4141
pub use pileup::{PileupNode, PileupProcessor, PileupSummary, PileupWorkload};

0 commit comments

Comments
 (0)