twitcher 0.6.6

Find template switch mutations in genomic data
use std::borrow::Borrow;

use rust_htslib::bcf::Record;

pub use crate::common::cluster_settings::ClusteringSettings;

impl ClusteringSettings {
    pub fn belongs<R: Borrow<Record>>(
        &self,
        last: Option<R>,
        candidate: R,
    ) -> anyhow::Result<bool> {
        let Some(last) = last else {
            return Ok(true);
        };
        Ok(candidate.borrow().pos().saturating_sub(last.borrow().end())
            <= self.max_gap.try_into()?)
    }

    pub fn is_cluster<R, F: Fn(&R) -> &Record, G: Fn(&R) -> Option<u32>>(
        &self,
        cluster: &[R],
        get_record: F,
        get_allele: G,
    ) -> bool {
        if cluster.is_empty() {
            return false;
        }
        let first = get_record(cluster.first().unwrap().borrow());
        let last = get_record(cluster.last().unwrap().borrow());
        // Reference extent of the cluster: `end()` is the exclusive end of the last record's
        // reference allele, so the difference is the number of reference bases covered.
        let l_ref = first.pos().abs_diff(last.end()) as f64;
        // Accumulate cluster mass, the net length change (Σ alt_len − ref_len) so we can derive
        // the query extent, and the number of events. Records without a usable allele pair are
        // skipped entirely: they contribute no mass, so they must not count towards the minimum
        // number of events either.
        let (mass, net, count) = cluster
            .iter()
            .map(|r| (get_record(r), get_allele(r)))
            .filter_map(|(r, a)| self.chosen_allele_lens(r, a))
            .fold((0.0, 0.0, 0), |(mass, net, count), (ref_len, alt_len)| {
                (
                    mass + self.event_mass(ref_len, alt_len),
                    net + alt_len as f64 - ref_len as f64,
                    count + 1,
                )
            });
        let span = self.span(l_ref, l_ref + net);
        self.is_valid_cluster(mass, span, count)
    }

    /// Lengths of the reference allele and the relevant alt allele for a record. When no specific
    /// allele is requested, the alt maximizing [`ClusteringSettings::event_mass`] is chosen so mass
    /// and span use the same allele. Returns `None` for records without at least two alleles.
    fn chosen_allele_lens(&self, record: &Record, allele: Option<u32>) -> Option<(usize, usize)> {
        let alls = record.alleles();
        if alls.len() < 2 {
            return None;
        }
        let ref_len = alls[0].len();
        let alt_len = if let Some(a) = allele {
            alls.get(a as usize)?.len()
        } else {
            alls[1..]
                .iter()
                .map(|a| a.len())
                .max_by(|x, y| {
                    self.event_mass(ref_len, *x)
                        .partial_cmp(&self.event_mass(ref_len, *y))
                        .unwrap()
                })
                .unwrap()
        };
        Some((ref_len, alt_len))
    }
}

#[cfg(test)]
mod tests {
    use rust_htslib::bcf::{self, Header, Writer, record::GenotypeAllele};

    use crate::common::cluster_settings::{ClusterStrategy, ClusteringSettings};

    fn make_writer() -> Writer {
        let mut header = Header::new();
        header.push_record(b"##contig=<ID=chr1,length=1000000>");
        header.push_record(b"##FORMAT=<ID=GT,Number=1,Type=String,Description=\"Genotype\">");
        header.push_sample(b"S1");
        Writer::from_path("/dev/null", &header, true, bcf::Format::Vcf).unwrap()
    }

    /// A minimal heterozygous record at the 0-based `pos` with the given alleles.
    fn make_record(writer: &Writer, pos: i64, alleles: &[&[u8]]) -> bcf::Record {
        let mut rec = writer.empty_record();
        let rid = rec.header().name2rid(b"chr1").unwrap();
        rec.set_rid(Some(rid));
        rec.set_pos(pos);
        rec.set_alleles(alleles).unwrap();
        rec.push_genotypes(&[GenotypeAllele::Unphased(0), GenotypeAllele::Unphased(1)])
            .unwrap();
        rec
    }

    fn is_cluster(records: &[bcf::Record], settings: &ClusteringSettings) -> bool {
        settings.is_cluster(records, |r| r, |_| None)
    }

    /// The span is the reference extent, not one base more: two SNVs 10 bases apart sit exactly at
    /// the default density threshold (mass 2 / span 10 = 0.2).
    #[test]
    fn legacy_span_is_the_reference_extent() {
        let w = make_writer();
        let settings = ClusteringSettings::default();

        let exact = [
            make_record(&w, 100, &[b"A", b"T"]),
            make_record(&w, 109, &[b"A", b"T"]),
        ];
        assert!(is_cluster(&exact, &settings));

        // One base further apart: mass 2 / span 11 = 0.18, below the threshold.
        let too_sparse = [
            make_record(&w, 100, &[b"A", b"T"]),
            make_record(&w, 110, &[b"A", b"T"]),
        ];
        assert!(!is_cluster(&too_sparse, &settings));
    }

    /// A lone insertion covers a single reference base but 21 query bases, so only the symmetric
    /// span of the `edit-mass` strategy makes it dense enough — and only that strategy admits a
    /// single-record cluster at all.
    #[test]
    fn edit_mass_span_follows_the_query_extent() {
        let w = make_writer();
        // 20-base insertion in VCF anchor form: ref "A", alt "A" + 20 bases.
        let alt = vec![b'A'; 21];
        let insertion = [make_record(&w, 100, &[b"A", &alt])];

        assert!(is_cluster(
            &insertion,
            &ClusteringSettings {
                strategy: ClusterStrategy::EditMass,
                ..Default::default()
            }
        ));
        // Legacy needs two records, so the same insertion is not a cluster there.
        assert!(!is_cluster(&insertion, &ClusteringSettings::default()));
    }

    /// A record without an alt allele contributes no mass, so it must not count towards
    /// `--cluster-min-records` either.
    #[test]
    fn record_without_alt_allele_does_not_count_as_an_event() {
        let w = make_writer();
        let mut ref_only = w.empty_record();
        let rid = ref_only.header().name2rid(b"chr1").unwrap();
        ref_only.set_rid(Some(rid));
        ref_only.set_pos(101);
        ref_only.set_alleles(&[b"A"]).unwrap();

        let records = [make_record(&w, 100, &[b"A", b"T"]), ref_only];
        // One real event only: below the legacy minimum of two.
        assert!(!is_cluster(&records, &ClusteringSettings::default()));
    }
}