use std::collections::HashMap;
use std::fs::File;
use std::io::{Read, Write};
use std::path::Path;
use anyhow::{Context, Result, bail};
use tempfile::TempDir;
use crate::readname::{ImagingLocation, ReadNameFormat};
use crate::sig::{PairSlot, stride_for};
pub(crate) const DEFAULT_SPILL_BUCKETS: u32 = 64;
const GOLDEN_RATIO_64: u64 = 0x9E37_79B9_7F4A_7C15;
const RESERVED_DESCRIPTORS: u64 = 32;
const BUCKET_BUFFER_BYTES: usize = 64 * 1024;
const BUCKET_READ_RECORDS: usize = 64 * 1024;
const BISECTION_STEPS: usize = 60;
const INVERSE_SEARCH_CEILING: f64 = 1e12;
const CARDINALITY_WARN: usize = 1_000_000;
const CARDINALITY_LIMIT: usize = 16_777_216;
#[derive(Clone, Debug)]
pub(crate) struct TileEntry {
pub library: u32,
pub unit: Box<[u8]>,
pub templates: u64,
}
#[derive(Clone, Debug, Default)]
pub(crate) struct LibraryTiles {
pub templates: u64,
pub tiles: usize,
pub collision_rate: f64,
pub shares: Vec<f64>,
}
pub(crate) struct TileDictionary {
format: ReadNameFormat,
ids: HashMap<Box<[u8]>, u32, rustc_hash::FxBuildHasher>,
entries: Vec<TileEntry>,
key: Vec<u8>,
memo: Memo,
warned: bool,
}
impl TileDictionary {
pub(crate) fn new(format: ReadNameFormat) -> Self {
Self {
format,
ids: HashMap::default(),
entries: Vec::new(),
key: Vec::new(),
memo: Memo::default(),
warned: false,
}
}
pub(crate) fn observe(&mut self, library: u32, name: &[u8]) -> Result<u32> {
let location = self.format.extract(name).ok_or_else(|| self.format.parse_error(name))?;
let id = self.intern(library, location)?;
self.entries[id as usize].templates += 1;
Ok(id)
}
fn intern(&mut self, library: u32, location: ImagingLocation<'_>) -> Result<u32> {
self.pack_key(library, location)?;
if self.memo.key == self.key {
return Ok(self.memo.id);
}
let id = match self.ids.get(self.key.as_slice()) {
Some(&id) => id,
None => self.insert(library, location)?,
};
std::mem::swap(&mut self.memo.key, &mut self.key);
self.memo.id = id;
Ok(id)
}
fn pack_key(&mut self, library: u32, location: ImagingLocation<'_>) -> Result<()> {
let Ok(unit_len) = u16::try_from(location.unit.len()) else {
bail!(
"sequencing-unit token is {} bytes, which cannot be a read-name field \
(SAM limits a QNAME to 254 bytes) — check the --read-name-format pattern",
location.unit.len()
);
};
self.key.clear();
self.key.extend_from_slice(&library.to_le_bytes());
self.key.extend_from_slice(&unit_len.to_le_bytes());
self.key.extend_from_slice(location.unit);
self.key.extend_from_slice(location.tile);
Ok(())
}
fn insert(&mut self, library: u32, location: ImagingLocation<'_>) -> Result<u32> {
if self.entries.len() >= CARDINALITY_LIMIT {
bail!(
"more than {CARDINALITY_LIMIT} distinct (library, sequencing unit, tile) \
triples: the --read-name-format is almost certainly extracting a per-read \
field such as an x/y coordinate rather than a tile"
);
}
let id = self.entries.len() as u32;
self.ids.insert(self.key.clone().into_boxed_slice(), id);
self.entries.push(TileEntry { library, unit: location.unit.into(), templates: 0 });
if !self.warned && self.entries.len() >= CARDINALITY_WARN {
self.warned = true;
log::warn!(
"{} distinct (library, sequencing unit, tile) triples seen, far more than any \
real flowcell geometry — check that --read-name-format names the tile field \
and not an x/y coordinate.",
self.entries.len()
);
}
Ok(id)
}
pub(crate) fn entries(&self) -> &[TileEntry] {
&self.entries
}
pub(crate) fn library_tiles(&self, num_libs: u32) -> Vec<LibraryTiles> {
let mut stats = vec![LibraryTiles::default(); num_libs as usize];
for entry in &self.entries {
if let Some(library) = stats.get_mut(entry.library as usize) {
library.templates += entry.templates;
library.tiles += 1;
}
}
for entry in &self.entries {
if let Some(library) = stats.get_mut(entry.library as usize)
&& library.templates > 0
{
let share = entry.templates as f64 / library.templates as f64;
library.collision_rate += share * share;
library.shares.push(share);
}
}
stats
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
struct SpillRecord {
sig: u64,
off: u32,
id: u32,
}
const SPILL_RECORD_BYTES: usize = 16;
const _: () = assert!(
size_of::<SpillRecord>() == SPILL_RECORD_BYTES,
"SpillRecord must stay padding-free so a bucket's sort buffer stays 16 B/record"
);
const _: () = assert!(
BUCKET_BUFFER_BYTES.is_multiple_of(SPILL_RECORD_BYTES),
"observe_pair flushes a bucket on len == BUCKET_BUFFER_BYTES exactly, so the buffer must \
hold a whole number of records"
);
impl SpillRecord {
#[inline]
fn to_bytes(self) -> [u8; SPILL_RECORD_BYTES] {
let mut bytes = [0u8; SPILL_RECORD_BYTES];
bytes[..8].copy_from_slice(&self.sig.to_le_bytes());
bytes[8..12].copy_from_slice(&self.off.to_le_bytes());
bytes[12..].copy_from_slice(&self.id.to_le_bytes());
bytes
}
#[inline]
fn from_bytes(bytes: &[u8; SPILL_RECORD_BYTES]) -> Self {
let sig = u64::from_le_bytes(bytes[..8].try_into().expect("8 bytes"));
let off = u32::from_le_bytes(bytes[8..12].try_into().expect("4 bytes"));
let id = u32::from_le_bytes(bytes[12..16].try_into().expect("4 bytes"));
Self { sig, off, id }
}
}
#[derive(Clone, Copy, Debug, Default, PartialEq)]
pub(crate) struct Decomposition {
pub duplicate_pairs: u64,
pub corrected_sequencing_duplicates: u64,
pub library_duplicates: u64,
pub raw_sequencing_duplicates: u64,
pub tile_collision_rate: f64,
pub tile_count: usize,
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct SequencingUnitStats {
pub library: u32,
pub unit: String,
pub templates: u64,
pub tiles: usize,
pub sequencing_duplicates: u64,
}
pub(crate) struct TileSpiller {
dictionary: TileDictionary,
buckets: Vec<SpillBucket>,
dir: TempDir,
bucket_count: u32,
spilled: u64,
level: Option<i32>,
}
impl TileSpiller {
pub(crate) fn new(
format: ReadNameFormat,
bin_count: u32,
buckets: u32,
tmp_dir: Option<&Path>,
level: Option<i32>,
) -> Result<Self> {
let stride = u64::from(stride_for(bin_count));
if stride * stride > u64::from(u32::MAX) {
bail!(
"partition cell count {} exceeds what a spill record can address; \
lower --min-bins",
stride * stride
);
}
let bucket_count = clamp_buckets(buckets);
let dir = match tmp_dir {
Some(dir) => TempDir::new_in(dir),
None => TempDir::new(),
}
.context("creating temp directory for the duplicate-decomposition spill")?;
let mut writers = Vec::with_capacity(bucket_count as usize);
for bucket in 0..bucket_count {
let path = dir.path().join(format!("spill-{bucket:04}"));
let sink = SpillSink::create(&path, level)?;
writers.push(SpillBucket { buf: Vec::with_capacity(BUCKET_BUFFER_BYTES), sink });
}
Ok(Self {
dictionary: TileDictionary::new(format),
buckets: writers,
dir,
bucket_count,
spilled: 0,
level,
})
}
pub(crate) fn observe_pair(&mut self, library: u32, name: &[u8], slot: PairSlot) -> Result<()> {
let id = self.dictionary.observe(library, name).context(
"cannot split sequencing from library duplicates. Pass --read-name-format to name \
this platform's read-name layout, or --sequencing-duplicate-detection off to skip the split",
)?;
let record = SpillRecord { sig: slot.sig, off: slot.off as u32, id };
let bucket_idx = self.bucket_of(record);
let bucket = &mut self.buckets[bucket_idx];
if bucket.buf.len() == BUCKET_BUFFER_BYTES {
bucket.sink.write_all(&bucket.buf).context(
"writing to the duplicate-decomposition spill (is the temp volume full?)",
)?;
bucket.buf.clear();
}
bucket.buf.extend_from_slice(&record.to_bytes());
self.spilled += 1;
Ok(())
}
pub(crate) fn decompose(mut self, num_libs: u32) -> Result<DecompositionResult> {
self.finish_spill(false)?;
let mut walker = GroupWalker::new(&self.dictionary, num_libs);
let mut records: Vec<SpillRecord> = Vec::new();
for bucket in 0..self.bucket_count {
let path = self.dir.path().join(format!("spill-{bucket:04}"));
read_bucket(&path, &mut records, self.level)?;
sort_bucket(&mut records, &walker.library_of, num_libs);
walker.walk(&records);
}
Ok(walker.finish())
}
#[inline]
fn bucket_of(&self, record: SpillRecord) -> usize {
let mixed = (record.sig ^ u64::from(record.off).wrapping_mul(GOLDEN_RATIO_64))
.wrapping_mul(GOLDEN_RATIO_64);
((mixed >> 32) % u64::from(self.bucket_count)) as usize
}
pub(crate) fn finish_spill(&mut self, measure: bool) -> Result<Option<u64>> {
for (bucket, mut writer) in std::mem::take(&mut self.buckets).into_iter().enumerate() {
writer.sink.write_all(&writer.buf).with_context(|| {
format!("flushing spill bucket {bucket} (is the temp volume full?)")
})?;
writer.sink.finish().with_context(|| {
format!("closing spill bucket {bucket} (is the temp volume full?)")
})?;
}
if !measure {
return Ok(None);
}
let mut on_disk = 0;
for bucket in 0..self.bucket_count {
let path = self.dir.path().join(format!("spill-{bucket:04}"));
on_disk += std::fs::metadata(&path)
.with_context(|| format!("sizing spill bucket {}", path.display()))?
.len();
}
Ok(Some(on_disk))
}
pub(crate) fn spilled_bytes(&self) -> u64 {
self.spilled * SPILL_RECORD_BYTES as u64
}
}
#[derive(Clone, Debug, PartialEq)]
pub(crate) struct DecompositionResult {
pub libraries: Vec<Decomposition>,
pub units: Vec<SequencingUnitStats>,
}
struct GroupWalker {
library_of: Vec<u32>,
unit_of: Vec<u32>,
models: Vec<ChanceModel>,
totals: Vec<GroupTotals>,
library_tiles: Vec<LibraryTiles>,
units: Vec<SequencingUnitStats>,
}
impl GroupWalker {
fn new(dictionary: &TileDictionary, num_libs: u32) -> Self {
let mut unit_index: HashMap<(u32, &[u8]), u32> = HashMap::new();
let mut units: Vec<SequencingUnitStats> = Vec::new();
let mut unit_of = Vec::with_capacity(dictionary.entries().len());
for entry in dictionary.entries() {
let unit = *unit_index.entry((entry.library, &entry.unit)).or_insert_with(|| {
units.push(SequencingUnitStats {
library: entry.library,
unit: String::from_utf8_lossy(&entry.unit).into_owned(),
templates: 0,
tiles: 0,
sequencing_duplicates: 0,
});
units.len() as u32 - 1
});
units[unit as usize].templates += entry.templates;
units[unit as usize].tiles += 1;
unit_of.push(unit);
}
let library_tiles = dictionary.library_tiles(num_libs);
Self {
library_of: dictionary.entries().iter().map(|entry| entry.library).collect(),
unit_of,
models: library_tiles
.iter()
.map(|library| ChanceModel::new(library.shares.clone()))
.collect(),
totals: vec![GroupTotals::default(); num_libs as usize],
library_tiles,
units,
}
}
fn walk(&mut self, records: &[SpillRecord]) {
let mut start = 0;
while start < records.len() {
let key = self.group_key(&records[start]);
let mut end = start + 1;
while end < records.len() && self.group_key(&records[end]) == key {
end += 1;
}
let group = &records[start..end];
start = end;
let k = group.len() as u64;
if k < 2 {
continue;
}
let mut tiles = 0u64;
let mut raw_sequencing = 0u64;
let mut run = 0;
while run < group.len() {
let id = group[run].id;
let mut run_end = run + 1;
while run_end < group.len() && group[run_end].id == id {
run_end += 1;
}
let members = (run_end - run) as u64;
tiles += 1;
raw_sequencing += members - 1;
let unit = self.unit_of[id as usize] as usize;
self.units[unit].sequencing_duplicates += members - 1;
run = run_end;
}
let library = key.0 as usize;
let independent = self.models[library].independent_molecules(tiles).min(k as f64);
self.totals[library].duplicates += k - 1;
self.totals[library].raw_sequencing += raw_sequencing;
self.totals[library].corrected_sequencing += k as f64 - independent;
}
}
#[inline]
fn group_key(&self, record: &SpillRecord) -> (u32, u32, u64) {
(self.library_of[record.id as usize], record.off, record.sig)
}
fn finish(self) -> DecompositionResult {
let libraries = self
.totals
.iter()
.zip(&self.library_tiles)
.map(|(totals, tiles)| totals.finish(tiles))
.collect();
let mut units = self.units;
units.sort_by(|a, b| a.library.cmp(&b.library).then_with(|| a.unit.cmp(&b.unit)));
DecompositionResult { libraries, units }
}
}
#[derive(Clone, Copy, Debug, Default)]
struct GroupTotals {
duplicates: u64,
raw_sequencing: u64,
corrected_sequencing: f64,
}
impl GroupTotals {
fn finish(&self, tiles: &LibraryTiles) -> Decomposition {
let sequencing = (self.corrected_sequencing.round().max(0.0) as u64).min(self.duplicates);
Decomposition {
duplicate_pairs: self.duplicates,
corrected_sequencing_duplicates: sequencing,
library_duplicates: self.duplicates - sequencing,
raw_sequencing_duplicates: self.raw_sequencing,
tile_collision_rate: tiles.collision_rate,
tile_count: tiles.tiles,
}
}
}
struct ChanceModel {
shares: Vec<f64>,
implied: HashMap<u64, f64>,
}
impl ChanceModel {
fn new(shares: Vec<f64>) -> Self {
Self { shares, implied: HashMap::new() }
}
fn independent_molecules(&mut self, observed: u64) -> f64 {
if self.shares.len() < 2 {
return f64::INFINITY;
}
let target = observed as f64;
if let Some(&implied) = self.implied.get(&observed) {
return implied;
}
let mut low = target;
let mut high = (target * 2.0).max(2.0);
while self.expected_tiles(high) < target && high < INVERSE_SEARCH_CEILING {
high *= 2.0;
}
for _ in 0..BISECTION_STEPS {
let mid = 0.5 * (low + high);
if self.expected_tiles(mid) < target {
low = mid;
} else {
high = mid;
}
if high - low <= 1e-9 * high {
break;
}
}
let implied = 0.5 * (low + high);
self.implied.insert(observed, implied);
implied
}
fn expected_tiles(&self, molecules: f64) -> f64 {
self.shares.iter().map(|share| 1.0 - (1.0 - share).powf(molecules)).sum()
}
}
#[derive(Default)]
struct Memo {
key: Vec<u8>,
id: u32,
}
struct SpillBucket {
buf: Vec<u8>,
sink: SpillSink,
}
enum SpillSink {
Raw(File),
Zstd(Box<zstd::stream::write::Encoder<'static, File>>),
}
impl SpillSink {
fn create(path: &Path, level: Option<i32>) -> Result<Self> {
let file = File::create(path)
.with_context(|| format!("creating spill bucket {}", path.display()))?;
match level {
None => Ok(Self::Raw(file)),
Some(level) => {
let encoder = zstd::stream::write::Encoder::new(file, level)
.with_context(|| format!("starting zstd for {}", path.display()))?;
Ok(Self::Zstd(Box::new(encoder)))
}
}
}
fn finish(self) -> Result<()> {
match self {
Self::Raw(mut file) => file.flush().context("flushing a spill bucket")?,
Self::Zstd(encoder) => {
encoder.finish().context("finishing a zstd spill bucket")?;
}
}
Ok(())
}
}
impl Write for SpillSink {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
match self {
Self::Raw(file) => file.write(buf),
Self::Zstd(encoder) => encoder.write(buf),
}
}
fn flush(&mut self) -> std::io::Result<()> {
match self {
Self::Raw(file) => file.flush(),
Self::Zstd(encoder) => encoder.flush(),
}
}
}
enum SpillSource {
Raw(File),
Zstd(Box<zstd::stream::read::Decoder<'static, std::io::BufReader<File>>>),
}
impl SpillSource {
fn open(path: &Path, level: Option<i32>) -> Result<Self> {
let file =
File::open(path).with_context(|| format!("opening spill bucket {}", path.display()))?;
match level {
None => Ok(Self::Raw(file)),
Some(_) => {
let decoder = zstd::stream::read::Decoder::new(file)
.with_context(|| format!("starting zstd decode for {}", path.display()))?;
Ok(Self::Zstd(Box::new(decoder)))
}
}
}
}
impl Read for SpillSource {
fn read(&mut self, buf: &mut [u8]) -> std::io::Result<usize> {
match self {
Self::Raw(file) => file.read(buf),
Self::Zstd(decoder) => decoder.read(buf),
}
}
}
fn sort_bucket(records: &mut [SpillRecord], library_of: &[u32], num_libs: u32) {
if num_libs == 1 {
records.sort_unstable_by_key(packed_sort_key);
} else {
records.sort_unstable_by_key(|record| {
(library_of[record.id as usize], packed_sort_key(record))
});
}
}
#[inline]
fn packed_sort_key(record: &SpillRecord) -> u128 {
(u128::from(record.off) << 96) | (u128::from(record.sig) << 32) | u128::from(record.id)
}
fn read_bucket(path: &Path, records: &mut Vec<SpillRecord>, level: Option<i32>) -> Result<()> {
records.clear();
let mut source = SpillSource::open(path, level)?;
let mut buffer = vec![0u8; BUCKET_READ_RECORDS * SPILL_RECORD_BYTES];
loop {
let filled = fill_buffer(&mut source, &mut buffer)
.with_context(|| format!("reading spill bucket {}", path.display()))?;
if filled == 0 {
break;
}
if filled % SPILL_RECORD_BYTES != 0 {
bail!(
"spill bucket {} ends mid-record ({filled} bytes is not a multiple of \
{SPILL_RECORD_BYTES}) — the temp volume may have filled",
path.display()
);
}
let (chunks, _) = buffer[..filled].as_chunks::<SPILL_RECORD_BYTES>();
records.extend(chunks.iter().map(SpillRecord::from_bytes));
if filled < buffer.len() {
break;
}
}
Ok(())
}
fn fill_buffer(source: &mut impl Read, buffer: &mut [u8]) -> std::io::Result<usize> {
let mut filled = 0;
while filled < buffer.len() {
match source.read(&mut buffer[filled..]) {
Ok(0) => break,
Ok(read) => filled += read,
Err(error) if error.kind() == std::io::ErrorKind::Interrupted => {}
Err(error) => return Err(error),
}
}
Ok(filled)
}
fn clamp_buckets(requested: u32) -> u32 {
let requested = requested.max(1);
let Some(limit) = open_file_limit() else {
return requested;
};
let usable = u32::try_from(limit.saturating_sub(RESERVED_DESCRIPTORS)).unwrap_or(u32::MAX);
let clamped = usable.min(requested).max(1);
if clamped < requested {
log::warn!(
"reducing duplicate-decomposition spill buckets from {requested} to {clamped}: the \
open-file limit is {limit}. Raise it with `ulimit -n` for larger buckets."
);
}
clamped
}
fn open_file_limit() -> Option<u64> {
let mut limit: libc::rlimit = unsafe { std::mem::zeroed() };
if unsafe { libc::getrlimit(libc::RLIMIT_NOFILE, &mut limit) } == 0 {
Some(limit.rlim_cur as u64)
} else {
None
}
}
#[cfg(test)]
mod tests {
use super::*;
fn dictionary() -> TileDictionary {
TileDictionary::new("illumina".parse().expect("illumina is a valid format"))
}
fn name(flowcell: &str, lane: u32, tile: u32) -> Vec<u8> {
format!("A00354:1305:{flowcell}:{lane}:{tile}:1027:1986").into_bytes()
}
#[test]
fn one_tile_interns_to_one_id() {
let mut dict = dictionary();
let first = dict.observe(0, &name("FC", 1, 1101)).expect("parses");
let second = dict.observe(0, &name("FC", 1, 1101)).expect("parses");
assert_eq!(first, second);
assert_eq!(dict.entries().len(), 1);
}
#[test]
fn distinct_tiles_intern_to_distinct_ids() {
let mut dict = dictionary();
let a = dict.observe(0, &name("FC", 1, 1101)).expect("parses");
let b = dict.observe(0, &name("FC", 1, 1102)).expect("parses");
assert_ne!(a, b);
assert_eq!(dict.entries().len(), 2);
}
#[test]
fn same_tile_number_on_two_flowcells_interns_separately() {
let mut dict = dictionary();
let a = dict.observe(0, &name("H72CFDSXF", 2, 1101)).expect("parses");
let b = dict.observe(0, &name("22T3L2LT4", 2, 1101)).expect("parses");
assert_ne!(a, b, "matching tile numbers across flowcells are different places");
}
#[test]
fn same_tile_in_two_libraries_interns_separately() {
let mut dict = dictionary();
let a = dict.observe(0, &name("FC", 1, 1101)).expect("parses");
let b = dict.observe(1, &name("FC", 1, 1101)).expect("parses");
assert_ne!(a, b, "duplicates are only called within a library");
assert_eq!(dict.entries()[a as usize].library, 0);
assert_eq!(dict.entries()[b as usize].library, 1);
}
#[test]
fn ids_are_assigned_serially_from_zero() {
let mut dict = dictionary();
for (expected, tile) in [1101, 1102, 1103].into_iter().enumerate() {
let id = dict.observe(0, &name("FC", 1, tile)).expect("parses");
assert_eq!(id as usize, expected);
}
}
#[test]
fn ids_reverse_to_the_original_unit_and_tile_names() {
let mut dict = dictionary();
let id = dict.observe(0, &name("H72CFDSXF", 2, 1101)).expect("parses");
let entry = &dict.entries()[id as usize];
assert_eq!(&*entry.unit, b"H72CFDSXF:2");
}
#[test]
fn templates_are_counted_per_tile() {
let mut dict = dictionary();
for _ in 0..3 {
dict.observe(0, &name("FC", 1, 1101)).expect("parses");
}
dict.observe(0, &name("FC", 1, 1102)).expect("parses");
assert_eq!(dict.entries()[0].templates, 3);
assert_eq!(dict.entries()[1].templates, 1);
assert_eq!(dict.library_tiles(1)[0].templates, 4);
}
#[test]
fn the_memo_does_not_change_which_id_is_returned() {
let mut alternating = dictionary();
let mut runs = dictionary();
for _ in 0..4 {
alternating.observe(0, &name("FC", 1, 1101)).expect("parses");
alternating.observe(0, &name("FC", 1, 1102)).expect("parses");
}
for tile in [1101, 1102] {
for _ in 0..4 {
runs.observe(0, &name("FC", 1, tile)).expect("parses");
}
}
assert_eq!(alternating.entries().len(), runs.entries().len());
assert_eq!(alternating.library_tiles(1)[0].templates, runs.library_tiles(1)[0].templates);
assert_eq!(
alternating.library_tiles(1)[0].collision_rate,
runs.library_tiles(1)[0].collision_rate
);
}
#[test]
fn the_single_library_sort_matches_the_general_one() {
let records = vec![
SpillRecord { sig: 7, off: 2, id: 5 },
SpillRecord { sig: 7, off: 1, id: 9 },
SpillRecord { sig: 3, off: 2, id: 1 },
SpillRecord { sig: 7, off: 2, id: 2 },
SpillRecord { sig: 3, off: 2, id: 4 },
SpillRecord { sig: u64::MAX, off: 0, id: 0 },
];
let library_of = vec![0u32; 10];
let mut packed = records.clone();
let mut tupled = records;
sort_bucket(&mut packed, &library_of, 1);
sort_bucket(&mut tupled, &library_of, 2);
assert_eq!(packed, tupled);
}
#[test]
fn the_single_library_sort_orders_by_off_then_sig_then_id() {
let mut records = vec![
SpillRecord { sig: 7, off: 2, id: 5 },
SpillRecord { sig: 7, off: 1, id: 9 },
SpillRecord { sig: 3, off: 2, id: 1 },
SpillRecord { sig: 7, off: 2, id: 2 },
SpillRecord { sig: 3, off: 2, id: 4 },
SpillRecord { sig: u64::MAX, off: 0, id: 0 },
SpillRecord { sig: 0, off: u32::MAX, id: u32::MAX },
];
sort_bucket(&mut records, &[], 1);
let keys: Vec<(u32, u64, u32)> = records.iter().map(|r| (r.off, r.sig, r.id)).collect();
assert_eq!(
keys,
vec![
(0, u64::MAX, 0),
(1, 7, 9),
(2, 3, 1),
(2, 3, 4),
(2, 7, 2),
(2, 7, 5),
(u32::MAX, 0, u32::MAX),
]
);
}
#[test]
fn the_multi_library_sort_groups_by_library_first() {
let library_of = vec![1, 0, 1];
let mut records = vec![
SpillRecord { sig: 1, off: 1, id: 0 },
SpillRecord { sig: 1, off: 9, id: 1 },
SpillRecord { sig: 1, off: 2, id: 2 },
];
sort_bucket(&mut records, &library_of, 2);
assert_eq!(records.iter().map(|r| r.id).collect::<Vec<_>>(), vec![1, 0, 2]);
}
#[test]
fn a_library_on_one_tile_has_a_collision_rate_of_one() {
let mut dict = dictionary();
for _ in 0..10 {
dict.observe(0, &name("FC", 1, 1101)).expect("parses");
}
assert_eq!(dict.library_tiles(1)[0].collision_rate, 1.0, "one tile carries no information");
}
#[test]
fn evenly_used_tiles_have_a_collision_rate_of_one_over_n() {
let mut dict = dictionary();
for tile in 0..4 {
dict.observe(0, &name("FC", 1, tile)).expect("parses");
}
let q = dict.library_tiles(1)[0].collision_rate;
assert!((q - 0.25).abs() < 1e-12, "q = {q}");
}
#[test]
fn a_skewed_tile_distribution_raises_the_collision_rate() {
let mut even = dictionary();
let mut skewed = dictionary();
for tile in 0..4 {
for _ in 0..4 {
even.observe(0, &name("FC", 1, tile)).expect("parses");
}
}
for _ in 0..13 {
skewed.observe(0, &name("FC", 1, 0)).expect("parses");
}
for tile in 1..4 {
skewed.observe(0, &name("FC", 1, tile)).expect("parses");
}
assert!(
skewed.library_tiles(1)[0].collision_rate > even.library_tiles(1)[0].collision_rate,
"{:?} should exceed {:?}",
skewed.library_tiles(1)[0].collision_rate,
even.library_tiles(1)[0].collision_rate
);
}
#[test]
fn collision_rate_is_computed_within_a_library_not_across_libraries() {
let mut dict = dictionary();
dict.observe(0, &name("FC", 1, 1101)).expect("parses");
dict.observe(1, &name("FC", 1, 1102)).expect("parses");
assert_eq!(dict.library_tiles(1)[0].collision_rate, 1.0);
assert_eq!(dict.library_tiles(2)[1].collision_rate, 1.0);
}
#[test]
fn a_library_with_no_templates_has_no_tiles_and_no_shares() {
let stats = &dictionary().library_tiles(1)[0];
assert_eq!(stats.templates, 0);
assert_eq!(stats.tiles, 0);
assert!(stats.shares.is_empty());
}
#[test]
fn tiles_are_counted_per_library() {
let mut dict = dictionary();
dict.observe(0, &name("FC", 1, 1101)).expect("parses");
dict.observe(0, &name("FC", 1, 1102)).expect("parses");
dict.observe(1, &name("FC", 1, 1103)).expect("parses");
assert_eq!(dict.library_tiles(2)[0].tiles, 2);
assert_eq!(dict.library_tiles(2)[1].tiles, 1);
}
#[test]
fn an_unparseable_read_name_is_an_error_naming_the_name() {
let err = dictionary().observe(0, b"SRR1234567.1").expect_err("must not parse");
assert!(err.to_string().contains("SRR1234567.1"), "{err}");
}
const TEST_BIN_COUNT: u32 = 47;
fn spiller() -> TileSpiller {
TileSpiller::new(
"illumina".parse().expect("illumina is a valid format"),
TEST_BIN_COUNT,
DEFAULT_SPILL_BUCKETS,
None,
None,
)
.expect("spiller opens")
}
fn observe(spiller: &mut TileSpiller, off: usize, sig: u64, tile: u32) {
spiller
.observe_pair(0, &name("FC", 1, tile), PairSlot { off, sig })
.expect("observation succeeds");
}
fn decompose(spiller: TileSpiller) -> Decomposition {
spiller.decompose(1).expect("decomposition succeeds").libraries[0]
}
const DIVERSE_TILES: u32 = 2000;
fn spread_over_tiles(spiller: &mut TileSpiller, tiles: u32) {
for tile in 0..tiles {
observe(spiller, 0, 1_000_000 + u64::from(tile), tile);
}
}
#[test]
fn a_spill_record_round_trips_through_its_on_disk_form() {
let record = SpillRecord { sig: 0xDEAD_BEEF_1234_5678, off: 9215, id: 6333 };
assert_eq!(SpillRecord::from_bytes(&record.to_bytes()), record);
}
#[test]
fn a_spill_record_is_sixteen_bytes() {
assert_eq!(size_of::<SpillRecord>(), 16);
}
#[test]
fn a_group_entirely_on_one_tile_is_all_sequencing_duplicates() {
let mut spiller = spiller();
spread_over_tiles(&mut spiller, DIVERSE_TILES);
for _ in 0..4 {
observe(&mut spiller, 7, 99, 1101);
}
let result = decompose(spiller);
assert_eq!(result.duplicate_pairs, 3);
assert_eq!(result.raw_sequencing_duplicates, 3);
assert_eq!(result.corrected_sequencing_duplicates, 3);
assert_eq!(result.library_duplicates, 0);
}
#[test]
fn a_single_tile_library_reports_no_sequencing_duplicates() {
let mut spiller = spiller();
for _ in 0..4 {
observe(&mut spiller, 7, 99, 1101);
}
let result = decompose(spiller);
assert_eq!(result.tile_collision_rate, 1.0);
assert_eq!(result.raw_sequencing_duplicates, 3);
assert_eq!(result.corrected_sequencing_duplicates, 0);
}
#[test]
fn a_group_with_one_member_per_tile_is_all_library_duplicates() {
let mut spiller = spiller();
for tile in 0..4 {
observe(&mut spiller, 7, 99, tile);
}
let result = decompose(spiller);
assert_eq!(result.duplicate_pairs, 3);
assert_eq!(result.raw_sequencing_duplicates, 0);
assert_eq!(result.corrected_sequencing_duplicates, 0);
assert_eq!(result.library_duplicates, 3);
}
#[test]
fn two_members_on_each_of_two_tiles_is_two_sequencing_and_one_library() {
let mut spiller = spiller();
spread_over_tiles(&mut spiller, DIVERSE_TILES);
for tile in [1101, 1102] {
for _ in 0..2 {
observe(&mut spiller, 7, 99, tile);
}
}
let result = decompose(spiller);
assert_eq!(result.duplicate_pairs, 3);
assert_eq!(result.raw_sequencing_duplicates, 2);
assert_eq!(result.corrected_sequencing_duplicates, 2);
assert_eq!(result.library_duplicates, 1);
}
#[test]
fn sequencing_and_library_duplicates_always_sum_to_the_duplicate_total() {
let mut spiller = spiller();
observe(&mut spiller, 1, 10, 1101);
for _ in 0..2 {
observe(&mut spiller, 2, 20, 1101);
}
observe(&mut spiller, 3, 30, 1101);
observe(&mut spiller, 3, 30, 1102);
for tile in [1101, 1101, 1101, 1102, 1103] {
observe(&mut spiller, 4, 40, tile);
}
let result = decompose(spiller);
assert_eq!(
result.corrected_sequencing_duplicates + result.library_duplicates,
result.duplicate_pairs
);
}
#[test]
fn a_signature_seen_once_contributes_no_duplicates() {
let mut spiller = spiller();
for sig in 0..8 {
observe(&mut spiller, 1, sig, 1101);
}
let result = decompose(spiller);
assert_eq!(result.duplicate_pairs, 0);
assert_eq!(result.corrected_sequencing_duplicates, 0);
assert_eq!(result.library_duplicates, 0);
}
#[test]
fn signatures_differing_only_in_the_partition_cell_are_different_groups() {
let mut spiller = spiller();
for off in [1, 2] {
for _ in 0..2 {
observe(&mut spiller, off, 99, 1101);
}
}
let result = decompose(spiller);
assert_eq!(result.duplicate_pairs, 2, "two groups of two, not one group of four");
}
#[test]
fn groups_do_not_span_libraries() {
let mut spiller = spiller();
for library in 0..2 {
spiller
.observe_pair(library, &name("FC", 1, 1101), PairSlot { off: 7, sig: 99 })
.expect("observation succeeds");
}
let results = spiller.decompose(2).expect("decomposition succeeds").libraries;
assert_eq!(results[0].duplicate_pairs, 0, "one template each is not a duplicate");
assert_eq!(results[1].duplicate_pairs, 0);
}
#[test]
fn the_chance_correction_removes_a_split_that_chance_alone_explains() {
let mut spiller = spiller();
spread_over_tiles(&mut spiller, 4);
for tile in 0..4 {
for _ in 0..2 {
observe(&mut spiller, 1, 99, tile);
}
}
let result = decompose(spiller);
assert_eq!(result.duplicate_pairs, 7);
assert_eq!(result.raw_sequencing_duplicates, 4, "two per tile over four tiles");
assert_eq!(result.corrected_sequencing_duplicates, 0, "chance explains all of it");
assert_eq!(result.library_duplicates, 7);
}
#[test]
fn a_group_on_one_of_many_tiles_keeps_its_full_raw_count() {
let mut spiller = spiller();
spread_over_tiles(&mut spiller, 4);
for _ in 0..8 {
observe(&mut spiller, 1, 99, 0);
}
let result = decompose(spiller);
assert_eq!(result.raw_sequencing_duplicates, 7);
assert_eq!(result.corrected_sequencing_duplicates, 7);
}
#[test]
fn the_chance_correction_is_negligible_when_tiles_are_many() {
let mut spiller = spiller();
spread_over_tiles(&mut spiller, DIVERSE_TILES);
for _ in 0..8 {
observe(&mut spiller, 1, 99, 0);
}
let result = decompose(spiller);
assert_eq!(result.raw_sequencing_duplicates, 7);
assert_eq!(result.corrected_sequencing_duplicates, 7);
}
#[test]
fn the_reported_split_does_not_depend_on_the_order_templates_arrive_in() {
let observations: Vec<(usize, u64, u32)> = vec![
(1, 10, 1101),
(1, 10, 1101),
(1, 10, 1102),
(2, 20, 1103),
(2, 20, 1103),
(3, 30, 1101),
];
let mut forward = spiller();
for &(off, sig, tile) in &observations {
observe(&mut forward, off, sig, tile);
}
let mut reversed = spiller();
for &(off, sig, tile) in observations.iter().rev() {
observe(&mut reversed, off, sig, tile);
}
assert_eq!(decompose(forward), decompose(reversed));
}
#[test]
fn the_collision_rate_and_tile_count_are_reported_with_the_split() {
let mut spiller = spiller();
for tile in 0..4 {
observe(&mut spiller, 1, u64::from(tile), tile);
}
let result = decompose(spiller);
assert_eq!(result.tile_count, 4);
assert!((result.tile_collision_rate - 0.25).abs() < 1e-12);
}
#[test]
fn an_unparseable_read_name_fails_the_spill_rather_than_being_skipped() {
let mut spiller = spiller();
let err = spiller
.observe_pair(0, b"SRR1234567.1", PairSlot { off: 1, sig: 1 })
.expect_err("must not be silently skipped");
let message = format!("{err:#}");
assert!(message.contains("SRR1234567.1"), "{message}");
assert!(message.contains("--sequencing-duplicate-detection off"), "{message}");
}
#[test]
fn bucket_count_is_clamped_to_the_descriptor_limit() {
let clamped = clamp_buckets(u32::MAX);
assert!(clamped >= 1);
assert!(clamped < u32::MAX);
}
#[test]
fn a_bucket_count_of_zero_still_yields_one_bucket() {
assert_eq!(clamp_buckets(0), 1);
}
#[test]
fn every_bucket_count_reaches_the_same_answer() {
let observations: Vec<(usize, u64, u32)> =
(0..64).map(|i| (i as usize % 9, i % 5, (i % 3) as u32)).collect();
let mut results = Vec::new();
for buckets in [1, 2, 16, 64] {
let mut spiller = TileSpiller::new(
"illumina".parse().expect("valid format"),
TEST_BIN_COUNT,
buckets,
None,
None,
)
.expect("spiller opens");
for &(off, sig, tile) in &observations {
observe(&mut spiller, off, sig, tile);
}
results.push(decompose(spiller));
}
assert!(
results.windows(2).all(|pair| pair[0] == pair[1]),
"bucket count changed the answer: {results:?}"
);
}
const RECORDS_PER_STREAM: u64 = 4 * (BUCKET_BUFFER_BYTES / SPILL_RECORD_BYTES) as u64;
const BUSY_STREAM_BUCKETS: u32 = 4;
fn spiller_with_level(level: Option<i32>) -> TileSpiller {
TileSpiller::new(
"illumina".parse().expect("valid format"),
TEST_BIN_COUNT,
DEFAULT_SPILL_BUCKETS,
None,
level,
)
.expect("spiller opens")
}
fn round_trip_at_level(level: Option<i32>) -> Decomposition {
let mut spiller = TileSpiller::new(
"illumina".parse().expect("valid format"),
TEST_BIN_COUNT,
BUSY_STREAM_BUCKETS,
None,
level,
)
.expect("spiller opens");
for i in 0..RECORDS_PER_STREAM * u64::from(BUSY_STREAM_BUCKETS) {
observe(&mut spiller, i as usize % 9, i % 977, (i % 31) as u32);
}
decompose(spiller)
}
#[test]
fn a_fast_tier_round_trips_every_record() {
assert_eq!(round_trip_at_level(Some(-5)), round_trip_at_level(None));
}
#[test]
fn the_default_level_round_trips_every_record() {
assert_eq!(round_trip_at_level(Some(3)), round_trip_at_level(None));
}
#[test]
fn the_highest_accepted_level_round_trips_every_record() {
assert_eq!(round_trip_at_level(Some(9)), round_trip_at_level(None));
}
#[test]
fn zstd_shrinks_a_busy_spill_below_its_logical_size() {
let mut spiller = TileSpiller::new(
"illumina".parse().expect("valid format"),
TEST_BIN_COUNT,
BUSY_STREAM_BUCKETS,
None,
Some(1),
)
.expect("spiller opens");
for i in 0..RECORDS_PER_STREAM * u64::from(BUSY_STREAM_BUCKETS) {
observe(&mut spiller, i as usize % 9, i % 977, (i % 31) as u32);
}
let logical = spiller.spilled_bytes();
let on_disk = spiller.finish_spill(true).expect("spill closes").expect("size measured");
assert!(on_disk < logical, "zstd grew the spill: {on_disk} vs {logical} logical");
}
#[test]
fn a_nearly_empty_spill_can_come_out_larger_than_its_logical_size() {
let mut spiller = spiller_with_level(Some(3));
observe(&mut spiller, 0, 0, 1101);
let logical = spiller.spilled_bytes();
let on_disk = spiller.finish_spill(true).expect("spill closes").expect("size measured");
assert!(
on_disk > logical,
"expected framing to dominate one record: {on_disk} vs {logical} logical"
);
}
#[test]
fn tokens_are_packed_unambiguously_so_a_split_cannot_collide() {
let mut dict =
TileDictionary::new(r"regex:^(?<su>\w+)-(?<tile>\w+)$".parse().expect("valid regex"));
let a = dict.observe(0, b"AB-C").expect("parses");
let b = dict.observe(0, b"A-BC").expect("parses");
assert_ne!(a, b);
assert_eq!(dict.entries().len(), 2);
}
}