Skip to main content

gwseq_io/bam/
bai.rs

1//! The BAI index.
2//!
3//! The binning index, the 16 kbp linear index, and the pseudo-bin 37450 that
4//! carries per-reference metadata rather than alignments.
5
6use indexmap::IndexMap;
7
8use crate::bam::bgzf::{Chunk, VirtualOffset};
9use crate::bytes::LeCursor;
10use crate::error::{Error, Result};
11use crate::source::ByteSource;
12
13/// Pseudo-bin holding a reference's metadata rather than its alignments.
14pub const MAGIC_BIN: u32 = 37450;
15
16/// BAI bins cover 2^29 bases, so nothing past that is indexed.
17///
18/// Bounding the request against it is not tidiness: `ends=[10**12]` used to push
19/// about 61 M bins through the lookup before finding that almost none of them
20/// exist.
21const BAI_MAX_POSITION: u64 = 1 << 29;
22
23/// Largest run of merely *adjacent* chunks to join into one, in compressed
24/// bytes. Chunks that truly overlap are joined whatever this says, since leaving
25/// them apart would hand the same records out twice.
26///
27/// It is a request covering most of a reference this is for: its chunks are
28/// contiguous and would otherwise come back as one, to be decompressed whole
29/// before a single record of it is read.
30pub const MAX_MERGE_SPAN: u64 = 64 * 1024 * 1024;
31
32#[derive(Debug, Clone, Default)]
33pub struct RefIndex {
34    /// Chunks by bin number.
35    pub bins: IndexMap<u32, Vec<Chunk>>,
36    /// The linear index: the earliest virtual offset an alignment overlapping
37    /// each 16 kbp window can start at.
38    pub linear: Vec<VirtualOffset>,
39    /// From the metadata pseudo-bin, when the file carries one.
40    pub metadata: Option<RefMetadata>,
41}
42
43#[derive(Debug, Clone, Copy)]
44pub struct RefMetadata {
45    pub ref_start: VirtualOffset,
46    pub ref_end: VirtualOffset,
47    pub mapped: u64,
48    pub unmapped: u64,
49}
50
51#[derive(Debug, Clone, Default)]
52pub struct BamIndex {
53    pub refs: Vec<RefIndex>,
54    /// Unplaced unmapped reads, when the file records the count.
55    pub unplaced_count: Option<u64>,
56}
57
58impl BamIndex {
59    /// Read a `.bai`. The index itself is not compressed.
60    pub fn read(source: &dyn ByteSource) -> Result<Self> {
61        let path = source.path();
62        let all = source.read_to_end(0)?;
63        let mut c = LeCursor::new(&all, 0, path);
64
65        if c.take(4)? != b"BAI\x01" {
66            return Err(Error::format(path, "invalid bam index magic"));
67        }
68        let n_ref = c.read_u32()? as usize;
69        let mut refs = Vec::with_capacity(n_ref.min(1 << 16));
70        for _ in 0..n_ref {
71            let mut index = RefIndex::default();
72            let n_bin = c.read_u32()? as usize;
73            for _ in 0..n_bin {
74                let bin = c.read_u32()?;
75                let n_chunk = c.read_u32()? as usize;
76                if bin == MAGIC_BIN {
77                    if n_chunk != 2 {
78                        return Err(Error::corrupt(
79                            path,
80                            c.file_offset(),
81                            "invalid metadata pseudo-bin",
82                        ));
83                    }
84                    index.metadata = Some(RefMetadata {
85                        ref_start: VirtualOffset(c.read_u64()?),
86                        ref_end: VirtualOffset(c.read_u64()?),
87                        mapped: c.read_u64()?,
88                        unmapped: c.read_u64()?,
89                    });
90                    continue;
91                }
92                let mut chunks = Vec::with_capacity(n_chunk.min(1 << 16));
93                for _ in 0..n_chunk {
94                    chunks.push(Chunk {
95                        begin: VirtualOffset(c.read_u64()?),
96                        end: VirtualOffset(c.read_u64()?),
97                    });
98                }
99                index.bins.insert(bin, chunks);
100            }
101            let n_intv = c.read_u32()? as usize;
102            index.linear = Vec::with_capacity(n_intv.min(1 << 20));
103            for _ in 0..n_intv {
104                index.linear.push(VirtualOffset(c.read_u64()?));
105            }
106            refs.push(index);
107        }
108        // Optional, at the very end, and absent from plenty of real files.
109        let unplaced_count = if c.remaining() >= 8 {
110            Some(c.read_u64()?)
111        } else {
112            None
113        };
114        Ok(Self {
115            refs,
116            unplaced_count,
117        })
118    }
119
120    /// The chunks that may hold alignments overlapping `[start, end)` on
121    /// `ref_index`, merged and in file order.
122    pub fn chunks(
123        &self,
124        ref_index: usize,
125        start: i64,
126        end: i64,
127        max_merge_span: Option<u64>,
128    ) -> Result<Vec<Chunk>> {
129        let reference = self
130            .refs
131            .get(ref_index)
132            .ok_or_else(|| Error::invalid(format!("ref index {ref_index} out of range")))?;
133
134        // A locus that ends before it starts is a request no answer fits, and
135        // an empty list is an answer — the one a caller reads as "nothing
136        // aligned here". bbi refuses the same shape in `IndexedLocs::build`,
137        // and the two formats should not disagree about what a locus is.
138        if end < start {
139            return Err(Error::invalid(format!(
140                "Locus {start}-{end} ends before it starts"
141            )));
142        }
143        // Clamped at 0, since a window may be asked for around a position near
144        // the start of a reference, and bounded above by what the index can
145        // address at all. An empty locus past that point genuinely has nothing
146        // in it, which is not the same as a reversed one.
147        let bounded_start = start.max(0) as u64;
148        let bounded_end = (end.max(0) as u64).min(BAI_MAX_POSITION);
149        if bounded_start >= BAI_MAX_POSITION || bounded_end <= bounded_start {
150            return Ok(Vec::new());
151        }
152
153        // No alignment overlapping the region starts before this.
154        let min_offset = if reference.linear.is_empty() {
155            VirtualOffset(0)
156        } else {
157            let window = (bounded_start >> 14) as usize;
158            // A window past the last one the linear index covers means no
159            // alignment starts that late, so the last entry is as far as the
160            // file needs to be read from.
161            *reference
162                .linear
163                .get(window)
164                .unwrap_or_else(|| reference.linear.last().expect("checked non-empty"))
165        };
166
167        let mut chunks: Vec<Chunk> = Vec::new();
168        for bin in reg2bins(bounded_start, bounded_end) {
169            let Some(bin_chunks) = reference.bins.get(&bin) else {
170                continue;
171            };
172            chunks.extend(bin_chunks.iter().copied().filter(|c| c.end >= min_offset));
173        }
174        chunks.sort_by_key(|c| c.begin);
175        Ok(merge(chunks, max_merge_span))
176    }
177}
178
179/// Merge overlapping chunks always, and adjacent ones while they stay under
180/// `max_merge_span` compressed bytes.
181fn merge(chunks: Vec<Chunk>, max_merge_span: Option<u64>) -> Vec<Chunk> {
182    if chunks.len() <= 1 {
183        return chunks;
184    }
185    let mut merged: Vec<Chunk> = Vec::with_capacity(chunks.len());
186    merged.push(chunks[0]);
187    for current in &chunks[1..] {
188        let last = merged.last_mut().expect("pushed one above");
189        if current.begin < last.end {
190            // Overlapping, so they have records in common; merging is what keeps
191            // those records from being read twice.
192            last.end = last.end.max(current.end);
193            continue;
194        }
195        // Compressed bytes the two would span together, which is what the reader
196        // has to hold to decompress the result in one piece.
197        let span = last.end.max(current.end).block_offset() - last.begin.block_offset();
198        let within_budget = max_merge_span.is_none_or(|budget| span <= budget);
199        if current.begin == last.end && within_budget {
200            // Adjacent, and joining them saves a read; they share no record, so
201            // leaving them apart would also have been right.
202            last.end = last.end.max(current.end);
203        } else {
204            merged.push(*current);
205        }
206    }
207    merged
208}
209
210/// Every bin that may hold an alignment overlapping `[start, end)`.
211///
212/// An empty interval comes back with no bins rather than being decremented:
213/// `end - 1` on an unsigned zero is a coordinate near the top of the range, and
214/// the loops below would then walk the whole of it — some 2^38 iterations,
215/// pushing into the list the entire way.
216pub fn reg2bins(start: u64, end: u64) -> Vec<u32> {
217    let mut bins = Vec::new();
218    if end <= start {
219        return bins;
220    }
221    let end = end - 1;
222    bins.push(0);
223    for (offset, shift) in [(1u64, 26u32), (9, 23), (73, 20), (585, 17), (4681, 14)] {
224        for bin in (offset + (start >> shift))..=(offset + (end >> shift)) {
225            bins.push(bin as u32);
226        }
227    }
228    bins
229}
230
231#[cfg(test)]
232mod tests {
233    use super::*;
234    use crate::source::testing::MemorySource;
235
236    fn vo(block: u64, within: u16) -> VirtualOffset {
237        VirtualOffset::new(block, within)
238    }
239
240    fn chunk(a: u64, b: u64) -> Chunk {
241        Chunk {
242            begin: vo(a, 0),
243            end: vo(b, 0),
244        }
245    }
246
247    /// A one-reference index with the given bins and linear windows.
248    fn bai(bins: &[(u32, &[Chunk])], linear: &[VirtualOffset], tail: bool) -> Vec<u8> {
249        let mut b = b"BAI\x01".to_vec();
250        b.extend_from_slice(&1u32.to_le_bytes()); // n_ref
251        b.extend_from_slice(&(bins.len() as u32).to_le_bytes());
252        for (bin, chunks) in bins {
253            b.extend_from_slice(&bin.to_le_bytes());
254            b.extend_from_slice(&(chunks.len() as u32).to_le_bytes());
255            for c in *chunks {
256                b.extend_from_slice(&c.begin.0.to_le_bytes());
257                b.extend_from_slice(&c.end.0.to_le_bytes());
258            }
259        }
260        b.extend_from_slice(&(linear.len() as u32).to_le_bytes());
261        for offset in linear {
262            b.extend_from_slice(&offset.0.to_le_bytes());
263        }
264        if tail {
265            b.extend_from_slice(&42u64.to_le_bytes());
266        }
267        b
268    }
269
270    #[test]
271    fn reads_bins_the_linear_index_and_the_optional_tail() {
272        let bytes = bai(
273            &[(4681, &[chunk(100, 200)]), (4682, &[chunk(200, 300)])],
274            &[vo(0, 0), vo(100, 0)],
275            true,
276        );
277        let index = BamIndex::read(&MemorySource::new(bytes)).unwrap();
278        assert_eq!(index.refs.len(), 1);
279        assert_eq!(index.refs[0].bins.len(), 2);
280        assert_eq!(index.refs[0].linear.len(), 2);
281        assert_eq!(index.unplaced_count, Some(42));
282    }
283
284    #[test]
285    fn the_optional_tail_really_is_optional() {
286        let bytes = bai(&[(4681, &[chunk(100, 200)])], &[vo(0, 0)], false);
287        let index = BamIndex::read(&MemorySource::new(bytes)).unwrap();
288        assert_eq!(index.unplaced_count, None);
289    }
290
291    #[test]
292    fn the_metadata_pseudo_bin_is_not_a_bin_of_chunks() {
293        let mut b = b"BAI\x01".to_vec();
294        b.extend_from_slice(&1u32.to_le_bytes());
295        b.extend_from_slice(&2u32.to_le_bytes()); // two "bins"
296        b.extend_from_slice(&4681u32.to_le_bytes());
297        b.extend_from_slice(&1u32.to_le_bytes());
298        b.extend_from_slice(&vo(100, 0).0.to_le_bytes());
299        b.extend_from_slice(&vo(200, 0).0.to_le_bytes());
300        b.extend_from_slice(&MAGIC_BIN.to_le_bytes());
301        b.extend_from_slice(&2u32.to_le_bytes());
302        for value in [7u64, 8, 9, 10] {
303            b.extend_from_slice(&value.to_le_bytes());
304        }
305        b.extend_from_slice(&0u32.to_le_bytes()); // n_intv
306
307        let index = BamIndex::read(&MemorySource::new(b)).unwrap();
308        assert_eq!(index.refs[0].bins.len(), 1, "the pseudo-bin is not a bin");
309        let meta = index.refs[0].metadata.unwrap();
310        assert_eq!((meta.mapped, meta.unmapped), (9, 10));
311    }
312
313    #[test]
314    fn a_bad_magic_is_refused() {
315        let err = BamIndex::read(&MemorySource::new(b"NOPE".to_vec()))
316            .unwrap_err()
317            .to_string();
318        assert!(err.contains("invalid bam index magic"), "{err}");
319    }
320
321    #[test]
322    fn a_truncated_index_is_corrupt_not_a_panic() {
323        let mut bytes = bai(&[(4681, &[chunk(100, 200)])], &[vo(0, 0)], false);
324        bytes.truncate(bytes.len() - 5);
325        assert!(matches!(
326            BamIndex::read(&MemorySource::new(bytes)),
327            Err(Error::Corrupt { .. })
328        ));
329    }
330
331    #[test]
332    fn reg2bins_covers_every_level_and_refuses_an_empty_region() {
333        assert!(reg2bins(100, 100).is_empty());
334        assert!(reg2bins(100, 50).is_empty());
335        // The root, and one bin at each of the five levels.
336        assert_eq!(reg2bins(0, 16384), [0, 1, 9, 73, 585, 4681]);
337        // A region reaching into the next 16 kbp window adds one more.
338        assert_eq!(reg2bins(0, 16385), [0, 1, 9, 73, 585, 4681, 4682]);
339    }
340
341    #[test]
342    fn a_region_past_what_the_index_addresses_asks_for_nothing() {
343        let index = BamIndex::read(&MemorySource::new(bai(
344            &[(4681, &[chunk(100, 200)])],
345            &[vo(0, 0)],
346            false,
347        )))
348        .unwrap();
349        // Unclamped, a query at 10^12 enumerates 61 M bins.
350        assert!(index
351            .chunks(0, 1_000_000_000_000, 1_000_000_001_000, None)
352            .unwrap()
353            .is_empty());
354        assert!(index
355            .chunks(0, 1 << 30, (1 << 30) + 10, None)
356            .unwrap()
357            .is_empty());
358    }
359
360    #[test]
361    fn the_linear_index_drops_chunks_that_end_before_the_region_can_start() {
362        // Bin 4681 is the first 16 kbp window; its chunk ends at block 100.
363        // The linear index says nothing overlapping window 0 starts before 500.
364        let index = BamIndex::read(&MemorySource::new(bai(
365            &[(4681, &[chunk(50, 100)])],
366            &[vo(500, 0)],
367            false,
368        )))
369        .unwrap();
370        assert!(index.chunks(0, 0, 1000, None).unwrap().is_empty());
371    }
372
373    #[test]
374    fn overlapping_chunks_merge_whatever_the_budget() {
375        assert_eq!(
376            merge(vec![chunk(0, 200), chunk(100, 300)], Some(0)),
377            [chunk(0, 300)]
378        );
379    }
380
381    #[test]
382    fn adjacent_chunks_merge_only_inside_the_budget() {
383        // Adjacent: the first ends exactly where the second begins.
384        assert_eq!(
385            merge(vec![chunk(0, 100), chunk(100, 200)], Some(1000)),
386            [chunk(0, 200)]
387        );
388        // The same pair with a budget too small to span them stays apart.
389        assert_eq!(
390            merge(vec![chunk(0, 100), chunk(100, 200)], Some(50)),
391            [chunk(0, 100), chunk(100, 200)]
392        );
393        // No budget at all means no limit.
394        assert_eq!(
395            merge(vec![chunk(0, 100), chunk(100, 200)], None),
396            [chunk(0, 200)]
397        );
398    }
399
400    #[test]
401    fn chunks_with_a_gap_between_them_never_merge() {
402        let apart = vec![chunk(0, 100), chunk(500, 600)];
403        assert_eq!(merge(apart.clone(), None), apart);
404    }
405
406    #[test]
407    fn an_out_of_range_reference_is_refused_by_name() {
408        let index = BamIndex::default();
409        let err = index.chunks(3, 0, 10, None).unwrap_err().to_string();
410        assert!(err.contains("ref index 3 out of range"), "{err}");
411    }
412}