From 9a3970037976b06a51b5f933a8ac0798d76bd1b8 Mon Sep 17 00:00:00 2001 From: wdecoster Date: Thu, 4 Jun 2026 10:02:23 +0200 Subject: [PATCH 1/8] fix: cap haplotype cluster-size threshold by --support (CNV-robust) The minimum cluster size required for a read cluster to be treated as a haplotype in unphased mode was computed purely as a fraction of the total read count (min_haplotype_fraction). Under copy-number gain the inflated read count raises this fraction-based threshold, so a genuine minority/ expanded allele supported by enough reads gets discarded as "too small". find_roots then descends into the majority cluster and over-splits it into two near-identical haplotypes, and find_outliers dumps the real expansion as length outliers (false-negative genotype). Cap the fraction-based threshold by the absolute --support count so that an allele backed by at least `support` reads can never be swallowed by high coverage. Behaviour is unchanged whenever fraction*n <= support (all normal coverage, incl. every existing test); it only relaxes the bar in the high- coverage/CNV regime that caused the bug. Regression test added from chr15:34419425-34419451 (GOLGA8A) where a length-variable expansion on 5/44 reads was lost. Co-Authored-By: Claude Opus 4.8 --- src/genotype.rs | 2 + src/phase_insertions.rs | 90 +++++++++++++++++++++++++++++++++++++++-- 2 files changed, 89 insertions(+), 3 deletions(-) diff --git a/src/genotype.rs b/src/genotype.rs index 400853c..fe78d39 100644 --- a/src/genotype.rs +++ b/src/genotype.rs @@ -276,6 +276,7 @@ pub fn genotype_with_extracted_reads( repeat, args.find_outliers, args.min_haplotype_fraction, + args.support, ); match phased.hap2 { Some(phase2) => { @@ -556,6 +557,7 @@ fn genotype_repeat( repeat, args.find_outliers, args.min_haplotype_fraction, + args.support, ); match phased.hap2 { Some(phase2) => { diff --git a/src/phase_insertions.rs b/src/phase_insertions.rs index 70f2f56..665b2e4 100644 --- a/src/phase_insertions.rs +++ b/src/phase_insertions.rs @@ -15,6 +15,7 @@ pub fn split( repeat: &crate::repeats::RepeatInterval, check_outliers: bool, min_haplotype_fraction: f32, + support: usize, ) -> SplitSequences { // the insertions are from an unphased experiment // and should be split in one (if homozygous) or two haplotypes @@ -112,12 +113,19 @@ pub fn split( // create a vector with candidate haplotype-clusters let mut haplotype_clusters = vec![]; // clusters have to represent at least min_haplotype_fraction of the reads, but never less than 1 - let min_cluster_size = max((insertions.len() as f32 * min_haplotype_fraction) as usize, 1); + // the fraction-based threshold is capped by `support` so that copy-number gains (which inflate + // the total read count, and thus the fraction-based threshold) cannot swallow a genuine minority + // allele that is supported by at least `support` reads + let min_cluster_size = max( + ((insertions.len() as f32 * min_haplotype_fraction) as usize).min(support), + 1, + ); debug!( - "{repeat}: Minimum cluster size: {} reads ({}% of {} total)", + "{repeat}: Minimum cluster size: {} reads (min of {}% of {} total and support {})", min_cluster_size, min_haplotype_fraction * 100.0, - insertions.len() + insertions.len(), + support ); for (index, step) in dend.steps().iter().enumerate() { @@ -383,6 +391,76 @@ mod tests { #[allow(unused_imports)] use super::*; + #[test] + fn test_split_expansion_minority_allele() { + // Regression test for chr15:34419425-34419451 from rr_NA99_170.log + // A real, length-variable expansion is present on only 5/44 reads (the rest + // are the short reference allele). The expansion reads must be recovered as a + // haplotype and must NOT all be discarded as length outliers. + let r43 = "TCTTTCTTTCTTTCCTTTCCTTTCCTTTCCTTTCCTTTCCTTCCTTCCCTTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTTTTTCTTTCTTTCTTT".to_string(); + let r2 = "TCTTTCTTTCTTCCTTTCCTTTCCTTTCCTTTCCTTTCCTTCCTTCCCTTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTTTCTTTCTTTCTTT".to_string(); + let r33 = "CTTTCTTTCCTTTCCTTTCCTTTCCTTTCCTTTCCTTCCTTCCCCCTGAGAGAGAGAGAGAGAGAGAGAGAGAGAGAGAGAGAGAGAGAGAGAGAGAGAGAGAGAGAGAGAGAGAGAGAGAGAAAAAAAAAAAAAAAAAAAAAAAATCTCTCTCTCTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTTTCTTTCTTTCTTTCTTTCTTTC".to_string(); + let r8 = "TCTTTCTTTCTTTCCTTTCCTTTCCTTTCCTTTCCTTTCCTTCCTTCCCTTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTTTCTTCTTTCTTT".to_string(); + let r23 = "TCTTTCTTTCCTTTCCTTTCCTTTCCTTTCCTTTCCTTCCTTCCCTTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTTCTCTCTCTCTCTCTCTCTCTTTCTTTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTCTTTCTTTCTTTCTTT".to_string(); + + let mut insertions = vec![r43, r2, r33, r8, r23]; + // 39 short reference-allele reads (lengths matching the logged distribution) + for _ in 0..20 { + insertions.push("TTTCTTTCTTTCTTTCTTTCTTTCTTT".to_string()); // 27 + } + for _ in 0..7 { + insertions.push("TTTCTTTCTTTCTTTCTTTCTTTCTTTT".to_string()); // 28 + } + for _ in 0..2 { + insertions.push("TTTCTTTCTTTCTTTCTTTCTTTCTT".to_string()); // 26 + } + for _ in 0..2 { + insertions.push("TTTCTTTCTTTCTTTCTTTCTTTCTTTTT".to_string()); // 29 + } + insertions.push("CTTTTCTTTCTTTCTTTCTTTC".to_string()); // 22 + insertions.push("TTTCTTTCTTTCTTTCTTTCTTT".to_string()); // 23 + insertions.push("TTTTCTTTCTTTCTTTCTTTCTTT".to_string()); // 24 + insertions.push("TTTCTTTCTTTCTTTCTTTCTTTCTTTCTTT".to_string()); // 31 + insertions.push("AGTTTCTTTCTTTCTTTCTTTCTTTTTTTCTTT".to_string()); // 33 + insertions.push("TTTCTTTCTTTCTTTCTTTTTTTCTTTTTTTCTTT".to_string()); // 35 + for _ in 0..2 { + insertions.push("TTTCTTTCTTTCTTTCTTTCTTTCTTTTTTTTTT".to_string()); // 34 + } + assert_eq!(insertions.len(), 44); + + use rand::seq::SliceRandom; + let mut rng = rand::rng(); + insertions.shuffle(&mut rng); + + let splitseqs = split( + &insertions, + &crate::repeats::RepeatInterval { + chrom: "chr15".to_string(), + start: 34419425, + end: 34419451, + created: None, + }, + true, + 0.1, + 2, + ); + + let hap2 = splitseqs + .hap2 + .expect("expected a heterozygous call with two haplotypes"); + // One of the two haplotypes should capture the expansion (median length >> reference) + let expansion_captured = + find_median(&splitseqs.hap1) > 100 || find_median(&hap2) > 100; + let n_outliers = splitseqs.outliers.as_ref().map_or(0, |o| o.len()); + assert!( + expansion_captured, + "expansion not captured as a haplotype; hap1 median {}, hap2 median {}, {} outliers", + find_median(&splitseqs.hap1), + find_median(&hap2), + n_outliers + ); + } + #[test] fn test_split_length() { // test that the split function identifies two haplotype, mainly based on insertion length @@ -409,6 +487,7 @@ mod tests { }, false, 0.1, + 3, ); assert!(splitseqs.hap1.len() == splitseqs.hap2.unwrap().len()); // check that all sequences in hap1 are the same length @@ -453,6 +532,7 @@ mod tests { }, false, 0.1, + 3, ); let mut hap1 = splitseqs.hap1; let mut hap2 = splitseqs.hap2.unwrap(); @@ -494,6 +574,7 @@ mod tests { }, false, 0.1, + 3, ); assert!(splitseqs.hap1.len() + splitseqs.hap2.unwrap().len() == insertions.len()); } @@ -538,6 +619,7 @@ mod tests { }, false, 0.1, + 3, ); assert!(splitseqs.hap2.is_none()); println!("hap1: {:?}", splitseqs.hap1); @@ -580,6 +662,7 @@ mod tests { }, false, 0.1, + 3, ); let mut hap1 = splitseqs.hap1; let mut hap2 = splitseqs.hap2.unwrap(); @@ -653,6 +736,7 @@ mod tests { }, false, 0.1, + 3, ); let mut hap1 = splitseqs.hap1; let mut hap2 = splitseqs.hap2.unwrap(); From 0d4e3eb4625d73c9af52eeef921757d3403aff40 Mon Sep 17 00:00:00 2001 From: wdecoster Date: Thu, 4 Jun 2026 10:41:30 +0200 Subject: [PATCH 2/8] fix: seed downsampling RNG for deterministic consensus MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Read downsampling for consensus used an unseeded rand::rng(), so any haplotype with more reads than --consensus-reads (default 20) drew a different random subset each run, making the consensus length (and thus the genotype) non-reproducible run-to-run. This dominated cohort A/B diffs, especially at high-variance expansion loci. Seed a StdRng with a fixed constant per consensus() call: deterministic and independent of locus/thread ordering. Not exposed as a CLI flag — the subset choice is purely a performance measure and shouldn't affect reproducibility. Co-Authored-By: Claude Opus 4.8 --- src/consensus.rs | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/src/consensus.rs b/src/consensus.rs index d4e7672..ea71e13 100644 --- a/src/consensus.rs +++ b/src/consensus.rs @@ -1,8 +1,17 @@ use bio::alignment::{pairwise::Scoring, poa::Aligner}; use log::debug; +use rand::SeedableRng; +use rand::rngs::StdRng; use rand::seq::IteratorRandom; use std::fmt; +/// Fixed seed for the read downsampling RNG. Downsampling is purely a +/// performance/memory measure, so the choice of subset should not make the +/// genotype non-reproducible: seeding with a constant makes every run (and +/// every comparison between runs) deterministic and independent of locus or +/// thread ordering. +const DOWNSAMPLE_SEED: u64 = 42; + #[derive(Clone)] pub struct Consensus { pub seq: Option, @@ -47,7 +56,7 @@ pub fn consensus( Consensus { seq: None, support: num_reads, std_dev, score: -1 } } else if consensus_reads == 1 { // if only on read should be used to generate the consensus, the consensus is a randomly selected read - let seq = seqs.into_iter().choose(&mut rand::rng()).unwrap(); + let seq = seqs.into_iter().choose(&mut StdRng::seed_from_u64(DOWNSAMPLE_SEED)).unwrap(); Consensus { seq: Some(seq.to_string()), support: num_reads, std_dev, score: 0 } } else { // if there are more than reads, downsample before taking the consensus @@ -55,7 +64,7 @@ pub fn consensus( let seqs_bytes = if num_reads > consensus_reads { debug!("{repeat}: Too many reads, downsampling to {consensus_reads}"); seqs.into_iter() - .sample(&mut rand::rng(), consensus_reads) + .sample(&mut StdRng::seed_from_u64(DOWNSAMPLE_SEED), consensus_reads) .into_iter() .map(|seq| seq.bytes().collect::>()) .collect::>>() From 55761f6906dc0c3ab7c1c6b33d89a12987dfe19f Mon Sep 17 00:00:00 2001 From: wdecoster Date: Thu, 4 Jun 2026 11:16:43 +0200 Subject: [PATCH 3/8] feat: optional DBSCAN phasing strategy (--phasing-strategy dbscan) Adds an opt-in alternative to the default Ward strategy for splitting unphased reads into haplotypes. Instead of a length-dominated Levenshtein distance (which fragments length-variable expansions and can lose them to the outlier bin), it builds length-invariant k-mer composition feature vectors and clusters them with DBSCAN, so reads of the same motif group together regardless of repeat length. - src/features.rs: canonical k-mer feature vectors + tandem-period detection (lifted from the trout cohort tool). - src/dbscan.rs: custom DBSCAN over CSR adjacency returning cluster labels. - split_dbscan(): min_samples = --support; the two largest clusters become the haplotypes, remaining clusters + noise become outliers. Reports n_clusters. - New INFO/NCLUSTERS field (DBSCAN mode only) so complex multi-population loci (>2 clusters) can be flagged downstream. - CLI: --phasing-strategy {ward,dbscan} (default ward), --dbscan-eps (0.2), --dbscan-length-weight (1.0). Default behaviour is unchanged (Ward remains the default). On the GOLGA8A regression locus the DBSCAN path recovers the expansion as a distinct haplotype (hap2 median ~606bp) instead of dropping it as outliers. Co-Authored-By: Claude Opus 4.8 --- src/dbscan.rs | 168 ++++++++++++++++++++++++++++++++++++ src/features.rs | 186 ++++++++++++++++++++++++++++++++++++++++ src/genotype.rs | 71 ++++++++++++--- src/main.rs | 30 ++++++- src/phase_insertions.rs | 167 +++++++++++++++++++++++++++++++++--- src/vcf.rs | 23 ++++- 6 files changed, 617 insertions(+), 28 deletions(-) create mode 100644 src/dbscan.rs create mode 100644 src/features.rs diff --git a/src/dbscan.rs b/src/dbscan.rs new file mode 100644 index 0000000..d92b5e5 --- /dev/null +++ b/src/dbscan.rs @@ -0,0 +1,168 @@ +//! DBSCAN clustering for the optional `--phasing-strategy dbscan` path. +//! +//! Adapted from the `trout` cohort-outlier tool, which used a custom DBSCAN +//! over a precomputed CSR adjacency for speed (one pairwise distance per pair, +//! no per-range-query allocations). Here it returns full cluster *labels* +//! (not just a noise mask) because phasing needs the read groupings. +//! +//! Distances are squared Euclidean over the feature vectors from +//! [`crate::features`]; `min_samples` is the standard DBSCAN core-point +//! threshold (a point counts itself, so a core point has ≥ `min_samples` +//! members including itself). + +/// Cluster label for each point. `None` == noise (DBSCAN outlier). +pub type Label = Option; + +/// Run DBSCAN and return a per-point label vector. Clustered points get a +/// `Some(cluster_id)`; noise points get `None`. Cluster ids are assigned in +/// the order clusters are discovered (0, 1, 2, …). +pub fn cluster(points: &[Vec], eps: f64, min_samples: usize) -> Vec