use super::complex::compute_cds_projected_length;
use super::{Consequence, Impact};
use crate::error::VarEffectError;
use crate::locate::LocateIndex;
use crate::transcript::TranscriptStore;
use crate::types::{Strand, TranscriptModel};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[non_exhaustive]
pub enum SvKind {
Deletion,
Duplication,
Inversion,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum SvShape {
Deletion,
Duplication,
Inversion,
Breakend,
Insertion,
}
impl From<SvKind> for SvShape {
fn from(kind: SvKind) -> Self {
match kind {
SvKind::Deletion => SvShape::Deletion,
SvKind::Duplication => SvShape::Duplication,
SvKind::Inversion => SvShape::Inversion,
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct SvConsequenceResult {
pub transcript: String,
pub gene_symbol: String,
pub hgnc_id: Option<String>,
pub strand: Strand,
pub tx_start: u64,
pub tx_end: u64,
pub consequences: Vec<Consequence>,
pub impact: Impact,
pub spans_whole_transcript: bool,
pub transcript_overlap_fraction: f64,
pub cds_bases_overlapped: u32,
pub exons_overlapped: u16,
pub overlaps_first_exon: bool,
pub overlaps_last_exon: bool,
pub breakpoint_within_transcript: bool,
pub both_breakpoints_within_tx: bool,
}
impl SvConsequenceResult {
pub fn most_severe_consequence(&self) -> Option<Consequence> {
self.consequences.first().copied()
}
}
struct ExonOverlap {
cds_bases_overlapped: u32,
exons_overlapped: u16,
overlaps_first_exon: bool,
overlaps_last_exon: bool,
}
fn compute_exon_overlap(sv_start: u64, sv_end: u64, transcript: &TranscriptModel) -> ExonOverlap {
let last_idx = transcript.exons.len().saturating_sub(1);
let mut exons_overlapped = 0u16;
let mut overlaps_first_exon = false;
let mut overlaps_last_exon = false;
for (i, exon) in transcript.exons.iter().enumerate() {
let lo = sv_start.max(exon.genomic_start);
let hi = sv_end.min(exon.genomic_end);
if lo < hi {
exons_overlapped += 1;
if i == 0 {
overlaps_first_exon = true;
}
if i == last_idx {
overlaps_last_exon = true;
}
}
}
ExonOverlap {
cds_bases_overlapped: compute_cds_projected_length(sv_start, sv_end, transcript),
exons_overlapped,
overlaps_first_exon,
overlaps_last_exon,
}
}
fn push_unique(out: &mut Vec<Consequence>, c: Consequence) {
if !out.contains(&c) {
out.push(c);
}
}
fn sv_region_consequences(fs: u64, fe: u64, transcript: &TranscriptModel) -> Vec<Consequence> {
let mut out: Vec<Consequence> = Vec::new();
let cds_bounds = match (transcript.cds_genomic_start, transcript.cds_genomic_end) {
(Some(start), Some(end)) if !transcript.cds_segments.is_empty() => Some((start, end)),
_ => None,
};
for exon in &transcript.exons {
let lo = fs.max(exon.genomic_start);
let hi = fe.min(exon.genomic_end);
if lo >= hi {
continue;
}
let Some((cds_start, cds_end)) = cds_bounds else {
push_unique(&mut out, Consequence::NonCodingTranscriptExonVariant);
continue;
};
if lo.max(cds_start) < hi.min(cds_end) {
push_unique(&mut out, Consequence::CodingSequenceVariant);
}
if lo < hi.min(cds_start) {
push_unique(
&mut out,
match transcript.strand {
Strand::Plus => Consequence::FivePrimeUtrVariant,
Strand::Minus => Consequence::ThreePrimeUtrVariant,
},
);
}
if lo.max(cds_end) < hi {
push_unique(
&mut out,
match transcript.strand {
Strand::Plus => Consequence::ThreePrimeUtrVariant,
Strand::Minus => Consequence::FivePrimeUtrVariant,
},
);
}
}
let mut spans: Vec<(u64, u64)> = transcript
.exons
.iter()
.map(|e| (e.genomic_start, e.genomic_end))
.collect();
spans.sort_unstable_by_key(|&(start, _)| start);
for pair in spans.windows(2) {
let intron_start = pair[0].1;
let intron_end = pair[1].0;
if intron_start < intron_end && fs.max(intron_start) < fe.min(intron_end) {
push_unique(&mut out, Consequence::IntronVariant);
break;
}
}
out
}
fn order_by_severity(consequences: &mut [Consequence]) {
consequences.sort_by(|a, b| {
b.impact()
.cmp(&a.impact())
.then_with(|| a.severity_rank().cmp(&b.severity_rank()))
});
}
fn build_sv_result(
fs: u64,
fe: u64,
shape: SvShape,
transcript: &TranscriptModel,
) -> SvConsequenceResult {
let overlap = compute_exon_overlap(fs, fe, transcript);
let spans_whole_transcript = fs <= transcript.tx_start && fe >= transcript.tx_end;
let both_breakpoints_within_tx = fs > transcript.tx_start && fe < transcript.tx_end;
let contained = fs >= transcript.tx_start && fe <= transcript.tx_end;
let exonic = overlap.exons_overlapped > 0;
let mut consequences = match shape {
SvShape::Deletion if spans_whole_transcript => vec![Consequence::TranscriptAblation],
SvShape::Duplication if spans_whole_transcript => {
vec![Consequence::TranscriptAmplification]
}
_ => {
let mut c = sv_region_consequences(fs, fe, transcript);
match shape {
SvShape::Breakend => push_unique(&mut c, Consequence::FeatureTruncation),
SvShape::Deletion if exonic => push_unique(&mut c, Consequence::FeatureTruncation),
SvShape::Duplication | SvShape::Insertion if contained && exonic => {
push_unique(&mut c, Consequence::FeatureElongation)
}
_ => {}
}
c
}
};
order_by_severity(&mut consequences);
let impact = consequences
.first()
.map_or(Impact::Modifier, |c| c.impact());
let tx_span = transcript.tx_end.saturating_sub(transcript.tx_start);
let covered = fe
.min(transcript.tx_end)
.saturating_sub(fs.max(transcript.tx_start));
let transcript_overlap_fraction = if tx_span == 0 {
0.0
} else {
covered as f64 / tx_span as f64
};
SvConsequenceResult {
transcript: transcript.accession.clone(),
gene_symbol: transcript.gene_symbol.clone(),
hgnc_id: transcript.hgnc_id.clone(),
strand: transcript.strand,
tx_start: transcript.tx_start,
tx_end: transcript.tx_end,
consequences,
impact,
spans_whole_transcript,
transcript_overlap_fraction,
cds_bases_overlapped: overlap.cds_bases_overlapped,
exons_overlapped: overlap.exons_overlapped,
overlaps_first_exon: overlap.overlaps_first_exon,
overlaps_last_exon: overlap.overlaps_last_exon,
breakpoint_within_transcript: !spans_whole_transcript,
both_breakpoints_within_tx,
}
}
pub(crate) fn annotate_interval(
chrom: &str,
start: u64,
end: u64,
sv_kind: SvKind,
store: &TranscriptStore,
) -> Result<Vec<SvConsequenceResult>, VarEffectError> {
if start >= end {
return Err(VarEffectError::Malformed(format!(
"SV interval start ({start}) must be < end ({end})"
)));
}
let shape = SvShape::from(sv_kind);
let overlaps: Vec<(&TranscriptModel, &LocateIndex)> = store.query_overlap(chrom, start, end);
let mut results = Vec::with_capacity(overlaps.len());
for (tx, _idx) in overlaps {
results.push(build_sv_result(start, end, shape, tx));
}
Ok(results)
}
pub(crate) fn annotate_breakend(
chrom: &str,
pos: u64,
store: &TranscriptStore,
) -> Vec<SvConsequenceResult> {
let overlaps = store.query_overlap(chrom, pos, pos + 1);
let mut results = Vec::with_capacity(overlaps.len());
for (tx, _idx) in overlaps {
results.push(build_sv_result(pos, pos + 1, SvShape::Breakend, tx));
}
results
}
pub(crate) fn annotate_sv_insertion(
chrom: &str,
pos: u64,
store: &TranscriptStore,
) -> Vec<SvConsequenceResult> {
let overlaps = store.query_overlap(chrom, pos, pos + 1);
let mut results = Vec::with_capacity(overlaps.len());
for (tx, _idx) in overlaps {
results.push(build_sv_result(pos, pos + 1, SvShape::Insertion, tx));
}
results
}
#[cfg(test)]
mod tests {
use super::*;
use crate::test_fixtures::{
minus_strand_coding, noncoding_2_exon, plus_strand_coding, single_exon_coding,
};
use crate::transcript::TranscriptStore;
fn fixture_store() -> TranscriptStore {
TranscriptStore::from_transcripts(vec![
plus_strand_coding(),
minus_strand_coding(),
noncoding_2_exon(),
single_exon_coding(),
])
}
fn terms(r: &SvConsequenceResult) -> Vec<&'static str> {
let mut v: Vec<&'static str> = r.consequences.iter().map(|c| c.as_str()).collect();
v.sort_unstable();
v
}
#[test]
fn deletion_spanning_whole_transcript_is_ablation_only() {
let store = fixture_store();
let results = annotate_interval("chr1", 500, 6000, SvKind::Deletion, &store).unwrap();
assert_eq!(results.len(), 1);
let r = &results[0];
assert_eq!(r.consequences, vec![Consequence::TranscriptAblation]);
assert_eq!(r.impact, Impact::High);
assert!(r.spans_whole_transcript);
assert_eq!(r.transcript_overlap_fraction, 1.0);
assert_eq!(r.cds_bases_overlapped, 1500);
}
#[test]
fn partial_deletion_is_truncation_plus_subregions() {
let store = fixture_store();
let r = &annotate_interval("chr1", 900, 2500, SvKind::Deletion, &store).unwrap()[0];
assert_eq!(
terms(r),
vec![
"5_prime_UTR_variant",
"coding_sequence_variant",
"feature_truncation",
"intron_variant",
]
);
assert_eq!(
r.most_severe_consequence(),
Some(Consequence::FeatureTruncation)
);
assert_eq!(r.impact, Impact::High);
assert!(!r.spans_whole_transcript);
assert!(r.overlaps_first_exon && !r.overlaps_last_exon);
}
#[test]
fn duplication_spanning_whole_transcript_is_amplification_only() {
let store = fixture_store();
let r = &annotate_interval("chr1", 500, 6000, SvKind::Duplication, &store).unwrap()[0];
assert_eq!(r.consequences, vec![Consequence::TranscriptAmplification]);
assert_eq!(r.impact, Impact::High);
}
#[test]
fn partial_duplication_is_elongation_plus_subregions() {
let store = fixture_store();
let r = &annotate_interval("chr1", 3200, 4200, SvKind::Duplication, &store).unwrap()[0];
assert!(r.consequences.contains(&Consequence::FeatureElongation));
assert!(r.consequences.contains(&Consequence::CodingSequenceVariant));
assert!(r.consequences.contains(&Consequence::IntronVariant));
assert_eq!(
r.most_severe_consequence(),
Some(Consequence::FeatureElongation)
);
assert!(!r.spans_whole_transcript);
}
#[test]
fn inversion_whole_span_has_no_headline_term() {
let store = fixture_store();
let r = &annotate_interval("chr1", 500, 6000, SvKind::Inversion, &store).unwrap()[0];
assert_eq!(
terms(r),
vec![
"3_prime_UTR_variant",
"5_prime_UTR_variant",
"coding_sequence_variant",
"intron_variant",
]
);
assert!(!r.consequences.contains(&Consequence::TranscriptAblation));
assert!(!r.consequences.contains(&Consequence::FeatureTruncation));
assert_eq!(r.impact, Impact::Modifier);
assert_eq!(
r.most_severe_consequence(),
Some(Consequence::CodingSequenceVariant)
);
}
#[test]
fn partial_inversion_is_subregions_only() {
let store = fixture_store();
let r = &annotate_interval("chr1", 900, 2500, SvKind::Inversion, &store).unwrap()[0];
assert_eq!(
terms(r),
vec![
"5_prime_UTR_variant",
"coding_sequence_variant",
"intron_variant"
]
);
}
#[test]
fn minus_strand_five_prime_utr_is_high_coordinate() {
let store = fixture_store();
let r = &annotate_interval("chr17", 19600, 21000, SvKind::Inversion, &store).unwrap()[0];
assert_eq!(r.strand, Strand::Minus);
assert!(r.consequences.contains(&Consequence::FivePrimeUtrVariant));
assert!(!r.consequences.contains(&Consequence::ThreePrimeUtrVariant));
}
#[test]
fn noncoding_partial_deletion() {
let store = fixture_store();
let r = &annotate_interval("chr2", 250, 450, SvKind::Deletion, &store).unwrap()[0];
assert_eq!(
terms(r),
vec![
"feature_truncation",
"intron_variant",
"non_coding_transcript_exon_variant",
]
);
assert_eq!(r.cds_bases_overlapped, 0);
}
#[test]
fn breakend_in_cds_is_truncation_plus_cds() {
let store = fixture_store();
let results = annotate_breakend("chr1", 1700, &store);
assert_eq!(results.len(), 1);
let r = &results[0];
assert_eq!(
terms(r),
vec!["coding_sequence_variant", "feature_truncation"]
);
assert_eq!(
r.most_severe_consequence(),
Some(Consequence::FeatureTruncation)
);
assert!(r.both_breakpoints_within_tx);
}
#[test]
fn breakend_in_intron_is_truncation_plus_intron() {
let store = fixture_store();
let r = &annotate_breakend("chr1", 2500, &store)[0];
assert_eq!(terms(r), vec!["feature_truncation", "intron_variant"]);
}
#[test]
fn breakend_outside_transcript_is_empty() {
let store = fixture_store();
assert!(annotate_breakend("chr1", 100, &store).is_empty());
}
#[test]
fn insertion_in_intron_is_positional_only() {
let store = fixture_store();
let r = &annotate_sv_insertion("chr1", 2500, &store)[0];
assert_eq!(terms(r), vec!["intron_variant"]);
assert!(!r.consequences.contains(&Consequence::FeatureTruncation));
assert_eq!(r.impact, Impact::Modifier);
}
#[test]
fn insertion_in_cds_is_coding_plus_elongation() {
let store = fixture_store();
let r = &annotate_sv_insertion("chr1", 1700, &store)[0];
assert_eq!(
terms(r),
vec!["coding_sequence_variant", "feature_elongation"]
);
}
#[test]
fn partial_duplication_extending_out_has_no_elongation() {
let store = fixture_store();
let r = &annotate_interval("chr1", 3200, 6000, SvKind::Duplication, &store).unwrap()[0];
assert!(!r.consequences.contains(&Consequence::FeatureElongation));
assert!(!r.spans_whole_transcript);
assert!(r.consequences.contains(&Consequence::CodingSequenceVariant));
}
#[test]
fn intronic_only_deletion_is_not_truncation() {
let store = fixture_store();
let r = &annotate_interval("chr1", 2200, 2800, SvKind::Deletion, &store).unwrap()[0];
assert_eq!(terms(r), vec!["intron_variant"]);
assert!(!r.consequences.contains(&Consequence::FeatureTruncation));
}
#[test]
fn no_overlap_returns_empty_not_error() {
let store = fixture_store();
assert!(
annotate_interval("chr1", 100, 200, SvKind::Deletion, &store)
.unwrap()
.is_empty()
);
}
#[test]
fn unknown_chrom_returns_empty_not_error() {
let store = fixture_store();
assert!(
annotate_interval("chrZZ", 100, 5000, SvKind::Deletion, &store)
.unwrap()
.is_empty()
);
}
#[test]
fn full_span_is_not_both_breakpoints_within() {
let store = fixture_store();
let r = &annotate_interval("chr1", 500, 6000, SvKind::Deletion, &store).unwrap()[0];
assert!(!r.both_breakpoints_within_tx);
}
#[test]
fn intragenic_deletion_touching_no_terminal_exon() {
let store = fixture_store();
let r = &annotate_interval("chr1", 3200, 3800, SvKind::Deletion, &store).unwrap()[0];
assert!(!r.spans_whole_transcript);
assert!(!r.overlaps_first_exon && !r.overlaps_last_exon);
assert!(r.both_breakpoints_within_tx);
}
#[test]
fn single_exon_full_span_is_not_within() {
let store = fixture_store();
let r = &annotate_interval("chr3", 1000, 6000, SvKind::Deletion, &store).unwrap()[0];
assert!(r.spans_whole_transcript);
assert!(!r.both_breakpoints_within_tx);
assert_eq!(r.consequences, vec![Consequence::TranscriptAblation]);
}
#[test]
fn inverted_interval_errors() {
let store = fixture_store();
assert!(matches!(
annotate_interval("chr1", 5000, 5000, SvKind::Deletion, &store),
Err(VarEffectError::Malformed(_))
));
assert!(matches!(
annotate_interval("chr1", 5000, 1000, SvKind::Deletion, &store),
Err(VarEffectError::Malformed(_))
));
}
}