use std::collections::HashMap;
use anyhow::{Result, bail};
use fgumi_raw_bam::RawRecord;
use noodles_sam::Header;
use noodles_sam::header::record::value::map::read_group::tag as rg_tag;
use crate::cigar::CigarInfo;
use crate::counts::CountsMap;
use crate::raw_writer::RawBamWriter;
use crate::sig::{
BinIndex, FragmentDupTable, MethylationMode, PairDupTable, SingleEndStrategy,
five_prime_aligned_pos, orphan_pos_override, single_end_slot,
};
pub const FLAG_PAIRED: u16 = 0x1;
pub const FLAG_UNMAPPED: u16 = 0x4;
pub const FLAG_MATE_UNMAPPED: u16 = 0x8;
pub const FLAG_REVERSE: u16 = 0x10;
pub const FLAG_FIRST_SEGMENT: u16 = 0x40;
pub const FLAG_LAST_SEGMENT: u16 = 0x80;
pub const FLAG_SECONDARY: u16 = 0x100;
pub const FLAG_DUPLICATE: u16 = 0x400;
pub const FLAG_SUPPLEMENTARY: u16 = 0x800;
#[derive(Debug, Clone)]
pub struct ProcessorOptions {
pub remove_dups: bool,
pub add_mate_tags: bool,
pub ignore_unmated: bool,
pub max_read_length: i32,
pub single_end_strategy: SingleEndStrategy,
pub methylation_mode: Option<MethylationMode>,
pub collect_counts: bool,
}
const UNKNOWN_LIBRARY: &str = "Unknown Library";
const ALL_READS: &str = "All Reads";
pub struct LibraryIndex {
rg_to_lib: HashMap<Box<[u8]>, u32>,
names: Vec<String>,
}
impl LibraryIndex {
pub fn from_header(header: &Header, disabled: bool) -> Self {
let mut rg_lb: Vec<(Vec<u8>, String)> = Vec::new();
let mut distinct: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for (id, map) in header.read_groups() {
if let Some(lb) = map.other_fields().get(&rg_tag::LIBRARY) {
let lb = lb.to_string();
if !lb.is_empty() {
distinct.insert(lb.clone());
rg_lb.push((id.as_slice().to_vec(), lb));
}
}
}
if disabled || distinct.len() <= 1 {
let name = match distinct.len() {
1 => distinct.into_iter().next().expect("one library present"),
0 => UNKNOWN_LIBRARY.to_string(),
_ => ALL_READS.to_string(),
};
return Self { rg_to_lib: HashMap::new(), names: vec![name] };
}
let mut names = Vec::with_capacity(distinct.len() + 1);
names.push(UNKNOWN_LIBRARY.to_string());
let mut lb_to_idx: HashMap<String, u32> = HashMap::new();
for lb in distinct {
let idx = names.len() as u32;
lb_to_idx.insert(lb.clone(), idx);
names.push(lb);
}
let rg_to_lib =
rg_lb.into_iter().map(|(id, lb)| (id.into_boxed_slice(), lb_to_idx[&lb])).collect();
Self { rg_to_lib, names }
}
pub fn num_libs(&self) -> u32 {
self.names.len() as u32
}
pub fn name(&self, idx: u32) -> &str {
&self.names[idx as usize]
}
fn lookup(&self, rg: &[u8]) -> u32 {
self.rg_to_lib.get(rg).copied().unwrap_or(0)
}
}
#[derive(Debug, Default, Clone)]
pub struct LibraryStats {
pub name: String,
pub id_count: u64,
pub dup_count: u64,
pub both_unmapped_id_count: u64,
pub unmapped_orphan_id_count: u64,
pub mapped_orphan_id_count: u64,
pub orphan_dup_count: u64,
pub both_mapped_id_count: u64,
pub both_mapped_dup_count: u64,
pub unmated_count: u64,
}
pub(crate) const CATEGORY_PAIRS: &str = "pairs";
pub(crate) const CATEGORY_SINGLE_END: &str = "single_end";
impl LibraryStats {
pub(crate) fn reported_category(&self) -> &'static str {
if self.both_mapped_id_count > 0 { CATEGORY_PAIRS } else { CATEGORY_SINGLE_END }
}
fn accumulate(&mut self, other: &LibraryStats) {
self.id_count += other.id_count;
self.dup_count += other.dup_count;
self.both_unmapped_id_count += other.both_unmapped_id_count;
self.unmapped_orphan_id_count += other.unmapped_orphan_id_count;
self.mapped_orphan_id_count += other.mapped_orphan_id_count;
self.orphan_dup_count += other.orphan_dup_count;
self.both_mapped_id_count += other.both_mapped_id_count;
self.both_mapped_dup_count += other.both_mapped_dup_count;
self.unmated_count += other.unmated_count;
}
}
#[derive(Debug, Default, Clone)]
pub struct Stats {
pub libraries: Vec<LibraryStats>,
pub clamped_template_count: u64,
}
impl Stats {
pub fn new(library_index: &LibraryIndex) -> Self {
let libraries = (0..library_index.num_libs())
.map(|i| LibraryStats { name: library_index.name(i).to_string(), ..Default::default() })
.collect();
Self { libraries, clamped_template_count: 0 }
}
pub fn totals(&self) -> LibraryStats {
let mut total = LibraryStats { name: ALL_READS.to_string(), ..Default::default() };
for lib in &self.libraries {
total.accumulate(lib);
}
total
}
}
pub struct RecordProcessor {
bins: BinIndex,
bin_count: u32,
dups: Vec<Option<PairDupTable>>,
frag_dups: Vec<Option<FragmentDupTable>>,
library_index: LibraryIndex,
partition_cap: usize,
last_rg: Vec<u8>,
last_lib_idx: u32,
opts: ProcessorOptions,
mate_cigar_scratch: Vec<u8>,
counts: Option<Vec<CountsMap>>,
}
impl RecordProcessor {
pub fn from_ref_lengths(
ref_lengths: &[i32],
opts: ProcessorOptions,
min_bin_count: u32,
library_index: LibraryIndex,
) -> Self {
let bins = BinIndex::from_ref_lengths(ref_lengths, opts.max_read_length, min_bin_count);
let num_libs = library_index.num_libs() as usize;
let partition_cap = scaled_partition_cap(library_index.num_libs());
let bin_count = bins.bin_count();
let dups = (0..num_libs).map(|_| None).collect();
let frag_dups = (0..num_libs).map(|_| None).collect();
let counts =
opts.collect_counts.then(|| (0..num_libs).map(|_| CountsMap::new(bin_count)).collect());
Self {
bins,
bin_count,
dups,
frag_dups,
library_index,
partition_cap,
last_rg: Vec::new(),
last_lib_idx: 0,
opts,
mate_cigar_scratch: Vec::with_capacity(64),
counts,
}
}
pub fn counts(&self) -> Option<&[CountsMap]> {
self.counts.as_deref()
}
fn pair_table(&mut self, lib: u32) -> &mut PairDupTable {
let (bin_count, cap) = (self.bin_count, self.partition_cap);
self.dups[lib as usize].get_or_insert_with(|| PairDupTable::new_pair(bin_count, cap))
}
fn frag_table(&mut self, lib: u32) -> &mut FragmentDupTable {
let (bin_count, cap) = (self.bin_count, self.partition_cap);
self.frag_dups[lib as usize]
.get_or_insert_with(|| FragmentDupTable::new_single_end(bin_count, cap))
}
fn resolve_library(&mut self, block: &[RawRecord]) -> u32 {
if self.library_index.num_libs() == 1 {
return 0;
}
match block[0].tags().find_string(b"RG") {
Some(rg) => {
if !self.last_rg.is_empty() && self.last_rg == rg {
return self.last_lib_idx;
}
let idx = self.library_index.lookup(rg);
self.last_rg.clear();
self.last_rg.extend_from_slice(rg);
self.last_lib_idx = idx;
idx
}
None => 0,
}
}
pub fn bin_shift(&self) -> u32 {
self.bins.bin_shift()
}
pub fn bin_count(&self) -> u32 {
self.bin_count
}
pub fn process_block(
&mut self,
block: &mut [RawRecord],
stats: &mut Stats,
out: &mut RawBamWriter,
) -> Result<u32> {
let lib = self.resolve_library(block);
let is_dup = self.mark_dups(block, lib, stats)?;
self.emit(block, is_dup, out)?;
Ok(lib)
}
fn mark_dups(&mut self, block: &mut [RawRecord], lib: u32, stats: &mut Stats) -> Result<bool> {
let class = self.classify_block(block)?;
let li = lib as usize;
stats.libraries[li].id_count += 1;
Ok(match class {
BlockClass::BothUnmapped => {
stats.libraries[li].both_unmapped_id_count += 1;
false
}
BlockClass::UnmappedOrphan => {
stats.libraries[li].unmapped_orphan_id_count += 1;
false
}
BlockClass::Unmated => {
stats.libraries[li].unmated_count += 1;
false
}
BlockClass::Pair { first, second } => {
if self.opts.add_mate_tags {
self.add_mate_tags_pair(block, first, second);
}
stats.libraries[li].both_mapped_id_count += 1;
let dup = self.check_pair_signature(block, first, second, lib, stats)?;
if dup {
stats.libraries[li].dup_count += 1;
stats.libraries[li].both_mapped_dup_count += 1;
}
dup
}
BlockClass::Fragment { mapped, mate } => {
if self.opts.add_mate_tags
&& let Some(m) = mate
{
self.add_mate_tags_pair(block, mapped, m);
}
stats.libraries[li].mapped_orphan_id_count += 1;
let dup = self.check_fragment_signature(block, mapped, lib, stats)?;
if dup {
stats.libraries[li].dup_count += 1;
stats.libraries[li].orphan_dup_count += 1;
}
dup
}
})
}
fn classify_block(&self, block: &[RawRecord]) -> Result<BlockClass> {
let mut first: Option<usize> = None;
let mut second: Option<usize> = None;
for (i, rec) in block.iter().enumerate() {
let f = rec.flags();
if f & (FLAG_SECONDARY | FLAG_SUPPLEMENTARY) != 0 {
continue;
}
if f & FLAG_PAIRED == 0 {
second = Some(i);
} else if f & FLAG_FIRST_SEGMENT != 0 {
first = Some(i);
} else if f & FLAG_LAST_SEGMENT != 0 {
second = Some(i);
}
}
if first.is_none() && second.is_none() {
bail!("{}", non_query_grouped_message(block));
}
if first.is_none() || second.is_none() {
let only_idx = first.or(second).expect("at least one primary present");
let only_flags = block[only_idx].flags();
if !has(only_flags, FLAG_PAIRED) {
if has(only_flags, FLAG_UNMAPPED) {
return Ok(BlockClass::UnmappedOrphan);
}
return Ok(BlockClass::Fragment { mapped: only_idx, mate: None });
}
if has(only_flags, FLAG_UNMAPPED) {
if self.opts.ignore_unmated {
return Ok(BlockClass::UnmappedOrphan);
}
bail!("{}", broken_block_message(block));
}
if !has(only_flags, FLAG_MATE_UNMAPPED) {
if self.opts.ignore_unmated {
return Ok(BlockClass::Unmated);
}
bail!("{}", broken_block_message(block));
}
return Ok(BlockClass::Fragment { mapped: only_idx, mate: None });
}
let f = first.expect("both primaries present");
let s = second.expect("both primaries present");
let ff = block[f].flags();
let sf = block[s].flags();
if has(ff, FLAG_UNMAPPED) && has(sf, FLAG_UNMAPPED) {
return Ok(BlockClass::BothUnmapped);
}
if has(ff, FLAG_UNMAPPED) {
return Ok(BlockClass::Fragment { mapped: s, mate: Some(f) });
}
if has(sf, FLAG_UNMAPPED) {
return Ok(BlockClass::Fragment { mapped: f, mate: Some(s) });
}
Ok(BlockClass::Pair { first: f, second: s })
}
fn check_pair_signature(
&mut self,
block: &[RawRecord],
first: usize,
second: usize,
lib: u32,
stats: &mut Stats,
) -> Result<bool> {
let f_derived = self.derive_for_dup(&block[first], false)?;
let s_derived = self.derive_for_dup(&block[second], false)?;
if f_derived.clamped || s_derived.clamped {
self.note_clamp(stats);
}
let template_order =
matches!(self.opts.methylation_mode, Some(MethylationMode::Directional));
let (a, b) = if !template_order && need_swap(&f_derived, &s_derived) {
(&s_derived, &f_derived)
} else {
(&f_derived, &s_derived)
};
let slot = self.pair_table(lib).pair_slot(
a.bin_num,
a.bin_pos,
a.is_reverse,
b.bin_num,
b.bin_pos,
b.is_reverse,
);
let is_dup = self.pair_table(lib).insert_pair(slot);
if self.opts.single_end_strategy == SingleEndStrategy::PicardApprox {
let frag = self.frag_table(lib);
let _ =
frag.check_or_insert(f_derived.bin_num, f_derived.bin_pos, f_derived.is_reverse);
let _ =
frag.check_or_insert(s_derived.bin_num, s_derived.bin_pos, s_derived.is_reverse);
}
if let Some(counts) = self.counts.as_mut() {
counts[lib as usize].observe_pair(slot, is_dup);
}
Ok(is_dup)
}
fn check_fragment_signature(
&mut self,
block: &[RawRecord],
mapped: usize,
lib: u32,
stats: &mut Stats,
) -> Result<bool> {
let d = self.derive_for_dup(&block[mapped], true)?;
if d.clamped {
self.note_clamp(stats);
}
let se_strand_aware = self.opts.single_end_strategy != SingleEndStrategy::SamblasterLegacy;
let slot = single_end_slot(d.bin_num, d.bin_pos, d.is_reverse, se_strand_aware);
let is_dup = match self.opts.single_end_strategy {
SingleEndStrategy::PicardApprox | SingleEndStrategy::PicardExact => {
self.frag_table(lib).insert(slot)
}
SingleEndStrategy::StrandAware | SingleEndStrategy::SamblasterLegacy => {
self.pair_table(lib).insert_orphan(slot)
}
};
if let Some(counts) = self.counts.as_mut() {
counts[lib as usize].observe_single_end(slot, is_dup);
}
Ok(is_dup)
}
pub fn process_block_phase1(
&mut self,
block: &mut [RawRecord],
stats: &mut Stats,
out: &mut RawBamWriter,
temp: &mut RawBamWriter,
) -> Result<u32> {
let lib = self.resolve_library(block);
let li = lib as usize;
match self.classify_block(block)? {
BlockClass::Fragment { mapped, mate } => {
if self.opts.add_mate_tags
&& let Some(m) = mate
{
self.add_mate_tags_pair(block, mapped, m);
}
for rec in block.iter() {
temp.write_record(rec)?;
}
}
BlockClass::BothUnmapped => {
stats.libraries[li].id_count += 1;
stats.libraries[li].both_unmapped_id_count += 1;
self.emit(block, false, out)?;
}
BlockClass::UnmappedOrphan => {
stats.libraries[li].id_count += 1;
stats.libraries[li].unmapped_orphan_id_count += 1;
self.emit(block, false, out)?;
}
BlockClass::Unmated => {
stats.libraries[li].id_count += 1;
stats.libraries[li].unmated_count += 1;
self.emit(block, false, out)?;
}
BlockClass::Pair { first, second } => {
stats.libraries[li].id_count += 1;
if self.opts.add_mate_tags {
self.add_mate_tags_pair(block, first, second);
}
stats.libraries[li].both_mapped_id_count += 1;
let dup = self.check_pair_signature(block, first, second, lib, stats)?;
if dup {
stats.libraries[li].dup_count += 1;
stats.libraries[li].both_mapped_dup_count += 1;
}
self.emit(block, dup, out)?;
}
}
Ok(lib)
}
pub fn finalize_fragment_table(&mut self) {
debug_assert!(
self.frag_dups.iter().all(Option::is_none),
"fragment tables must be built exactly once"
);
let cap = self.partition_cap;
for lib in 0..self.dups.len() {
if let Some(pair) = self.dups[lib].as_mut() {
self.frag_dups[lib] = Some(pair.drain_into_fragment_table(cap));
}
}
}
pub fn process_fragment_block(
&mut self,
block: &mut [RawRecord],
stats: &mut Stats,
out: &mut RawBamWriter,
) -> Result<u32> {
let lib = self.resolve_library(block);
let li = lib as usize;
stats.libraries[li].id_count += 1;
let dup = match self.classify_block(block)? {
BlockClass::Fragment { mapped, .. } => {
stats.libraries[li].mapped_orphan_id_count += 1;
let d = self.check_fragment_signature(block, mapped, lib, stats)?;
if d {
stats.libraries[li].dup_count += 1;
stats.libraries[li].orphan_dup_count += 1;
}
d
}
other => bail!(
"picard-exact phase 2 encountered a non-fragment block ({}); \
this is an internal error in fragment buffering",
other.describe()
),
};
self.emit(block, dup, out)?;
Ok(lib)
}
fn add_mate_tags_pair(&mut self, block: &mut [RawRecord], a: usize, b: usize) {
self.add_mate_tags(block, a, b);
self.add_mate_tags(block, b, a);
}
fn derive_for_dup(&self, rec: &RawRecord, orphan: bool) -> Result<DerivedAlignment> {
let info = CigarInfo::from_cigar_ops(rec.cigar_ops_iter());
let rapos = rapos_of(rec);
let is_reverse = has(rec.flags(), FLAG_REVERSE);
let mut pos =
five_prime_aligned_pos(rapos, info.sclip, info.eclip, info.ra_len, is_reverse);
if orphan
&& is_reverse
&& self.opts.single_end_strategy == SingleEndStrategy::SamblasterLegacy
{
pos = orphan_pos_override(rapos, info.sclip);
}
let seq_num = seq_num_of(rec);
if seq_num >= self.bins.num_seqs() {
bail!(
"record {} references reference id {} but the header declares only {} \
reference sequence(s) — input BAM is malformed",
String::from_utf8_lossy(rec.read_name()),
rec.ref_id(),
self.bins.num_seqs() - 1,
);
}
let (bin_num, bin_pos, clamped) = self.bins.bin_for(seq_num, pos);
Ok(DerivedAlignment { bin_num, bin_pos, is_reverse, seq_num, pos, clamped })
}
fn note_clamp(&self, stats: &mut Stats) {
if stats.clamped_template_count == 0 {
log::warn!(
"A read's 5' coordinate was clamped to its contig: its clipping extends more \
than --max-read-length ({}) bases past a contig edge, so duplicate marking may \
be imprecise for such reads. Re-run with a larger --max-read-length to avoid it.",
self.opts.max_read_length,
);
}
stats.clamped_template_count += 1;
}
fn add_mate_tags(&mut self, block: &mut [RawRecord], target_first: usize, mate: usize) {
let mate_flags = block[mate].flags();
if has(mate_flags, FLAG_UNMAPPED) {
return;
}
self.mate_cigar_scratch.clear();
write_cigar_text(block[mate].cigar_ops_iter(), &mut self.mate_cigar_scratch);
let mq = block[mate].mapq();
let target_first_bit = has(block[target_first].flags(), FLAG_FIRST_SEGMENT);
for rec in block.iter_mut() {
if has(rec.flags(), FLAG_FIRST_SEGMENT) != target_first_bit {
continue;
}
let has_mc = rec.tags().find_string(b"MC").is_some();
let has_mq = rec.tags().find_int(b"MQ").is_some();
let mut editor = rec.tags_editor();
if !has_mc {
editor.append_string(b"MC", &self.mate_cigar_scratch);
}
if !has_mq {
editor.append_int(b"MQ", i32::from(mq));
}
}
}
fn emit(
&mut self,
block: &mut [RawRecord],
is_dup: bool,
out: &mut RawBamWriter,
) -> Result<()> {
for rec in block.iter_mut() {
let f = rec.flags() & !FLAG_DUPLICATE;
rec.set_flags(if is_dup { f | FLAG_DUPLICATE } else { f });
if !(self.opts.remove_dups && is_dup) {
out.write_record(rec)?;
}
}
Ok(())
}
}
#[derive(Debug, Clone, Copy)]
enum BlockClass {
BothUnmapped,
UnmappedOrphan,
Unmated,
Pair {
first: usize,
second: usize,
},
Fragment {
mapped: usize,
mate: Option<usize>,
},
}
impl BlockClass {
fn describe(&self) -> &'static str {
match self {
BlockClass::BothUnmapped => "both-unmapped",
BlockClass::UnmappedOrphan => "unmapped-orphan",
BlockClass::Unmated => "unmated",
BlockClass::Pair { .. } => "pair",
BlockClass::Fragment { .. } => "fragment",
}
}
}
#[derive(Debug, Clone, Copy)]
struct DerivedAlignment {
bin_num: u32,
bin_pos: u32,
is_reverse: bool,
seq_num: usize,
pos: i32,
clamped: bool,
}
#[inline]
fn has(flags: u16, bit: u16) -> bool {
flags & bit != 0
}
#[inline]
fn rapos_of(rec: &RawRecord) -> i32 {
let p = rec.pos();
if p < 0 { 0 } else { p + 1 }
}
#[inline]
fn seq_num_of(rec: &RawRecord) -> usize {
let tid = rec.ref_id();
if tid < 0 { 0 } else { (tid + 1) as usize }
}
fn need_swap(a: &DerivedAlignment, b: &DerivedAlignment) -> bool {
(a.pos, a.seq_num, a.is_reverse) > (b.pos, b.seq_num, b.is_reverse)
}
const BASE_PARTITION_CAP: usize = 64;
const MIN_PARTITION_CAP: usize = 8;
fn scaled_partition_cap(num_libs: u32) -> usize {
let divisor = isqrt_ceil(num_libs.max(1)) as usize;
(BASE_PARTITION_CAP / divisor.max(1)).max(MIN_PARTITION_CAP)
}
fn isqrt_ceil(n: u32) -> u32 {
if n == 0 {
return 0;
}
let s = n.isqrt();
if s * s == n { s } else { s + 1 }
}
fn write_cigar_text<I: IntoIterator<Item = u32>>(ops: I, out: &mut Vec<u8>) {
use std::io::Write as _;
for word in ops {
let len = word >> 4;
let code = (word & 0xf) as usize;
const OPS: [u8; 9] = *b"MIDNSHP=X";
let _ = write!(out, "{len}");
debug_assert!(code < OPS.len(), "invalid CIGAR op code {code}");
out.push(*OPS.get(code).unwrap_or(&b'?'));
}
if out.is_empty() {
out.push(b'*');
}
}
fn broken_block_message(block: &[RawRecord]) -> String {
let qname = block.first().map(|r| r.read_name().to_vec()).unwrap_or_default();
let qname_str = String::from_utf8_lossy(&qname);
format!(
"Can't find first and/or second of pair in sam block of length {} for id: {}\n\
dupblaster: Are you sure the input is sorted by read ids?",
block.len(),
qname_str
)
}
fn non_query_grouped_message(block: &[RawRecord]) -> String {
let qname = block.first().map(|r| r.read_name().to_vec()).unwrap_or_default();
let qname_str = String::from_utf8_lossy(&qname);
format!(
"QNAME {} appeared with {} secondary/supplementary record(s) but no \
primary — the primary must be in a different part of the stream. \
This is almost certainly because the input is not query-grouped \
(e.g. coordinate-sorted). Re-sort with `samtools sort -n` or \
`mako sort --queryname` and re-run.",
qname_str,
block.len(),
)
}
#[cfg(test)]
mod tests {
use noodles_sam::header::record::value::Map;
use noodles_sam::header::record::value::map::ReadGroup;
use super::*;
fn header_with_read_groups(rgs: &[(&str, Option<&str>)]) -> Header {
let mut header = Header::default();
for (id, lb) in rgs {
let mut rg = Map::<ReadGroup>::default();
if let Some(lb) = lb {
rg.other_fields_mut().insert(rg_tag::LIBRARY, (*lb).into());
}
header.read_groups_mut().insert((*id).into(), rg);
}
header
}
#[test]
fn isqrt_ceil_rounds_up_to_the_next_integer_root() {
assert_eq!(isqrt_ceil(0), 0);
assert_eq!(isqrt_ceil(1), 1);
assert_eq!(isqrt_ceil(2), 2);
assert_eq!(isqrt_ceil(4), 2);
assert_eq!(isqrt_ceil(5), 3);
assert_eq!(isqrt_ceil(9), 3);
assert_eq!(isqrt_ceil(10), 4);
}
#[test]
fn scaled_partition_cap_is_unscaled_for_a_single_library() {
assert_eq!(scaled_partition_cap(1), BASE_PARTITION_CAP);
}
#[test]
fn scaled_partition_cap_shrinks_by_ceil_sqrt_of_library_count() {
assert_eq!(scaled_partition_cap(4), BASE_PARTITION_CAP / 2); assert_eq!(scaled_partition_cap(5), BASE_PARTITION_CAP / 3); assert_eq!(scaled_partition_cap(9), BASE_PARTITION_CAP / 3); }
#[test]
fn scaled_partition_cap_never_drops_below_the_floor() {
assert_eq!(scaled_partition_cap(10_000), MIN_PARTITION_CAP);
}
#[test]
fn library_index_is_single_bucket_with_no_libraries() {
let idx = LibraryIndex::from_header(&header_with_read_groups(&[]), false);
assert_eq!(idx.num_libs(), 1);
assert_eq!(idx.name(0), UNKNOWN_LIBRARY);
}
#[test]
fn library_index_is_single_bucket_with_one_library() {
let idx =
LibraryIndex::from_header(&header_with_read_groups(&[("A", Some("lib1"))]), false);
assert_eq!(idx.num_libs(), 1);
assert_eq!(idx.name(0), "lib1");
}
#[test]
fn library_index_assigns_a_bucket_per_distinct_library() {
let header = header_with_read_groups(&[("A", Some("lib1")), ("B", Some("lib2"))]);
let idx = LibraryIndex::from_header(&header, false);
assert_eq!(idx.num_libs(), 3);
assert_eq!(idx.name(0), UNKNOWN_LIBRARY);
assert_ne!(idx.lookup(b"A"), idx.lookup(b"B"));
assert_ne!(idx.lookup(b"A"), 0);
assert_ne!(idx.lookup(b"B"), 0);
}
#[test]
fn library_index_dedups_read_groups_by_library() {
let header = header_with_read_groups(&[("A", Some("lib1")), ("B", Some("lib1"))]);
let idx = LibraryIndex::from_header(&header, false);
assert_eq!(idx.num_libs(), 1);
}
#[test]
fn library_index_unknown_read_group_maps_to_bucket_zero() {
let header = header_with_read_groups(&[("A", Some("lib1")), ("B", Some("lib2"))]);
let idx = LibraryIndex::from_header(&header, false);
assert_eq!(idx.lookup(b"Z"), 0);
}
#[test]
fn library_index_disabled_collapses_to_one_all_reads_bucket() {
let header = header_with_read_groups(&[("A", Some("lib1")), ("B", Some("lib2"))]);
let idx = LibraryIndex::from_header(&header, true);
assert_eq!(idx.num_libs(), 1);
assert_eq!(idx.name(0), ALL_READS);
}
}