From 7ff45f80a1fa891123e7928128679da283d68fa0 Mon Sep 17 00:00:00 2001 From: John Lees Date: Thu, 14 May 2026 17:36:02 +0100 Subject: [PATCH 01/13] Bring optimised Jaccard code into main --- Cargo.toml | 3 + src/distances/jaccard.rs | 160 +++++++++++++++--- src/lib.rs | 4 +- src/sketch/mod.rs | 102 ++++++++--- src/sketch/multisketch.rs | 9 +- src/sketch/sketch_datafile.rs | 48 +++++- tests/sketch.rs | 27 ++- .../test_results_correct/dists_knn_ani.stdout | 8 +- .../test_results_correct/dists_knn_ca.stdout | 8 +- .../dists_knn_jaccard.stdout | 8 +- .../test_results_correct/dists_subset.stdout | 4 +- tests/test_results_correct/inverted.skq | 2 +- .../inverted_precluster.stdout | 8 +- .../inverted_precluster_ani.stdout | 8 +- .../inverted_query_count.stdout | 8 +- .../inverted_sketch_info.stdout | 2 +- .../read_sketch_full_info.stdout | 4 +- 17 files changed, 326 insertions(+), 87 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 2f680aa..5027dbf 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,6 +31,9 @@ crate-type = ["cdylib", "lib"] [features] 3di = ["dep:pyo3", "dep:pyo3-build-config"] +[dependencies] +aligned-vec = "0.6.4" + [target.'cfg(not(target_arch = "wasm32"))'.dependencies] # data structures hashbrown = { version = "0.14", features = ["rayon", "serde"] } diff --git a/src/distances/jaccard.rs b/src/distances/jaccard.rs index 91f82d6..d6b9c29 100644 --- a/src/distances/jaccard.rs +++ b/src/distances/jaccard.rs @@ -1,6 +1,6 @@ //! Implementation of Jaccard, core and accessory distance calculations use crate::sketch::multisketch::MultiSketch; -use crate::sketch::BBITS; +use crate::sketch::BIN_BITS; /// Returns the Jaccard index between two samples pub fn jaccard_index( @@ -12,25 +12,11 @@ pub fn jaccard_index( completeness_cutoff: f64, ) -> f64 { let unionsize = (u64::BITS as u64 * sketchsize64) as f64; - let samebits: u32 = sketch1 - .chunks_exact(BBITS as usize) - .zip(sketch2.chunks_exact(BBITS as usize)) - .map(|(chunk1, chunk2)| { - let mut bits: u64 = !0; - chunk1.iter().zip(chunk2.iter()).for_each(|(&s1, &s2)| { - bits &= !(s1 ^ s2); - }); - bits.count_ones() - }) - .sum(); - let maxnbits = sketchsize64 as u32 * u64::BITS; - let expected_samebits = maxnbits >> BBITS; + let samebits = jaccard_same_bits(sketch1, sketch2); + let raw_jaccard = samebits as f64 / unionsize; + let mut jaccard_index = random_match_correction(raw_jaccard); - log::trace!("samebits:{samebits} expected_samebits:{expected_samebits} maxnbits:{maxnbits}"); - let diff = samebits.saturating_sub(expected_samebits); - let intersize = (diff as f64 * maxnbits as f64) / (maxnbits - expected_samebits) as f64; - log::trace!("intersize:{intersize} unionsize:{unionsize}"); - let mut jaccard_index = intersize / unionsize; + log::trace!("samebits:{samebits} raw_jaccard:{raw_jaccard} jaccard:{jaccard_index}"); // Apply completeness correction if both completeness values are provided if let (Some(c1_val), Some(c2_val)) = (c1, c2) { @@ -44,6 +30,142 @@ pub fn jaccard_index( jaccard_index } +#[inline(always)] +fn jaccard_same_bits(sketch1: &[u64], sketch2: &[u64]) -> u32 { + #[cfg(target_arch = "aarch64")] + unsafe { + jaccard_neon_unroll2_inner(sketch1, sketch2) + } + + #[cfg(not(target_arch = "aarch64"))] + { + jaccard_same_bits_general(sketch1, sketch2) + } +} + +#[cfg_attr(target_arch = "aarch64", allow(dead_code))] +pub(crate) fn jaccard_same_bits_general(sketch1: &[u64], sketch2: &[u64]) -> u32 { + debug_assert_eq!(sketch1.len(), sketch2.len()); + debug_assert_eq!(sketch1.len() % BIN_BITS, 0); + sketch1 + .chunks_exact(BIN_BITS) + .zip(sketch2.chunks_exact(BIN_BITS)) + .map(|(chunk1, chunk2)| { + let mut bits: u64 = !0; + chunk1.iter().zip(chunk2.iter()).for_each(|(&s1, &s2)| { + bits &= !(s1 ^ s2); + }); + bits.count_ones() + }) + .sum() +} + +#[cfg(target_arch = "aarch64")] +#[target_feature(enable = "neon")] +unsafe fn jaccard_neon_unroll2_inner(a: &[u64], b: &[u64]) -> u32 { + use std::arch::aarch64::*; + + debug_assert_eq!(a.len(), b.len()); + debug_assert_eq!(a.len() % BIN_BITS, 0); + + let chunk = BIN_BITS; + let n_chunks = a.len() / chunk; + let n_pairs = n_chunks / 2; + let mut total = 0u32; + + #[inline(always)] + unsafe fn one_chunk(ap: *const u8, bp: *const u8) -> u8 { + let a0 = vld1q_u8(ap); + let a1 = vld1q_u8(ap.add(16)); + let a2 = vld1q_u8(ap.add(32)); + let a3 = vld1q_u8(ap.add(48)); + let a4 = vld1q_u8(ap.add(64)); + let a5 = vld1q_u8(ap.add(80)); + let a6 = vld1q_u8(ap.add(96)); + let a7 = vld1q_u8(ap.add(112)); + let b0 = vld1q_u8(bp); + let b1 = vld1q_u8(bp.add(16)); + let b2 = vld1q_u8(bp.add(32)); + let b3 = vld1q_u8(bp.add(48)); + let b4 = vld1q_u8(bp.add(64)); + let b5 = vld1q_u8(bp.add(80)); + let b6 = vld1q_u8(bp.add(96)); + let b7 = vld1q_u8(bp.add(112)); + let x0 = veorq_u8(a0, b0); + let x1 = veorq_u8(a1, b1); + let x2 = veorq_u8(a2, b2); + let x3 = veorq_u8(a3, b3); + let x4 = veorq_u8(a4, b4); + let x5 = veorq_u8(a5, b5); + let x6 = veorq_u8(a6, b6); + let x7 = veorq_u8(a7, b7); + let or01 = vorrq_u8(x0, x1); + let or23 = vorrq_u8(x2, x3); + let or45 = vorrq_u8(x4, x5); + let or67 = vorrq_u8(x6, x7); + let or_all = vorrq_u8(vorrq_u8(or01, or23), vorrq_u8(or45, or67)); + let not_or = vmvnq_u8(or_all); + vaddv_u8(vcnt_u8(vand_u8(vget_low_u8(not_or), vget_high_u8(not_or)))) + } + + for i in 0..n_pairs { + let base = i * 2 * chunk; + total += one_chunk(a.as_ptr().add(base) as _, b.as_ptr().add(base) as _) as u32; + total += one_chunk( + a.as_ptr().add(base + chunk) as _, + b.as_ptr().add(base + chunk) as _, + ) as u32; + } + if n_chunks % 2 != 0 { + let base = (n_chunks - 1) * chunk; + total += one_chunk(a.as_ptr().add(base) as _, b.as_ptr().add(base) as _) as u32; + } + total +} + +#[inline(always)] +fn random_match_correction(jaccard: f64) -> f64 { + let bb = (1u32 << (BIN_BITS as u32)) as f64; + ((bb * jaccard - 1.0).max(0.0)) / (bb - 1.0) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn random_match_correction_maps_expected_random_to_zero() { + let expected_random = 1.0 / ((1u32 << (BIN_BITS as u32)) as f64); + assert_eq!(random_match_correction(expected_random), 0.0); + assert_eq!(random_match_correction(1.0), 1.0); + } + + #[test] + fn scalar_same_bits_counts_matching_bins() { + let sketch1 = [u64::MAX; BIN_BITS * 2]; + let mut sketch2 = [u64::MAX; BIN_BITS * 2]; + sketch2[BIN_BITS] = 0; + + assert_eq!(jaccard_same_bits_general(&sketch1, &sketch1), 128); + assert_eq!(jaccard_same_bits_general(&sketch1, &sketch2), 64); + } + + #[cfg(target_arch = "aarch64")] + #[test] + fn neon_same_bits_matches_scalar() { + let mut sketch1 = [0u64; BIN_BITS * 3]; + let mut sketch2 = [0u64; BIN_BITS * 3]; + for i in 0..sketch1.len() { + sketch1[i] = (i as u64).wrapping_mul(0x9E37_79B9_7F4A_7C15); + sketch2[i] = sketch1[i] ^ ((i as u64) << (i % 17)); + } + + let scalar = jaccard_same_bits_general(&sketch1, &sketch2); + let neon = unsafe { jaccard_neon_unroll2_inner(&sketch1, &sketch2) }; + assert_eq!(neon, scalar); + } +} + /// Converts between Jaccard distance and ANI, using a Poisson model of mutations #[inline(always)] pub fn ani_pois(jaccard: f64, k: f64) -> f64 { diff --git a/src/lib.rs b/src/lib.rs index 755d1f8..6e813e8 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,8 +9,8 @@ //! above these distances is unsupported and may lead to poor estimation of distances //! without clear warning. //! - Short k-mer lengths are likely to match at random, see [PopPUNK's docs](https://poppunk-docs.bacpop.org/sketching.html#choosing-the-right-k-mer-lengths) -//! for information on how to select good lengths. Note that this library does not support -//! random match correction. +//! for information on how to select good lengths. Jaccard distances include random match +//! correction for the retained sketch signature width. //! - ANI distance resolution is highly affected by sketch size at higher mismatch //! levels, so note that if you see lots of samples at around 80% they may be much lower than //! this. We recommend checking the Jaccard values in this case, if they are close to 0 diff --git a/src/sketch/mod.rs b/src/sketch/mod.rs index 34a1f13..f9769f7 100644 --- a/src/sketch/mod.rs +++ b/src/sketch/mod.rs @@ -4,6 +4,7 @@ use std::fmt; #[cfg(not(target_arch = "wasm32"))] use std::sync::mpsc; +use aligned_vec::{AVec, ConstAlign}; #[cfg(not(target_arch = "wasm32"))] use indicatif::ParallelProgressIterator; #[cfg(not(target_arch = "wasm32"))] @@ -38,9 +39,28 @@ pub mod sketch_datafile; use self::sketch_datafile::SketchArrayWriter; /// Bin bits (lowest of 64-bits to keep) -pub const BBITS: u64 = 14; -/// Total width of all bins (used as sign % sign_mod) -pub const SIGN_MOD: u64 = (1 << 61) - 1; +pub const BIN_BITS: usize = 16; +#[doc(hidden)] +pub const BBITS: usize = BIN_BITS; +/// Byte alignment used for bit-packed sketch buffers. +pub const SKETCH_ALIGNMENT: usize = 64; + +/// Aligned storage for bit-packed sketch buffers. +pub type SketchVec = AVec>; + +pub(crate) fn aligned_sketch_vec() -> SketchVec { + SketchVec::new(SKETCH_ALIGNMENT) +} + +pub(crate) fn aligned_sketch_vec_with_capacity(capacity: usize) -> SketchVec { + SketchVec::with_capacity(SKETCH_ALIGNMENT, capacity) +} + +pub(crate) fn aligned_sketch_vec_filled(len: usize, value: u64) -> SketchVec { + let mut vec = aligned_sketch_vec_with_capacity(len); + vec.resize(len, value); + vec +} /// Get the number of elements in the sketch vectors for a given sketch size /// @@ -56,7 +76,7 @@ pub const SIGN_MOD: u64 = (1 << 61) - 1; pub fn num_bins(sketch_size: u64) -> (u64, u64, u64) { let sketchsize64 = sketch_size.div_ceil(u64::BITS as u64); let signs_size = sketchsize64 * (u64::BITS as u64); - let usigs_size = sketchsize64 * BBITS; + let usigs_size = sketchsize64 * (BIN_BITS as u64); (sketchsize64, signs_size, usigs_size) } @@ -101,10 +121,10 @@ impl Default for SketchingOpts { } /// A single sample's sketch -#[derive(Serialize, Deserialize, Debug, Default, Clone, PartialEq)] +#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)] pub struct Sketch { - #[serde(skip)] - usigs: Vec, + #[serde(skip, default = "aligned_sketch_vec")] + usigs: SketchVec, name: String, index: Option, rc: bool, @@ -115,6 +135,22 @@ pub struct Sketch { non_acgt: usize, } +impl Default for Sketch { + fn default() -> Self { + Self { + usigs: aligned_sketch_vec(), + name: String::default(), + index: None, + rc: false, + reads: false, + seq_length: 0, + densified: false, + acgt: [0; 4], + non_acgt: 0, + } + } +} + // TODO: should this take hash_it and filter as input? impl Sketch { /// Sketch a sample from a hash generator over its k-mers, transposing @@ -128,7 +164,7 @@ impl Sketch { ) -> Self { let (_sketchsize64, num_bins, usigs_size) = num_bins(sketch_size); let flattened_size_u64 = usigs_size as usize * kmer_lengths.len(); - let mut usigs = Vec::with_capacity(flattened_size_u64); + let mut usigs = aligned_sketch_vec_with_capacity(flattened_size_u64); let mut read_filter = if seq_hashes.reads() { let mut filter = KmerFilter::new(min_count); @@ -145,11 +181,11 @@ impl Sketch { log::debug!("Running sketching at k={k}"); let (signs, k_densified) = Self::get_signs(seq_hashes, *k, &mut read_filter, num_bins); densified |= k_densified; - minhash_sum += (signs[0] as f64) / (SIGN_MOD as f64); + minhash_sum += (signs[0] as f64) / (u64::MAX as f64); // Transpose the bins and save to the sketch map log::debug!("Transposing bins"); - let mut kmer_usigs = vec![0; usigs_size as usize]; + let mut kmer_usigs = aligned_sketch_vec_filled(usigs_size as usize, 0); Self::fill_usigs(&mut kmer_usigs, &signs); usigs.append(&mut kmer_usigs); } @@ -190,9 +226,8 @@ impl Sketch { seq_hashes.set_k(kmer_size); // Calculate bin minima across all sequence - let bin_size: u64 = SIGN_MOD.div_ceil(num_bins); for hash in seq_hashes.iter() { - Self::bin_sign(&mut signs, hash % SIGN_MOD, bin_size, filter); + Self::bin_sign(&mut signs, hash, num_bins, filter); } // Densify let densified = Self::densify_bin(&mut signs); @@ -214,9 +249,8 @@ impl Sketch { seq_hashes.set_k(kmer_size); // Calculate bin minima across all sequence - let bin_size: u64 = SIGN_MOD.div_ceil(num_bins); for hash in seq_hashes.iter() { - Self::bin_sign(&mut signs, hash % SIGN_MOD, bin_size, filter); + Self::bin_sign(&mut signs, hash, num_bins, filter); } signs @@ -238,12 +272,12 @@ impl Sketch { } /// Take the (transposed) sketch, emptying it from the [`Sketch`] - pub fn get_usigs(&mut self) -> Vec { - std::mem::take(&mut self.usigs) + pub fn get_usigs(&mut self) -> SketchVec { + std::mem::replace(&mut self.usigs, aligned_sketch_vec()) } - fn bin_sign(signs: &mut [u64], sign: u64, binsize: u64, read_filter: &mut Option) { - let binidx = (sign / binsize) as usize; + fn bin_sign(signs: &mut [u64], sign: u64, num_bins: u64, read_filter: &mut Option) { + let binidx = Self::bin_index(sign, num_bins); // log::trace!("sign:{sign} idx:{binidx} curr_sign:{}", signs[binidx]); if let Some(filter) = read_filter { if sign < signs[binidx] && filter.filter(sign) == Ordering::Equal { @@ -254,6 +288,11 @@ impl Sketch { } } + #[inline(always)] + fn bin_index(sign: u64, num_bins: u64) -> usize { + (((sign as u128) * (num_bins as u128)) >> u64::BITS) as usize + } + #[inline(always)] fn bit_at_pos(x: u64, pos: u64) -> u64 { (x & (1_u64 << pos)) >> pos @@ -262,9 +301,9 @@ impl Sketch { fn fill_usigs(usigs: &mut [u64], signs: &[u64]) { for (sign_index, sign) in signs.iter().enumerate() { let leftshift = sign_index % (u64::BITS as usize); - for i in 0..BBITS { - let orval = Self::bit_at_pos(*sign, i) << leftshift; - usigs[sign_index / (u64::BITS as usize) * (BBITS as usize) + (i as usize)] |= orval; + for i in 0..BIN_BITS { + let orval = Self::bit_at_pos(*sign, i as u64) << leftshift; + usigs[sign_index / (u64::BITS as usize) * BIN_BITS + i] |= orval; } } } @@ -305,6 +344,25 @@ impl Sketch { } } +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn sketch_vec_is_64_byte_aligned_after_allocation() { + let mut sketch_vec = aligned_sketch_vec_with_capacity(16); + sketch_vec.resize(16, 0); + assert_eq!(sketch_vec.as_ptr() as usize % SKETCH_ALIGNMENT, 0); + } + + #[test] + fn bin_index_uses_full_u64_hash_space() { + assert_eq!(Sketch::bin_index(0, 64), 0); + assert_eq!(Sketch::bin_index(1_u64 << 63, 64), 32); + assert_eq!(Sketch::bin_index(u64::MAX, 64), 63); + } +} + impl fmt::Display for Sketch { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { writeln!( @@ -485,7 +543,7 @@ pub fn sketch_files( quiet: bool, ) -> Vec { let bin_stride = 1; - let kmer_stride = (sketch_size * BBITS) as usize; + let kmer_stride = sketch_size as usize * BIN_BITS; let sample_stride = kmer_stride * k.len(); #[cfg(feature = "3di")] diff --git a/src/sketch/multisketch.rs b/src/sketch/multisketch.rs index c849545..58cea69 100644 --- a/src/sketch/multisketch.rs +++ b/src/sketch/multisketch.rs @@ -11,10 +11,11 @@ use hashbrown::{HashMap, HashSet}; use serde::{Deserialize, Serialize}; use crate::hashing::HashType; +use crate::sketch::aligned_sketch_vec; use crate::sketch::num_bins; use crate::sketch::sketch_datafile::SketchArrayReader; use crate::sketch::sketch_datafile::SketchArrayWriter; -use crate::sketch::Sketch; +use crate::sketch::{Sketch, SketchVec}; use super::sketch_datafile::append_batch; @@ -33,8 +34,8 @@ pub struct MultiSketch { // NB: another way to do this is with the ouroboros crate, which allows this to reference self // But this requires manual impl for ser and deser, and does the same indirection as an index anyway so not worth it block_reindex: Option>, - #[serde(skip)] - sketch_bins: Vec, + #[serde(skip, default = "aligned_sketch_vec")] + sketch_bins: SketchVec, bin_stride: usize, kmer_stride: usize, sample_stride: usize, @@ -67,7 +68,7 @@ impl MultiSketch { sketch_metadata: mem::take(sketches), name_map, block_reindex: None, - sketch_bins: Vec::new(), + sketch_bins: aligned_sketch_vec(), bin_stride: 1, kmer_stride, sample_stride: kmer_stride * kmer_lengths.len(), diff --git a/src/sketch/sketch_datafile.rs b/src/sketch/sketch_datafile.rs index a71d4d5..9907f16 100644 --- a/src/sketch/sketch_datafile.rs +++ b/src/sketch/sketch_datafile.rs @@ -8,6 +8,8 @@ use std::sync::atomic::{AtomicUsize, Ordering}; use anyhow::{Error, Result}; +use super::{aligned_sketch_vec_with_capacity, SketchVec}; + /// Write to an .skd or .skq file /// This writer is not parallel safe, serial only! #[derive(Debug)] @@ -29,7 +31,7 @@ pub fn append_batch( ) -> Result<(), Error> { if let Some(mmap) = input_reader.mmap_file() { for &sample_idx in sample_indices { - let mut sample_data = Vec::with_capacity(input_reader.sample_stride); + let mut sample_data = aligned_sketch_vec_with_capacity(input_reader.sample_stride); for bin_idx in 0..input_reader.sample_stride { let start_byte = (sample_idx * input_reader.sample_stride + bin_idx) * mem::size_of::(); @@ -156,11 +158,11 @@ impl SketchArrayReader { } /// Read all sketch data from an .skd into memory - pub fn read_all_from_skd(&mut self, total_number_bins: usize) -> Vec { + pub fn read_all_from_skd(&mut self, total_number_bins: usize) -> SketchVec { // Fixed-size buffer for u64 let mut buffer = [0u8; std::mem::size_of::()]; // Stream the whole file and convert to u64 vec - let mut flat_sketch_array: Vec = Vec::with_capacity(total_number_bins); + let mut flat_sketch_array = aligned_sketch_vec_with_capacity(total_number_bins); while let Ok(_read) = self.sketch_reader.read_exact(&mut buffer) { flat_sketch_array.push(u64::from_le_bytes(buffer)); } @@ -169,9 +171,9 @@ impl SketchArrayReader { /// Read selected samples from an .skd into memory, providing the selected /// samples with `sample_indices` - pub fn read_batch_from_skd(&self, sample_indices: &[usize], sample_stride: usize) -> Vec { - let mut flat_sketch_array: Vec = - Vec::with_capacity(sample_stride * sample_indices.len()); + pub fn read_batch_from_skd(&self, sample_indices: &[usize], sample_stride: usize) -> SketchVec { + let mut flat_sketch_array = + aligned_sketch_vec_with_capacity(sample_stride * sample_indices.len()); // TODO possible improvement would be to combine adjacent indices into ranges if let Some(mmap_array) = &self.mmap_file { for sample_idx in sample_indices { @@ -200,3 +202,37 @@ impl SketchArrayReader { Ok(mmap) } } + +#[cfg(test)] +mod tests { + use super::*; + use crate::sketch::SKETCH_ALIGNMENT; + use std::fs; + + #[test] + fn skd_reads_are_64_byte_aligned() { + let filename = std::env::temp_dir().join(format!( + "sketchlib_skd_alignment_{}.skd", + std::process::id() + )); + let filename = filename.to_str().expect("temporary path must be UTF-8"); + + { + let mut writer = SketchArrayWriter::new(filename, 1, 4, 4); + writer.write_sketch(&[1_u64, 2, 3, 4]); + writer.flush().expect("failed to flush test sketch"); + } + + let mut reader = SketchArrayReader::open(filename, false, 1, 4, 4); + let all_sketches = reader.read_all_from_skd(4); + assert_eq!(all_sketches.as_ptr() as usize % SKETCH_ALIGNMENT, 0); + + let reader = SketchArrayReader::open(filename, true, 1, 4, 4); + let batch_sketches = reader.read_batch_from_skd(&[0], 4); + assert_eq!(batch_sketches.as_ptr() as usize % SKETCH_ALIGNMENT, 0); + + drop(batch_sketches); + drop(reader); + fs::remove_file(filename).expect("failed to remove test sketch"); + } +} diff --git a/tests/sketch.rs b/tests/sketch.rs index b66c1e0..fb05115 100644 --- a/tests/sketch.rs +++ b/tests/sketch.rs @@ -162,17 +162,36 @@ mod tests { .assert() .success(); - // Check .skm the same + // Check legacy metadata and data still load, without requiring identical + // derived stride fields from old hash-space behavior. let new_sketch: MultiSketch = MultiSketch::load_metadata(&sandbox.file_string("new_db", TestDir::Output)) .expect("Failed to load new sketch"); - let expected_sketch = + let mut expected_sketch = MultiSketch::load_metadata(&sandbox.file_string("legacy_db", TestDir::Input)) .expect("Failed to load legacy sketch"); + expected_sketch.read_sketch_data(&sandbox.file_string("legacy_db", TestDir::Input)); + + assert!( + new_sketch.is_compatible_with(&expected_sketch), + "Legacy sketch should be compatible with newly generated sketch metadata" + ); assert_eq!( - new_sketch, expected_sketch, - "Sketch metadata does not match legacy and new version" + new_sketch.number_samples_loaded(), + expected_sketch.number_samples_loaded(), + "Legacy sketch sample count does not match new sketch" + ); + for idx in 0..new_sketch.number_samples_loaded() { + assert_eq!( + new_sketch.get_sample_name(idx), + expected_sketch.get_sample_name(idx), + "Legacy sketch sample name does not match new sketch" + ); + } + assert!( + !expected_sketch.get_sketch_slice(0, 0).is_empty(), + "Legacy sketch data did not load" ); } } diff --git a/tests/test_results_correct/dists_knn_ani.stdout b/tests/test_results_correct/dists_knn_ani.stdout index be4e0af..d1fda9d 100644 --- a/tests/test_results_correct/dists_knn_ani.stdout +++ b/tests/test_results_correct/dists_knn_ani.stdout @@ -1,4 +1,4 @@ -14412_3#82.contigs_velvet.fa.gz 14412_3#84.contigs_velvet.fa.gz 0.9887168 -14412_3#84.contigs_velvet.fa.gz 14412_3#82.contigs_velvet.fa.gz 0.9887168 -R6.fa.gz TIGR4.fa.gz 0.99009615 -TIGR4.fa.gz R6.fa.gz 0.99009615 +14412_3#82.contigs_velvet.fa.gz 14412_3#84.contigs_velvet.fa.gz 0.9891178 +14412_3#84.contigs_velvet.fa.gz 14412_3#82.contigs_velvet.fa.gz 0.9891178 +R6.fa.gz TIGR4.fa.gz 0.9904671 +TIGR4.fa.gz R6.fa.gz 0.9904671 diff --git a/tests/test_results_correct/dists_knn_ca.stdout b/tests/test_results_correct/dists_knn_ca.stdout index 7e77fde..c1a9fe4 100644 --- a/tests/test_results_correct/dists_knn_ca.stdout +++ b/tests/test_results_correct/dists_knn_ca.stdout @@ -1,4 +1,4 @@ -14412_3#82.contigs_velvet.fa.gz 14412_3#84.contigs_velvet.fa.gz 0.008820182 0.2137754 -14412_3#84.contigs_velvet.fa.gz 14412_3#82.contigs_velvet.fa.gz 0.008820182 0.2137754 -R6.fa.gz TIGR4.fa.gz 0.010040261 0.1535005 -TIGR4.fa.gz R6.fa.gz 0.010040261 0.1535005 +14412_3#82.contigs_velvet.fa.gz 14412_3#84.contigs_velvet.fa.gz 0.011049058 0.17300157 +14412_3#84.contigs_velvet.fa.gz 14412_3#82.contigs_velvet.fa.gz 0.011049058 0.17300157 +R6.fa.gz TIGR4.fa.gz 0.0099692345 0.15083496 +TIGR4.fa.gz R6.fa.gz 0.0099692345 0.15083496 diff --git a/tests/test_results_correct/dists_knn_jaccard.stdout b/tests/test_results_correct/dists_knn_jaccard.stdout index fa4f633..aac95d9 100644 --- a/tests/test_results_correct/dists_knn_jaccard.stdout +++ b/tests/test_results_correct/dists_knn_jaccard.stdout @@ -1,4 +1,4 @@ -14412_3#82.contigs_velvet.fa.gz 14412_3#84.contigs_velvet.fa.gz 0.34842753 -14412_3#84.contigs_velvet.fa.gz 14412_3#82.contigs_velvet.fa.gz 0.34842753 -R6.fa.gz TIGR4.fa.gz 0.31618232 -TIGR4.fa.gz R6.fa.gz 0.31618232 +14412_3#82.contigs_velvet.fa.gz 14412_3#84.contigs_velvet.fa.gz 0.33927667 +14412_3#84.contigs_velvet.fa.gz 14412_3#82.contigs_velvet.fa.gz 0.33927667 +R6.fa.gz TIGR4.fa.gz 0.3071305 +TIGR4.fa.gz R6.fa.gz 0.3071305 diff --git a/tests/test_results_correct/dists_subset.stdout b/tests/test_results_correct/dists_subset.stdout index 7b4ec99..9cdd0d6 100644 --- a/tests/test_results_correct/dists_subset.stdout +++ b/tests/test_results_correct/dists_subset.stdout @@ -1,3 +1,3 @@ -R6.fa.gz TIGR4.fa.gz 0.010040261 0.1535005 +R6.fa.gz TIGR4.fa.gz 0.0099692345 0.15083496 R6.fa.gz 14412_3#84.contigs_velvet.fa.gz 1 1 -TIGR4.fa.gz 14412_3#84.contigs_velvet.fa.gz 0.061570305 0.9990812 +TIGR4.fa.gz 14412_3#84.contigs_velvet.fa.gz 1 1 diff --git a/tests/test_results_correct/inverted.skq b/tests/test_results_correct/inverted.skq index 5e0c52e..04e106c 100644 --- a/tests/test_results_correct/inverted.skq +++ b/tests/test_results_correct/inverted.skq @@ -1 +1 @@ -é<¡íꚈ¾vp‘ÊŠ°…!Êié<3!ꉀ¾¼Áæа…!pOëŸ ÇlñDàl�ò>1“�ÑnëŸ ÇFÁ·~�Sò>1“�Ñn \ No newline at end of file +¼%+Ò�ÊÕXæó¸ÒZ1Ù¾;¼%+ÒåÕXæód5ûbÙƒ¼tÅËôñô"Rí"^ư\d¿GtÅËôñx>Rí"^ư\d¿G \ No newline at end of file diff --git a/tests/test_results_correct/inverted_precluster.stdout b/tests/test_results_correct/inverted_precluster.stdout index 879e2ba..4ee4da2 100644 --- a/tests/test_results_correct/inverted_precluster.stdout +++ b/tests/test_results_correct/inverted_precluster.stdout @@ -1,4 +1,4 @@ -14412_3#82.contigs_velvet.fa.gz 14412_3#84.contigs_velvet.fa.gz 0.35546875 -14412_3#84.contigs_velvet.fa.gz 14412_3#82.contigs_velvet.fa.gz 0.35546875 -R6.fa.gz TIGR4.fa.gz 0.29492188 -TIGR4.fa.gz R6.fa.gz 0.29492188 +14412_3#82.contigs_velvet.fa.gz 14412_3#84.contigs_velvet.fa.gz 0.35352102 +14412_3#84.contigs_velvet.fa.gz 14412_3#82.contigs_velvet.fa.gz 0.35352102 +R6.fa.gz TIGR4.fa.gz 0.32324713 +TIGR4.fa.gz R6.fa.gz 0.32324713 diff --git a/tests/test_results_correct/inverted_precluster_ani.stdout b/tests/test_results_correct/inverted_precluster_ani.stdout index d492dfa..8695191 100644 --- a/tests/test_results_correct/inverted_precluster_ani.stdout +++ b/tests/test_results_correct/inverted_precluster_ani.stdout @@ -1,4 +1,4 @@ -14412_3#82.contigs_velvet.fa.gz 14412_3#84.contigs_velvet.fa.gz 0.98840284 -14412_3#84.contigs_velvet.fa.gz 14412_3#82.contigs_velvet.fa.gz 0.98840284 -R6.fa.gz TIGR4.fa.gz 0.99095666 -TIGR4.fa.gz R6.fa.gz 0.99095666 +14412_3#82.contigs_velvet.fa.gz 14412_3#84.contigs_velvet.fa.gz 0.98849016 +14412_3#84.contigs_velvet.fa.gz 14412_3#82.contigs_velvet.fa.gz 0.98849016 +R6.fa.gz TIGR4.fa.gz 0.9898019 +TIGR4.fa.gz R6.fa.gz 0.9898019 diff --git a/tests/test_results_correct/inverted_query_count.stdout b/tests/test_results_correct/inverted_query_count.stdout index 7a10a4e..aba31d7 100644 --- a/tests/test_results_correct/inverted_query_count.stdout +++ b/tests/test_results_correct/inverted_query_count.stdout @@ -1,5 +1,5 @@ Query 14412_3#82.contigs_velvet.fa.gz 14412_3#84.contigs_velvet.fa.gz R6.fa.gz TIGR4.fa.gz -14412_3#82.contigs_velvet.fa.gz 10 5 0 0 -R6.fa.gz 0 0 10 7 -14412_3#84.contigs_velvet.fa.gz 5 10 0 0 -TIGR4.fa.gz 0 0 7 10 +14412_3#82.contigs_velvet.fa.gz 10 6 0 0 +R6.fa.gz 0 0 10 9 +14412_3#84.contigs_velvet.fa.gz 6 10 0 0 +TIGR4.fa.gz 0 0 9 10 diff --git a/tests/test_results_correct/inverted_sketch_info.stdout b/tests/test_results_correct/inverted_sketch_info.stdout index f31f8f7..a6722d8 100644 --- a/tests/test_results_correct/inverted_sketch_info.stdout +++ b/tests/test_results_correct/inverted_sketch_info.stdout @@ -7,4 +7,4 @@ rc=true inverted=true max_hashes_per_bin=2 min_hashes_per_bin=1 -avg_hashes_per_bin=1.381 +avg_hashes_per_bin=1.413 diff --git a/tests/test_results_correct/read_sketch_full_info.stdout b/tests/test_results_correct/read_sketch_full_info.stdout index fee0685..37cf35b 100644 --- a/tests/test_results_correct/read_sketch_full_info.stdout +++ b/tests/test_results_correct/read_sketch_full_info.stdout @@ -1,4 +1,4 @@ Name Sequence length Base frequencies Missing/ambig bases From reads Single strand Densified -test_1 1 [603, 330, 334, 603] 0 true false true -test_2 1 [617, 312, 314, 617] 0 true false true +test_1 91 [603, 330, 334, 603] 0 true false true +test_2 91 [617, 312, 314, 617] 0 true false true From 43b469ba8d3990a0ae953cf13fd00f8e70b99204 Mon Sep 17 00:00:00 2001 From: John Lees Date: Fri, 22 May 2026 15:49:29 +0100 Subject: [PATCH 02/13] Some code review suggestions --- src/distances/jaccard.rs | 36 ++++++++++++++++- src/sketch/mod.rs | 22 ++++++---- tests/inverted.rs | 86 ++++++++++++++++++++++++++++++++++++++++ tests/sketch.rs | 1 + 4 files changed, 136 insertions(+), 9 deletions(-) diff --git a/src/distances/jaccard.rs b/src/distances/jaccard.rs index d6b9c29..a755d73 100644 --- a/src/distances/jaccard.rs +++ b/src/distances/jaccard.rs @@ -44,6 +44,7 @@ fn jaccard_same_bits(sketch1: &[u64], sketch2: &[u64]) -> u32 { } #[cfg_attr(target_arch = "aarch64", allow(dead_code))] +#[inline(always)] pub(crate) fn jaccard_same_bits_general(sketch1: &[u64], sketch2: &[u64]) -> u32 { debug_assert_eq!(sketch1.len(), sketch2.len()); debug_assert_eq!(sketch1.len() % BIN_BITS, 0); @@ -134,10 +135,41 @@ mod tests { use super::*; #[test] - fn random_match_correction_maps_expected_random_to_zero() { - let expected_random = 1.0 / ((1u32 << (BIN_BITS as u32)) as f64); + fn random_match_correction_handles_expected_random_and_bounds() { + let bb = (1u32 << (BIN_BITS as u32)) as f64; + let expected_random = 1.0 / bb; + let raw_above_random = 0.25; + let expected_corrected = (bb * raw_above_random - 1.0) / (bb - 1.0); + assert_eq!(random_match_correction(expected_random), 0.0); + assert_eq!(random_match_correction(expected_random / 2.0), 0.0); assert_eq!(random_match_correction(1.0), 1.0); + assert_eq!( + random_match_correction(raw_above_random), + expected_corrected + ); + } + + #[test] + fn jaccard_index_matches_expected_samebits_formula() { + let sketchsize64 = 1; + let unionsize = (u64::BITS as u64 * sketchsize64) as f64; + let bb = (1u32 << (BIN_BITS as u32)) as f64; + let expected_random_samebits = unionsize / bb; + let target_samebits = 42_u32; + let intersize = target_samebits as f64 - expected_random_samebits; + let previous_formula = intersize / (unionsize - expected_random_samebits); + + let sketch1 = [0_u64; BIN_BITS]; + let mut sketch2 = [0_u64; BIN_BITS]; + sketch2[0] = (1_u64 << (u64::BITS - target_samebits)) - 1; + + assert_eq!( + jaccard_same_bits_general(&sketch1, &sketch2), + target_samebits + ); + let current_formula = jaccard_index(&sketch1, &sketch2, sketchsize64, None, None, 0.0); + assert!((current_formula - previous_formula).abs() <= f64::EPSILON); } #[test] diff --git a/src/sketch/mod.rs b/src/sketch/mod.rs index f9769f7..d728153 100644 --- a/src/sketch/mod.rs +++ b/src/sketch/mod.rs @@ -40,8 +40,6 @@ use self::sketch_datafile::SketchArrayWriter; /// Bin bits (lowest of 64-bits to keep) pub const BIN_BITS: usize = 16; -#[doc(hidden)] -pub const BBITS: usize = BIN_BITS; /// Byte alignment used for bit-packed sketch buffers. pub const SKETCH_ALIGNMENT: usize = 64; @@ -273,6 +271,7 @@ impl Sketch { /// Take the (transposed) sketch, emptying it from the [`Sketch`] pub fn get_usigs(&mut self) -> SketchVec { + // Move the old aligned buffer out and install an empty one; elements are not copied. std::mem::replace(&mut self.usigs, aligned_sketch_vec()) } @@ -299,11 +298,20 @@ impl Sketch { } fn fill_usigs(usigs: &mut [u64], signs: &[u64]) { - for (sign_index, sign) in signs.iter().enumerate() { - let leftshift = sign_index % (u64::BITS as usize); - for i in 0..BIN_BITS { - let orval = Self::bit_at_pos(*sign, i as u64) << leftshift; - usigs[sign_index / (u64::BITS as usize) * BIN_BITS + i] |= orval; + debug_assert_eq!(signs.len() % (u64::BITS as usize), 0); + debug_assert_eq!(usigs.len(), signs.len() / (u64::BITS as usize) * BIN_BITS); + + for (usig_chunk, sign_chunk) in usigs + .chunks_exact_mut(BIN_BITS) + .zip(signs.chunks_exact(u64::BITS as usize)) + { + for (bit_pos, usig) in usig_chunk.iter_mut().enumerate() { + *usig = sign_chunk + .iter() + .enumerate() + .fold(0, |bits, (sign_index, &sign)| { + bits | (Self::bit_at_pos(sign, bit_pos as u64) << sign_index) + }); } } } diff --git a/tests/inverted.rs b/tests/inverted.rs index 251c6fc..5aae0a3 100644 --- a/tests/inverted.rs +++ b/tests/inverted.rs @@ -6,10 +6,26 @@ use crate::common::*; #[cfg(test)] mod tests { + use sketchlib::sketch::{ + multisketch::MultiSketch, sketch_datafile::SketchArrayReader, BIN_BITS, + }; use snapbox::assert_data_eq; use super::*; + fn unpack_skd_bin(bit_planes: &[u64], bin_idx: usize) -> u16 { + let word_idx = bin_idx / (u64::BITS as usize); + let bit_offset = bin_idx % (u64::BITS as usize); + let plane_offset = word_idx * BIN_BITS; + + bit_planes[plane_offset..(plane_offset + BIN_BITS)] + .iter() + .enumerate() + .fold(0_u16, |bin_value, (bit_pos, &plane)| { + bin_value | (((plane >> bit_offset) & 1) as u16) << bit_pos + }) + } + #[test] fn inverted_sketch() { let sandbox = TestSetup::setup(); @@ -48,6 +64,76 @@ mod tests { .stdout_eq(sandbox.snapbox_file("inverted_sketch_full_info.stdout", TestDir::Correct)); } + #[test] + fn inverted_skq_matches_standard_skd_bins() { + let sandbox = TestSetup::setup(); + + sandbox.copy_input_file_to_wd("14412_3#82.contigs_velvet.fa.gz"); + sandbox.copy_input_file_to_wd("14412_3#84.contigs_velvet.fa.gz"); + sandbox.copy_input_file_to_wd("R6.fa.gz"); + sandbox.copy_input_file_to_wd("TIGR4.fa.gz"); + sandbox.copy_input_file_to_wd("rfile.txt"); + + Command::new(cmd::cargo_bin!("sketchlib")) + .current_dir(sandbox.get_wd()) + .arg("inverted") + .arg("build") + .arg("-o") + .arg("inverted") + .args(["-v", "-k", "21", "-s", "64"]) + .arg("-f") + .arg("rfile.txt") + .arg("--write-skq") + .assert() + .success(); + + Command::new(cmd::cargo_bin!("sketchlib")) + .current_dir(sandbox.get_wd()) + .arg("sketch") + .arg("-o") + .arg("standard") + .args(["-v", "--k-vals", "21", "-s", "64"]) + .arg("-f") + .arg("rfile.txt") + .assert() + .success(); + + let mut standard_sketches = + MultiSketch::load_metadata(&sandbox.file_string("standard", TestDir::Output)) + .expect("failed to load standard sketch metadata"); + standard_sketches.read_sketch_data(&sandbox.file_string("standard", TestDir::Output)); + + let sketch_size = standard_sketches.sketch_size as usize; + assert_eq!(sketch_size, 64); + assert_eq!(standard_sketches.kmer_lengths(), &[21]); + + let mut skq_reader = SketchArrayReader::open( + &sandbox.file_string("inverted.skq", TestDir::Output), + false, + 1, + 1, + sketch_size, + ); + let skq_bins = + skq_reader.read_all_from_skq(sketch_size * standard_sketches.number_samples_loaded()); + assert_eq!( + skq_bins.len(), + sketch_size * standard_sketches.number_samples_loaded() + ); + + for sample_idx in 0..standard_sketches.number_samples_loaded() { + let skd_bins = standard_sketches.get_sketch_slice(sample_idx, 0); + let skq_offset = sample_idx * sketch_size; + for bin_idx in 0..sketch_size { + assert_eq!( + skq_bins[skq_offset + bin_idx], + unpack_skd_bin(skd_bins, bin_idx), + "sample {sample_idx} bin {bin_idx} differs between .skq and .skd" + ); + } + } + } + #[test] fn inverted_sketch_with_metadata() { let sandbox = TestSetup::setup(); diff --git a/tests/sketch.rs b/tests/sketch.rs index fb05115..77e23c6 100644 --- a/tests/sketch.rs +++ b/tests/sketch.rs @@ -70,6 +70,7 @@ mod tests { .assert() .stdout_eq(sandbox.snapbox_file("read_sketch_info.stdout", TestDir::Correct)); + // FASTQ read length is estimated from minima normalized over the full u64 hash space. Command::new(cmd::cargo_bin!("sketchlib")) .current_dir(sandbox.get_wd()) .arg("info") From 71bd1a5fe2054b8fd6e5fcd715f61570ac807eae Mon Sep 17 00:00:00 2001 From: John Lees Date: Mon, 27 Jul 2026 11:08:30 +0100 Subject: [PATCH 03/13] Update to aligned vecs, cargo fmt --- Cargo.toml | 4 +- src/distances/mod.rs | 3 +- src/hashing/aahash_iterator.rs | 6 +- src/hashing/nthash_iterator.rs | 6 +- src/io.rs | 12 +-- src/lib.rs | 10 +- src/sketch/mod.rs | 49 ++++++---- src/sketch/multisketch.rs | 13 ++- tests/distance.rs | 163 +++++++++++++++++++++++++-------- 9 files changed, 181 insertions(+), 85 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 5027dbf..380a93a 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,11 +31,10 @@ crate-type = ["cdylib", "lib"] [features] 3di = ["dep:pyo3", "dep:pyo3-build-config"] -[dependencies] -aligned-vec = "0.6.4" [target.'cfg(not(target_arch = "wasm32"))'.dependencies] # data structures +aligned-vec = "0.6.4" hashbrown = { version = "0.14", features = ["rayon", "serde"] } ahash = ">=0.8.7" roaring = { version = "0.10", features = ["serde"] } @@ -66,6 +65,7 @@ num_cpus = "1.0" [target.'cfg(target_arch = "wasm32")'.dependencies] # data structures +aligned-vec = "0.6.4" hashbrown = { version = "0.14", features = ["rayon", "serde"] } # ahash = ">=0.8.7" roaring = { version = "0.10", features = ["serde"] } diff --git a/src/distances/mod.rs b/src/distances/mod.rs index 634c9ec..21b6379 100644 --- a/src/distances/mod.rs +++ b/src/distances/mod.rs @@ -359,8 +359,7 @@ pub fn cross_dists_knn<'a>( if ani { heap.into_sorted_vec().iter().zip(row_dist_slice).for_each( |(inverse_ani, output_ani)| { - *output_ani = - SparseJaccard(inverse_ani.0, 1.0_f32 - inverse_ani.1); + *output_ani = SparseJaccard(inverse_ani.0, 1.0_f32 - inverse_ani.1); }, ); } else { diff --git a/src/hashing/aahash_iterator.rs b/src/hashing/aahash_iterator.rs index e5053cf..5f87413 100644 --- a/src/hashing/aahash_iterator.rs +++ b/src/hashing/aahash_iterator.rs @@ -79,11 +79,11 @@ impl AaHashIterator { } /// Create a new aaHash iterator from a fastX reader, at the set comparison level - pub fn new, Option>)>>( + pub fn new, Option>)>>( readers: &mut [I], file: &String, level: AaLevel, - concat_fasta: bool + concat_fasta: bool, ) -> Vec { let mut hash_vec = Vec::new(); @@ -124,7 +124,7 @@ impl AaHashIterator { } /// Create a new iterator from a 3di embedding file of a structure - pub fn from_3di_file, Option>)>>( + pub fn from_3di_file, Option>)>>( readers: &mut [I], file: &String, ) -> Vec { diff --git a/src/hashing/nthash_iterator.rs b/src/hashing/nthash_iterator.rs index 66454f8..adf48ea 100644 --- a/src/hashing/nthash_iterator.rs +++ b/src/hashing/nthash_iterator.rs @@ -89,14 +89,13 @@ impl RollHash for NtHashIterator { impl NtHashIterator { #[cfg(not(target_arch = "wasm32"))] /// Creates a new ntHash iterator, by loading DNA sequences into memory - pub fn new, Option>)>>( + pub fn new, Option>)>>( files: &mut [I], k: usize, rc: bool, min_qual: u8, reads: bool, ) -> Vec { - let mut seq = Vec::new(); let mut offsets = Vec::new(); let mut acgt = [0, 0, 0, 0]; @@ -192,7 +191,7 @@ impl NtHashIterator { } #[cfg(not(target_arch = "wasm32"))] - fn add_dna_seq, Option>)>>( + fn add_dna_seq, Option>)>>( records: &mut I, min_qual: u8, seq: &mut Vec, @@ -200,7 +199,6 @@ impl NtHashIterator { acgt: &mut [usize; 4], non_acgt: &mut usize, ) { - // Current byte let mut b = 0; // Nucleotide index within byte 0-3 diff --git a/src/io.rs b/src/io.rs index 99a0e91..c902d0c 100644 --- a/src/io.rs +++ b/src/io.rs @@ -24,12 +24,8 @@ pub struct NeedletailIterator { #[cfg(not(target_arch = "wasm32"))] impl NeedletailIterator { /// Construct from needletail readers - pub fn new( - reader: Box, - ) -> Self { - Self { - reader, - } + pub fn new(reader: Box) -> Self { + Self { reader } } } @@ -37,9 +33,7 @@ impl NeedletailIterator { impl Iterator for NeedletailIterator { type Item = (Vec, Option>); - fn next( - &mut self, - ) -> Option<(Vec, Option>)> { + fn next(&mut self) -> Option<(Vec, Option>)> { let record = self.reader.next()?.expect("Invalid FASTA/Q record"); let seq = record.seq(); let qual = record.qual().map(|qual| qual.to_vec()); diff --git a/src/lib.rs b/src/lib.rs index 6e813e8..1c7f4d5 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -409,7 +409,9 @@ pub fn main() -> Result<(), Error> { Some(mut nn) => { // Cross-query mode: query genomes never overlap ref genomes, so knn=n is valid if nn > n { - log::warn!("knn={nn} is higher than number of reference samples={n}"); + log::warn!( + "knn={nn} is higher than number of reference samples={n}" + ); nn = n; } // Cross-query mode (sparse kNN) @@ -427,7 +429,8 @@ pub fn main() -> Result<(), Error> { *completeness_cutoff, ); log::info!("Writing out in sparse matrix form"); - write!(output_file, "{distances}").expect("Error writing output distances"); + write!(output_file, "{distances}") + .expect("Error writing output distances"); } None => { // Cross-query mode (dense, all pairs) @@ -444,7 +447,8 @@ pub fn main() -> Result<(), Error> { *completeness_cutoff, ); log::info!("Writing out in long matrix form"); - write!(output_file, "{distances}").expect("Error writing output distances"); + write!(output_file, "{distances}") + .expect("Error writing output distances"); } } } diff --git a/src/sketch/mod.rs b/src/sketch/mod.rs index d728153..f4358a2 100644 --- a/src/sketch/mod.rs +++ b/src/sketch/mod.rs @@ -472,29 +472,33 @@ impl fmt::Display for Sketch { /// # assert_eq!(sketch[0].get_usigs(), vec![10446655729443322257_u64, 4179589106973994628, 8878020266243511022, 15496134240677377755, 12077142249206779756, 2557808496963489941, 11187838061323059739, 2644643690855717913, 4938295307178618234, 3755990044489396820, 5853149455415045639, 13413802265437751679, 13026670255550945707, 17600625581895810275, 15514998287561100248, 16224101823335952861, 7650478683895450690, 12490835276570242802, 16446545056545572452, 9136098023486151969, 14353135930022752998, 17596669057648315390, 13032397772767758586, 14311172789545189524, 8634896743882272518, 13813990681410911957, 15274287431720689540, 17130711307909519409, 14074157117691102709, 3977024316243443606, 11614473757740315713, 8590442866276072648, 3525327762139029339, 7654958233148978252, 14646652205652799167, 5876269956202259935, 16360345219485058576, 15734568599691562397, 11148612413168737116, 11587453912179871137, 2605646798685730264, 3886875076450406060]); /// # assert_eq!(sketch[0].name(), fastq_path_str); /// ``` -pub fn sketch_data, Option>)>>( +pub fn sketch_data, Option>)>>( records_readers: &mut [I], opts: SketchingOpts, - #[cfg(feature = "3di")] - convert_pdb: bool, - #[cfg(feature = "3di")] - struct_string: Option, + #[cfg(feature = "3di")] convert_pdb: bool, + #[cfg(feature = "3di")] struct_string: Option, ) -> Vec { // Read in sequence and set up rolling hash by alphabet type let mut hash_its: Vec> = match opts.seq_type { - HashType::DNA => { - - NtHashIterator::new(records_readers, opts.k_vals[0], opts.add_rc, opts.min_qual, opts.is_reads) - .into_iter() - .map(|it| Box::new(it) as Box) - .collect() - }, - HashType::AA(level) => { - AaHashIterator::new(records_readers, &opts.name, level.clone(), opts.concat_fasta) - .into_iter() - .map(|it| Box::new(it) as Box) - .collect() - } + HashType::DNA => NtHashIterator::new( + records_readers, + opts.k_vals[0], + opts.add_rc, + opts.min_qual, + opts.is_reads, + ) + .into_iter() + .map(|it| Box::new(it) as Box) + .collect(), + HashType::AA(level) => AaHashIterator::new( + records_readers, + &opts.name, + level.clone(), + opts.concat_fasta, + ) + .into_iter() + .map(|it| Box::new(it) as Box) + .collect(), HashType::PDB => { #[cfg(feature = "3di")] if let Some(di) = &struct_string { @@ -529,7 +533,14 @@ pub fn sketch_data, Option>)>>( panic!("{sample_name} has no valid sequence"); } // Run the sketching - Sketch::new(&mut **hash_it, &sample_name, &opts.k_vals, opts.sketch_size, opts.add_rc, opts.min_count) + Sketch::new( + &mut **hash_it, + &sample_name, + &opts.k_vals, + opts.sketch_size, + opts.add_rc, + opts.min_count, + ) }) .collect::>() } diff --git a/src/sketch/multisketch.rs b/src/sketch/multisketch.rs index 58cea69..f5c88bc 100644 --- a/src/sketch/multisketch.rs +++ b/src/sketch/multisketch.rs @@ -11,10 +11,8 @@ use hashbrown::{HashMap, HashSet}; use serde::{Deserialize, Serialize}; use crate::hashing::HashType; -use crate::sketch::aligned_sketch_vec; -use crate::sketch::num_bins; -use crate::sketch::sketch_datafile::SketchArrayReader; -use crate::sketch::sketch_datafile::SketchArrayWriter; +use crate::sketch::sketch_datafile::{SketchArrayReader, SketchArrayWriter}; +use crate::sketch::{aligned_sketch_vec, aligned_sketch_vec_with_capacity, num_bins}; use crate::sketch::{Sketch, SketchVec}; use super::sketch_datafile::append_batch; @@ -99,9 +97,10 @@ impl MultiSketch { let (sketchsize64, _signs_size, usigs_size) = num_bins(sketch_size); let kmer_stride = usigs_size as usize; - let sketch_bins = sketches.iter_mut().flat_map(|sketch| { - sketch.get_usigs() - }).collect(); + let mut sketch_bins = aligned_sketch_vec_with_capacity(sketches.len() * kmer_stride); + for sketch in sketches.iter_mut() { + sketch_bins.extend_from_slice(&sketch.get_usigs()); + } Self { sketch_size, diff --git a/tests/distance.rs b/tests/distance.rs index 015ad26..940a919 100644 --- a/tests/distance.rs +++ b/tests/distance.rs @@ -365,21 +365,51 @@ mod tests { // Disjoint reference: 14412_3#82 + 14412_3#84 only Command::new(cmd::cargo_bin("sketchlib")) .current_dir(sandbox.get_wd()) - .args(["sketch", "-f", "rfile_ref.txt", "--k-seq", "17,31,4", "-s", "1000", "-o", "bact_db"]) + .args([ + "sketch", + "-f", + "rfile_ref.txt", + "--k-seq", + "17,31,4", + "-s", + "1000", + "-o", + "bact_db", + ]) .assert() .success(); // Query: R6 + TIGR4 Command::new(cmd::cargo_bin("sketchlib")) .current_dir(sandbox.get_wd()) - .args(["sketch", "-f", "qfile.txt", "--k-seq", "17,31,4", "-s", "1000", "-o", "query_db"]) + .args([ + "sketch", + "-f", + "qfile.txt", + "--k-seq", + "17,31,4", + "-s", + "1000", + "-o", + "query_db", + ]) .assert() .success(); // All 4 genomes — used for self-kNN consistency test only Command::new(cmd::cargo_bin("sketchlib")) .current_dir(sandbox.get_wd()) - .args(["sketch", "-f", "rfile.txt", "--k-seq", "17,31,4", "-s", "1000", "-o", "ref_db"]) + .args([ + "sketch", + "-f", + "rfile.txt", + "--k-seq", + "17,31,4", + "-s", + "1000", + "-o", + "ref_db", + ]) .assert() .success(); } @@ -394,13 +424,18 @@ mod tests { let output = std::process::Command::new(cmd::cargo_bin("sketchlib")) .current_dir(sandbox.get_wd()) - .args(["dist", "bact_db", "query_db", "--knn", "1", "-k", "21", "--ani"]) + .args([ + "dist", "bact_db", "query_db", "--knn", "1", "-k", "21", "--ani", + ]) .output() .expect("Failed to run sketchlib"); let stdout = String::from_utf8(output.stdout).unwrap(); let n_lines = stdout.lines().filter(|l| !l.is_empty()).count(); - assert_eq!(n_lines, 2, "Expected 2 queries × 1 neighbour = 2 rows, got {n_lines}"); + assert_eq!( + n_lines, 2, + "Expected 2 queries × 1 neighbour = 2 rows, got {n_lines}" + ); } /// Test 2: kNN output contains the same top-k neighbours as the dense output sorted by ANI. @@ -428,7 +463,10 @@ mod tests { let reference = parts[0].to_string(); let query = parts[1].to_string(); let ani: f64 = parts[2].parse().expect("Could not parse ANI"); - dense_by_query.entry(query).or_default().push((reference, ani)); + dense_by_query + .entry(query) + .or_default() + .push((reference, ani)); } // For each query, sort by ANI descending and keep top-1 @@ -441,19 +479,26 @@ mod tests { // kNN output columns: query(0) \t ref(1) \t ani(2) let knn_out = std::process::Command::new(cmd::cargo_bin("sketchlib")) .current_dir(sandbox.get_wd()) - .args(["dist", "bact_db", "query_db", "--knn", "1", "-k", "21", "--ani"]) + .args([ + "dist", "bact_db", "query_db", "--knn", "1", "-k", "21", "--ani", + ]) .output() .expect("Failed to run kNN dist"); let knn_triples = parse_dist_output(&String::from_utf8(knn_out.stdout).unwrap()); // Assert kNN top-1 matches dense top-1 for every query genome for (query, reference, knn_ani) in &knn_triples { - let (dense_ref, dense_ani) = dense_top1.get(query) + let (dense_ref, dense_ani) = dense_top1 + .get(query) .unwrap_or_else(|| panic!("Query {query} not found in dense output")); - assert_eq!(reference, dense_ref, - "Top neighbour mismatch for {query}: knn={reference}, dense={dense_ref}"); - assert!((knn_ani - dense_ani).abs() < 1e-5, - "ANI mismatch for {query}/{reference}: knn={knn_ani}, dense={dense_ani}"); + assert_eq!( + reference, dense_ref, + "Top neighbour mismatch for {query}: knn={reference}, dense={dense_ref}" + ); + assert!( + (knn_ani - dense_ani).abs() < 1e-5, + "ANI mismatch for {query}/{reference}: knn={knn_ani}, dense={dense_ani}" + ); } } @@ -468,7 +513,9 @@ mod tests { let output = std::process::Command::new(cmd::cargo_bin("sketchlib")) .current_dir(sandbox.get_wd()) - .args(["dist", "bact_db", "query_db", "--knn", "1", "-k", "21", "--ani"]) + .args([ + "dist", "bact_db", "query_db", "--knn", "1", "-k", "21", "--ani", + ]) .output() .expect("Failed to run sketchlib"); @@ -479,13 +526,20 @@ mod tests { let ref_names: HashSet<&str> = [ "14412_3#82.contigs_velvet.fa.gz", "14412_3#84.contigs_velvet.fa.gz", - ].iter().cloned().collect(); + ] + .iter() + .cloned() + .collect(); for (query, reference, _) in &triples { - assert!(query_names.contains(query.as_str()), - "Row '{query}' is not a query genome"); - assert!(ref_names.contains(reference.as_str()), - "Column '{reference}' is not a reference genome"); + assert!( + query_names.contains(query.as_str()), + "Row '{query}' is not a query genome" + ); + assert!( + ref_names.contains(reference.as_str()), + "Column '{reference}' is not a reference genome" + ); } } @@ -512,7 +566,9 @@ mod tests { // kNN output columns: query(0) \t ref(1) \t ani(2) let cross_out = std::process::Command::new(cmd::cargo_bin("sketchlib")) .current_dir(sandbox.get_wd()) - .args(["dist", "bact_db", "query_db", "--knn", "1", "-k", "21", "--ani"]) + .args([ + "dist", "bact_db", "query_db", "--knn", "1", "-k", "21", "--ani", + ]) .output() .expect("Failed to run cross-query kNN dist"); let cross_triples = parse_dist_output(&String::from_utf8(cross_out.stdout).unwrap()); @@ -522,11 +578,15 @@ mod tests { let bact_names: HashSet<&str> = [ "14412_3#82.contigs_velvet.fa.gz", "14412_3#84.contigs_velvet.fa.gz", - ].iter().cloned().collect(); + ] + .iter() + .cloned() + .collect(); let mut self_best_bact: HashMap = HashMap::new(); for query in &["R6.fa.gz", "TIGR4.fa.gz"] { - let best = self_triples.iter() + let best = self_triples + .iter() .filter(|(q, r, _)| q == query && bact_names.contains(r.as_str())) .max_by(|a, b| a.2.partial_cmp(&b.2).unwrap_or(std::cmp::Ordering::Equal)) .unwrap_or_else(|| panic!("No bacterial hits for {query} in self-kNN")); @@ -535,12 +595,17 @@ mod tests { // Compare: cross-query kNN top-1 should match self-kNN top-1 from bacterial genomes for (query, cross_ref, cross_ani) in &cross_triples { - let (self_ref, self_ani) = self_best_bact.get(query) + let (self_ref, self_ani) = self_best_bact + .get(query) .unwrap_or_else(|| panic!("Query {query} not found in self-kNN")); - assert_eq!(cross_ref, self_ref, - "Nearest bacterial genome mismatch for {query}: cross={cross_ref}, self={self_ref}"); - assert!((cross_ani - self_ani).abs() < 1e-5, - "ANI mismatch for {query}/{cross_ref}: cross={cross_ani}, self={self_ani}"); + assert_eq!( + cross_ref, self_ref, + "Nearest bacterial genome mismatch for {query}: cross={cross_ref}, self={self_ref}" + ); + assert!( + (cross_ani - self_ani).abs() < 1e-5, + "ANI mismatch for {query}/{cross_ref}: cross={cross_ani}, self={self_ani}" + ); } } @@ -573,10 +638,18 @@ mod tests { let comp_out = std::process::Command::new(cmd::cargo_bin("sketchlib")) .current_dir(sandbox.get_wd()) .args([ - "dist", "bact_db", "query_db", - "--knn", "1", "-k", "21", "--ani", - "--ref-completeness-file", "ref_completeness.txt", - "--query-completeness-file", "query_completeness.txt", + "dist", + "bact_db", + "query_db", + "--knn", + "1", + "-k", + "21", + "--ani", + "--ref-completeness-file", + "ref_completeness.txt", + "--query-completeness-file", + "query_completeness.txt", ]) .output() .expect("Failed to run with completeness"); @@ -606,9 +679,16 @@ mod tests { let bad_out = std::process::Command::new(cmd::cargo_bin("sketchlib")) .current_dir(sandbox.get_wd()) .args([ - "dist", "bact_db", "query_db", - "--knn", "1", "-k", "21", "--ani", - "--ref-completeness-file", "bad_completeness.txt", + "dist", + "bact_db", + "query_db", + "--knn", + "1", + "-k", + "21", + "--ani", + "--ref-completeness-file", + "bad_completeness.txt", ]) .output() .expect("Failed to run with bad completeness"); @@ -646,11 +726,20 @@ mod tests { let stdout = String::from_utf8(output.stdout).unwrap(); let lines: Vec<&str> = stdout.lines().filter(|l| !l.is_empty()).collect(); - assert_eq!(lines.len(), 2, "Expected 2 rows (2 queries × knn=1), got {}", lines.len()); + assert_eq!( + lines.len(), + 2, + "Expected 2 rows (2 queries × knn=1), got {}", + lines.len() + ); for line in &lines { let parts: Vec<&str> = line.split_whitespace().collect(); - assert_eq!(parts.len(), 4, "Expected 4 columns (query, ref, core, acc): {line}"); + assert_eq!( + parts.len(), + 4, + "Expected 4 columns (query, ref, core, acc): {line}" + ); parts[2].parse::().expect("Core distance not a float"); parts[3].parse::().expect("Acc distance not a float"); } @@ -668,7 +757,9 @@ mod tests { let output = std::process::Command::new(cmd::cargo_bin("sketchlib")) .current_dir(sandbox.get_wd()) - .args(["dist", "bact_db", "query_db", "--knn", "2", "-k", "21", "--ani"]) + .args([ + "dist", "bact_db", "query_db", "--knn", "2", "-k", "21", "--ani", + ]) .output() .expect("Failed to run knn=n_ref cross-query"); From 01ec1d5b50d4caa9e3b1326bdcc864ed09b57eb4 Mon Sep 17 00:00:00 2001 From: John Lees Date: Mon, 27 Jul 2026 11:28:06 +0100 Subject: [PATCH 04/13] Remove overly specific test; bump v0.4.0 --- Cargo.toml | 5 +++-- README.md | 7 +++++-- src/sketch/mod.rs | 1 - 3 files changed, 8 insertions(+), 5 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 380a93a..605d265 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,12 +1,13 @@ [package] name = "sketchlib" -version = "0.3.0" +version = "0.4.0" authors = [ "John Lees ", "Nicholas Croucher ", "Johanna von Wachsmann ", "Victor Rodriguez Bouza ", - "Joel Hellewell ", + "Tommi Maklin", ] edition = "2021" description = "Genome and amino-acid sketching" diff --git a/README.md b/README.md index 0069fe7..0aaebf5 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,11 @@ This is a reimplementation and extension of [pp-sketchlib](https://github.com/ba in the rust language. This version is optimised for larger sample numbers, particularly allowing subsets of samples to be compared. -v0.2.0 is the first stable release. We intend to keep the file format unchanged after this -point so sketch libraries will not need to be rebuilt. +### News + +- **Data format changed in v0.4.0**. If you have an older database (see `sketchlib info | grep "sketch_version"`) +you should resketch samples with >v0.4. Otherwise you will need to use an older release available on crates.io. +- v0.2.0 was the first stable release. ## Documentation diff --git a/src/sketch/mod.rs b/src/sketch/mod.rs index f4358a2..dff0193 100644 --- a/src/sketch/mod.rs +++ b/src/sketch/mod.rs @@ -469,7 +469,6 @@ impl fmt::Display for Sketch { /// /// # let mut sketch = sketch; /// # assert_eq!(sketch.len(), 1); -/// # assert_eq!(sketch[0].get_usigs(), vec![10446655729443322257_u64, 4179589106973994628, 8878020266243511022, 15496134240677377755, 12077142249206779756, 2557808496963489941, 11187838061323059739, 2644643690855717913, 4938295307178618234, 3755990044489396820, 5853149455415045639, 13413802265437751679, 13026670255550945707, 17600625581895810275, 15514998287561100248, 16224101823335952861, 7650478683895450690, 12490835276570242802, 16446545056545572452, 9136098023486151969, 14353135930022752998, 17596669057648315390, 13032397772767758586, 14311172789545189524, 8634896743882272518, 13813990681410911957, 15274287431720689540, 17130711307909519409, 14074157117691102709, 3977024316243443606, 11614473757740315713, 8590442866276072648, 3525327762139029339, 7654958233148978252, 14646652205652799167, 5876269956202259935, 16360345219485058576, 15734568599691562397, 11148612413168737116, 11587453912179871137, 2605646798685730264, 3886875076450406060]); /// # assert_eq!(sketch[0].name(), fastq_path_str); /// ``` pub fn sketch_data, Option>)>>( From 608b9ba471905bd7a971fe7c7bb96ecb70871f97 Mon Sep 17 00:00:00 2001 From: John Lees Date: Mon, 27 Jul 2026 14:12:34 +0100 Subject: [PATCH 05/13] Update the random match correction for large sketch size --- src/distances/jaccard.rs | 58 ++++++---------------------------------- src/lib.rs | 3 +-- 2 files changed, 9 insertions(+), 52 deletions(-) diff --git a/src/distances/jaccard.rs b/src/distances/jaccard.rs index a755d73..4cb0a34 100644 --- a/src/distances/jaccard.rs +++ b/src/distances/jaccard.rs @@ -11,19 +11,21 @@ pub fn jaccard_index( c2: Option, completeness_cutoff: f64, ) -> f64 { + let samebits = jaccard_same_bits(sketch1, sketch2) as f64; + // Correction for random matches + let expected_random = sketchsize64 as f64 / (1u64 << BIN_BITS) as f64; + let samebits_corrected = ((samebits - expected_random) / (sketchsize64 as f64 - expected_random)).clamp(0.0, 1.0); + let unionsize = (u64::BITS as u64 * sketchsize64) as f64; - let samebits = jaccard_same_bits(sketch1, sketch2); - let raw_jaccard = samebits as f64 / unionsize; - let mut jaccard_index = random_match_correction(raw_jaccard); + let mut jaccard_index = samebits / unionsize; - log::trace!("samebits:{samebits} raw_jaccard:{raw_jaccard} jaccard:{jaccard_index}"); + log::trace!("samebits:{samebits} samebits:{samebits_corrected} jaccard:{jaccard_index}"); // Apply completeness correction if both completeness values are provided if let (Some(c1_val), Some(c2_val)) = (c1, c2) { if c1_val * c2_val >= completeness_cutoff { - jaccard_index = completeness_correction(jaccard_index, c1_val, c2_val); // Cap the corrected Jaccard index at 1.0 to prevent negative distances - jaccard_index = jaccard_index.min(1.0); + jaccard_index = completeness_correction(jaccard_index, c1_val, c2_val).min(1.0); } } @@ -124,54 +126,10 @@ unsafe fn jaccard_neon_unroll2_inner(a: &[u64], b: &[u64]) -> u32 { total } -#[inline(always)] -fn random_match_correction(jaccard: f64) -> f64 { - let bb = (1u32 << (BIN_BITS as u32)) as f64; - ((bb * jaccard - 1.0).max(0.0)) / (bb - 1.0) -} - #[cfg(test)] mod tests { use super::*; - #[test] - fn random_match_correction_handles_expected_random_and_bounds() { - let bb = (1u32 << (BIN_BITS as u32)) as f64; - let expected_random = 1.0 / bb; - let raw_above_random = 0.25; - let expected_corrected = (bb * raw_above_random - 1.0) / (bb - 1.0); - - assert_eq!(random_match_correction(expected_random), 0.0); - assert_eq!(random_match_correction(expected_random / 2.0), 0.0); - assert_eq!(random_match_correction(1.0), 1.0); - assert_eq!( - random_match_correction(raw_above_random), - expected_corrected - ); - } - - #[test] - fn jaccard_index_matches_expected_samebits_formula() { - let sketchsize64 = 1; - let unionsize = (u64::BITS as u64 * sketchsize64) as f64; - let bb = (1u32 << (BIN_BITS as u32)) as f64; - let expected_random_samebits = unionsize / bb; - let target_samebits = 42_u32; - let intersize = target_samebits as f64 - expected_random_samebits; - let previous_formula = intersize / (unionsize - expected_random_samebits); - - let sketch1 = [0_u64; BIN_BITS]; - let mut sketch2 = [0_u64; BIN_BITS]; - sketch2[0] = (1_u64 << (u64::BITS - target_samebits)) - 1; - - assert_eq!( - jaccard_same_bits_general(&sketch1, &sketch2), - target_samebits - ); - let current_formula = jaccard_index(&sketch1, &sketch2, sketchsize64, None, None, 0.0); - assert!((current_formula - previous_formula).abs() <= f64::EPSILON); - } - #[test] fn scalar_same_bits_counts_matching_bins() { let sketch1 = [u64::MAX; BIN_BITS * 2]; diff --git a/src/lib.rs b/src/lib.rs index 1c7f4d5..abf6e9d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,8 +9,7 @@ //! above these distances is unsupported and may lead to poor estimation of distances //! without clear warning. //! - Short k-mer lengths are likely to match at random, see [PopPUNK's docs](https://poppunk-docs.bacpop.org/sketching.html#choosing-the-right-k-mer-lengths) -//! for information on how to select good lengths. Jaccard distances include random match -//! correction for the retained sketch signature width. +//! for information on how to select good lengths. //! - ANI distance resolution is highly affected by sketch size at higher mismatch //! levels, so note that if you see lots of samples at around 80% they may be much lower than //! this. We recommend checking the Jaccard values in this case, if they are close to 0 From d730ca5c725c416a3e536d5f40b7edd6a74361f4 Mon Sep 17 00:00:00 2001 From: John Lees Date: Mon, 27 Jul 2026 14:13:33 +0100 Subject: [PATCH 06/13] Prevent use on older versions bbits from 14 -> 16 prevents backwards compatibility --- src/inverted.rs | 24 +++ src/sketch/multisketch.rs | 32 +++- tests/distance.rs | 169 ++++++++---------- tests/inverted.rs | 151 ++++++++++------ tests/sketch.rs | 56 +----- tests/test_files_in/sketches1.skd | Bin 7168 -> 2048 bytes tests/test_files_in/sketches1.skm | Bin 420 -> 241 bytes tests/test_files_in/sketches2.skd | Bin 70336 -> 4096 bytes tests/test_files_in/sketches2.skm | Bin 420 -> 242 bytes tests/test_files_in/sketches3.skd | Bin 7168 -> 4096 bytes tests/test_files_in/sketches3.skm | Bin 417 -> 276 bytes .../sketchlib_output_true.txt | 4 - 12 files changed, 241 insertions(+), 195 deletions(-) diff --git a/src/inverted.rs b/src/inverted.rs index 5aad2b7..e38dc9a 100644 --- a/src/inverted.rs +++ b/src/inverted.rs @@ -27,6 +27,7 @@ use super::hashing::{ use crate::distances::distance_matrix::square_to_condensed; #[cfg(not(target_arch = "wasm32"))] use crate::io::InputFastx; +use crate::sketch::multisketch::{parse_version, MIN_SKETCH_VERSION}; #[cfg(target_arch = "wasm32")] use crate::sketch::Sketch; #[cfg(not(target_arch = "wasm32"))] @@ -43,6 +44,27 @@ use wasm_bindgen_file_reader::WebSysFile; type InvSketches = (Vec>, Vec); +/// Warns (does not fail) if a loaded .ski index was built with a sketchlib +/// version older than the current minimum sketch format version. The .ski +/// format itself hasn't changed, so this is informational only. +fn warn_if_old_version(sketch_version: &str) { + let version_ok = + parse_version(sketch_version).is_some_and(|version| version >= MIN_SKETCH_VERSION); + if !version_ok { + let found_version = if sketch_version.is_empty() { + "" + } else { + sketch_version + }; + log::warn!( + "Inverted index was created with sketchlib v{found_version}, older than v{}.{}.{}. Consider re-sketching with the current version.", + MIN_SKETCH_VERSION.0, + MIN_SKETCH_VERSION.1, + MIN_SKETCH_VERSION.2 + ); + } +} + /// An inverted index and associated metadata #[derive(Serialize, Deserialize, Default, Clone, PartialEq)] pub struct Inverted { @@ -210,6 +232,7 @@ impl Inverted { let ski_file = BufReader::new(File::open(filename)?); let decompress_reader = snap::read::FrameDecoder::new(ski_file); let ski_obj: Self = rmp_serde::decode::from_read(decompress_reader)?; + warn_if_old_version(&ski_obj.sketch_version); Ok(ski_obj) } @@ -221,6 +244,7 @@ impl Inverted { let ski_file = BufReader::new(WebSysFile::new(file.clone())); let decompress_reader = snap::read::FrameDecoder::new(ski_file); let ski_obj: Self = rmp_serde::decode::from_read(decompress_reader)?; + warn_if_old_version(&ski_obj.sketch_version); Ok(ski_obj) } diff --git a/src/sketch/multisketch.rs b/src/sketch/multisketch.rs index f5c88bc..72f6f69 100644 --- a/src/sketch/multisketch.rs +++ b/src/sketch/multisketch.rs @@ -17,6 +17,17 @@ use crate::sketch::{Sketch, SketchVec}; use super::sketch_datafile::append_batch; +/// Minimum supported .skd/.skm format version. The on-disk `MultiSketch` layout +/// changed as of v0.4, so files written by older versions cannot be loaded. +pub const MIN_SKETCH_VERSION: (u64, u64, u64) = (0, 4, 0); + +/// Parses a `major.minor.patch` version string, returning `None` if it is +/// missing or malformed (which covers files predating the `sketch_version` field). +pub fn parse_version(version: &str) -> Option<(u64, u64, u64)> { + let mut parts = version.split('.').map(str::parse::); + Some((parts.next()?.ok()?, parts.next()?.ok()?, parts.next()?.ok()?)) +} + /// A set of sketch files, with underlying storage as .skd/.skm #[derive(Serialize, Deserialize)] pub struct MultiSketch { @@ -37,6 +48,7 @@ pub struct MultiSketch { bin_stride: usize, kmer_stride: usize, sample_stride: usize, + #[serde(default)] sketch_version: String, hash_type: HashType, } @@ -132,9 +144,27 @@ impl MultiSketch { pub fn load_metadata(file_prefix: &str) -> Result { let filename = format!("{file_prefix}.skm"); log::info!("Loading sketch metadata from {filename}"); - let skm_file = BufReader::new(File::open(filename)?); + let skm_file = BufReader::new(File::open(&filename)?); let decompress_reader = snap::read::FrameDecoder::new(skm_file); let mut skm_obj: Self = ciborium::de::from_reader(decompress_reader)?; + + let version_ok = parse_version(&skm_obj.sketch_version) + .is_some_and(|version| version >= MIN_SKETCH_VERSION); + if !version_ok { + let found_version = if skm_obj.sketch_version.is_empty() { + "" + } else { + &skm_obj.sketch_version + }; + log::error!( + "Sketch file {filename} was created with sketchlib v{found_version}, which is older than the minimum supported v{}.{}.{}. Please re-sketch with the current version.", + MIN_SKETCH_VERSION.0, + MIN_SKETCH_VERSION.1, + MIN_SKETCH_VERSION.2 + ); + bail!("Incompatible sketch file version"); + } + // For backwards compatibility (field added in v0.2.0) if skm_obj.sketchsize64 == 0 { skm_obj.sketchsize64 = skm_obj.sketch_size; diff --git a/tests/distance.rs b/tests/distance.rs index 940a919..a0dbb6a 100644 --- a/tests/distance.rs +++ b/tests/distance.rs @@ -12,21 +12,52 @@ use std::io::{BufRead, BufReader}; mod tests { use super::*; - // assert function with tolerance for distances - fn assert_with_tolerance(actual: f64, expected: f64) { - let rounded_actual = (actual * 1000.0).round() / 1000.0; - let rounded_expected = (expected * 1000.0).round() / 1000.0; + use approx::assert_abs_diff_eq; - let abs_tolerance = 0.05; - let diff = (rounded_actual - rounded_expected).abs(); + // Assert function with tolerance for distances computed against the external + // C++ sketchlib reference values. Some of these (e.g. very short sequences) + // are known to diverge more than floating-point noise between implementations, + // so this stays a fairly loose absolute tolerance rather than a tight epsilon. + fn assert_with_tolerance(actual: f64, expected: f64) { + assert_abs_diff_eq!(actual, expected, epsilon = 0.05); + } - assert!( - diff <= abs_tolerance, - "Absolute difference exceeds tolerance of {}. Actual: {}, Expected: {}", - abs_tolerance, - rounded_actual, - rounded_expected + // Compares tab-separated distance output against a golden file, allowing + // numeric fields to differ by a small epsilon (SIMD reordering can shift + // the last few bits of a float) while requiring an exact match on names. + fn assert_dist_stdout_with_tolerance(actual: &str, expected: &snapbox::Data) { + let expected_str = expected + .render() + .expect("Failed to render expected snapshot data"); + let actual_lines: Vec<&str> = actual.lines().filter(|l| !l.is_empty()).collect(); + let expected_lines: Vec<&str> = expected_str.lines().filter(|l| !l.is_empty()).collect(); + assert_eq!( + actual_lines.len(), + expected_lines.len(), + "Line count mismatch.\nActual:\n{actual}\nExpected:\n{expected_str}" ); + for (actual_line, expected_line) in actual_lines.iter().zip(expected_lines.iter()) { + let actual_fields: Vec<&str> = actual_line.split('\t').collect(); + let expected_fields: Vec<&str> = expected_line.split('\t').collect(); + assert_eq!( + actual_fields.len(), + expected_fields.len(), + "Field count mismatch. Actual: {actual_line}, Expected: {expected_line}" + ); + for (actual_field, expected_field) in + actual_fields.iter().zip(expected_fields.iter()) + { + match (actual_field.parse::(), expected_field.parse::()) { + (Ok(actual_val), Ok(expected_val)) => { + assert_abs_diff_eq!(actual_val, expected_val, epsilon = 1e-4); + } + _ => assert_eq!( + actual_field, expected_field, + "Field mismatch. Actual line: {actual_line}, Expected line: {expected_line}" + ), + } + } + } } fn read_expected_distances( @@ -62,63 +93,10 @@ mod tests { } } - fn read_calculated_distances(full_file_name: &str) -> f64 { - //Read in the rust sketchlib results from file - BufReader::new(File::open(full_file_name).expect("Failed to open file")) - .lines() - .next() - .expect("File is empty") - .expect("Failed to read line") - .split_whitespace() - .last() - .expect("Line has incorrect format") - .parse() - .expect("Failed to parse number") - } - #[test] fn dense_distances() { let sandbox = TestSetup::setup(); - //Test 1: One short sequence vs one short seqeunce (1 SNP apart) - - //Test 1 begin ------------- - Command::new(cmd::cargo_bin!("sketchlib")) - .current_dir(sandbox.get_wd()) - .arg("sketch") - .args(&["--k-vals", "3"]) - .arg(sandbox.file_string("short_sequence.fa", TestDir::Input)) - .arg("-v") - .args(&["-o", "test1_part1"]) - .assert() - .success(); - assert_eq!(true, sandbox.file_exists("test1_part1.skd")); - assert_eq!(true, sandbox.file_exists("test1_part1.skm")); - - Command::new(cmd::cargo_bin!("sketchlib")) - .current_dir(sandbox.get_wd()) - .arg("sketch") - .args(&["--k-vals", "3"]) - .arg(sandbox.file_string("short_sequence_SNP.fa", TestDir::Input)) - .arg("-v") - .args(&["-o", "test1_part2"]) - .assert() - .success(); - assert_eq!(true, sandbox.file_exists("test1_part2.skd")); - assert_eq!(true, sandbox.file_exists("test1_part2.skm")); - - Command::new(cmd::cargo_bin!("sketchlib")) - .current_dir(sandbox.get_wd()) - .arg("dist") - .arg("test1_part1") - .arg("test1_part2") - .args(&["-k", "3"]) - .args(&["-o", "short_sequence_dist_3"]) - .arg("-v") - .assert() - .success(); - assert_eq!(true, sandbox.file_exists("short_sequence_dist_3")); - //Test 2 begin ------------- Command::new(cmd::cargo_bin!("sketchlib")) .current_dir(sandbox.get_wd()) @@ -203,19 +181,6 @@ mod tests { let mut sketchlib_true_dict: HashMap> = HashMap::new(); read_expected_distances("sketchlib_output_true.txt", &mut sketchlib_true_dict); - let sketchlib_dist_3 = sketchlib_true_dict - .get("short_sequence_jaccard_dists_3") - .expect("Key not found"); - - let rust_dist_3 = read_calculated_distances( - &sandbox.file_string("short_sequence_dist_3", TestDir::Output), - ); - - // Assert or use the value as needed - let sketchlib_dist_3 = sketchlib_dist_3[0]; - - assert_with_tolerance(sketchlib_dist_3, rust_dist_3); - // TEST 2: let rust_whole_genome: f64 = BufReader::new( File::open(sandbox.file_string("test2_rust_results", TestDir::Output)) @@ -290,18 +255,23 @@ mod tests { .success(); // C-a dists at knn=1 - Command::new(cmd::cargo_bin!("sketchlib")) + let ca_output = Command::new(cmd::cargo_bin!("sketchlib")) .current_dir(sandbox.get_wd()) .arg("dist") .arg("sketch_db") .arg("-v") .arg("--knn") .arg("1") - .assert() - .stdout_eq(sandbox.snapbox_file("dists_knn_ca.stdout", TestDir::Correct)); + .output() + .expect("Failed to run C-a dist"); + assert!(ca_output.status.success()); + assert_dist_stdout_with_tolerance( + &String::from_utf8(ca_output.stdout).unwrap(), + &sandbox.snapbox_file("dists_knn_ca.stdout", TestDir::Correct), + ); // Jaccard dists at knn=1 - Command::new(cmd::cargo_bin!("sketchlib")) + let jaccard_output = Command::new(cmd::cargo_bin!("sketchlib")) .current_dir(sandbox.get_wd()) .arg("dist") .arg("sketch_db") @@ -310,11 +280,16 @@ mod tests { .arg("1") .arg("-k") .arg("21") - .assert() - .stdout_eq(sandbox.snapbox_file("dists_knn_jaccard.stdout", TestDir::Correct)); + .output() + .expect("Failed to run Jaccard dist"); + assert!(jaccard_output.status.success()); + assert_dist_stdout_with_tolerance( + &String::from_utf8(jaccard_output.stdout).unwrap(), + &sandbox.snapbox_file("dists_knn_jaccard.stdout", TestDir::Correct), + ); // ANI dists at knn=1 - Command::new(cmd::cargo_bin!("sketchlib")) + let ani_output = Command::new(cmd::cargo_bin!("sketchlib")) .current_dir(sandbox.get_wd()) .arg("dist") .arg("sketch_db") @@ -324,8 +299,13 @@ mod tests { .arg("-k") .arg("21") .arg("--ani") - .assert() - .stdout_eq(sandbox.snapbox_file("dists_knn_ani.stdout", TestDir::Correct)); + .output() + .expect("Failed to run ANI dist"); + assert!(ani_output.status.success()); + assert_dist_stdout_with_tolerance( + &String::from_utf8(ani_output.stdout).unwrap(), + &sandbox.snapbox_file("dists_knn_ani.stdout", TestDir::Correct), + ); } /// Helper: parse ANI distance output lines into (query, reference, ani) triples. @@ -496,7 +476,7 @@ mod tests { "Top neighbour mismatch for {query}: knn={reference}, dense={dense_ref}" ); assert!( - (knn_ani - dense_ani).abs() < 1e-5, + (knn_ani - dense_ani).abs() < 1e-4, "ANI mismatch for {query}/{reference}: knn={knn_ani}, dense={dense_ani}" ); } @@ -603,7 +583,7 @@ mod tests { "Nearest bacterial genome mismatch for {query}: cross={cross_ref}, self={self_ref}" ); assert!( - (cross_ani - self_ani).abs() < 1e-5, + (cross_ani - self_ani).abs() < 1e-4, "ANI mismatch for {query}/{cross_ref}: cross={cross_ani}, self={self_ani}" ); } @@ -801,14 +781,19 @@ mod tests { .success(); // Subset three samples and calc dists - Command::new(cmd::cargo_bin!("sketchlib")) + let subset_output = Command::new(cmd::cargo_bin!("sketchlib")) .current_dir(sandbox.get_wd()) .arg("dist") .arg("sketch_db") .arg("-v") .arg("--subset") .arg(sandbox.file_string("subset.txt", TestDir::Input)) - .assert() - .stdout_eq(sandbox.snapbox_file("dists_subset.stdout", TestDir::Correct)); + .output() + .expect("Failed to run subset dist"); + assert!(subset_output.status.success()); + assert_dist_stdout_with_tolerance( + &String::from_utf8(subset_output.stdout).unwrap(), + &sandbox.snapbox_file("dists_subset.stdout", TestDir::Correct), + ); } } diff --git a/tests/inverted.rs b/tests/inverted.rs index 5aae0a3..1d2fdad 100644 --- a/tests/inverted.rs +++ b/tests/inverted.rs @@ -9,10 +9,54 @@ mod tests { use sketchlib::sketch::{ multisketch::MultiSketch, sketch_datafile::SketchArrayReader, BIN_BITS, }; + use approx::assert_abs_diff_eq; use snapbox::assert_data_eq; use super::*; + // Compares tab-separated distance output against a golden file, ignoring + // line order and allowing numeric fields to differ by a small epsilon + // (SIMD reordering can shift the last few bits of a float). + fn assert_dist_stdout_unordered_with_tolerance(actual: &str, expected: &snapbox::Data) { + let expected_str = expected + .render() + .expect("Failed to render expected snapshot data"); + let actual_lines: Vec<&str> = actual.lines().filter(|l| !l.is_empty()).collect(); + let mut expected_lines: Vec<&str> = + expected_str.lines().filter(|l| !l.is_empty()).collect(); + assert_eq!( + actual_lines.len(), + expected_lines.len(), + "Line count mismatch.\nActual:\n{actual}\nExpected:\n{expected_str}" + ); + + for actual_line in &actual_lines { + let actual_fields: Vec<&str> = actual_line.split('\t').collect(); + let match_idx = expected_lines.iter().position(|expected_line| { + let expected_fields: Vec<&str> = expected_line.split('\t').collect(); + actual_fields.len() == expected_fields.len() + && actual_fields.iter().zip(expected_fields.iter()).all( + |(actual_field, expected_field)| { + match (actual_field.parse::(), expected_field.parse::()) { + (Ok(actual_val), Ok(expected_val)) => { + (actual_val - expected_val).abs() < 1e-4 + } + _ => actual_field == expected_field, + } + }, + ) + }); + match match_idx { + Some(idx) => { + expected_lines.remove(idx); + } + None => panic!( + "No matching expected line for actual line: {actual_line}\nRemaining expected lines: {expected_lines:?}" + ), + } + } + } + fn unpack_skd_bin(bit_planes: &[u64], bin_idx: usize) -> u16 { let word_idx = bin_idx / (u64::BITS as usize); let bit_offset = bin_idx % (u64::BITS as usize); @@ -383,7 +427,7 @@ mod tests { .success(); // Run preclustering mode - Command::new(cmd::cargo_bin!("sketchlib")) + let precluster_output = Command::new(cmd::cargo_bin!("sketchlib")) .current_dir(sandbox.get_wd()) .arg("inverted") .arg("precluster") @@ -391,15 +435,16 @@ mod tests { .arg("--skd") .arg("standard") .arg("inverted.ski") - .assert() - .stdout_eq( - sandbox - .snapbox_file("inverted_precluster.stdout", TestDir::Correct) - .unordered(), - ); + .output() + .expect("Failed to run precluster"); + assert!(precluster_output.status.success()); + assert_dist_stdout_unordered_with_tolerance( + &String::from_utf8(precluster_output.stdout).unwrap(), + &sandbox.snapbox_file("inverted_precluster.stdout", TestDir::Correct), + ); // Same with ANI - Command::new(cmd::cargo_bin!("sketchlib")) + let precluster_ani_output = Command::new(cmd::cargo_bin!("sketchlib")) .current_dir(sandbox.get_wd()) .arg("inverted") .arg("precluster") @@ -408,15 +453,16 @@ mod tests { .arg("--skd") .arg("standard") .arg("inverted.ski") - .assert() - .stdout_eq( - sandbox - .snapbox_file("inverted_precluster_ani.stdout", TestDir::Correct) - .unordered(), - ); + .output() + .expect("Failed to run precluster --ani"); + assert!(precluster_ani_output.status.success()); + assert_dist_stdout_unordered_with_tolerance( + &String::from_utf8(precluster_ani_output.stdout).unwrap(), + &sandbox.snapbox_file("inverted_precluster_ani.stdout", TestDir::Correct), + ); // Default knn, check no junk distances printed - Command::new(cmd::cargo_bin!("sketchlib")) + let precluster_knn50_output = Command::new(cmd::cargo_bin!("sketchlib")) .current_dir(sandbox.get_wd()) .arg("inverted") .arg("precluster") @@ -424,12 +470,13 @@ mod tests { .arg("--skd") .arg("standard") .arg("inverted.ski") - .assert() - .stdout_eq( - sandbox - .snapbox_file("inverted_precluster.stdout", TestDir::Correct) - .unordered(), - ); + .output() + .expect("Failed to run precluster --knn 50"); + assert!(precluster_knn50_output.status.success()); + assert_dist_stdout_unordered_with_tolerance( + &String::from_utf8(precluster_knn50_output.stdout).unwrap(), + &sandbox.snapbox_file("inverted_precluster.stdout", TestDir::Correct), + ); } // Helper to set up reordered SKI + standard SKD @@ -477,7 +524,7 @@ mod tests { setup_reordered_precluster(&sandbox); // Run preclustering with different SKI vs SKD orderings - Command::new(cmd::cargo_bin!("sketchlib")) + let output = Command::new(cmd::cargo_bin!("sketchlib")) .current_dir(sandbox.get_wd()) .arg("inverted") .arg("precluster") @@ -485,12 +532,13 @@ mod tests { .arg("--skd") .arg("standard") .arg("reordered_inv.ski") - .assert() - .stdout_eq( - sandbox - .snapbox_file("inverted_precluster.stdout", TestDir::Correct) - .unordered(), - ); + .output() + .expect("Failed to run precluster"); + assert!(output.status.success()); + assert_dist_stdout_unordered_with_tolerance( + &String::from_utf8(output.stdout).unwrap(), + &sandbox.snapbox_file("inverted_precluster.stdout", TestDir::Correct), + ); } #[test] @@ -498,7 +546,7 @@ mod tests { let sandbox = TestSetup::setup(); setup_reordered_precluster(&sandbox); - Command::new(cmd::cargo_bin!("sketchlib")) + let output = Command::new(cmd::cargo_bin!("sketchlib")) .current_dir(sandbox.get_wd()) .arg("inverted") .arg("precluster") @@ -507,12 +555,13 @@ mod tests { .arg("--skd") .arg("standard") .arg("reordered_inv.ski") - .assert() - .stdout_eq( - sandbox - .snapbox_file("inverted_precluster_ani.stdout", TestDir::Correct) - .unordered(), - ); + .output() + .expect("Failed to run precluster --ani"); + assert!(output.status.success()); + assert_dist_stdout_unordered_with_tolerance( + &String::from_utf8(output.stdout).unwrap(), + &sandbox.snapbox_file("inverted_precluster_ani.stdout", TestDir::Correct), + ); } #[test] @@ -521,7 +570,7 @@ mod tests { setup_reordered_precluster(&sandbox); // knn=50 >> n_samples=4, tests padding with reordered indices - Command::new(cmd::cargo_bin!("sketchlib")) + let output = Command::new(cmd::cargo_bin!("sketchlib")) .current_dir(sandbox.get_wd()) .arg("inverted") .arg("precluster") @@ -529,12 +578,13 @@ mod tests { .arg("--skd") .arg("standard") .arg("reordered_inv.ski") - .assert() - .stdout_eq( - sandbox - .snapbox_file("inverted_precluster.stdout", TestDir::Correct) - .unordered(), - ); + .output() + .expect("Failed to run precluster --knn 50"); + assert!(output.status.success()); + assert_dist_stdout_unordered_with_tolerance( + &String::from_utf8(output.stdout).unwrap(), + &sandbox.snapbox_file("inverted_precluster.stdout", TestDir::Correct), + ); } #[test] @@ -591,7 +641,7 @@ mod tests { .success(); // Run precluster with different orderings - Command::new(cmd::cargo_bin!("sketchlib")) + let output = Command::new(cmd::cargo_bin!("sketchlib")) .current_dir(sandbox.get_wd()) .arg("inverted") .arg("precluster") @@ -599,12 +649,13 @@ mod tests { .arg("--skd") .arg("standard") .arg("reversed_inv.ski") - .assert() - .stdout_eq( - sandbox - .snapbox_file("inverted_precluster.stdout", TestDir::Correct) - .unordered(), - ); + .output() + .expect("Failed to run precluster"); + assert!(output.status.success()); + assert_dist_stdout_unordered_with_tolerance( + &String::from_utf8(output.stdout).unwrap(), + &sandbox.snapbox_file("inverted_precluster.stdout", TestDir::Correct), + ); } #[test] @@ -821,7 +872,7 @@ mod tests { // Self-match lines should have distance 0 if fields[0] == fields[1] { let dist: f64 = fields[2].parse().expect("Failed to parse distance"); - assert_eq!(dist, 0.0, "Singleton self-match should have distance 0"); + assert_abs_diff_eq!(dist, 0.0, epsilon = 1e-6); } } diff --git a/tests/sketch.rs b/tests/sketch.rs index 77e23c6..74dd929 100644 --- a/tests/sketch.rs +++ b/tests/sketch.rs @@ -141,58 +141,18 @@ mod tests { } #[test] - /// Check that older databases can still be read correctly - /// (some fields added on .skm in v0.2.0) - fn legacy_databases() { + /// The MultiSketch (.skd/.skm) format changed as of v0.4, so databases + /// created with older versions (this fixture is v0.1.3) must be refused + /// rather than silently loaded. + fn legacy_database_rejected() { use sketchlib::sketch::multisketch::MultiSketch; let sandbox = TestSetup::setup(); - sandbox.copy_input_file_to_wd("R6.fa.gz"); - sandbox.copy_input_file_to_wd("TIGR4.fa.gz"); - // Old command: - // sketchlib sketch -v -o legacy_db --k-vals 17,21,25 -s 100 R6.fa.gz TIGR4.fa.gz - - Command::new(cmd::cargo_bin!("sketchlib")) - .current_dir(sandbox.get_wd()) - .arg("sketch") - .arg("-o") - .arg("new_db") - .args(["-v", "--k-vals", "17,21,25", "-s", "100"]) - .arg("R6.fa.gz") - .arg("TIGR4.fa.gz") - .assert() - .success(); - - // Check legacy metadata and data still load, without requiring identical - // derived stride fields from old hash-space behavior. - let new_sketch: MultiSketch = - MultiSketch::load_metadata(&sandbox.file_string("new_db", TestDir::Output)) - .expect("Failed to load new sketch"); - let mut expected_sketch = - MultiSketch::load_metadata(&sandbox.file_string("legacy_db", TestDir::Input)) - .expect("Failed to load legacy sketch"); - - expected_sketch.read_sketch_data(&sandbox.file_string("legacy_db", TestDir::Input)); - - assert!( - new_sketch.is_compatible_with(&expected_sketch), - "Legacy sketch should be compatible with newly generated sketch metadata" - ); - assert_eq!( - new_sketch.number_samples_loaded(), - expected_sketch.number_samples_loaded(), - "Legacy sketch sample count does not match new sketch" - ); - for idx in 0..new_sketch.number_samples_loaded() { - assert_eq!( - new_sketch.get_sample_name(idx), - expected_sketch.get_sample_name(idx), - "Legacy sketch sample name does not match new sketch" - ); - } + let err = MultiSketch::load_metadata(&sandbox.file_string("legacy_db", TestDir::Input)) + .expect_err("Loading a pre-v0.4 sketch file should fail"); assert!( - !expected_sketch.get_sketch_slice(0, 0).is_empty(), - "Legacy sketch data did not load" + err.to_string().contains("Incompatible sketch file version"), + "Unexpected error loading legacy sketch: {err}" ); } } diff --git a/tests/test_files_in/sketches1.skd b/tests/test_files_in/sketches1.skd index f7f96d4bdc97d6ae773eeca449300cfee38e8c74..72a8f8c12c6f9bea00b0c9c47f941f2b1caf5c9a 100644 GIT binary patch literal 2048 zcmV+b2>M8EJ_oZXBpf}#4acTVf`7|I_e2FgcBY;>Z2X{T1<5KL=Y=m&ml7E!AN z?mD#b!Y~gm14YFuG?@9XI#i$IOf1zIg+!F(Z5*nG?3h)Vq>DxcG0gcQxM4MSLqRwa zo5*1KIEc6M33XxhBJ5x#az1EXzVS1KSdMws2}}(0gj)~*nW3_nvlPZO14Id!C9_(x z#8v=auUS^|m}2`Gchk&s8WbA$DE0K0@O;&%e>(PWf?zh4mWb%}g`;8dnsx2en?_vG z=w`}_Ps800S4*!a*XhqN!El~^-_3~uWI42(Ka=sRXu`QrnkIAOyW#B6Hl*qU zPh(VEoAn7j=i|cJ(J>67ADcFXs)NblU$!$JuYoW&78L?d1+otyRgMTL6DU4FXoYhz)0t33 zm+D37_|#^CClYdQakY}@Sx_xO5!G0lP=aX&lF=^Oa*32muVyVVaOb1uGRv-!5|tD+ z(nq*2Nj?u(FeZEL5H6&L@Tc3_!1O8xfB)7@chD{s$ZeUw59&zzuIRr&209i2sgG84 ze3n&^y=mpt;EFdQxuAK(0iDW5%NQGvMxQ|>-&$a2lRagP6Daz}7 z;jv4$JkDZWKYs_Oy@;uC}j2vWr)KSf8WZ`7GrcV_{{bF5cQmUOoBRitV+> zO~!V|!7r#r2rZQokfnmj2zP?EdKBel=|J+F^T6dx-(6v{&ubh_{CJAeX|Tn)qh&>8Zg*n&ezZ1)H30c$c9)F3I?KFt9d z7r+CW(*_L|MHqW=tjp4E$)7OB?#a-G#=$ywO1`CRGNmUD zj!<*}92H=a{(rjlHf~tqji#lUS`I&wvct?q6Y;T`JcUZSg0N`2{GMvM&nvQU=F?@4 z!5-`}QnH4kqzZ;;5uaO9bF@FolRgbE*|w76koF!(drGq)z57sykc=&Ac#aobb zwXgv#^k^XWVnju%s%pu{BVe=83kI;Dmh45I9@9h3CMZsxxHJ}L8MJ~)iY#cm#ry7RerCymO2ZB2ikPhKeu6G%qL0w{_EWWC?`@Xuz&nMwd=KSF;OkIV4c|H z<%W1zk)Vc|8oqhii!ElUWWOUY$s)5#<*+~l?oMyn*WMG}F5j|zf?G!^`tU?DtE4V` z9&-IKlr^!-)lB*~7s?thp)E%)-(>Rq+~B7wGX{Q;v)6Pb#Eu1Bvw!)2yu`04P3e>q zzixh8R~oy6|6;bZGG1UPmc$nEipzs%DWMfr7Lu4IqlS?b!MJ;FD!gd(T?-AXn z=m)N<(`+<1e)@JCHDg56HJXI9_X+unw_E$?&>J`duC^QU{IR#q083( z7RoR$!Od6?c?T$GJ=i4(gkE?0=Qs1un@L`Xxy9whk|^`duC>XNN-dk=T1$!L%KS|2 z?{^zVDjL@keCk!a6#{qzy>j&*`a|b+OEcvF&bF0-q3}$Y?yrn}6kbd-};mfU~rZDL_PLtw~}pY<_qxv literal 7168 zcmV+b9RK551N~KI;0joIxGHfZ!~x7rr%w#2M&2W}nX1msAD5v%0j_9F)Kw^pTk{=? zi}x))pLc9;cy>&F#y(`84SH@PDToh|*f>T5WriEOz}r#BLg!Nyq_G1vOJ;?$R}GQA zyX^K~e`-=%VlHgKZl$)`e5SJQVcP{xKIKk)2Ym<{b8uqW@>Ct)8Y3AQMGNN|vz$T% z*0A{tQSXq|^|DHQsl1IM3xhYt9;xf3Ei>K6?*wTfMYq_CU#V*$Q7dyAESMJxb(+{i zIdoLPwe?;~Y<&f(<@58+TM{wd!hp)fL!(L16k6HEjTeL&2w zk06Ge+|WsczLS{}3+FfmmqD=UW)brdOz7;EViEkDnCuga7)AwklP0p1(i+yDKwK9T zSDlkp*h}4_;~q4p{XMz0(Xr8uI#a9btth!#9q)n`B2q==fwgY!x6bE?Hkc zdYgWr0^zul`ufx%p+;Np*ra~r<$U(-d`_7Iqj)md!GI4a7yCf|9)WnPmK*Rb}5 zHbjPQQy}gh&Zwvkn12?H!Md=ra7L0joAJ;1{cXJY6C=@hQR0g3l5V?PLwVPt1Un7^ zy(@TQ$)U!)*q^&&u|C?rq0K8MSkbw4Q<4QKI*$LEfD2orTyj|(;Potf?&g?&&iwd( zZEbXhPA?=-gV8Ah9Ynon7&43($B8)f=LJ>#onKR)P;~PKtbD_FdYEEAmtqsvrd-KFs_+hq-X$_Bym=HgM(>$ejWP+V?i37J&=}) zCnXaE2U&tRB_uvBv?#lXB92_api4E$aQb zr?G-znNFT?G5pN-{EmzAmH1yG{g@y1QO~qRl z1*Q=QJV}`JUv`Y)m+v?5CI{s~D&i9F7E1R8sYD=r3o>%#-MXC#hE=>VGmvq3LE+to z(^Kf-6;TFeL94~TxUG+Wm0=z@Fzri2 zrl{E*J$zgFMzZ#7CfsvD@K}Elm$ldJQiKYstnM_Q`ecK~cpo;$)f>^J1 zp>HE{z+m`s$?Bt4y21p(;ZJ!AkYk|W)(!=q24PSv73h{OFkvCYqNbgkf^P~qJ>hI-nj>4rkw-3@$HQEErS7JKP`^e(!lk{>mOdo^d;=XijMF_k)a#um z2R3LifdE%)qktC8aw5tlwtktUQM=Jg2kb1_rb}0d#XgcuT>I;DheJE~QcVYx0GEQ=roBOevl)8{BdU3hK@AM)<5W*j>lg(UMKQpAVo~=KOQLBE& z{b$K12oWbktP--~bR)DCsoI<^qzn>ktugN~8=gB_6Y*4b;0j$kI4UtD#G%fOCqoRW zMqek>8Lp4rA6A9&0WK+A)YS`3OY3L)++a&MDvfI^aqaJQ2Moz(nYvye<<9i$a#7!&c3s6YI%v#C;g#!z z5JfxSz{MdtaCW4?Da2ZagPNV<2JC|(dk^LJb^3qRY`>l4~{7-@^0F!3B z7yl;6m0kLiI9Q3eE`7PgWem|$mzENrHrC>0Qw9bE&Iqbc*xR~~!6=omIAoIZn-k0P z{pkc@6NC0}SwdEJlD?TAOL>=K1e*#8y$f!!$z$XH*quQ$p*&iXfDc$D(@*lbEP( zsbrEJ;rpCC6#X>m<^K~W7esncW*OqREoRAFfId^K5!%K>RhMxPV zpK%!q@E8awNxQ+exTTD41#22HsDDZmrl8p!rR;RMM!522w!T?FKvI1WkLCKT(~b_R ztZowD>t=(;;E|CA!ja3@_cvat5tF;ISS{;Vq8gN73!R<@SwH=AJah}6SL7b+>sp;g zGkt-3|9$&q&RHN6tbF3^+C6EpnwTP5pid|87RK;h$jY}Cy={!;V1IqemF1w!!X6PF z)J9MsgwT?1bzmvTroM?>)9%1#`x7@)Xx+7oKkwadZXTw@+4I7$Sc=_CS81X|GafZ0dm#_l{cnF zONiIUKX{z5+BLx0;1HQ-$-6cGmLi{#7F$W7#UC6e;K~X%pZPqW;)H|>d>R;zu^h?t zm6->Gcgo*dfEK~t&CyA&(_N&>se!mD2P0nT�g0HzO2a5)5L$i~709(Ym55YA~_G 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 CJMMk} diff --git a/tests/test_files_in/sketches1.skm b/tests/test_files_in/sketches1.skm index 0192e69d3937309ef91b510ec42d76b396fb20b1..7039cf10e78f79bb556c9e4aab67c50b82e23d19 100644 GIT binary patch delta 222 zcmZ3&{E;!_KN|x>v0q|9K_tUt28KJVH+mYG99Cr)XQ!4VXT%q0R;5a^FywIZGxB+u znF!=$=cX3L=cMMPmt+(-isp05?ugG#ElEsCEJ5~o0<`1HZf1O-eHFngS>Qj zW=d*aab{X(YRcD?#N_mn7AXeK2xtUjHtPaTUKL43hTP)B+=84`NkN7@-i;Y$sYS(^`FW`ZdM0`X TnHh=28Sy2R1*yp{evFO)4R}Xr literal 420 zcmWm9PiqrF7{~Ev(==oc)gT6%?VnTx@gPeZm(W66(tk>d1_{#IgBfRMpJXPxJ4ttj z(C(!a0=@vjlX&t_^s1#r-+*T?9z1xHc=FKRL>(_bK8Nq+_csTi8uU?r4_;dE*}wJv zWW4q^R3U1{i4rN6vJlxT{e0(ZM6D{%bq0GY`&D)SWf}GZVFk;Z<+m4Z>CWDUp@J1`sX8~ zrHB{PZ0^WT3%pr%+of-iY59;ZPwj5|uxYcl1G(F$4%knZj4GL;4`4sL9zL$$Z+b2R z7;7=e&;8kIFsO}Qo94w&Q~!QvG`v<~z;B!uNKE^0RzB{mSzr}MkeRu&^TPsgKY%yZ y^@Su#C+3_LZq@CHxykRbqH#aMVgcmCT|U4>iJruHx8lwVP8BEmu#arBvGxy35Ps1B diff --git a/tests/test_files_in/sketches2.skd b/tests/test_files_in/sketches2.skd index 99bf3b0b2500313035f1c72d33cef4b11176eed5..e79626a979ab26b276c1b497cfa510e53ab01a09 100644 GIT binary patch literal 4096 zcmV+b5dZHA=8`(~O(54&1dd{f=4{^$UEnKB+JCF)y706xJp@cCTnM9b5XU@Cz80&4 z6Xk9L)ueg=2Va2ZB$$P2K||k-{7^9Rjyv-b^jW@8tH$oZYq~_|NSv62259H2wi5>o zp-0QgQV70=|6zc;zoC{phK4=<4L}yT!0H=xO2a*7JMuws(R%9l<~0|7u+LPUq@D;& z_#G0|HA}W1&b%|svEbad0A3$Dpj1?B53uJ{rlo=zx*78I#Lg`c8{gqWsS|2CGMekQ zVp??SK{+!KB7vt`SixPL6)h5}7(fTf{b((~NF7f*cZP;|TnfH%Idsi|-G!|i^WiA^ zl5#37UogN=kq84qV+F^$sl0GvOBt*$$HcMTvHxUYIX)9>tDm1Q(bDi2|4O7e*O1-H z%>6Y^1vd8?OSDPC!pM99|IFzdAKCPa(LhwTzHHF-3LQDeIQ`BdrwQ<&75+hEPN!fc z@naMFjon4{&1Q{*aFpVZ=CRQt!)7V$M|VB|fMMZ*?2JbkFD0lLIztEs?-JTRxe6$z z-C)V2oKJNnALfa$E6gc9VS-os9WinD6fWYRv^EW~U$No^M4`UAQ_ab=GWwBb$X&1v z@Y#E}jy2oC_TTtGzhD^mI!5sFa^1CqFjpe^OfzLTl*-f8RtnBbFYIr|MCNit@`Nyw zydkp|Hgd!8UHOAsaSt(43WmRy$5c^+pvjH9qNL)gZlImOF}XtFVo_;a5QI~FgOfqs z?}4o4Q|CVZB;feB&vXydRozU#xn29&dTGkXV|^_TVT;|Mn?z}p2B*J(`#cS}=4@7S zo5kai-Dx1{*`f2P2Y4tuN@hmLN9n%^VsXBmR*HhZ_t~Qd28!@1g$2N(TLBhEwb_1y zaYJse2xie&v&S(?RTa7qETc4OYi!PK3Ar#mWo`y#E-dA|ozGv`s6jyiqOd+|FA_yX zWI!AwT0>_KcE{%dUQ$%AB6qN@wwOEdB^A`IUanXK#$O?A$vm&<3UZ&eV72X24S{-a zz*kTS*p>4z`Gm%qpS8NReQ@X zU?_(sdEf%-&qxc1XT(_P&LZJxc50*~w`|G{O$vPLZNS(JnEh}z6kV~L?T@E6g*E{( zi=?Uo2Duwt!eEL5km{bBVvQAWF4RO(JW14d;&yu7r+uW4S?(ut-$4jq3Hf@G1bA&bFLKx#K4~jLH{3q-o>7*$WNjia5j7yMo zC0`=je2OX5rZVKr(JJ~f*JcFp5S5n#`~C*^XaAtiR>~TRIu?RX4?Y2~mDnG&V=+0H z^Pt>hz&9_N(g_0jW9EA6eCXd2c{V=Yl!?7L?WF6Ynj<4Kamo`k2rNON+gy(3npJK- zwEHd;{EiRp*#71jC4^fyIAU!bo2muA21OsgYN*wf%xBp&D)*Pw-;{eX0qFYCzQajm zcie1f*8lCJsN>{SN)!{KL($Je6r1*pF2n7rOiQ&EkWE-XtK1H*5j*PXIebWk7W^G^ zi%_zaCrIiZT&Iel+l3h`R+(Hq$kV=H5jCs~db3k){*L`d#@)weo zyWDQ0yBR<_Pq?a7uTGgZhHDC!PCS^1CfDBrrh9Wb=RNV=%izItJOgV#qWZ&o&Qk=5 z!00Czc~|Ggcyw?IyI3^gb$ynPYl+YATeTL0w!*BUMpxF9I?Ws92U&otzjchkmyS{p z0zJ_43{s+&tHk;bdYh+nal-9V1lmOE;MU=ELt}g%G>K?IRN;EXi`fkDPk3Oi*Xzeu zf;azOC3kym5LRzzR<+&Nc6N@TRHw*S5Wz9HQo86~X zm6fP;#uyEiu4IsQVgvZ@CWtunihTS2eY}YG(v3Y%bw)-w2PB6xC&tQh<3u8#RvAze zWwo zPs9H3=4*sz`0aK+M_7mUST39w?%7XBU_cauDed&A%52yQ9GKP~h1sTvTW6ol7z~LYKbQwqz#KWT1hTu+)p`hszMuy5h&*mSpAsOEK$t~5Qco9@?)K7+( z^{N=-7=2ejRfNaRO`A`_6sqwD6$PQ={&RpSoi*PjmrX5CNl3WPd=;E#=j;Rbs1_j~ z2jO&@(?DWYmEf7zNe4_$BQKl@z8St^7~GT7CBgk4@>3eLQk<8zn8KjZjJrW;@?wa! zmyF1{9!p$;vINy_~65ii-6cF~jcSx3zWW z+8VV^P5;(9!_qr@0H<;eZpd4NLZy({UIQR)JPZxu@pPxr*eQr)X(#!f0MP)WzuN9k z+NROGa(?#XSSZ^L{BIz_uI)y+*Pql^bbWlkKg<~1PnquAsN7(pvl109>-_a~7wpA- ztroo>F;I5Bh=I8HM?AQyV9)mKl^lTb*3TuV;I5$Y_X1PN1E$yE8v3-IeZ7deqdHS6 zjLD=-3AH1jSdAhSqp=ujiARZw#d7y{!_>{%rj;TW29v-*!cR(;ZQA;Vnz~YXZn3q z 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 K>4f}N#HjY9h(BEb diff --git a/tests/test_files_in/sketches2.skm b/tests/test_files_in/sketches2.skm index 0c6154c138b98547b4e14159160adb60805417c7..ef81487b810b29a09191cf4ddd52b4b132602997 100644 GIT binary patch delta 223 zcmZ3&{E0F2KN|x>v0q|9K_tTy28O%)UoUTBa#)pJoSj;doDpA~S(Pfu!H~nr&&cOv zW+I`GlbxGd6rYotmtK-l+$fsQDYqj&H?<@&C9x#2aYagAVs2_iklDn1*?NZ^QVjCa z;h8C^dBvG&nW-sXQxcQYOIoBDIFr+)7}zwn0a>w@K$a0xMqYkiJfj>7Lk45KKzwdu z!9vj#6Nap$%)I#Gk|LlHjM=OUIC)hhnHX}56LSl4QY8f$@_099l%*CGXXfXn8t9qm V8DwT87H7nlR2HNryZA9W0s#9=NQeLc literal 420 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 literal 7168 zcmV+b9RK5Q_)EfMmf{P(FG#uDx}f^Jz4;g|`ytZppmon5c5*=UTqzfSFJ57-(7+VM zq+@5BA5er!5immWU%CybQnu#qOv^w?5)==%>FPy}xnM9)rYtp$^H+(f0deaAFT=3a zH6MVxH_N66*nf_P-CxEC{k}lV23!XX|j7uz3dELROwuJyuT5oUF%J zxMKDWw&2#%x#|CfHU6;J9V`d?qDj7-FPg;dAxRtLNkoWM3Tr;?U@~11>!7O4LRocOD)=u z^rdMHV6KorunuVg4+-4N91K$aBiZLa?lg46NnNjRiQhkugv<+LUR z#5#6kzikGy9bBndpm_%C*{-x>4MTXNT&!aAv;>E;TjhU(@HH?Z&r>^kb$%=MY9qfC z#Tb@H41HxdzELeNl%>C8_ry|mXJr^ZJy1(7lR%v03O|sTg>aVrW)L|M_MAqGoO+|(y{n?XM zlIFM6dOTG>XNvx~VKoq2uyRK*)Cb;!Tt7in?n&pu+ArBZocEFa3ZgGg#a(thoLW9t z9{~&JW?jgIz&?2}3D63Tqh7H=&_SEyf=S7axFF()-fFZAxN8BA_8b=>U6klpT5X-g3gUP_apu;bFa)z_c0BVhwt`^ooMt522{_6j5oatH9u>p(qKaX>mmLK-2QF`;CH=qUJ@&f5W3M z*dE=NNLb>`+itt34`Et3y33_}ZJA0~rUw=)=gMuxPCBXASa;YsBAcTW1;G}8C zORkL(crav_V&9?{FzeYZ9Ms?6!h>muX3GN`(~Xi6_`t0KPq|UzQ@S+(CP8?Bp&k%i zT4QZq_??^mGY)Ols~kK(GY_6)cC!T*|CpaqrBex^<=`{hZDtCP!0MvB_?@uNUW*f* zH{A|>o(x}PtIB(L!iIPJcMzn}41O%nM^3?!wfBzs4--slQU_o8ls7Z%2X2T`6s1^3 zaN)f9n@8Yu{bhcJQmulZm|$|v<+v!;DrugVelzI6!)`;iTP!GhFJ5!*?TR0r7s2tOL8`-;%1+r7$swg5mI_;I2B3K3bk-FTXwzu0xTt~90xbwA-A!=hO0$6J~5F_UpMYB5xi*~b&QYFsA zjNs5@M+x5FW}xuEL)AU5H&J%I2o92F*unXwF=+oYm~$tR7JzJ*#?7~T8ygeVWX5V5 z$Tj3m&kKVIW+}Z-(X~??H}1Oa;*3ijlUwQa7$D`C|0zq9T;>HHyxBisgZMqBM~(hG`2l*TDOfeg{hxn$eCAH8@T z>*exoEH#E7WL98w@NIriJ|1E%R=`~4q{BoT6%?&X6VO6&SGr}mL2|_I?2BMY6Z8+Z zL!msO6FF}6(utgxRgE_5cj~TNVB+r za;-8DELwedr%hi~xo&A&Fu#2Yqaw`T_P9ek--n0f}c*{_sCHA86UT#W+r zqy?8QTl9aTXtXdk&D(RjHD9Z8Y7hU_#W~d13^y%#u5jh6mP!Ak=fxOeWergNEpUN8 zlZe!$T0a;JhZNTRCJ=E%ISq_cVk&yQj4SMfC*vBIEabv()zjp1#Ex)2q%x2%`|72# zd|y8I{nS?p`N}fQMiSM}U1ScHeckg^vgEW4 zEKP?_+O1n3-~X}sIwr5#15p;Zyj;BV-2e+>c3H}BL^~O<3D66wW?Qky@W&X?!A;2u zh@#;?+hWv-sB3{KHxL)CeG+S!n(jfLV$V56f7ZyW;kkU#Bt_@)d_B_)P4gcO3F&;c zw8z>xCt(0amBJH15etEApW%_RZBym_-x-~1f}WS6_#^)&Bfiet@^fsOm!|%Xo@mqy z7Dd+rfi<0yC4YOU)gWV;yD|1t{q3u`d{P+uk z>hC4^@~09kP}lM@tB(eeqV`0{eS@#9*c#o3SXkyx+iJVBjbq-pxZkG?ZWzj3rUVWu z;mhg9%6l=FUU$$qrrE0%iW8bb3G^@!x7*yBTLc;l}WlDn? zQ_mVB{=h88XU?HK zCFhd)42!(W$_rPxwx?svpKgE?6s?%!@a>@ZnqMGwb!qm-ay4=d7<_h&<{Pp z*RtxuH-Efqi_w|C>;RuO2t4VA9mc|rMc2k zdM&PDG-S}bXc_XjDTtlG5oZ7y5dE#Gu-Ck7IH!{=*xiT>#wGBb*RIX1U5e3P+5g|k zBzqOx9UO0CjH+6%LJq@Q@h$QxP;I#K;Qi(r3X874_yH`QRA6cfYR-qRUPrR2So;}| zBQkR-LRdpR5j)=)#QB>CifXfr@@?wFjj+IEXG_-KXW-z#LzR)LG)Zo~NDj6}*_HXF zaUlN}nl}fM7J_V(#f_(XI#VS^~h zJOJAEBy=!1gU*M0z>Im5_>1)M=YmGLF#eht`~r20hsPh~g(YdJ1OYpe40ojrRpPf9 z3m%$wSKM1Om`8$qF{TeN9o_AP71|O2xn zaoE_#AylEP`xNuc4xojLUYnO^10p&P9Hr-~^T=sL7_q#ozHY)%dAqnP5j|CUgl!hJ zYG4@^bGMx307YwTsCKy7f76h9$qB`J=^Az|L~FZbeq>D(iUGngVa zL_6=H_=*YK-|X}n|hQjOCPulk> z?%eEzm!NWwHKA4(SyRol7Kk)S}ADORFo)5Da zM-svy_0zhJorQEFnTn1cC<@XP)uNaco`$Ak40>YI{c6DxnQ-tGBTw!9F62m758CmX z`>wn=H0r*j5U%#PkQEqUn}bPEH}%_XMUPER=o3}HzurGQ((&2{sW7tgwMiMWSeT!e zNf-zL9P%DevD3}?!)$eSKb-=o-q{?*+lP%0c?Xd2$5au!LeTd#bb#J+4mYfl_z8PO6s(+K@&i}hCRlEkU;`#W zrkAAL)n@@5NVm;7F6jqXX-}*z3oTuJJ> z9^-vQhyp&cHs7$lKN}^vDYjDBSt4xe1N(!q&SVyQ@&$c@+HRC^G+?spsn{{y2R6W6 zqUekbo|+zzkHOidXG{%Jy%k$8CZQi}oLwk*mo8i}Z);gMC=S{fFUawb%RVt!z~eBg z16Ub}9%fV%1wA8KCbWvjW^ik92(9A`7qV9fw1~cdTW^td& zT6B}Wo-Y%c&X)Ailq16paXhl7IG$XK10MAfCh9IZ(PdT%wYPtC(`Jvxys)`dDWG^LM-iemQLk;LXt9DJGMK@jO=D`5nV(7-Y0gd_#S<(*xYGHte506CXZQ8= zCD4r}#Fd{F=1wdioUI9B!^|!gTx-vd0w>(_9eG~osD5H(-6Z6qbotB)hR6Xx07>46 zZRb=ZNQ-k^tU5bP6y~%jV=yUVy-{Vz2+W1lC+A2=9!DNj{Tvn%h*$iM4MW4)ux@5+ z1W^IPp@u`!&kTjmWNYo&mK6&CGiys=5}?a?K~z^)Wuf!uhD#j|I$m{fcVLmQq-bBF z9O%^e`{|@diki^yAXBo@4suCgs5XRc!fvCPzJOwh<47qD!M!bh7tl2q}Xk;;mwsLh*!UCfn1IjP<)BLh|BT?yXTWd8iC~ z)WBo`n}KS4Y9 zc^)h|DgnoHb^i5R%ssGpBygp~iYS6q^jn&e*T?3OvT3N&3nrX)W@+IsXx~=FuWsI> zMsi=vaZBQq&`;jJ2=y&vX=z4?uWo{k0dNppQb%t&D~wF!KC4NtrdktvmZgQ21FF*^ zoQD+O{yvyC>buWS`m`wW4L93o`CGjBv7~2LKrVw}!R`(b8W6_qvKVeG*}$u5m|gD> zvjwvz%sNQgq>ak$ojtD`YU7xW;ZBP5O3QrMX7fCMnzm_XmDj~@iAkVdYN-Pa zhgzrqHeu9@Um1DpF(>Tmgu?CCZ`!vh?$qvtxA+i;cc@EszP^@h;Qlx+%M*&1mpt362$Po*q4^Im2_D!N)_%w63^^^HY zG4hQ$Fz78`OM;g*Hb=sQFouxz~xrYuOC@R7P)uK2Ro`$w? ztbRh1{c7P68GZ0oBTwD^j^*f957Nv`La{) zVr#!>+WxPXS%Ob|l>yFcUj{ccQ`OiFV~W7lkIEbt3y(l|$ebwoX%uO$(S8?3NA;oi z5U?L_liE@{q)+Jlh)G6G{0%*6WFKX)L#gt)drL3$5IjKc%8&pJpMps-7q$5f0gi1h z=#y8U``!->(y{mltudC8l?fY?T$qo>X;>-&So1DWk(2G%!)-wZKiwk8-q{?L`-hEh zc?N)x=hP9~UEtOUS& z+A=q!HuYbcsF1*ToyhT*Ljf`zM3b7ZQ}+AQzO1g0o_VW|_X@(uWFXvE{eYvk;5wHl ziW0Qb2GXY!e(l*r?fOmItSWc6n>knPY@&!$;fajC?}gg&D^gK6H~0d2t#QQ`Dhn%( zjj#o)?YJvWfFUN^yBqtyrSp7cT{;y{6vus5hyuQnGTX4eKO3dFD}qwnSte}C58Z>Y z&R`Z>{xf}%+HRCkGDNcIsoXLB0|3BWqUa1QoV*@`kHJ}{X+deT+gX@r@6U?~@Ty9iT6&<6|WVou&WK(ZFPJvR;0FMn6WoW^unl8hkSKj=!^-&YEc}`AT1pz5QxYAj>xS6DNhBevm z4te~oY)64c5so!Yz}ue#;IUReq)Ir?ZUsx zy90JA?(O9@Df7;DCHN1DY2B%*x~_L3aO35nh%&~pE}Arih8hAIwD|2Z;`3`a7&$X4 zLj8npGT+;QjPbiEQug9b?(KOnZK#Cr)PpD5s3;Q%LArk?3`UuN5 zj-9^2U?!=m^63!o3s}oeFf)MJKSeY1Z90TGDhcOuVfxiuj4c^?Bs24Xs5pvLbXUlx z*GK1&vM>75OC}Y5<|y%6YER$IuWyRRMq>BEaY5pgvS0qTh_*X7Noj70uWW+o1Msk2 zQVTB`OB}@HKI&hegvzsWmh9ER11i!goQ?pS+^#t`YW|Brm^29b4Rqak`FFbfv*c&V zKyHQQ%PtNL8g#&EvKVZz*@&y@n4j+mvkA8*#CS;3q^Zblm;bL9YUiE`;f{##O3!}S zWN|*@n!0HUK_^mH^s*5j!&^n{9-Rv~51ZfpYnE_EAesQClT$L)jiQI~-^}J7{W8K{doxwDNLz z)Q-oVA{vbN{B9UM+3R^xVo`0#v7tv;1-+#}Xykh>bUzAmI61y?hFDzb$ZQSjD!%5h zuge_L)6YKLUG(XEV!35Cv|tCl^4Puo|A6(%v62{iRZ{;A+(DLr7xZ02W1CHY$H5O2 V#I;P*VpdF@MX@SAb)>Hrm%nUCVlw~$ literal 417 zcmWm9v1=1y9LDkA#WduG+8_p+Yip}6PDdNhU_nZ%t+oY&1hI86Z{K_GmwVsb-JA2? zm(boKmOyvAij$z|URvlM;O51Ym*;N@K(*+T^f??@FnaU-?88{| zQ=}r)j1whttQ8@)C#=hB4X+YM31%cl8EURxSNrzNuZJW-O#`aw@M9opf;+Pdp0`jZ zw{PC9yR0v@2o)J%JV5PssT<}fWPm&1XY2wqq=7o`DSSD;b>zU*#fAt_D$x}foClPJ zdgws;xZ%JUy=U}x+Gy#7%f2MmwQ{ZS;C>F^^>Qd$e#h+B^>z-;|JBy1>GwxQi!ra} z#nLM~ukd!m?bg0Qq2oh&c4~9OhZUQ39Vp#8bijVGXjCs$eE@s=YV>sdQQLDFz@-j@ z^4y=*7K7&KooNn!n)>%=qv4er1AhImLK2$3cS95JeX#&$ZahmZu Date: Mon, 27 Jul 2026 15:38:35 +0100 Subject: [PATCH 07/13] Fix random correction --- src/distances/jaccard.rs | 43 ++++++++++++++++++++++++++++++++++------ 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/src/distances/jaccard.rs b/src/distances/jaccard.rs index 4cb0a34..84687cf 100644 --- a/src/distances/jaccard.rs +++ b/src/distances/jaccard.rs @@ -12,14 +12,13 @@ pub fn jaccard_index( completeness_cutoff: f64, ) -> f64 { let samebits = jaccard_same_bits(sketch1, sketch2) as f64; - // Correction for random matches - let expected_random = sketchsize64 as f64 / (1u64 << BIN_BITS) as f64; - let samebits_corrected = ((samebits - expected_random) / (sketchsize64 as f64 - expected_random)).clamp(0.0, 1.0); - let unionsize = (u64::BITS as u64 * sketchsize64) as f64; - let mut jaccard_index = samebits / unionsize; + // Correction for random matches + let expected_random = unionsize / (1u64 << BIN_BITS) as f64; + let mut jaccard_index = + ((samebits - expected_random) / (unionsize - expected_random)).clamp(0.0, 1.0); - log::trace!("samebits:{samebits} samebits:{samebits_corrected} jaccard:{jaccard_index}"); + log::trace!("samebits:{samebits} expected_random:{expected_random} jaccard:{jaccard_index}"); // Apply completeness correction if both completeness values are provided if let (Some(c1_val), Some(c2_val)) = (c1, c2) { @@ -154,6 +153,38 @@ mod tests { let neon = unsafe { jaccard_neon_unroll2_inner(&sketch1, &sketch2) }; assert_eq!(neon, scalar); } + + #[test] + fn random_match_correction_applied_for_large_sketch_size() { + // sketchsize64 chosen so that unionsize = sketchsize64 * 64 exceeds + // 2^BIN_BITS, the regime the random-match correction is meant for. + let sketchsize64: u64 = 2048; + let unionsize = (u64::BITS as u64 * sketchsize64) as f64; + assert!(unionsize > (1u64 << BIN_BITS) as f64); + + let n_chunks = sketchsize64 as usize; + let sketch1 = vec![u64::MAX; BIN_BITS * n_chunks]; + let mut sketch2 = sketch1.clone(); + // Mismatch exactly one chunk (one 64-bit "column") out of n_chunks. + for word in sketch2[..BIN_BITS].iter_mut() { + *word = 0; + } + + let samebits = jaccard_same_bits_general(&sketch1, &sketch2) as f64; + let expected_random = unionsize / (1u64 << BIN_BITS) as f64; + let expected_jaccard = + ((samebits - expected_random) / (unionsize - expected_random)).clamp(0.0, 1.0); + // The correction should be non-trivial (i.e. not equal to the raw, + // uncorrected ratio) for this sketch size. + assert!((expected_jaccard - samebits / unionsize).abs() > f64::EPSILON); + + let jaccard = jaccard_index(&sketch1, &sketch2, sketchsize64, None, None, 0.0); + assert_eq!(jaccard, expected_jaccard); + + // Identical sketches should give a jaccard index of exactly 1.0. + let identical = jaccard_index(&sketch1, &sketch1, sketchsize64, None, None, 0.0); + assert_eq!(identical, 1.0); + } } /// Converts between Jaccard distance and ANI, using a Poisson model of mutations From 5bd17d03e6a1d56045a2239af7f9685c51bb29e4 Mon Sep 17 00:00:00 2001 From: John Lees Date: Mon, 27 Jul 2026 15:47:46 +0100 Subject: [PATCH 08/13] Same version check on .ski files --- src/inverted.rs | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/src/inverted.rs b/src/inverted.rs index e38dc9a..502e63d 100644 --- a/src/inverted.rs +++ b/src/inverted.rs @@ -33,7 +33,7 @@ use crate::sketch::Sketch; #[cfg(not(target_arch = "wasm32"))] use crate::sketch::{sketch_datafile::SketchArrayWriter, Sketch}; use crate::utils::get_progress_bar; -use anyhow::Error; +use anyhow::{bail, Error}; use std::fs::File; use std::io::{BufReader, BufWriter}; @@ -44,10 +44,9 @@ use wasm_bindgen_file_reader::WebSysFile; type InvSketches = (Vec>, Vec); -/// Warns (does not fail) if a loaded .ski index was built with a sketchlib -/// version older than the current minimum sketch format version. The .ski -/// format itself hasn't changed, so this is informational only. -fn warn_if_old_version(sketch_version: &str) { +/// Fails if a loaded .ski index was built with a sketchlib version older +/// than the current minimum sketch format version. +fn check_version(sketch_version: &str) -> Result<(), Error> { let version_ok = parse_version(sketch_version).is_some_and(|version| version >= MIN_SKETCH_VERSION); if !version_ok { @@ -56,13 +55,15 @@ fn warn_if_old_version(sketch_version: &str) { } else { sketch_version }; - log::warn!( - "Inverted index was created with sketchlib v{found_version}, older than v{}.{}.{}. Consider re-sketching with the current version.", + log::error!( + "Inverted index was created with sketchlib v{found_version}, which is older than the minimum supported v{}.{}.{}. Please re-sketch with the current version.", MIN_SKETCH_VERSION.0, MIN_SKETCH_VERSION.1, MIN_SKETCH_VERSION.2 ); + bail!("Incompatible sketch file version"); } + Ok(()) } /// An inverted index and associated metadata @@ -232,7 +233,7 @@ impl Inverted { let ski_file = BufReader::new(File::open(filename)?); let decompress_reader = snap::read::FrameDecoder::new(ski_file); let ski_obj: Self = rmp_serde::decode::from_read(decompress_reader)?; - warn_if_old_version(&ski_obj.sketch_version); + check_version(&ski_obj.sketch_version)?; Ok(ski_obj) } @@ -244,7 +245,7 @@ impl Inverted { let ski_file = BufReader::new(WebSysFile::new(file.clone())); let decompress_reader = snap::read::FrameDecoder::new(ski_file); let ski_obj: Self = rmp_serde::decode::from_read(decompress_reader)?; - warn_if_old_version(&ski_obj.sketch_version); + check_version(&ski_obj.sketch_version)?; Ok(ski_obj) } From 5d75f70cabb4e83f3119a4d46b98f4e43d6ed8d1 Mon Sep 17 00:00:00 2001 From: John Lees Date: Mon, 27 Jul 2026 16:51:14 +0100 Subject: [PATCH 09/13] API improvements for PopPUNK access --- src/cli.rs | 12 +++++ src/distances/distance_matrix.rs | 19 ++++++++ src/distances/mod.rs | 9 ++++ src/lib.rs | 32 ++++++------- src/sketch/multisketch.rs | 18 +++++++- tests/concat.rs | 42 +++++++++++++++++ tests/distance.rs | 77 ++++++++++++++++++++++++++++++-- tests/inverted.rs | 22 ++++----- tests/sketch.rs | 41 +++++++++++++++++ 9 files changed, 238 insertions(+), 34 deletions(-) diff --git a/src/cli.rs b/src/cli.rs index 4570a7e..2977f5e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -85,6 +85,18 @@ pub fn check_and_set_threads(threads: usize) { .unwrap(); } +/// Sets the global rayon thread pool size for operations that spawn an internal +/// writer thread (sketching, and appending to an existing sketch database). +/// +/// Reserves one extra thread for the writer so that `threads` workers remain +/// available for compute; equivalent to `check_and_set_threads(threads + 1)`. +/// Callers embedding this crate as a library (rather than via the CLI) should call +/// this before sketching/appending, and plain [`check_and_set_threads`] before +/// distance/query/delete operations that don't spawn a writer. +pub fn set_threads_with_writer(threads: usize) { + check_and_set_threads(threads + 1); +} + /// Options that apply to all subcommands #[derive(Parser)] #[command(author, version, about, long_about = None)] diff --git a/src/distances/distance_matrix.rs b/src/distances/distance_matrix.rs index ec39dd9..f234bca 100644 --- a/src/distances/distance_matrix.rs +++ b/src/distances/distance_matrix.rs @@ -164,6 +164,25 @@ impl<'a> DistanceMatrix<'a> { pub fn dists_mut(&mut self) -> &mut Vec { &mut self.distances } + + /// Iterates over the distances as `(primary, accessory)` pairs. + /// + /// The second element is `Some` only for [`DistType::CoreAcc`] matrices + /// (interleaved core/accessory pairs written by `self_dists_all`/`cross_dists_all`); + /// `None` for Jaccard/ANI matrices, where each entry is a single value. Iteration + /// order matches the row-major order used by `Display` (ref-outer, query- or + /// ref-inner — see `self_dists_all`/`cross_dists_all` docs). + pub fn dists_iter(&self) -> Box)> + '_> { + if self.jaccard == DistType::CoreAcc { + Box::new( + self.distances + .chunks_exact(2) + .map(|pair| (pair[0], Some(pair[1]))), + ) + } else { + Box::new(self.distances.iter().copied().map(|d| (d, None))) + } + } } impl<'a> Distances<'a> for DistanceMatrix<'a> { diff --git a/src/distances/mod.rs b/src/distances/mod.rs index 21b6379..7a287bd 100644 --- a/src/distances/mod.rs +++ b/src/distances/mod.rs @@ -55,6 +55,10 @@ fn push_heap(heap: &mut BinaryHeap, dist_item: T, knn: u // Streaming out of distances, when a sample is 'ready' /// Self query mode (dense, all distances) +/// +/// Computes all pairwise distances within a single set of `sketches`, iterating +/// the upper triangle: `i` and `j` both index into `sketches`, with `i` the outer +/// (row) index and `j` the inner (column) index, `j` always `> i`. pub fn self_dists_all<'a>( sketches: &'a MultiSketch, n: usize, @@ -224,6 +228,11 @@ pub fn self_dists_knn<'a>( } /// Cross-query mode (dense, all distances) +/// +/// Computes all pairwise distances between `ref_sketches` and `query_sketches`: +/// `i` indexes `ref_sketches` (outer/row), `j` indexes `query_sketches` +/// (inner/column), chunked by `n_query` — i.e. ref-outer, query-inner ordering, +/// matching the row-major layout consumed by [`DistanceMatrix`]. pub fn cross_dists_all<'a>( ref_sketches: &'a MultiSketch, query_sketches: &'a MultiSketch, diff --git a/src/lib.rs b/src/lib.rs index abf6e9d..29bd300 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,7 +9,7 @@ //! above these distances is unsupported and may lead to poor estimation of distances //! without clear warning. //! - Short k-mer lengths are likely to match at random, see [PopPUNK's docs](https://poppunk-docs.bacpop.org/sketching.html#choosing-the-right-k-mer-lengths) -//! for information on how to select good lengths. +//! for information on how to select good lengths. //! - ANI distance resolution is highly affected by sketch size at higher mismatch //! levels, so note that if you see lots of samples at around 80% they may be much lower than //! this. We recommend checking the Jaccard values in this case, if they are close to 0 @@ -200,6 +200,7 @@ pub mod utils; use crate::utils::get_progress_bar; #[cfg(not(target_arch = "wasm32"))] use crate::utils::strip_sketch_extension; +pub use utils::save_sketch_data; #[cfg(target_arch = "wasm32")] pub mod fastx_wasm; @@ -258,8 +259,7 @@ pub fn main() -> Result<(), Error> { panic!("--concat-fasta currently only supported with --seq-type aa"); } - // An extra thread is needed for the writer. This doesn't 'overuse' CPU - check_and_set_threads(*threads + 1); + set_threads_with_writer(*threads); // Read input log::info!("Getting input files"); @@ -343,11 +343,9 @@ pub fn main() -> Result<(), Error> { // Read queries if supplied. Note no subsetting here let queries = if let Some(query_db_name) = query_db { - let mut queries = MultiSketch::load_metadata(query_db_name).unwrap_or_else(|_| { - panic!("Could not read sketch metadata from {query_db_name}.skm") - }); - log::info!("Loading query sketch data from {query_db_name}.skd"); - queries.read_sketch_data(query_db_name); + log::info!("Loading query sketches from {query_db_name}.skm/.skd"); + let queries = MultiSketch::load(query_db_name) + .unwrap_or_else(|_| panic!("Could not read sketch data from {query_db_name}")); log::info!("Read query sketches:\n{queries:?}"); Some(queries) } else { @@ -499,8 +497,7 @@ pub fn main() -> Result<(), Error> { sketch_size, kmer_length, } => { - // An extra thread is needed for the writer - check_and_set_threads(*threads + 1); + set_threads_with_writer(*threads); // Get input files log::info!("Getting input files"); @@ -616,7 +613,7 @@ pub fn main() -> Result<(), Error> { log::info!("Parsed {} samples in input query list", input_files.len()); log::info!("Sketching input queries"); - check_and_set_threads(*threads + 1); // Writer thread + set_threads_with_writer(*threads); // Writer thread let (queries, query_names) = inverted_index.sketch_queries(&input_files, *min_count, *min_qual, args.quiet); @@ -732,12 +729,10 @@ pub fn main() -> Result<(), Error> { // Load the .skd/.skm let ref_db_name = utils::strip_sketch_extension(ref_db_input); - let mut references = - MultiSketch::load_metadata(ref_db_name).unwrap_or_else(|_| { - panic!("Could not read sketch metadata from {ref_db_name}.skm") - }); - log::info!("Loading sketch data from {ref_db_name}.skd"); - references.read_sketch_data(ref_db_name); + log::info!("Loading reference sketches from {ref_db_name}.skm/.skd"); + let references = MultiSketch::load(ref_db_name).unwrap_or_else(|_| { + panic!("Could not read sketch data from {ref_db_name}") + }); log::info!("Read reference sketches:\n{references:?}"); let n = references.number_samples_loaded(); if knn >= n { @@ -805,8 +800,7 @@ pub fn main() -> Result<(), Error> { threads, level, } => { - // An extra thread is needed for the writer. This doesn't 'overuse' CPU - check_and_set_threads(*threads + 1); + set_threads_with_writer(*threads); //get input files log::info!("Getting input files"); let input_files: Vec<(String, Vec)> = get_input_list(file_list, seq_files); diff --git a/src/sketch/multisketch.rs b/src/sketch/multisketch.rs index 72f6f69..478dc09 100644 --- a/src/sketch/multisketch.rs +++ b/src/sketch/multisketch.rs @@ -25,7 +25,11 @@ pub const MIN_SKETCH_VERSION: (u64, u64, u64) = (0, 4, 0); /// missing or malformed (which covers files predating the `sketch_version` field). pub fn parse_version(version: &str) -> Option<(u64, u64, u64)> { let mut parts = version.split('.').map(str::parse::); - Some((parts.next()?.ok()?, parts.next()?.ok()?, parts.next()?.ok()?)) + Some(( + parts.next()?.ok()?, + parts.next()?.ok()?, + parts.next()?.ok()?, + )) } /// A set of sketch files, with underlying storage as .skd/.skm @@ -174,6 +178,13 @@ impl MultiSketch { Ok(skm_obj) } + /// Loads both metadata (.skm) and sketch data (.skd) + pub fn load(file_prefix: &str) -> Result { + let mut multisketch = Self::load_metadata(file_prefix)?; + multisketch.read_sketch_data(file_prefix); + Ok(multisketch) + } + /// Number of samples loaded from the .skm/.skd pub fn number_samples_loaded(&self) -> usize { match &self.block_reindex { @@ -202,6 +213,11 @@ impl MultiSketch { &self.hash_type } + /// The stored metadata for each loaded sketch + pub fn sketch_metadata(&self) -> &[Sketch] { + &self.sketch_metadata + } + /// Returns the name of the sample at the given index. pub fn get_sample_name(&self, index: usize) -> &str { self.sketch_metadata[index].name() diff --git a/tests/concat.rs b/tests/concat.rs index 3420640..c5e2f90 100644 --- a/tests/concat.rs +++ b/tests/concat.rs @@ -101,4 +101,46 @@ mod tests { "Concat sketch data does not match" ); } + + #[test] + fn save_sketch_data_reexport_matches_utils() { + let sandbox = TestSetup::setup(); + + Command::new(cmd::cargo_bin!("sketchlib")) + .current_dir(sandbox.get_wd()) + .arg("sketch") + .args(&["--k-vals", "17"]) + .arg(sandbox.file_string("14412_3#82.contigs_velvet.fa.gz", TestDir::Input)) + .arg("--quiet") + .args(&["-o", "reexport_part1"]) + .assert() + .success(); + + Command::new(cmd::cargo_bin!("sketchlib")) + .current_dir(sandbox.get_wd()) + .arg("sketch") + .args(&["--k-vals", "17"]) + .arg(sandbox.file_string("14412_3#84.contigs_velvet.fa.gz", TestDir::Input)) + .arg("--quiet") + .args(&["-o", "reexport_part2"]) + .assert() + .success(); + + let part1 = sandbox.file_string("reexport_part1", TestDir::Output); + let part2 = sandbox.file_string("reexport_part2", TestDir::Output); + let via_reexport = sandbox.file_string("via_reexport", TestDir::Output); + let via_utils = sandbox.file_string("via_utils", TestDir::Output); + + sketchlib::save_sketch_data(&part1, &part2, &via_reexport) + .expect("save_sketch_data (re-export) failed"); + sketchlib::utils::save_sketch_data(&part1, &part2, &via_utils) + .expect("utils::save_sketch_data failed"); + + let predicate_file = predicate::path::eq_file(Path::new(&format!("{via_utils}.skd"))); + assert_eq!( + true, + predicate_file.eval(Path::new(&format!("{via_reexport}.skd"))), + "Re-exported save_sketch_data output does not match utils::save_sketch_data" + ); + } } diff --git a/tests/distance.rs b/tests/distance.rs index a0dbb6a..5a5966c 100644 --- a/tests/distance.rs +++ b/tests/distance.rs @@ -44,9 +44,7 @@ mod tests { expected_fields.len(), "Field count mismatch. Actual: {actual_line}, Expected: {expected_line}" ); - for (actual_field, expected_field) in - actual_fields.iter().zip(expected_fields.iter()) - { + for (actual_field, expected_field) in actual_fields.iter().zip(expected_fields.iter()) { match (actual_field.parse::(), expected_field.parse::()) { (Ok(actual_val), Ok(expected_val)) => { assert_abs_diff_eq!(actual_val, expected_val, epsilon = 1e-4); @@ -796,4 +794,77 @@ mod tests { &sandbox.snapbox_file("dists_subset.stdout", TestDir::Correct), ); } + + /// Helper: parse `DistanceMatrix`'s `Display`/TSV output into (primary, accessory) pairs. + fn parse_display_pairs(display: &str, core_acc: bool) -> Vec<(f64, Option)> { + display + .lines() + .filter(|l| !l.is_empty()) + .map(|line| { + let fields: Vec<&str> = line.split('\t').collect(); + let primary = fields[2].parse().expect("Could not parse distance"); + if core_acc { + let accessory = fields[3].parse().expect("Could not parse accessory dist"); + (primary, Some(accessory)) + } else { + (primary, None) + } + }) + .collect() + } + + #[test] + fn dists_iter_matches_display() { + use sketchlib::distances::{self_dists_all, set_k}; + use sketchlib::sketch::multisketch::MultiSketch; + + let sandbox = TestSetup::setup(); + sandbox.copy_input_file_to_wd("14412_3#82.contigs_velvet.fa.gz"); + sandbox.copy_input_file_to_wd("14412_3#84.contigs_velvet.fa.gz"); + sandbox.copy_input_file_to_wd("R6.fa.gz"); + sandbox.copy_input_file_to_wd("TIGR4.fa.gz"); + sandbox.copy_input_file_to_wd("rfile.txt"); + + Command::new(cmd::cargo_bin!("sketchlib")) + .current_dir(sandbox.get_wd()) + .arg("sketch") + .arg("-o") + .arg("iter_db") + .args(["-v", "--k-seq", "17,31,4", "-s", "1000"]) + .arg("-f") + .arg("rfile.txt") + .assert() + .success(); + + let sketches = MultiSketch::load(&sandbox.file_string("iter_db", TestDir::Output)) + .expect("failed to load sketches"); + let n = sketches.number_samples_loaded(); + + // CoreAcc matrix (multi-k, no -k given): dists_iter should yield Some(accessory) + let core_acc_type = set_k(&sketches, None, false).expect("set_k failed"); + let core_acc_matrix = self_dists_all(&sketches, n, core_acc_type, true, None, 0.0); + let display_pairs = parse_display_pairs(&core_acc_matrix.to_string(), true); + let iter_pairs: Vec<(f32, Option)> = core_acc_matrix.dists_iter().collect(); + assert_eq!(display_pairs.len(), iter_pairs.len()); + for ((core, accessory), (iter_core, iter_accessory)) in + display_pairs.iter().zip(iter_pairs.iter()) + { + assert_abs_diff_eq!(*core, *iter_core as f64, epsilon = 1e-4); + let iter_accessory = iter_accessory.expect("dists_iter should yield Some for CoreAcc"); + assert_abs_diff_eq!(accessory.expect("Some in CoreAcc"), iter_accessory as f64, epsilon = 1e-4); + } + + // Jaccard matrix (-k given): dists_iter should yield None + let jaccard_type = set_k(&sketches, Some(17), false).expect("set_k failed"); + let jaccard_matrix = self_dists_all(&sketches, n, jaccard_type, true, None, 0.0); + let display_pairs = parse_display_pairs(&jaccard_matrix.to_string(), false); + let iter_pairs: Vec<(f32, Option)> = jaccard_matrix.dists_iter().collect(); + assert_eq!(display_pairs.len(), iter_pairs.len()); + for ((dist, _), (iter_dist, iter_accessory)) in + display_pairs.iter().zip(iter_pairs.iter()) + { + assert_abs_diff_eq!(*dist, *iter_dist as f64, epsilon = 1e-4); + assert!(iter_accessory.is_none()); + } + } } diff --git a/tests/inverted.rs b/tests/inverted.rs index 1d2fdad..3b7eca7 100644 --- a/tests/inverted.rs +++ b/tests/inverted.rs @@ -6,10 +6,10 @@ use crate::common::*; #[cfg(test)] mod tests { + use approx::assert_abs_diff_eq; use sketchlib::sketch::{ multisketch::MultiSketch, sketch_datafile::SketchArrayReader, BIN_BITS, }; - use approx::assert_abs_diff_eq; use snapbox::assert_data_eq; use super::*; @@ -36,13 +36,14 @@ mod tests { let expected_fields: Vec<&str> = expected_line.split('\t').collect(); actual_fields.len() == expected_fields.len() && actual_fields.iter().zip(expected_fields.iter()).all( - |(actual_field, expected_field)| { - match (actual_field.parse::(), expected_field.parse::()) { - (Ok(actual_val), Ok(expected_val)) => { - (actual_val - expected_val).abs() < 1e-4 - } - _ => actual_field == expected_field, + |(actual_field, expected_field)| match ( + actual_field.parse::(), + expected_field.parse::(), + ) { + (Ok(actual_val), Ok(expected_val)) => { + (actual_val - expected_val).abs() < 1e-4 } + _ => actual_field == expected_field, }, ) }); @@ -142,10 +143,9 @@ mod tests { .assert() .success(); - let mut standard_sketches = - MultiSketch::load_metadata(&sandbox.file_string("standard", TestDir::Output)) - .expect("failed to load standard sketch metadata"); - standard_sketches.read_sketch_data(&sandbox.file_string("standard", TestDir::Output)); + let standard_sketches = + MultiSketch::load(&sandbox.file_string("standard", TestDir::Output)) + .expect("failed to load standard sketch data"); let sketch_size = standard_sketches.sketch_size as usize; assert_eq!(sketch_size, 64); diff --git a/tests/sketch.rs b/tests/sketch.rs index 74dd929..1bbe548 100644 --- a/tests/sketch.rs +++ b/tests/sketch.rs @@ -155,4 +155,45 @@ mod tests { "Unexpected error loading legacy sketch: {err}" ); } + + #[test] + fn load_matches_load_metadata_then_read_sketch_data() { + use sketchlib::sketch::multisketch::MultiSketch; + let sandbox = TestSetup::setup(); + + Command::new(cmd::cargo_bin!("sketchlib")) + .current_dir(sandbox.get_wd()) + .arg("sketch") + .arg("-o") + .arg("loadtest") + .args(["-k", "31"]) + .arg(sandbox.file_string("14412_3#82.contigs_velvet.fa.gz", TestDir::Input)) + .arg(sandbox.file_string("14412_3#84.contigs_velvet.fa.gz", TestDir::Input)) + .assert() + .success(); + + let prefix = sandbox.file_string("loadtest", TestDir::Output); + + let mut separate = + MultiSketch::load_metadata(&prefix).expect("failed to load metadata separately"); + separate.read_sketch_data(&prefix); + + let combined = MultiSketch::load(&prefix).expect("failed to load combined"); + + assert_eq!( + separate, combined, + "MultiSketch::load should match load_metadata + read_sketch_data" + ); + + // sketch_metadata() should expose the same names as the indexed accessors + let names: Vec<&str> = combined + .sketch_metadata() + .iter() + .map(|s| s.name()) + .collect(); + assert_eq!(names.len(), combined.number_samples_loaded()); + for (idx, name) in names.iter().enumerate() { + assert_eq!(*name, combined.sketch_name(idx)); + } + } } From 7c5ee0dbd132f56854cb8cb21925d62a605ca1da Mon Sep 17 00:00:00 2001 From: John Lees Date: Wed, 29 Jul 2026 15:17:30 +0100 Subject: [PATCH 10/13] Stream output distances Closes #18 Uses formatted strings in a buffer to send to a writer thread Keeps old functions which don't steam for e.g. PopPUNK, but CLI will use these by default --- src/distances/mod.rs | 331 +++++++++++++++++++++++++++++++++++++++++++ src/io.rs | 6 +- src/lib.rs | 29 ++-- tests/distance.rs | 168 +++++++++++++++++++++- 4 files changed, 518 insertions(+), 16 deletions(-) diff --git a/src/distances/mod.rs b/src/distances/mod.rs index 7a287bd..80270d6 100644 --- a/src/distances/mod.rs +++ b/src/distances/mod.rs @@ -1,5 +1,8 @@ //! Functions to calculate distances between sample sets use std::collections::BinaryHeap; +use std::fmt::Write as FmtWrite; +use std::io::Write as IoWrite; +use std::sync::mpsc; use anyhow::{Context, Error}; use hashbrown::HashMap; @@ -133,6 +136,107 @@ pub fn self_dists_all<'a>( distances } +/// Self query mode (dense, all distances), streaming text output directly to `writer` +/// instead of materializing a [`DistanceMatrix`]. This keeps peak memory bounded +/// (independent of `n`) for large all-vs-all comparisons. +/// +/// Line format matches [`DistanceMatrix`]'s `Display` impl exactly, but pair order is +/// not guaranteed to match it (chunks may be written as they complete, from whichever +/// thread finishes first) — every pair is still written exactly once. +pub fn self_dists_all_stream( + writer: &mut W, + sketches: &MultiSketch, + n: usize, + dist_type: DistType, + quiet: bool, + completeness_vec: Option<&Vec>, + completeness_cutoff: f64, + threads: usize, +) -> Result<(), Error> { + let ani = matches!(dist_type, DistType::Jaccard(_, _, true)); + let k_vals = match dist_type { + DistType::Jaccard(k_idx, k_val, _) => Some((k_idx, k_val)), + DistType::CoreAcc => None, + }; + let ref_names = ::sketch_names(sketches); + + let n_distances = n * (n - 1) / 2; + let n_chunks = n_distances.div_ceil(CHUNK_SIZE); + let progress_bar = get_progress_bar(n_chunks, BAR_PERCENT, quiet); + + // Bounded channel: caps in-flight chunk memory at a small constant regardless of + // n, rather than relying on producer/writer speeds happening to balance out. + let channel_bound = threads.max(1) * 2; + let (tx, rx) = mpsc::sync_channel::(channel_bound); + + rayon::scope(|s| -> Result<(), Error> { + s.spawn(move |_| { + (0..n_chunks) + .into_par_iter() + .progress_with(progress_bar) + .for_each_with(tx, |tx, chunk_idx| { + let start = chunk_idx * CHUNK_SIZE; + let end = (start + CHUNK_SIZE).min(n_distances); + let mut i = calc_row_idx(start, n); + let mut j = calc_col_idx(start, i, n); + let mut buf = String::with_capacity((end - start) * 24); + + for _ in start..end { + if let Some((k_idx, k_f64)) = k_vals { + let c1 = completeness_vec.map(|cv| cv[i]); + let c2 = completeness_vec.map(|cv| cv[j]); + let j_index = jaccard_index( + sketches.get_sketch_slice(i, k_idx), + sketches.get_sketch_slice(j, k_idx), + sketches.sketchsize64, + c1, + c2, + completeness_cutoff, + ); + let dist = if ani { + ani_pois(j_index, k_f64) as f32 + } else { + (1.0_f64 - j_index) as f32 + }; + let _ = + writeln!(buf, "{}\t{}\t{dist}", ref_names[i], ref_names[j]); + } else { + let d = core_acc_dist( + sketches, + sketches, + i, + j, + completeness_vec, + completeness_vec, + completeness_cutoff, + ); + let _ = writeln!( + buf, + "{}\t{}\t{}\t{}", + ref_names[i], ref_names[j], d.0, d.1 + ); + } + + // Move to next index in upper triangle + j += 1; + if j >= n { + i += 1; + j = i + 1; + } + } + let _ = tx.send(buf); + }); + }); + + for chunk_text in rx { + writer + .write_all(chunk_text.as_bytes()) + .context("Error writing streamed distance output")?; + } + Ok(()) + }) +} + /// Self query mode (dense, all distances) pub fn self_dists_knn<'a>( sketches: &'a MultiSketch, @@ -305,6 +409,106 @@ pub fn cross_dists_all<'a>( distances } +/// Cross-query mode (dense, all distances), streaming variant of [`cross_dists_all`]. +/// Same ordering/format contract as [`self_dists_all_stream`]. +pub fn cross_dists_all_stream( + writer: &mut W, + ref_sketches: &MultiSketch, + query_sketches: &MultiSketch, + n: usize, + n_query: usize, + dist_type: DistType, + quiet: bool, + ref_completeness_vec: Option<&Vec>, + query_completeness_vec: Option<&Vec>, + completeness_cutoff: f64, + threads: usize, +) -> Result<(), Error> { + let ani = matches!(dist_type, DistType::Jaccard(_, _, true)); + let k_vals = match dist_type { + DistType::Jaccard(k_idx, k_val, _) => Some((k_idx, k_val)), + DistType::CoreAcc => None, + }; + let ref_names = ::sketch_names(ref_sketches); + let query_names = ::sketch_names(query_sketches); + + let n_distances = n * n_query; + let n_chunks = n_distances.div_ceil(CHUNK_SIZE); + let progress_bar = get_progress_bar(n_chunks, BAR_PERCENT, quiet); + + let channel_bound = threads.max(1) * 2; + let (tx, rx) = mpsc::sync_channel::(channel_bound); + + rayon::scope(|s| -> Result<(), Error> { + s.spawn(move |_| { + (0..n_chunks) + .into_par_iter() + .progress_with(progress_bar) + .for_each_with(tx, |tx, chunk_idx| { + let start = chunk_idx * CHUNK_SIZE; + let end = (start + CHUNK_SIZE).min(n_distances); + let (mut i, mut j) = calc_query_indices(start, n_query); + let mut buf = String::with_capacity((end - start) * 24); + + for _ in start..end { + if let Some((k_idx, k_f64)) = k_vals { + let c1 = ref_completeness_vec.map(|cv| cv[i]); + let c2 = query_completeness_vec.map(|cv| cv[j]); + let j_index = jaccard_index( + ref_sketches.get_sketch_slice(i, k_idx), + query_sketches.get_sketch_slice(j, k_idx), + ref_sketches.sketchsize64, + c1, + c2, + completeness_cutoff, + ); + let dist = if ani { + ani_pois(j_index, k_f64) as f32 + } else { + (1.0_f64 - j_index) as f32 + }; + let _ = writeln!( + buf, + "{}\t{}\t{dist}", + ref_names[i], query_names[j] + ); + } else { + let d = core_acc_dist( + ref_sketches, + query_sketches, + i, + j, + ref_completeness_vec, + query_completeness_vec, + completeness_cutoff, + ); + let _ = writeln!( + buf, + "{}\t{}\t{}\t{}", + ref_names[i], query_names[j], d.0, d.1 + ); + } + + // Move to next index + j += 1; + if j >= n_query { + i += 1; + j = 0; + } + } + let _ = tx.send(buf); + }); + }); + + for chunk_text in rx { + writer + .write_all(chunk_text.as_bytes()) + .context("Error writing streamed distance output")?; + } + Ok(()) + }) +} + /// Cross-query mode with kNN filtering. /// /// For each query genome, computes distances to all `n` reference genomes and @@ -559,3 +763,130 @@ pub fn self_dists_knn_precluster<'a>( } sp_distances } + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::HashSet; + + /// Replays the chunk generation/stepping logic used by `self_dists_all_stream` + /// (self/upper-triangle mode), without any sketch data, to verify the index + /// arithmetic visits every pair exactly once. + fn self_mode_chunk_pairs(n: usize) -> Vec<(usize, usize)> { + let n_distances = n * n.saturating_sub(1) / 2; + let n_chunks = n_distances.div_ceil(CHUNK_SIZE); + let mut pairs = Vec::with_capacity(n_distances); + for chunk_idx in 0..n_chunks { + let start = chunk_idx * CHUNK_SIZE; + let end = (start + CHUNK_SIZE).min(n_distances); + let mut i = calc_row_idx(start, n); + let mut j = calc_col_idx(start, i, n); + for _ in start..end { + pairs.push((i, j)); + j += 1; + if j >= n { + i += 1; + j = i + 1; + } + } + } + pairs + } + + /// Replays the chunk generation/stepping logic used by `cross_dists_all_stream` + /// (rectangular ref-vs-query mode). + fn cross_mode_chunk_pairs(n: usize, n_query: usize) -> Vec<(usize, usize)> { + let n_distances = n * n_query; + let n_chunks = n_distances.div_ceil(CHUNK_SIZE); + let mut pairs = Vec::with_capacity(n_distances); + for chunk_idx in 0..n_chunks { + let start = chunk_idx * CHUNK_SIZE; + let end = (start + CHUNK_SIZE).min(n_distances); + let (mut i, mut j) = calc_query_indices(start, n_query); + for _ in start..end { + pairs.push((i, j)); + j += 1; + if j >= n_query { + i += 1; + j = 0; + } + } + } + pairs + } + + #[test] + fn self_mode_chunk_boundaries_cover_every_pair_once() { + // n values chosen to exercise: no pairs, a single pair, small n, and + // n_distances landing exactly on / either side of the CHUNK_SIZE (1000) + // boundary (n=46 -> 1035 pairs: one full chunk of 1000 + a 35-pair tail). + for n in [0usize, 1, 2, 3, 4, 45, 46, 47, 63, 64, 100] { + let pairs = self_mode_chunk_pairs(n); + let expected_count = n * n.saturating_sub(1) / 2; + assert_eq!(pairs.len(), expected_count, "wrong pair count for n={n}"); + + let unique: HashSet<_> = pairs.iter().copied().collect(); + assert_eq!( + unique.len(), + pairs.len(), + "duplicate pair detected for n={n}" + ); + + for &(i, j) in &pairs { + assert!(i < j && j < n, "pair ({i},{j}) out of range for n={n}"); + } + for i in 0..n { + for j in (i + 1)..n { + assert!( + unique.contains(&(i, j)), + "missing pair ({i},{j}) for n={n}" + ); + } + } + } + } + + #[test] + fn cross_mode_chunk_boundaries_cover_every_pair_once() { + for (n, n_query) in [ + (0usize, 0usize), + (1, 1), + (1, 5), + (5, 1), + (4, 4), + (32, 32), + (31, 33), + (10, 100), + ] { + let pairs = cross_mode_chunk_pairs(n, n_query); + let expected_count = n * n_query; + assert_eq!( + pairs.len(), + expected_count, + "wrong pair count for n={n}, n_query={n_query}" + ); + + let unique: HashSet<_> = pairs.iter().copied().collect(); + assert_eq!( + unique.len(), + pairs.len(), + "duplicate pair detected for n={n}, n_query={n_query}" + ); + + for &(i, j) in &pairs { + assert!( + i < n && j < n_query, + "pair ({i},{j}) out of range for n={n}, n_query={n_query}" + ); + } + for i in 0..n { + for j in 0..n_query { + assert!( + unique.contains(&(i, j)), + "missing pair ({i},{j}) for n={n}, n_query={n_query}" + ); + } + } + } + } +} diff --git a/src/io.rs b/src/io.rs index c902d0c..103391f 100644 --- a/src/io.rs +++ b/src/io.rs @@ -187,13 +187,13 @@ pub fn parse_kmers(k: &Kmers) -> Vec { /// Set a buffered stream to write to. /// /// Either a file (if [`Some`]) or stdout otherwise (if [`None`]). -pub fn set_ostream(oprefix: &Option) -> BufWriter> { +pub fn set_ostream(oprefix: &Option) -> BufWriter> { let out_writer = match oprefix { Some(prefix) => { let path = Path::new(prefix); - Box::new(File::create(path).unwrap()) as Box + Box::new(File::create(path).unwrap()) as Box } - None => Box::new(stdout()) as Box, + None => Box::new(stdout()) as Box, }; BufWriter::new(out_writer) } diff --git a/src/lib.rs b/src/lib.rs index 29bd300..7da630d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -312,7 +312,14 @@ pub fn main() -> Result<(), Error> { query_completeness_file, completeness_cutoff, } => { - check_and_set_threads(*threads); + if knn.is_none() { + // Dense path streams output via an internal writer thread running + // concurrently with compute (mirrors Sketch/Append/Inverted Build/Query). + set_threads_with_writer(*threads); + } else { + // Sparse/kNN path: unchanged, single-shot write, no writer thread. + check_and_set_threads(*threads); + } let mut output_file = set_ostream(output); @@ -359,17 +366,17 @@ pub fn main() -> Result<(), Error> { None => { // Self mode (dense) log::info!("Calculating all ref vs ref distances"); - let distances = self_dists_all( + log::info!("Streaming out in long matrix form"); + self_dists_all_stream( + &mut output_file, &references, n, dist_type, args.quiet, ref_completeness_vec.as_ref(), *completeness_cutoff, - ); - log::info!("Writing out in long matrix form"); - write!(output_file, "{distances}") - .expect("Error writing output distances"); + *threads, + )?; } Some(mut nn) => { // Self mode (sparse): a genome cannot be its own neighbour @@ -432,7 +439,9 @@ pub fn main() -> Result<(), Error> { None => { // Cross-query mode (dense, all pairs) log::info!("Calculating all ref vs query distances"); - let distances = cross_dists_all( + log::info!("Streaming out in long matrix form"); + cross_dists_all_stream( + &mut output_file, &references, &query_db, n, @@ -442,10 +451,8 @@ pub fn main() -> Result<(), Error> { ref_completeness_vec.as_ref(), query_completeness_vec.as_ref(), *completeness_cutoff, - ); - log::info!("Writing out in long matrix form"); - write!(output_file, "{distances}") - .expect("Error writing output distances"); + *threads, + )?; } } } diff --git a/tests/distance.rs b/tests/distance.rs index 5a5966c..9d29bc7 100644 --- a/tests/distance.rs +++ b/tests/distance.rs @@ -25,12 +25,27 @@ mod tests { // Compares tab-separated distance output against a golden file, allowing // numeric fields to differ by a small epsilon (SIMD reordering can shift // the last few bits of a float) while requiring an exact match on names. + // + // Rows are sorted by their (name1, name2) key columns before comparing, since + // streamed dense output order is not guaranteed to match the golden file's + // row-major order. Sorting by the name columns (not the whole line) avoids the + // sort itself being perturbed by the numeric noise this function already + // tolerates. fn assert_dist_stdout_with_tolerance(actual: &str, expected: &snapbox::Data) { let expected_str = expected .render() .expect("Failed to render expected snapshot data"); - let actual_lines: Vec<&str> = actual.lines().filter(|l| !l.is_empty()).collect(); - let expected_lines: Vec<&str> = expected_str.lines().filter(|l| !l.is_empty()).collect(); + let name_key = |line: &&str| { + let mut fields = line.splitn(3, '\t'); + ( + fields.next().unwrap_or("").to_string(), + fields.next().unwrap_or("").to_string(), + ) + }; + let mut actual_lines: Vec<&str> = actual.lines().filter(|l| !l.is_empty()).collect(); + let mut expected_lines: Vec<&str> = expected_str.lines().filter(|l| !l.is_empty()).collect(); + actual_lines.sort_by_key(name_key); + expected_lines.sort_by_key(name_key); assert_eq!( actual_lines.len(), expected_lines.len(), @@ -867,4 +882,153 @@ mod tests { assert!(iter_accessory.is_none()); } } + + /// Streaming self-mode dense output (`self_dists_all_stream`) must match the + /// non-streaming `self_dists_all`/`Display` output exactly, as a sorted line + /// multiset (chunk order across threads is not guaranteed, but both paths call + /// the same per-pair math, so results should be bit-identical once sorted). + /// Covers CoreAcc, Jaccard, and ANI. + #[test] + fn self_dists_stream_matches_non_streaming() { + use sketchlib::distances::{self_dists_all, self_dists_all_stream, set_k}; + use sketchlib::sketch::multisketch::MultiSketch; + + let sandbox = TestSetup::setup(); + sandbox.copy_input_file_to_wd("14412_3#82.contigs_velvet.fa.gz"); + sandbox.copy_input_file_to_wd("14412_3#84.contigs_velvet.fa.gz"); + sandbox.copy_input_file_to_wd("R6.fa.gz"); + sandbox.copy_input_file_to_wd("TIGR4.fa.gz"); + sandbox.copy_input_file_to_wd("rfile.txt"); + + Command::new(cmd::cargo_bin!("sketchlib")) + .current_dir(sandbox.get_wd()) + .arg("sketch") + .arg("-o") + .arg("stream_db") + .args(["-v", "--k-seq", "17,31,4", "-s", "1000"]) + .arg("-f") + .arg("rfile.txt") + .assert() + .success(); + + let sketches = MultiSketch::load(&sandbox.file_string("stream_db", TestDir::Output)) + .expect("failed to load sketches"); + let n = sketches.number_samples_loaded(); + + for (dist_type_a, dist_type_b) in [ + ( + set_k(&sketches, None, false).expect("set_k failed"), + set_k(&sketches, None, false).expect("set_k failed"), + ), + ( + set_k(&sketches, Some(17), false).expect("set_k failed"), + set_k(&sketches, Some(17), false).expect("set_k failed"), + ), + ( + set_k(&sketches, Some(17), true).expect("set_k failed"), + set_k(&sketches, Some(17), true).expect("set_k failed"), + ), + ] { + let matrix = self_dists_all(&sketches, n, dist_type_a, true, None, 0.0); + let non_streamed = matrix.to_string(); + + let mut streamed_bytes: Vec = Vec::new(); + self_dists_all_stream( + &mut streamed_bytes, + &sketches, + n, + dist_type_b, + true, + None, + 0.0, + 2, + ) + .expect("self_dists_all_stream failed"); + let streamed = String::from_utf8(streamed_bytes).expect("non-utf8 stream output"); + + let mut non_streamed_lines: Vec<&str> = + non_streamed.lines().filter(|l| !l.is_empty()).collect(); + let mut streamed_lines: Vec<&str> = + streamed.lines().filter(|l| !l.is_empty()).collect(); + non_streamed_lines.sort_unstable(); + streamed_lines.sort_unstable(); + assert_eq!( + non_streamed_lines, streamed_lines, + "Streamed and non-streamed self-mode output differ" + ); + } + } + + /// Cross-query analogue of `self_dists_stream_matches_non_streaming`. + #[test] + fn cross_dists_stream_matches_non_streaming() { + use sketchlib::distances::{cross_dists_all, cross_dists_all_stream, set_k}; + use sketchlib::sketch::multisketch::MultiSketch; + + let sandbox = TestSetup::setup(); + sketch_ref_and_query(&sandbox); + + let references = MultiSketch::load(&sandbox.file_string("bact_db", TestDir::Output)) + .expect("failed to load reference sketches"); + let queries = MultiSketch::load(&sandbox.file_string("query_db", TestDir::Output)) + .expect("failed to load query sketches"); + let n = references.number_samples_loaded(); + let n_query = queries.number_samples_loaded(); + + for (dist_type_a, dist_type_b) in [ + ( + set_k(&references, None, false).expect("set_k failed"), + set_k(&references, None, false).expect("set_k failed"), + ), + ( + set_k(&references, Some(17), false).expect("set_k failed"), + set_k(&references, Some(17), false).expect("set_k failed"), + ), + ( + set_k(&references, Some(17), true).expect("set_k failed"), + set_k(&references, Some(17), true).expect("set_k failed"), + ), + ] { + let matrix = cross_dists_all( + &references, + &queries, + n, + n_query, + dist_type_a, + true, + None, + None, + 0.0, + ); + let non_streamed = matrix.to_string(); + + let mut streamed_bytes: Vec = Vec::new(); + cross_dists_all_stream( + &mut streamed_bytes, + &references, + &queries, + n, + n_query, + dist_type_b, + true, + None, + None, + 0.0, + 2, + ) + .expect("cross_dists_all_stream failed"); + let streamed = String::from_utf8(streamed_bytes).expect("non-utf8 stream output"); + + let mut non_streamed_lines: Vec<&str> = + non_streamed.lines().filter(|l| !l.is_empty()).collect(); + let mut streamed_lines: Vec<&str> = + streamed.lines().filter(|l| !l.is_empty()).collect(); + non_streamed_lines.sort_unstable(); + streamed_lines.sort_unstable(); + assert_eq!( + non_streamed_lines, streamed_lines, + "Streamed and non-streamed cross-mode output differ" + ); + } + } } From 6022c2e54f7f526fb344f80452a297c8763a92f8 Mon Sep 17 00:00:00 2001 From: John Lees Date: Fri, 31 Jul 2026 16:09:07 +0100 Subject: [PATCH 11/13] Add limited support for legacy DBs Can still do dists with older versions, but not compare with newer versions --- src/distances/jaccard.rs | 188 +++++++++++++++-- src/distances/mod.rs | 277 +++++++++++++++++++++++-- src/lib.rs | 22 +- src/sketch/mod.rs | 6 + tests/delete.rs | 149 +++++++++++++ tests/distance.rs | 273 ++++++++++++++++++++++++ tests/sketch.rs | 53 ++++- tests/test_files_in/legacy_bitpack.skd | Bin 0 -> 1792 bytes tests/test_files_in/legacy_bitpack.skm | Bin 0 -> 269 bytes 9 files changed, 923 insertions(+), 45 deletions(-) create mode 100644 tests/test_files_in/legacy_bitpack.skd create mode 100644 tests/test_files_in/legacy_bitpack.skm diff --git a/src/distances/jaccard.rs b/src/distances/jaccard.rs index 84687cf..a44ebdd 100644 --- a/src/distances/jaccard.rs +++ b/src/distances/jaccard.rs @@ -1,9 +1,8 @@ //! Implementation of Jaccard, core and accessory distance calculations use crate::sketch::multisketch::MultiSketch; -use crate::sketch::BIN_BITS; +use crate::sketch::{BIN_BITS, LEGACY_BIN_BITS}; -/// Returns the Jaccard index between two samples -pub fn jaccard_index( +pub(super) fn jaccard_index_generic( sketch1: &[u64], sketch2: &[u64], sketchsize64: u64, @@ -11,10 +10,10 @@ pub fn jaccard_index( c2: Option, completeness_cutoff: f64, ) -> f64 { - let samebits = jaccard_same_bits(sketch1, sketch2) as f64; + let samebits = same_bits_dispatch::(sketch1, sketch2) as f64; let unionsize = (u64::BITS as u64 * sketchsize64) as f64; // Correction for random matches - let expected_random = unionsize / (1u64 << BIN_BITS) as f64; + let expected_random = unionsize / (1u64 << BB) as f64; let mut jaccard_index = ((samebits - expected_random) / (unionsize - expected_random)).clamp(0.0, 1.0); @@ -31,6 +30,63 @@ pub fn jaccard_index( jaccard_index } +/// Returns the Jaccard index between two samples +pub fn jaccard_index( + sketch1: &[u64], + sketch2: &[u64], + sketchsize64: u64, + c1: Option, + c2: Option, + completeness_cutoff: f64, +) -> f64 { + jaccard_index_generic::(sketch1, sketch2, sketchsize64, c1, c2, completeness_cutoff) +} + +/// Legacy analogue of [`jaccard_index`], for pre-v0.4 (`BIN_BITS` = 14) +/// databases — see [`LEGACY_BIN_BITS`] and +/// [`crate::sketch::multisketch::MultiSketch::is_legacy_format`]. +/// Random-match correction is applied identically to `jaccard_index`, just +/// using the 14-bit random-match denominator. Dispatched automatically by +/// `distances::self_dists_*`/`distances::cross_dists_*` based on the loaded +/// database's format. +// `distances::mod` dispatches via `jaccard_index_generic::` directly +// (no extra monomorphization hop in the per-pair hot loop), so this named +// entry point is only exercised by tests — kept for API discoverability. +#[allow(dead_code)] +pub(crate) fn jaccard_index_legacy( + sketch1: &[u64], + sketch2: &[u64], + sketchsize64: u64, + c1: Option, + c2: Option, + completeness_cutoff: f64, +) -> f64 { + jaccard_index_generic::( + sketch1, + sketch2, + sketchsize64, + c1, + c2, + completeness_cutoff, + ) +} + +/// Computes the "same bits" popcount for a given bin-packing width `BB`. +/// Dispatches to the NEON-accelerated path only when `BB == BIN_BITS` (the +/// current/new format); every other width — in practice only +/// [`LEGACY_BIN_BITS`] — always uses the portable scalar implementation, on +/// every architecture including aarch64. `BB == BIN_BITS` is resolved per +/// monomorphization, so for the legacy instantiation this is unconditionally +/// `false` and `jaccard_same_bits`/the NEON kernel is never invoked. +#[inline(always)] +fn same_bits_dispatch(sketch1: &[u64], sketch2: &[u64]) -> u32 { + if BB == BIN_BITS { + jaccard_same_bits(sketch1, sketch2) + } else { + jaccard_same_bits_general::(sketch1, sketch2) + } +} + #[inline(always)] fn jaccard_same_bits(sketch1: &[u64], sketch2: &[u64]) -> u32 { #[cfg(target_arch = "aarch64")] @@ -40,18 +96,19 @@ fn jaccard_same_bits(sketch1: &[u64], sketch2: &[u64]) -> u32 { #[cfg(not(target_arch = "aarch64"))] { - jaccard_same_bits_general(sketch1, sketch2) + jaccard_same_bits_general::(sketch1, sketch2) } } -#[cfg_attr(target_arch = "aarch64", allow(dead_code))] #[inline(always)] -pub(crate) fn jaccard_same_bits_general(sketch1: &[u64], sketch2: &[u64]) -> u32 { +pub(crate) fn jaccard_same_bits_general(sketch1: &[u64], sketch2: &[u64]) -> u32 { debug_assert_eq!(sketch1.len(), sketch2.len()); - debug_assert_eq!(sketch1.len() % BIN_BITS, 0); + debug_assert_eq!(sketch1.len() % BB, 0); sketch1 - .chunks_exact(BIN_BITS) - .zip(sketch2.chunks_exact(BIN_BITS)) + .as_chunks::() + .0 + .iter() + .zip(sketch2.as_chunks::().0.iter()) .map(|(chunk1, chunk2)| { let mut bits: u64 = !0; chunk1.iter().zip(chunk2.iter()).for_each(|(&s1, &s2)| { @@ -135,8 +192,8 @@ mod tests { let mut sketch2 = [u64::MAX; BIN_BITS * 2]; sketch2[BIN_BITS] = 0; - assert_eq!(jaccard_same_bits_general(&sketch1, &sketch1), 128); - assert_eq!(jaccard_same_bits_general(&sketch1, &sketch2), 64); + assert_eq!(jaccard_same_bits_general::(&sketch1, &sketch1), 128); + assert_eq!(jaccard_same_bits_general::(&sketch1, &sketch2), 64); } #[cfg(target_arch = "aarch64")] @@ -149,7 +206,7 @@ mod tests { sketch2[i] = sketch1[i] ^ ((i as u64) << (i % 17)); } - let scalar = jaccard_same_bits_general(&sketch1, &sketch2); + let scalar = jaccard_same_bits_general::(&sketch1, &sketch2); let neon = unsafe { jaccard_neon_unroll2_inner(&sketch1, &sketch2) }; assert_eq!(neon, scalar); } @@ -170,7 +227,7 @@ mod tests { *word = 0; } - let samebits = jaccard_same_bits_general(&sketch1, &sketch2) as f64; + let samebits = jaccard_same_bits_general::(&sketch1, &sketch2) as f64; let expected_random = unionsize / (1u64 << BIN_BITS) as f64; let expected_jaccard = ((samebits - expected_random) / (unionsize - expected_random)).clamp(0.0, 1.0); @@ -185,6 +242,54 @@ mod tests { let identical = jaccard_index(&sketch1, &sketch1, sketchsize64, None, None, 0.0); assert_eq!(identical, 1.0); } + + #[test] + fn legacy_scalar_same_bits_counts_matching_bins() { + // Mirrors `scalar_same_bits_counts_matching_bins`, but for the legacy + // 14-bit bin-packing width. Chunk width affects how many words feed + // into each chunk, not the per-chunk `count_ones()` range, so the + // expected popcounts are numerically identical to the BIN_BITS case. + let sketch1 = [u64::MAX; LEGACY_BIN_BITS * 2]; + let mut sketch2 = [u64::MAX; LEGACY_BIN_BITS * 2]; + sketch2[LEGACY_BIN_BITS] = 0; + + assert_eq!( + jaccard_same_bits_general::(&sketch1, &sketch1), + 128 + ); + assert_eq!( + jaccard_same_bits_general::(&sketch1, &sketch2), + 64 + ); + } + + #[test] + fn legacy_random_match_correction_applied_for_large_sketch_size() { + // Mirrors `random_match_correction_applied_for_large_sketch_size`, + // but exercising `jaccard_index_legacy` (LEGACY_BIN_BITS = 14). + let sketchsize64: u64 = 2048; + let unionsize = (u64::BITS as u64 * sketchsize64) as f64; + assert!(unionsize > (1u64 << LEGACY_BIN_BITS) as f64); + + let n_chunks = sketchsize64 as usize; + let sketch1 = vec![u64::MAX; LEGACY_BIN_BITS * n_chunks]; + let mut sketch2 = sketch1.clone(); + for word in sketch2[..LEGACY_BIN_BITS].iter_mut() { + *word = 0; + } + + let samebits = jaccard_same_bits_general::(&sketch1, &sketch2) as f64; + let expected_random = unionsize / (1u64 << LEGACY_BIN_BITS) as f64; + let expected_jaccard = + ((samebits - expected_random) / (unionsize - expected_random)).clamp(0.0, 1.0); + assert!((expected_jaccard - samebits / unionsize).abs() > f64::EPSILON); + + let jaccard = jaccard_index_legacy(&sketch1, &sketch2, sketchsize64, None, None, 0.0); + assert_eq!(jaccard, expected_jaccard); + + let identical = jaccard_index_legacy(&sketch1, &sketch1, sketchsize64, None, None, 0.0); + assert_eq!(identical, 1.0); + } } /// Converts between Jaccard distance and ANI, using a Poisson model of mutations @@ -199,9 +304,7 @@ pub fn completeness_correction(jaccard: f64, c1: f64, c2: f64) -> f64 { jaccard / (c1 * c2 / (c1 + c2 - c1 * c2)) } -/// Core and accessory distances between two sketches, using the PopPUNK regression -/// model -pub fn core_acc_dist( +pub(super) fn core_acc_dist_generic( ref_sketches: &MultiSketch, query_sketches: &MultiSketch, ref_sketch_idx: usize, @@ -220,7 +323,7 @@ pub fn core_acc_dist( for (k_idx, k) in ref_sketches.kmer_lengths().iter().enumerate() { let c1 = ref_completeness_vec.map(|cv| cv[ref_sketch_idx]); let c2 = query_completeness_vec.map(|cv| cv[query_sketch_idx]); - let y = jaccard_index( + let y = jaccard_index_generic::( ref_sketches.get_sketch_slice(ref_sketch_idx, k_idx), query_sketches.get_sketch_slice(query_sketch_idx, k_idx), ref_sketches.sketchsize64, @@ -243,6 +346,53 @@ pub fn core_acc_dist( simple_linear_regression(xsum, ysum, xysum, xsquaresum, ysquaresum, n) } +/// Core and accessory distances between two sketches, using the PopPUNK regression +/// model +// `distances::mod` dispatches via `core_acc_dist_generic::` directly; +// kept as a named, behaviorally-unchanged entry point. +#[allow(dead_code)] +pub fn core_acc_dist( + ref_sketches: &MultiSketch, + query_sketches: &MultiSketch, + ref_sketch_idx: usize, + query_sketch_idx: usize, + ref_completeness_vec: Option<&Vec>, + query_completeness_vec: Option<&Vec>, + completeness_cutoff: f64, +) -> (f32, f32) { + core_acc_dist_generic::( + ref_sketches, + query_sketches, + ref_sketch_idx, + query_sketch_idx, + ref_completeness_vec, + query_completeness_vec, + completeness_cutoff, + ) +} + +/// Legacy analogue of [`core_acc_dist`] — see [`jaccard_index_legacy`]. +#[allow(dead_code)] +pub(crate) fn core_acc_dist_legacy( + ref_sketches: &MultiSketch, + query_sketches: &MultiSketch, + ref_sketch_idx: usize, + query_sketch_idx: usize, + ref_completeness_vec: Option<&Vec>, + query_completeness_vec: Option<&Vec>, + completeness_cutoff: f64, +) -> (f32, f32) { + core_acc_dist_generic::( + ref_sketches, + query_sketches, + ref_sketch_idx, + query_sketch_idx, + ref_completeness_vec, + query_completeness_vec, + completeness_cutoff, + ) +} + // Linear regression for calculating core/accessory distances from matches, with some // sensible bounds for bad fits fn simple_linear_regression( diff --git a/src/distances/mod.rs b/src/distances/mod.rs index 80270d6..2fbbefd 100644 --- a/src/distances/mod.rs +++ b/src/distances/mod.rs @@ -4,7 +4,7 @@ use std::fmt::Write as FmtWrite; use std::io::Write as IoWrite; use std::sync::mpsc; -use anyhow::{Context, Error}; +use anyhow::{bail, Context, Error}; use hashbrown::HashMap; use indicatif::ParallelProgressIterator; use rayon::prelude::*; @@ -13,6 +13,17 @@ use crate::cli::RetainUnmatched; use crate::get_progress_bar; use crate::inverted::Inverted; use crate::sketch::multisketch::MultiSketch; +use crate::sketch::{BIN_BITS, LEGACY_BIN_BITS}; + +/// Error message used whenever a reference and query database with +/// mismatched sketch generations (legacy pre-v0.4 vs. current) are compared. +/// Legacy and new-format databases use incompatible bin-packing schemes, so +/// this is an explicit rejection rather than a silent/lossy conversion. +fn mismatched_generation_message(ref_legacy: bool, query_legacy: bool) -> String { + format!( + "Cannot compare reference and query databases with different sketch generations (reference is_legacy={ref_legacy}, query is_legacy={query_legacy}): legacy (pre-v0.4) and new-format databases use incompatible bin-packing schemes and cannot be directly compared. Please re-sketch both databases with the current version." + ) +} pub mod distance_matrix; use self::distance_matrix::*; @@ -69,6 +80,35 @@ pub fn self_dists_all<'a>( quiet: bool, completeness_vec: Option<&Vec>, completeness_cutoff: f64, +) -> DistanceMatrix<'a> { + if sketches.is_legacy_format() { + self_dists_all_generic::( + sketches, + n, + dist_type, + quiet, + completeness_vec, + completeness_cutoff, + ) + } else { + self_dists_all_generic::( + sketches, + n, + dist_type, + quiet, + completeness_vec, + completeness_cutoff, + ) + } +} + +fn self_dists_all_generic<'a, const BB: usize>( + sketches: &'a MultiSketch, + n: usize, + dist_type: DistType, + quiet: bool, + completeness_vec: Option<&Vec>, + completeness_cutoff: f64, ) -> DistanceMatrix<'a> { let mut distances = DistanceMatrix::new(sketches, None, dist_type); let k_vals = distances.k_vals(); @@ -93,7 +133,7 @@ pub fn self_dists_all<'a>( // This uses Option::map to safely access the completeness value for each sample. let c1 = completeness_vec.map(|cv| cv[i]); let c2 = completeness_vec.map(|cv| cv[j]); - let j_index = jaccard_index( + let j_index = jaccard_index_generic::( sketches.get_sketch_slice(i, k_idx), sketches.get_sketch_slice(j, k_idx), sketches.sketchsize64, @@ -108,7 +148,7 @@ pub fn self_dists_all<'a>( }; dist_slice[dist_idx] = dist; } else { - let dist = core_acc_dist( + let dist = core_acc_dist_generic::( sketches, sketches, i, @@ -152,6 +192,41 @@ pub fn self_dists_all_stream( completeness_vec: Option<&Vec>, completeness_cutoff: f64, threads: usize, +) -> Result<(), Error> { + if sketches.is_legacy_format() { + self_dists_all_stream_generic::( + writer, + sketches, + n, + dist_type, + quiet, + completeness_vec, + completeness_cutoff, + threads, + ) + } else { + self_dists_all_stream_generic::( + writer, + sketches, + n, + dist_type, + quiet, + completeness_vec, + completeness_cutoff, + threads, + ) + } +} + +fn self_dists_all_stream_generic( + writer: &mut W, + sketches: &MultiSketch, + n: usize, + dist_type: DistType, + quiet: bool, + completeness_vec: Option<&Vec>, + completeness_cutoff: f64, + threads: usize, ) -> Result<(), Error> { let ani = matches!(dist_type, DistType::Jaccard(_, _, true)); let k_vals = match dist_type { @@ -185,7 +260,7 @@ pub fn self_dists_all_stream( if let Some((k_idx, k_f64)) = k_vals { let c1 = completeness_vec.map(|cv| cv[i]); let c2 = completeness_vec.map(|cv| cv[j]); - let j_index = jaccard_index( + let j_index = jaccard_index_generic::( sketches.get_sketch_slice(i, k_idx), sketches.get_sketch_slice(j, k_idx), sketches.sketchsize64, @@ -201,7 +276,7 @@ pub fn self_dists_all_stream( let _ = writeln!(buf, "{}\t{}\t{dist}", ref_names[i], ref_names[j]); } else { - let d = core_acc_dist( + let d = core_acc_dist_generic::( sketches, sketches, i, @@ -246,6 +321,38 @@ pub fn self_dists_knn<'a>( quiet: bool, completeness_vec: Option<&Vec>, completeness_cutoff: f64, +) -> SparseDistanceMatrix<'a> { + if sketches.is_legacy_format() { + self_dists_knn_generic::( + sketches, + n, + knn, + dist_type, + quiet, + completeness_vec, + completeness_cutoff, + ) + } else { + self_dists_knn_generic::( + sketches, + n, + knn, + dist_type, + quiet, + completeness_vec, + completeness_cutoff, + ) + } +} + +fn self_dists_knn_generic<'a, const BB: usize>( + sketches: &'a MultiSketch, + n: usize, + knn: usize, + dist_type: DistType, + quiet: bool, + completeness_vec: Option<&Vec>, + completeness_cutoff: f64, ) -> SparseDistanceMatrix<'a> { let mut sp_distances = SparseDistanceMatrix::new(sketches, knn, dist_type); let k_vals = sp_distances.k_vals(); @@ -270,7 +377,7 @@ pub fn self_dists_knn<'a>( // This uses Option::map to safely access the completeness value for each sample. let c1 = completeness_vec.map(|cv| cv[i]); let c2 = completeness_vec.map(|cv| cv[j]); - let dist = jaccard_index( + let dist = jaccard_index_generic::( i_sketch, sketches.get_sketch_slice(j, k_idx), sketches.sketchsize64, @@ -311,7 +418,7 @@ pub fn self_dists_knn<'a>( if i == j { continue; } - let dists = core_acc_dist( + let dists = core_acc_dist_generic::( sketches, sketches, i, @@ -347,6 +454,51 @@ pub fn cross_dists_all<'a>( ref_completeness_vec: Option<&Vec>, query_completeness_vec: Option<&Vec>, completeness_cutoff: f64, +) -> DistanceMatrix<'a> { + let (ref_legacy, query_legacy) = ( + ref_sketches.is_legacy_format(), + query_sketches.is_legacy_format(), + ); + if ref_legacy != query_legacy { + panic!("{}", mismatched_generation_message(ref_legacy, query_legacy)); + } + if ref_legacy { + cross_dists_all_generic::( + ref_sketches, + query_sketches, + n, + n_query, + dist_type, + quiet, + ref_completeness_vec, + query_completeness_vec, + completeness_cutoff, + ) + } else { + cross_dists_all_generic::( + ref_sketches, + query_sketches, + n, + n_query, + dist_type, + quiet, + ref_completeness_vec, + query_completeness_vec, + completeness_cutoff, + ) + } +} + +fn cross_dists_all_generic<'a, const BB: usize>( + ref_sketches: &'a MultiSketch, + query_sketches: &'a MultiSketch, + n: usize, + n_query: usize, + dist_type: DistType, + quiet: bool, + ref_completeness_vec: Option<&Vec>, + query_completeness_vec: Option<&Vec>, + completeness_cutoff: f64, ) -> DistanceMatrix<'a> { let mut distances = DistanceMatrix::new(ref_sketches, Some(query_sketches), dist_type); let k_vals = distances.k_vals(); @@ -366,7 +518,7 @@ pub fn cross_dists_all<'a>( if let Some((k_idx, k_f64)) = k_vals { let c1 = ref_completeness_vec.map(|cv| cv[i]); let c2 = query_completeness_vec.map(|cv| cv[j]); - let j_index = jaccard_index( + let j_index = jaccard_index_generic::( ref_sketches.get_sketch_slice(i, k_idx), query_sketches.get_sketch_slice(j, k_idx), ref_sketches.sketchsize64, @@ -381,7 +533,7 @@ pub fn cross_dists_all<'a>( }; dist_slice[dist_idx] = dist; } else { - let dist = core_acc_dist( + let dist = core_acc_dist_generic::( ref_sketches, query_sketches, i, @@ -423,6 +575,57 @@ pub fn cross_dists_all_stream( query_completeness_vec: Option<&Vec>, completeness_cutoff: f64, threads: usize, +) -> Result<(), Error> { + let (ref_legacy, query_legacy) = ( + ref_sketches.is_legacy_format(), + query_sketches.is_legacy_format(), + ); + if ref_legacy != query_legacy { + bail!(mismatched_generation_message(ref_legacy, query_legacy)); + } + if ref_legacy { + cross_dists_all_stream_generic::( + writer, + ref_sketches, + query_sketches, + n, + n_query, + dist_type, + quiet, + ref_completeness_vec, + query_completeness_vec, + completeness_cutoff, + threads, + ) + } else { + cross_dists_all_stream_generic::( + writer, + ref_sketches, + query_sketches, + n, + n_query, + dist_type, + quiet, + ref_completeness_vec, + query_completeness_vec, + completeness_cutoff, + threads, + ) + } +} + +fn cross_dists_all_stream_generic( + writer: &mut W, + ref_sketches: &MultiSketch, + query_sketches: &MultiSketch, + n: usize, + n_query: usize, + dist_type: DistType, + quiet: bool, + ref_completeness_vec: Option<&Vec>, + query_completeness_vec: Option<&Vec>, + completeness_cutoff: f64, + threads: usize, ) -> Result<(), Error> { let ani = matches!(dist_type, DistType::Jaccard(_, _, true)); let k_vals = match dist_type { @@ -454,7 +657,7 @@ pub fn cross_dists_all_stream( if let Some((k_idx, k_f64)) = k_vals { let c1 = ref_completeness_vec.map(|cv| cv[i]); let c2 = query_completeness_vec.map(|cv| cv[j]); - let j_index = jaccard_index( + let j_index = jaccard_index_generic::( ref_sketches.get_sketch_slice(i, k_idx), query_sketches.get_sketch_slice(j, k_idx), ref_sketches.sketchsize64, @@ -473,7 +676,7 @@ pub fn cross_dists_all_stream( ref_names[i], query_names[j] ); } else { - let d = core_acc_dist( + let d = core_acc_dist_generic::( ref_sketches, query_sketches, i, @@ -527,6 +730,54 @@ pub fn cross_dists_knn<'a>( ref_completeness_vec: Option<&Vec>, query_completeness_vec: Option<&Vec>, completeness_cutoff: f64, +) -> SparseDistanceMatrix<'a> { + let (ref_legacy, query_legacy) = ( + ref_sketches.is_legacy_format(), + query_sketches.is_legacy_format(), + ); + if ref_legacy != query_legacy { + panic!("{}", mismatched_generation_message(ref_legacy, query_legacy)); + } + if ref_legacy { + cross_dists_knn_generic::( + ref_sketches, + query_sketches, + n, + n_query, + knn, + dist_type, + quiet, + ref_completeness_vec, + query_completeness_vec, + completeness_cutoff, + ) + } else { + cross_dists_knn_generic::( + ref_sketches, + query_sketches, + n, + n_query, + knn, + dist_type, + quiet, + ref_completeness_vec, + query_completeness_vec, + completeness_cutoff, + ) + } +} + +fn cross_dists_knn_generic<'a, const BB: usize>( + ref_sketches: &'a MultiSketch, + query_sketches: &'a MultiSketch, + n: usize, + n_query: usize, + knn: usize, + dist_type: DistType, + quiet: bool, + ref_completeness_vec: Option<&Vec>, + query_completeness_vec: Option<&Vec>, + completeness_cutoff: f64, ) -> SparseDistanceMatrix<'a> { if n == 0 { panic!("Reference database has no loaded samples"); @@ -554,7 +805,7 @@ pub fn cross_dists_knn<'a>( for ri in 0..n { let c1 = query_completeness_vec.map(|cv| cv[qi]); let c2 = ref_completeness_vec.map(|cv| cv[ri]); - let dist = jaccard_index( + let dist = jaccard_index_generic::( qi_sketch, ref_sketches.get_sketch_slice(ri, k_idx), ref_sketches.sketchsize64, @@ -588,7 +839,7 @@ pub fn cross_dists_knn<'a>( .for_each(|(qi, row_dist_slice)| { let mut heap = BinaryHeap::with_capacity(knn + 1); for ri in 0..n { - let dists = core_acc_dist( + let dists = core_acc_dist_generic::( ref_sketches, query_sketches, ri, diff --git a/src/lib.rs b/src/lib.rs index 7da630d..4db095a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -151,7 +151,7 @@ use std::time::Instant; #[macro_use] extern crate arrayref; extern crate num_cpus; -use anyhow::Error; +use anyhow::{bail, Error}; #[cfg(not(target_arch = "wasm32"))] use indicatif::ParallelProgressIterator; #[cfg(not(target_arch = "wasm32"))] @@ -354,6 +354,13 @@ pub fn main() -> Result<(), Error> { let queries = MultiSketch::load(query_db_name) .unwrap_or_else(|_| panic!("Could not read sketch data from {query_db_name}")); log::info!("Read query sketches:\n{queries:?}"); + if references.is_legacy_format() != queries.is_legacy_format() { + bail!( + "Cannot compare reference and query databases with different sketch generations (reference is_legacy={}, query is_legacy={}): legacy (pre-v0.4) and new-format databases use incompatible bin-packing schemes and cannot be directly compared. Please re-sketch both databases with the current version.", + references.is_legacy_format(), + queries.is_legacy_format() + ); + } Some(queries) } else { None @@ -477,6 +484,9 @@ pub fn main() -> Result<(), Error> { if !sketches1.is_compatible_with(&sketches2) { panic!("Databases are not compatible for merging.") } + if sketches1.is_legacy_format() { + log::warn!("Merging legacy-format (pre-v0.4) sketch databases; distances calculated against the result will use legacy mode. Resketching advised."); + } log::info!("Merging metadata to {output}.skm"); let merged_sketch = sketches1.merge_sketches(&sketches2); @@ -899,13 +909,15 @@ pub fn main() -> Result<(), Error> { let mut sketches: MultiSketch = MultiSketch::load_metadata(ref_db) .unwrap_or_else(|_| panic!("Could not read sketch metadata from {ref_db}.skm")); - // write new .skm - sketches.remove_metadata(output_file, &ids)?; - - // remove samples from .skd file + // Remove samples from the .skd file first: this reads positions from + // the original (pre-filter) name_map/sketch count, which + // remove_metadata below would otherwise mutate out from under it. log::info!("Remove genomes and writing output"); sketches.remove_genomes(ref_db, output_file, &ids)?; + // write new .skm + sketches.remove_metadata(output_file, &ids)?; + log::info!("Finished writing filtered sketch data to {output_file}"); Ok(()) diff --git a/src/sketch/mod.rs b/src/sketch/mod.rs index dff0193..39a56cd 100644 --- a/src/sketch/mod.rs +++ b/src/sketch/mod.rs @@ -40,6 +40,12 @@ use self::sketch_datafile::SketchArrayWriter; /// Bin bits (lowest of 64-bits to keep) pub const BIN_BITS: usize = 16; +/// Legacy bin-packing width used by `.skd`/`.skm` databases written before +/// v0.4.0 (see [`multisketch::MIN_SKETCH_VERSION`]), prior to the `BIN_BITS` +/// 14->16 change. Distance-calculation use only — there is no code path +/// that creates new sketches with this width; sketch creation always uses +/// [`BIN_BITS`]. +pub const LEGACY_BIN_BITS: usize = 14; /// Byte alignment used for bit-packed sketch buffers. pub const SKETCH_ALIGNMENT: usize = 64; diff --git a/tests/delete.rs b/tests/delete.rs index bfd6809..a5097b8 100644 --- a/tests/delete.rs +++ b/tests/delete.rs @@ -80,4 +80,153 @@ mod tests { "Merged sketch metadata does not match" ); } + + #[test] + /// Regression test: deleting a *non-trailing* sample (here, the second of + /// four) used to corrupt the output `.skd`. `remove_genomes` was called + /// after `remove_metadata` had already truncated `sketch_metadata`, so + /// `indices_to_keep` was built from the wrong (post-truncation) length + /// and silently dropped trailing on-disk records that should have been + /// kept. The bug was masked by the original `test_delete_sketches` test, + /// which only ever deletes a trailing block. + fn test_delete_non_trailing_sample() { + let sandbox = TestSetup::setup(); + + let rfile1 = sandbox.create_rfile(&vec![ + "14412_3#82.contigs_velvet.fa.gz", + "14412_3#84.contigs_velvet.fa.gz", + "R6.fa.gz", + "TIGR4.fa.gz", + ]); + Command::new(cmd::cargo_bin!("sketchlib")) + .current_dir(sandbox.get_wd()) + .arg("sketch") + .args(&["--k-vals", "17"]) + .args(&["-f", rfile1]) + .arg("-v") + .args(&["-o", "full_db"]) + .assert() + .success(); + + let rfile2 = sandbox.create_rfile(&vec![ + "14412_3#82.contigs_velvet.fa.gz", + "R6.fa.gz", + "TIGR4.fa.gz", + ]); + Command::new(cmd::cargo_bin!("sketchlib")) + .current_dir(sandbox.get_wd()) + .arg("sketch") + .args(&["--k-vals", "17"]) + .args(&["-f", rfile2]) + .arg("-v") + .args(&["-o", "result_db"]) + .assert() + .success(); + + std::fs::write( + sandbox.file_string("delete_middle.txt", TestDir::Output), + "14412_3#84.contigs_velvet.fa.gz\n", + ) + .expect("Failed to write delete list"); + + Command::new(cmd::cargo_bin!("sketchlib")) + .current_dir(sandbox.get_wd()) + .arg("delete") + .arg("full_db") + .arg("delete_middle.txt") + .arg("deleted_db") + .assert() + .success(); + + let predicate_file = predicate::path::eq_file(Path::new( + &sandbox.file_string("deleted_db.skd", TestDir::Output), + )); + assert!( + predicate_file.eval(Path::new( + &sandbox.file_string("result_db.skd", TestDir::Output) + )), + "Deleting a non-trailing sample produced a .skd that doesn't match a fresh sketch of the remaining samples" + ); + + let deleted_sketch: MultiSketch = + MultiSketch::load_metadata(&sandbox.file_string("deleted_db", TestDir::Output)) + .expect("Failed to load deleted sketch"); + assert_eq!(deleted_sketch.number_samples_loaded(), 3); + assert_eq!( + deleted_sketch.get_sample_index("R6.fa.gz"), + Some(1), + "name_map should map retained samples to their new compacted position" + ); + assert_eq!(deleted_sketch.get_sample_index("TIGR4.fa.gz"), Some(2)); + assert_eq!( + deleted_sketch.get_sample_index("14412_3#84.contigs_velvet.fa.gz"), + None, + "name_map should not retain the deleted sample" + ); + } + + #[test] + /// Regression test: `remove_metadata` never used to update `name_map`, so + /// a deleted sample's name stayed in it. Merging two databases each + /// produced by deleting down to a disjoint single sample would then + /// spuriously panic with " appears in both databases", even though + /// that sample was never actually present on the other side. + fn test_merge_after_delete_does_not_see_stale_names() { + let sandbox = TestSetup::setup(); + + let rfile = sandbox.create_rfile(&vec!["R6.fa.gz", "TIGR4.fa.gz"]); + Command::new(cmd::cargo_bin!("sketchlib")) + .current_dir(sandbox.get_wd()) + .arg("sketch") + .args(&["--k-vals", "17"]) + .args(&["-f", rfile]) + .arg("-v") + .args(&["-o", "pair_db"]) + .assert() + .success(); + + std::fs::write( + sandbox.file_string("remove_tigr4.txt", TestDir::Output), + "TIGR4.fa.gz\n", + ) + .expect("Failed to write delete list"); + std::fs::write( + sandbox.file_string("remove_r6.txt", TestDir::Output), + "R6.fa.gz\n", + ) + .expect("Failed to write delete list"); + + Command::new(cmd::cargo_bin!("sketchlib")) + .current_dir(sandbox.get_wd()) + .arg("delete") + .arg("pair_db") + .arg("remove_tigr4.txt") + .arg("r6_only") + .assert() + .success(); + Command::new(cmd::cargo_bin!("sketchlib")) + .current_dir(sandbox.get_wd()) + .arg("delete") + .arg("pair_db") + .arg("remove_r6.txt") + .arg("tigr4_only") + .assert() + .success(); + + Command::new(cmd::cargo_bin!("sketchlib")) + .current_dir(sandbox.get_wd()) + .arg("merge") + .arg("r6_only") + .arg("tigr4_only") + .args(&["-o", "remerged"]) + .assert() + .success(); + + let merged: MultiSketch = + MultiSketch::load_metadata(&sandbox.file_string("remerged", TestDir::Output)) + .expect("Failed to load remerged sketch"); + assert_eq!(merged.number_samples_loaded(), 2); + assert_eq!(merged.get_sample_index("R6.fa.gz"), Some(0)); + assert_eq!(merged.get_sample_index("TIGR4.fa.gz"), Some(1)); + } } diff --git a/tests/distance.rs b/tests/distance.rs index 9d29bc7..8dfb7e6 100644 --- a/tests/distance.rs +++ b/tests/distance.rs @@ -1031,4 +1031,277 @@ mod tests { ); } } + + /// Loads `legacy_db` (v0.1.3, legacy 14-bit format, R6 vs TIGR4, k=[17,21,25], + /// sketch_size=128) as the legacy side of a mismatched-generation pair, and + /// freshly sketches R6.fa.gz as the new-format side. Used only by the + /// rejection tests below — the two sides deliberately don't need matching + /// k-mer lengths/sketch size, since the generation guard fires first. + fn legacy_and_fresh_dbs( + sandbox: &TestSetup, + ) -> ( + sketchlib::sketch::multisketch::MultiSketch, + sketchlib::sketch::multisketch::MultiSketch, + ) { + use sketchlib::sketch::multisketch::MultiSketch; + + let legacy = MultiSketch::load(&sandbox.file_string("legacy_db", TestDir::Input)) + .expect("failed to load legacy_db"); + + sandbox.copy_input_file_to_wd("R6.fa.gz"); + Command::new(cmd::cargo_bin!("sketchlib")) + .current_dir(sandbox.get_wd()) + .arg("sketch") + .args(["-o", "fresh_for_mismatch"]) + .args(["-v", "-k", "21"]) + .arg("R6.fa.gz") + .assert() + .success(); + let fresh = MultiSketch::load(&sandbox.file_string("fresh_for_mismatch", TestDir::Output)) + .expect("failed to load fresh_for_mismatch"); + + (legacy, fresh) + } + + /// Jaccard distance still computes correctly in legacy mode: `legacy_db`'s + /// R6-vs-TIGR4 pair at k=17, via the public `self_dists_all` dispatch (which + /// resolves to the legacy code path via `MultiSketch::is_legacy_format`). + /// The expected value is a deterministic, previously-measured baseline for + /// this fixture — this is a regression check, not a tolerance-based one. + #[test] + fn legacy_db_jaccard_distance_via_self_dists_all() { + use sketchlib::distances::{self_dists_all, set_k}; + use sketchlib::sketch::multisketch::MultiSketch; + + let sandbox = TestSetup::setup(); + let sketches = MultiSketch::load(&sandbox.file_string("legacy_db", TestDir::Input)) + .expect("failed to load legacy_db"); + assert!(sketches.is_legacy_format()); + let n = sketches.number_samples_loaded(); + + let dist_type = set_k(&sketches, Some(17), false).expect("set_k failed"); + let matrix = self_dists_all(&sketches, n, dist_type, true, None, 0.0); + let (dist, accessory) = matrix.dists_iter().next().expect("expected one pair"); + assert!(accessory.is_none()); + assert_abs_diff_eq!(dist as f64, 0.2343893, epsilon = 1e-4); + } + + /// Core/accessory distance still computes correctly in legacy mode, using + /// all three of `legacy_db`'s k-mer lengths (17, 21, 25). + #[test] + fn legacy_db_core_accessory_distance_via_self_dists_all() { + use sketchlib::distances::{self_dists_all, set_k}; + use sketchlib::sketch::multisketch::MultiSketch; + + let sandbox = TestSetup::setup(); + let sketches = MultiSketch::load(&sandbox.file_string("legacy_db", TestDir::Input)) + .expect("failed to load legacy_db"); + let n = sketches.number_samples_loaded(); + + let dist_type = set_k(&sketches, None, false).expect("set_k failed"); + let matrix = self_dists_all(&sketches, n, dist_type, true, None, 0.0); + let (core, accessory) = matrix.dists_iter().next().expect("expected one pair"); + let accessory = accessory.expect("CoreAcc pair should have an accessory value"); + assert_abs_diff_eq!(core as f64, 0.022036541, epsilon = 1e-4); + assert_abs_diff_eq!(accessory as f64, 0.0, epsilon = 1e-4); + } + + /// Cross-checks legacy-mode distance calculation against an independently + /// re-sketched (new-format) version of the same two genomes. `legacy_db` + /// (R6 vs TIGR4, v0.1.3, k=[17,21,25], sketch_size=128) is compared in + /// legacy mode; R6.fa.gz/TIGR4.fa.gz are freshly sketched with the current + /// version at matching k/sketch_size and compared in new mode. The two + /// computed distances estimate the same true genomic distance via + /// different (incompatible) binning schemes, so they should agree within a + /// generous tolerance but are not expected to be bit-identical. + #[test] + fn legacy_and_new_format_distances_agree_for_same_genomes() { + use sketchlib::distances::{self_dists_all, set_k}; + use sketchlib::sketch::multisketch::MultiSketch; + + let sandbox = TestSetup::setup(); + let legacy = MultiSketch::load(&sandbox.file_string("legacy_db", TestDir::Input)) + .expect("failed to load legacy_db"); + assert!(legacy.is_legacy_format()); + + sandbox.copy_input_file_to_wd("R6.fa.gz"); + sandbox.copy_input_file_to_wd("TIGR4.fa.gz"); + Command::new(cmd::cargo_bin!("sketchlib")) + .current_dir(sandbox.get_wd()) + .arg("sketch") + .args(["-o", "fresh_db"]) + .args(["-v", "--k-vals", "17,21,25", "-s", "128"]) + .arg("R6.fa.gz") + .arg("TIGR4.fa.gz") + .assert() + .success(); + let fresh = MultiSketch::load(&sandbox.file_string("fresh_db", TestDir::Output)) + .expect("failed to load fresh_db"); + assert!(!fresh.is_legacy_format()); + + let legacy_n = legacy.number_samples_loaded(); + let fresh_n = fresh.number_samples_loaded(); + + // Jaccard at k=17 + let legacy_jaccard = self_dists_all( + &legacy, + legacy_n, + set_k(&legacy, Some(17), false).expect("set_k failed"), + true, + None, + 0.0, + ) + .dists_iter() + .next() + .expect("expected one pair") + .0; + let fresh_jaccard = self_dists_all( + &fresh, + fresh_n, + set_k(&fresh, Some(17), false).expect("set_k failed"), + true, + None, + 0.0, + ) + .dists_iter() + .next() + .expect("expected one pair") + .0; + assert_abs_diff_eq!(legacy_jaccard as f64, fresh_jaccard as f64, epsilon = 0.05); + + // Core/accessory + let (legacy_core, legacy_acc) = self_dists_all( + &legacy, + legacy_n, + set_k(&legacy, None, false).expect("set_k failed"), + true, + None, + 0.0, + ) + .dists_iter() + .next() + .map(|(c, a)| (c, a.expect("Some in CoreAcc"))) + .expect("expected one pair"); + let (fresh_core, fresh_acc) = self_dists_all( + &fresh, + fresh_n, + set_k(&fresh, None, false).expect("set_k failed"), + true, + None, + 0.0, + ) + .dists_iter() + .next() + .map(|(c, a)| (c, a.expect("Some in CoreAcc"))) + .expect("expected one pair"); + assert_abs_diff_eq!(legacy_core as f64, fresh_core as f64, epsilon = 0.1); + assert_abs_diff_eq!(legacy_acc as f64, fresh_acc as f64, epsilon = 0.1); + } + + #[test] + #[should_panic( + expected = "Cannot compare reference and query databases with different sketch generations" + )] + fn cross_dists_all_rejects_mismatched_generations() { + use sketchlib::distances::{cross_dists_all, set_k}; + + let sandbox = TestSetup::setup(); + let (legacy, fresh) = legacy_and_fresh_dbs(&sandbox); + let n = legacy.number_samples_loaded(); + let n_query = fresh.number_samples_loaded(); + let dist_type = set_k(&legacy, Some(17), false).expect("set_k failed"); + cross_dists_all(&legacy, &fresh, n, n_query, dist_type, true, None, None, 0.0); + } + + #[test] + #[should_panic( + expected = "Cannot compare reference and query databases with different sketch generations" + )] + fn cross_dists_knn_rejects_mismatched_generations() { + use sketchlib::distances::{cross_dists_knn, set_k}; + + let sandbox = TestSetup::setup(); + let (legacy, fresh) = legacy_and_fresh_dbs(&sandbox); + let n = legacy.number_samples_loaded(); + let n_query = fresh.number_samples_loaded(); + let dist_type = set_k(&legacy, Some(17), false).expect("set_k failed"); + cross_dists_knn( + &legacy, &fresh, n, n_query, 1, dist_type, true, None, None, 0.0, + ); + } + + #[test] + fn cross_dists_all_stream_rejects_mismatched_generations() { + use sketchlib::distances::{cross_dists_all_stream, set_k}; + + let sandbox = TestSetup::setup(); + let (legacy, fresh) = legacy_and_fresh_dbs(&sandbox); + let n = legacy.number_samples_loaded(); + let n_query = fresh.number_samples_loaded(); + let dist_type = set_k(&legacy, Some(17), false).expect("set_k failed"); + let mut buf: Vec = Vec::new(); + let result = cross_dists_all_stream( + &mut buf, &legacy, &fresh, n, n_query, dist_type, true, None, None, 0.0, 1, + ); + let err = result.expect_err("expected mismatched-generation rejection"); + assert!( + err.to_string() + .contains("Cannot compare reference and query databases with different sketch generations"), + "Unexpected error: {err}" + ); + } + + #[test] + fn cli_dist_rejects_mismatched_generations() { + let sandbox = TestSetup::setup(); + sandbox.copy_input_file_to_wd("R6.fa.gz"); + Command::new(cmd::cargo_bin!("sketchlib")) + .current_dir(sandbox.get_wd()) + .arg("sketch") + .args(["-o", "cli_fresh"]) + .args(["-v", "-k", "21"]) + .arg("R6.fa.gz") + .assert() + .success(); + + Command::new(cmd::cargo_bin!("sketchlib")) + .current_dir(sandbox.get_wd()) + .arg("dist") + .arg(sandbox.file_string("legacy_db", TestDir::Input)) + .arg("cli_fresh") + .args(["-k", "21"]) + .assert() + .failure(); + } + + /// Once the hard version error becomes a warning, `Merge` gains a real risk + /// it didn't need to guard against before: silently byte-concatenating a + /// legacy (14-bit-packed) and new-format (16-bit-packed) `.skd` into a + /// corrupted file. `MultiSketch::is_compatible_with` now also requires + /// matching `is_legacy_format()`, so this must fail loudly instead. The + /// fresh database is sketched with the same k-mer lengths/sketch_size as + /// `legacy_db` so the *only* mismatch is generation, isolating this check + /// from the pre-existing kmer_lengths/sketch_size/hash_type checks. + #[test] + fn merge_rejects_mismatched_generations() { + let sandbox = TestSetup::setup(); + sandbox.copy_input_file_to_wd("R6.fa.gz"); + Command::new(cmd::cargo_bin!("sketchlib")) + .current_dir(sandbox.get_wd()) + .arg("sketch") + .args(["-o", "fresh_matching_shape"]) + .args(["-v", "--k-vals", "17,21,25", "-s", "128"]) + .arg("R6.fa.gz") + .assert() + .success(); + + Command::new(cmd::cargo_bin!("sketchlib")) + .current_dir(sandbox.get_wd()) + .arg("merge") + .arg(sandbox.file_string("legacy_db", TestDir::Input)) + .arg("fresh_matching_shape") + .args(["-o", "bad_merge"]) + .assert() + .failure(); + } } diff --git a/tests/sketch.rs b/tests/sketch.rs index 1bbe548..a43fa29 100644 --- a/tests/sketch.rs +++ b/tests/sketch.rs @@ -141,19 +141,56 @@ mod tests { } #[test] - /// The MultiSketch (.skd/.skm) format changed as of v0.4, so databases - /// created with older versions (this fixture is v0.1.3) must be refused - /// rather than silently loaded. - fn legacy_database_rejected() { + /// The MultiSketch (.skd/.skm) format changed as of v0.4; older databases + /// (this fixture is v0.1.3) must still load successfully — with a + /// `log::warn!`, not a hard error — so distances can be calculated + /// against them in legacy mode. See `MultiSketch::is_legacy_format`. + fn legacy_database_loads_with_warning() { use sketchlib::sketch::multisketch::MultiSketch; let sandbox = TestSetup::setup(); - let err = MultiSketch::load_metadata(&sandbox.file_string("legacy_db", TestDir::Input)) - .expect_err("Loading a pre-v0.4 sketch file should fail"); + let sketches = MultiSketch::load_metadata(&sandbox.file_string("legacy_db", TestDir::Input)) + .expect("Loading a pre-v0.4 sketch file should now succeed (legacy mode)"); assert!( - err.to_string().contains("Incompatible sketch file version"), - "Unexpected error loading legacy sketch: {err}" + sketches.is_legacy_format(), + "legacy_db.skm (v0.1.3) should be detected as legacy format" ); + assert_eq!(sketches.number_samples_loaded(), 2); + assert_eq!(sketches.kmer_lengths(), &[17, 21, 25]); + } + + #[test] + fn legacy_bitpack_metadata_loads_correctly() { + use sketchlib::sketch::multisketch::MultiSketch; + let sandbox = TestSetup::setup(); + + let sketches = + MultiSketch::load_metadata(&sandbox.file_string("legacy_bitpack", TestDir::Input)) + .expect("Loading legacy_bitpack.skm should succeed"); + assert!(sketches.is_legacy_format()); + assert_eq!(sketches.number_samples_loaded(), 1); + assert_eq!(sketches.kmer_lengths(), &[31]); + assert_eq!(sketches.sketch_size, 1024); + } + + #[test] + fn new_database_is_not_legacy_format() { + use sketchlib::sketch::multisketch::MultiSketch; + let sandbox = TestSetup::setup(); + + Command::new(cmd::cargo_bin!("sketchlib")) + .current_dir(sandbox.get_wd()) + .arg("sketch") + .arg("-o") + .arg("fresh") + .args(["-v", "-k", "21"]) + .arg(sandbox.file_string("R6.fa.gz", TestDir::Input)) + .assert() + .success(); + + let sketches = MultiSketch::load_metadata(&sandbox.file_string("fresh", TestDir::Output)) + .expect("Loading a freshly-sketched database should succeed"); + assert!(!sketches.is_legacy_format()); } #[test] diff --git a/tests/test_files_in/legacy_bitpack.skd b/tests/test_files_in/legacy_bitpack.skd new file mode 100644 index 0000000000000000000000000000000000000000..3bed87182be1148a7a2c602b4a6b8548b10c7ed3 GIT binary patch literal 1792 zcmV+b2mkn*=tR=*g2wnI{ zEkWG*=+Qq;k?S)u-jwv^eL%(06H26t?Q_ox2nu`I*?Jx^=}#CuZ@Y&%zoItl5OPgI z^jQ~Rv7@Qh+ewR-Y04QLem^piPrW05N0QgXcQ#R2h4ZQ0Ey{`r7KkarH2)5>C%r73 z{k~;y%kdVc7L$SRNpztiksgepkf?yW8z$rOT$@{oVeE?~CCFbAenbk>Cv?D)AM>9T zb07s4YAmL>Lne&Ur)o}ryh4v389QAVwJFx7sO~v*O>&g-naeBP1dh+EZ0= zf@A^>Qd%d{ujMjeMDx<9*D>RpbV3qmMpw1-3rFb^Iqrsf`?}YwAh}l<$w`B-bz`(1 zStlwoxO@wW)Q*lvo-Uwg8CGBrH@3eA9i0q}tZ;1>iM&e>wG|OM7>3rFkS3xsgb(=n z0=I#OwZ}1B7te*aL^v%Ytk`B+CEG^yw%Lx?2|-5T@yGi(c8Kyp=~dz5YbC&oFZ4Nz zdQ`LKtuQWhLATN(UX1}6$!gO+U0QeF@s>A8hN9PDYa?;XEMMaxdvn`!tWe+YYD=|n zw-sybn=TFGRZS%m#t+t{GqvSdb+ctAF8W4^=M*aniv=zVAI4qiS1^YQd<6Bl<{>Zu z6*2}oK8kI)2z-x{_kT?Bz2Oy`VJ*yY)*{f*)DreFtn$OfW=>lGi*f|{eRbo|3l=I0 z$}dXv<+TwoSEH@z(>Drp6te!EK&Cf%OcauC4ntizRnnuE?-Ej&g0A<T>;@K?#bO(PVa={9=c)Ky`VRp*1aHq1ft^k#NPwVrlqnNE3>O@m%*5daw z;8j9;5R)={hwvv_rj)(g**jl6U`$R$KWt>}%I!lQ9STbT?ze58Tp zYg-OFP&5=`73c>m|A#ne89S=}=qa>%ij_BRXDuQ|!7(=!)}eqhe45a97yS}c^&~%0 zz$cX?Ly!Ed@-*by_5S}hM(sk{?TxR23=jkcopt(Mj@PRjt05U1!8B|X@&bAa>gaFA z(?w*85@z^)F-Nix5%)aNbA*+%#hfH0ATY!`?O<$3`&>2n#nJVHiyI!MGP^!jjc?Yh z?sRTT7wka=ha9S_&#!+UJElGUa!4xBPWw;|@NlXBmRRs{XYpcu;y73CGeGZnViG5_iU;52hvfE zey2&N_};jNYTOJJ{zR`(=+HoF!f~JFADDs>j$(zy;7LTi~gX6;x)!{YN8 z1vj#L0N1%oV-6TA@NkbD4}@~59F`k8Dl*4r$C<_SX_6#7`<+aJkrauP$aQ2=`(xh6 z=f13uKt6KiBpTO!5Y!0Iu`faOAI-xkuP$^I*mSs!BS{L855?Bw`1Xe0MP=qK7pi&t z<_66iDip?YL1)ul)LMSMkMoY&USjitf2ludOlIN#xw!O#xk=yKi|d<4<6_8{FntZ5 zx=mhNF>uDDYUO$ja%jYiTv~-|U%+b{+%IH?KrbkCCP*Ic8Iv={y5Yy~n8TpkUHf&) zvRA(Z^c^*EO8B3f52$M-^b7K{5a_Ofx9s3YM31wX^1Cw=cqeEf#KVQ5;j&O3n67-=YSfd-Oc(!vf&x`|JL>a`+hQ7){8D7E^32L1M32t7qN7 zO=5{TNl-sh^>+O3~cBGFJr4V^tOMJ0~gognD=WM^y7_rg>e5ouCjQlRmM)QhGl z6vHtL^6(dPq)g988ZVvxOA~5;7lNVEVknT!84V+I(}d;MZWF3>ztrtUse?DJQbnxR zN~3bjt%*w{&F-$RAb`<8D56bXE>>K9x1G)UhtyPN39h@KoKIe0u0^VF+w;9$!KvoN KOfn2ldPn~rr(H$> literal 0 HcmV?d00001 From 6888c8c246058e9b0232ed64ce42d974eed1795b Mon Sep 17 00:00:00 2001 From: John Lees Date: Fri, 31 Jul 2026 16:09:32 +0100 Subject: [PATCH 12/13] Also fix a bug with sample order after delete --- src/sketch/multisketch.rs | 33 +++++++++++++++++++++++++++------ 1 file changed, 27 insertions(+), 6 deletions(-) diff --git a/src/sketch/multisketch.rs b/src/sketch/multisketch.rs index 478dc09..d8884ba 100644 --- a/src/sketch/multisketch.rs +++ b/src/sketch/multisketch.rs @@ -152,21 +152,18 @@ impl MultiSketch { let decompress_reader = snap::read::FrameDecoder::new(skm_file); let mut skm_obj: Self = ciborium::de::from_reader(decompress_reader)?; - let version_ok = parse_version(&skm_obj.sketch_version) - .is_some_and(|version| version >= MIN_SKETCH_VERSION); - if !version_ok { + if skm_obj.is_legacy_format() { let found_version = if skm_obj.sketch_version.is_empty() { "" } else { &skm_obj.sketch_version }; - log::error!( - "Sketch file {filename} was created with sketchlib v{found_version}, which is older than the minimum supported v{}.{}.{}. Please re-sketch with the current version.", + log::warn!( + "Sketch file {filename} was created with sketchlib v{found_version}, older than v{}.{}.{} — legacy mode in use, resketching advised.", MIN_SKETCH_VERSION.0, MIN_SKETCH_VERSION.1, MIN_SKETCH_VERSION.2 ); - bail!("Incompatible sketch file version"); } // For backwards compatibility (field added in v0.2.0) @@ -213,6 +210,17 @@ impl MultiSketch { &self.hash_type } + /// Returns `true` if this database predates [`MIN_SKETCH_VERSION`] (i.e. + /// was written with the legacy 14-bit, `SIGN_MOD`-based binning scheme + /// used before v0.4). Legacy databases can still have distances + /// calculated — `distances::self_dists_*`/`distances::cross_dists_*` + /// dispatch to the legacy-width Jaccard math automatically based on this + /// flag — but cannot be freshly created, and cannot be compared (query + /// mode) or merged against a new-format database. + pub fn is_legacy_format(&self) -> bool { + parse_version(&self.sketch_version).is_none_or(|version| version < MIN_SKETCH_VERSION) + } + /// The stored metadata for each loaded sketch pub fn sketch_metadata(&self) -> &[Sketch] { &self.sketch_metadata @@ -311,6 +319,7 @@ impl MultiSketch { self.kmer_lengths() == sketch2.kmer_lengths() && self.sketch_size == sketch2.sketch_size && self.get_hash_type() == sketch2.get_hash_type() + && self.is_legacy_format() == sketch2.is_legacy_format() } /// Checks for append compatibility with another [`MultiSketch`] object @@ -384,7 +393,19 @@ impl MultiSketch { } } + // Re-index retained samples to their new (compacted) position and + // rebuild name_map to match. `remove_genomes` filters the .skd in the + // same original relative order (using the pre-mutation name_map/count, + // by design called before this), so position `idx` here also matches + // the newly written .skd record `idx` it produces. + let mut new_name_map = HashMap::with_capacity(new_sketch_metadata.len()); + for (idx, sketch) in new_sketch_metadata.iter_mut().enumerate() { + sketch.set_index(idx); + new_name_map.insert(sketch.name().to_string(), idx); + } + self.sketch_metadata = new_sketch_metadata; + self.name_map = new_name_map; self.save_metadata(output_file_name)?; Ok(()) } From f494bd0bebad3ce6fec7891ceeed66c977a2cc2f Mon Sep 17 00:00:00 2001 From: John Lees Date: Fri, 31 Jul 2026 16:24:56 +0100 Subject: [PATCH 13/13] lints and format --- README.md | 2 +- src/distances/distance_matrix.rs | 4 +++- src/distances/jaccard.rs | 10 ++++++++-- src/distances/mod.rs | 24 +++++++++++------------- src/lib.rs | 2 ++ src/sketch/mod.rs | 6 ++++-- tests/distance.rs | 21 ++++++++++++++------- tests/sketch.rs | 5 +++-- 8 files changed, 46 insertions(+), 28 deletions(-) diff --git a/README.md b/README.md index 0aaebf5..23588c9 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ allowing subsets of samples to be compared. ### News - **Data format changed in v0.4.0**. If you have an older database (see `sketchlib info | grep "sketch_version"`) -you should resketch samples with >v0.4. Otherwise you will need to use an older release available on crates.io. +you should resketch samples with >v0.4 if possible. Otherwise you will need to use an older release available on crates.io. - v0.2.0 was the first stable release. ## Documentation diff --git a/src/distances/distance_matrix.rs b/src/distances/distance_matrix.rs index f234bca..96894be 100644 --- a/src/distances/distance_matrix.rs +++ b/src/distances/distance_matrix.rs @@ -176,7 +176,9 @@ impl<'a> DistanceMatrix<'a> { if self.jaccard == DistType::CoreAcc { Box::new( self.distances - .chunks_exact(2) + .as_chunks::<2>() + .0 + .iter() .map(|pair| (pair[0], Some(pair[1]))), ) } else { diff --git a/src/distances/jaccard.rs b/src/distances/jaccard.rs index a44ebdd..97d8883 100644 --- a/src/distances/jaccard.rs +++ b/src/distances/jaccard.rs @@ -192,8 +192,14 @@ mod tests { let mut sketch2 = [u64::MAX; BIN_BITS * 2]; sketch2[BIN_BITS] = 0; - assert_eq!(jaccard_same_bits_general::(&sketch1, &sketch1), 128); - assert_eq!(jaccard_same_bits_general::(&sketch1, &sketch2), 64); + assert_eq!( + jaccard_same_bits_general::(&sketch1, &sketch1), + 128 + ); + assert_eq!( + jaccard_same_bits_general::(&sketch1, &sketch2), + 64 + ); } #[cfg(target_arch = "aarch64")] diff --git a/src/distances/mod.rs b/src/distances/mod.rs index 2fbbefd..5d34cde 100644 --- a/src/distances/mod.rs +++ b/src/distances/mod.rs @@ -273,8 +273,7 @@ fn self_dists_all_stream_generic( } else { (1.0_f64 - j_index) as f32 }; - let _ = - writeln!(buf, "{}\t{}\t{dist}", ref_names[i], ref_names[j]); + let _ = writeln!(buf, "{}\t{}\t{dist}", ref_names[i], ref_names[j]); } else { let d = core_acc_dist_generic::( sketches, @@ -460,7 +459,10 @@ pub fn cross_dists_all<'a>( query_sketches.is_legacy_format(), ); if ref_legacy != query_legacy { - panic!("{}", mismatched_generation_message(ref_legacy, query_legacy)); + panic!( + "{}", + mismatched_generation_message(ref_legacy, query_legacy) + ); } if ref_legacy { cross_dists_all_generic::( @@ -670,11 +672,7 @@ fn cross_dists_all_stream_generic( } else { (1.0_f64 - j_index) as f32 }; - let _ = writeln!( - buf, - "{}\t{}\t{dist}", - ref_names[i], query_names[j] - ); + let _ = writeln!(buf, "{}\t{}\t{dist}", ref_names[i], query_names[j]); } else { let d = core_acc_dist_generic::( ref_sketches, @@ -736,7 +734,10 @@ pub fn cross_dists_knn<'a>( query_sketches.is_legacy_format(), ); if ref_legacy != query_legacy { - panic!("{}", mismatched_generation_message(ref_legacy, query_legacy)); + panic!( + "{}", + mismatched_generation_message(ref_legacy, query_legacy) + ); } if ref_legacy { cross_dists_knn_generic::( @@ -1088,10 +1089,7 @@ mod tests { } for i in 0..n { for j in (i + 1)..n { - assert!( - unique.contains(&(i, j)), - "missing pair ({i},{j}) for n={n}" - ); + assert!(unique.contains(&(i, j)), "missing pair ({i},{j}) for n={n}"); } } } diff --git a/src/lib.rs b/src/lib.rs index 4db095a..ea9eff7 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -20,6 +20,8 @@ //! Sketch databases have two files: `.skm` which is the metadata (samples names, base counts etc) //! and `.skd` which is the actual sketch data. These must have the same prefix. //! +//! *NB: Sketch format was updated in v0.4.0 (breaking). Previous databases are 'legacy' and you can still calculate distances within them, but not as queries between legacy and new format databases. We recommend resketching if possible. Older releases are also available to continue to work with these files in full.* +//! //! Inverted indexes are `.ski` files, and should be specified using their full name (not just the prefix). //! These optionally include and `.skq` file which is needed if used for preclustering. //! diff --git a/src/sketch/mod.rs b/src/sketch/mod.rs index 39a56cd..840f99f 100644 --- a/src/sketch/mod.rs +++ b/src/sketch/mod.rs @@ -308,8 +308,10 @@ impl Sketch { debug_assert_eq!(usigs.len(), signs.len() / (u64::BITS as usize) * BIN_BITS); for (usig_chunk, sign_chunk) in usigs - .chunks_exact_mut(BIN_BITS) - .zip(signs.chunks_exact(u64::BITS as usize)) + .as_chunks_mut::() + .0 + .iter_mut() + .zip(signs.as_chunks::<{ u64::BITS as usize }>().0.iter()) { for (bit_pos, usig) in usig_chunk.iter_mut().enumerate() { *usig = sign_chunk diff --git a/tests/distance.rs b/tests/distance.rs index 8dfb7e6..9bebb41 100644 --- a/tests/distance.rs +++ b/tests/distance.rs @@ -43,7 +43,8 @@ mod tests { ) }; let mut actual_lines: Vec<&str> = actual.lines().filter(|l| !l.is_empty()).collect(); - let mut expected_lines: Vec<&str> = expected_str.lines().filter(|l| !l.is_empty()).collect(); + let mut expected_lines: Vec<&str> = + expected_str.lines().filter(|l| !l.is_empty()).collect(); actual_lines.sort_by_key(name_key); expected_lines.sort_by_key(name_key); assert_eq!( @@ -866,7 +867,11 @@ mod tests { { assert_abs_diff_eq!(*core, *iter_core as f64, epsilon = 1e-4); let iter_accessory = iter_accessory.expect("dists_iter should yield Some for CoreAcc"); - assert_abs_diff_eq!(accessory.expect("Some in CoreAcc"), iter_accessory as f64, epsilon = 1e-4); + assert_abs_diff_eq!( + accessory.expect("Some in CoreAcc"), + iter_accessory as f64, + epsilon = 1e-4 + ); } // Jaccard matrix (-k given): dists_iter should yield None @@ -875,8 +880,7 @@ mod tests { let display_pairs = parse_display_pairs(&jaccard_matrix.to_string(), false); let iter_pairs: Vec<(f32, Option)> = jaccard_matrix.dists_iter().collect(); assert_eq!(display_pairs.len(), iter_pairs.len()); - for ((dist, _), (iter_dist, iter_accessory)) in - display_pairs.iter().zip(iter_pairs.iter()) + for ((dist, _), (iter_dist, iter_accessory)) in display_pairs.iter().zip(iter_pairs.iter()) { assert_abs_diff_eq!(*dist, *iter_dist as f64, epsilon = 1e-4); assert!(iter_accessory.is_none()); @@ -1210,7 +1214,9 @@ mod tests { let n = legacy.number_samples_loaded(); let n_query = fresh.number_samples_loaded(); let dist_type = set_k(&legacy, Some(17), false).expect("set_k failed"); - cross_dists_all(&legacy, &fresh, n, n_query, dist_type, true, None, None, 0.0); + cross_dists_all( + &legacy, &fresh, n, n_query, dist_type, true, None, None, 0.0, + ); } #[test] @@ -1245,8 +1251,9 @@ mod tests { ); let err = result.expect_err("expected mismatched-generation rejection"); assert!( - err.to_string() - .contains("Cannot compare reference and query databases with different sketch generations"), + err.to_string().contains( + "Cannot compare reference and query databases with different sketch generations" + ), "Unexpected error: {err}" ); } diff --git a/tests/sketch.rs b/tests/sketch.rs index a43fa29..ac31df0 100644 --- a/tests/sketch.rs +++ b/tests/sketch.rs @@ -149,8 +149,9 @@ mod tests { use sketchlib::sketch::multisketch::MultiSketch; let sandbox = TestSetup::setup(); - let sketches = MultiSketch::load_metadata(&sandbox.file_string("legacy_db", TestDir::Input)) - .expect("Loading a pre-v0.4 sketch file should now succeed (legacy mode)"); + let sketches = + MultiSketch::load_metadata(&sandbox.file_string("legacy_db", TestDir::Input)) + .expect("Loading a pre-v0.4 sketch file should now succeed (legacy mode)"); assert!( sketches.is_legacy_format(), "legacy_db.skm (v0.1.3) should be detected as legacy format"