Skip to content

Commit c7c62e8

Browse files
committed
Annotate insertions at internal exon junctions as coding, not intergenic
A VCF insertion places its bases after the anchor, so an insertion anchored at the last base of an internal coding exon lands at the exon-exon junction inside the spliced CDS. The half-open exon interval used for overlap and per-segment anchoring (< segment.end) excluded that anchor, so such insertions were dropped from the transcript model and reported as intergenic. Add Gene::insertion_at_internal_junction, which recognises an anchor on an internal exon's last base (using transcript order: not the last segment on the plus strand, not the first on the minus strand, so a CDS-terminal anchor stays UTR). Use it in gene_overlaps_variant, variant_overlaps_coding_model, and the per-segment insertion anchoring so the inserted bases are placed at the junction of the reconstructed CDS and the protein effect is computed. The change is additive: in-exon variants and every existing classification are unchanged. Adds plus- and minus-strand regression tests (the minus-strand inserted bases are reverse-complemented at the junction). 258 -> 260 lib tests; clippy, fmt and the 36-scenario suite pass.
1 parent 4dc74a9 commit c7c62e8

5 files changed

Lines changed: 151 additions & 15 deletions

File tree

src/io/annotation.rs

Lines changed: 13 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@
77
88
use super::vcf::VcfPosition;
99
use crate::error::AppResult;
10-
use crate::variants::Gene;
10+
use crate::variants::{AlleleComponentKind, Gene};
1111
use std::collections::HashMap;
1212

1313
mod cds_model;
@@ -52,9 +52,20 @@ pub fn gene_overlaps_variant(gene: &Gene, variant: &VcfPosition) -> bool {
5252
if gene.cds_segments.is_empty() {
5353
return variant.overlaps_interval(gene.start, gene.end);
5454
}
55-
gene.cds_segments
55+
if gene
56+
.cds_segments
5657
.iter()
5758
.any(|segment| variant.overlaps_interval(segment.start, segment.end))
59+
{
60+
return true;
61+
}
62+
// An insertion anchored at an internal exon-exon junction is coding even
63+
// though overlaps_interval (half-open) excludes the exon's last base; keep it
64+
// out of the intergenic set so it is annotated on the spliced CDS instead.
65+
variant.event().components.iter().any(|component| {
66+
matches!(component.kind, AlleleComponentKind::Insertion)
67+
&& gene.insertion_at_internal_junction(component.position)
68+
})
5869
}
5970

6071
fn gene_has_variant(gene: &Gene, snp_list: &[VcfPosition]) -> bool {

src/variants/codon/allele_apply.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ pub(super) fn apply_allele_to_transcript(
5757
}
5858
}
5959
AlleleComponentKind::Insertion => {
60-
if insertion_anchor_in_segment(segment, component.position) {
60+
if insertion_anchor_in_segment(gene, segment, component.position) {
6161
match insertions.get(&component.position) {
6262
Some(existing)
6363
if !existing.eq_ignore_ascii_case(&component.alt_allele) =>

src/variants/codon/tests.rs

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -476,6 +476,94 @@ fn test_minus_strand_legacy_deletion_reports_consistent_codons() {
476476
assert_eq!(variants[0].mnv_codon.as_deref(), Some("GGG"));
477477
}
478478

479+
#[test]
480+
fn test_plus_strand_insertion_at_internal_exon_junction_is_coding() {
481+
// Regression: an insertion anchored at the last base of an internal exon
482+
// lands at the exon-exon junction inside the spliced CDS, so it must be
483+
// annotated as a coding indel, not dropped (and later reported intergenic).
484+
// exon1 = [1,3] "ATG", exon2 = [10,15] "AAATTT"; CDS = "ATGAAATTT"
485+
// (Met-Lys-Phe). Inserting GGG after pos 3 -> spliced CDS "ATGGGGAAATTT".
486+
let gene = Gene {
487+
name: "tx_cds".to_string(),
488+
start: 1,
489+
end: 15,
490+
strand: Strand::Plus,
491+
phase: 0,
492+
protein_offset: 0,
493+
transcript_id: Some("tx1".to_string()),
494+
cds_segments: vec![
495+
CdsSegment { start: 1, end: 3 },
496+
CdsSegment { start: 10, end: 15 },
497+
],
498+
};
499+
let reference = crate::io::Reference {
500+
sequence: "ATGCCCCCCAAATTT",
501+
};
502+
let variants = crate::variants::get_mnv_variants_for_gene(
503+
&gene,
504+
&[crate::io::VcfPosition {
505+
position: 3,
506+
ref_allele: "G".to_string(),
507+
alt_allele: "GGGG".to_string(),
508+
original_dp: None,
509+
original_freq: None,
510+
original_info: None,
511+
}],
512+
&reference,
513+
"chr1",
514+
crate::genetic_code::GeneticCode::default(),
515+
);
516+
assert_eq!(variants.len(), 1);
517+
assert_ne!(variants[0].gene, "intergenic");
518+
assert_eq!(variants[0].variant_type, VariantType::Indel);
519+
assert_eq!(variants[0].change_type, ChangeType::InFrameIndel);
520+
assert_eq!(variants[0].event_components, vec!["INS:3:+GGG".to_string()]);
521+
}
522+
523+
#[test]
524+
fn test_minus_strand_insertion_at_internal_exon_junction_is_coding() {
525+
// Same junction case on the minus strand. cds_segments are in transcript
526+
// order (descending genomic): exon0 = [10,15], exon1 = [1,3]. CDS =
527+
// RC("TTTCAT") + RC("AAA") = "ATGAAATTT". An insertion of GGG after genomic
528+
// pos 3 (exon1's genomic end = transcript-5' boundary, an internal junction)
529+
// becomes RC("GGG")="CCC" at the exon0|exon1 junction -> "ATGAAACCCTTT".
530+
let gene = Gene {
531+
name: "tx_cds".to_string(),
532+
start: 1,
533+
end: 15,
534+
strand: Strand::Minus,
535+
phase: 0,
536+
protein_offset: 0,
537+
transcript_id: Some("tx1".to_string()),
538+
cds_segments: vec![
539+
CdsSegment { start: 10, end: 15 },
540+
CdsSegment { start: 1, end: 3 },
541+
],
542+
};
543+
let reference = crate::io::Reference {
544+
sequence: "AAACCCCCCTTTCAT",
545+
};
546+
let variants = crate::variants::get_mnv_variants_for_gene(
547+
&gene,
548+
&[crate::io::VcfPosition {
549+
position: 3,
550+
ref_allele: "A".to_string(),
551+
alt_allele: "AGGG".to_string(),
552+
original_dp: None,
553+
original_freq: None,
554+
original_info: None,
555+
}],
556+
&reference,
557+
"chr1",
558+
crate::genetic_code::GeneticCode::default(),
559+
);
560+
assert_eq!(variants.len(), 1);
561+
assert_ne!(variants[0].gene, "intergenic");
562+
assert_eq!(variants[0].variant_type, VariantType::Indel);
563+
assert_eq!(variants[0].change_type, ChangeType::InFrameIndel);
564+
assert_eq!(variants[0].event_components, vec!["INS:3:+GGG".to_string()]);
565+
}
566+
479567
// H5: an in-frame insertion that introduces a stop codon must be classified
480568
// as Stop gained rather than the generic In-frame Indel. Inserting TAA after
481569
// the start codon of ATGAAATTT yields ATG-TAA-AAA-TTT (Met-*-Lys-Phe).

src/variants/codon/transcript_model.rs

Lines changed: 27 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -56,26 +56,41 @@ pub(super) fn transcript_offset_for_position(gene: &Gene, position: usize) -> Op
5656
None
5757
}
5858

59-
/// Whether an insertion anchored at `position` falls strictly inside this CDS
60-
/// segment. The upper bound is exclusive (`< segment.end`) on purpose: a VCF
61-
/// insertion places the inserted bases *after* the anchor, so an anchor at the
62-
/// segment's last base sits on the exon|intron boundary, where the inserted
63-
/// sequence is genomically ambiguous between "end of this exon" and "start of
64-
/// the intron" (spliced out). Such boundary insertions are intentionally left
65-
/// out of the spliced-CDS model rather than assigned a possibly-wrong codon;
66-
/// they surface as intergenic instead. Anchors inside an exon are unaffected.
67-
pub(super) fn insertion_anchor_in_segment(segment: &CdsSegment, position: usize) -> bool {
68-
position >= segment.start && position < segment.end
59+
/// Whether an insertion anchored at `position` belongs to this CDS `segment`
60+
/// when reconstructing the spliced transcript. True when the anchor is strictly
61+
/// inside the exon, or when it sits exactly on the exon's last base and that base
62+
/// is an internal exon-exon junction: a VCF insertion places its bases *after*
63+
/// the anchor, so an anchor at an internal exon's last base lands the inserted
64+
/// sequence at the junction with the next exon, inside the spliced CDS. An anchor
65+
/// at the CDS terminus is UTR and is excluded (see
66+
/// [`Gene::insertion_at_internal_junction`]).
67+
pub(super) fn insertion_anchor_in_segment(
68+
gene: &Gene,
69+
segment: &CdsSegment,
70+
position: usize,
71+
) -> bool {
72+
(position >= segment.start && position < segment.end)
73+
|| (position == segment.end && gene.insertion_at_internal_junction(position))
6974
}
7075

7176
pub(super) fn variant_overlaps_coding_model(gene: &Gene, variant: &VcfPosition) -> bool {
7277
if !has_transcript_cds_model(gene) {
7378
return variant.overlaps_interval(gene.start, gene.end);
7479
}
7580

76-
gene.cds_segments
81+
if gene
82+
.cds_segments
7783
.iter()
7884
.any(|segment| variant.overlaps_interval(segment.start, segment.end))
85+
{
86+
return true;
87+
}
88+
// An insertion anchored at an internal exon-exon junction is coding even
89+
// though overlaps_interval (half-open) excludes the exon's last base.
90+
variant.event().components.iter().any(|component| {
91+
matches!(component.kind, AlleleComponentKind::Insertion)
92+
&& gene.insertion_at_internal_junction(component.position)
93+
})
7994
}
8095

8196
pub(super) fn variant_touched_transcript_offsets(gene: &Gene, variant: &VcfPosition) -> Vec<usize> {
@@ -93,7 +108,7 @@ pub(super) fn variant_touched_transcript_offsets(gene: &Gene, variant: &VcfPosit
93108
}
94109
AlleleComponentKind::Insertion => {
95110
for segment in &gene.cds_segments {
96-
if insertion_anchor_in_segment(segment, component.position) {
111+
if insertion_anchor_in_segment(gene, segment, component.position) {
97112
if let Some(offset) =
98113
transcript_offset_for_position(gene, component.position)
99114
{

src/variants/types.rs

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,28 @@ pub struct Gene {
195195
pub cds_segments: Vec<CdsSegment>,
196196
}
197197

198+
impl Gene {
199+
/// Whether an insertion anchored at genomic `position` lands at an internal
200+
/// exon-exon junction of the spliced CDS — i.e. immediately after the last
201+
/// base of an internal coding exon. The inserted bases then fall between two
202+
/// exons in the spliced transcript, so the insertion is coding, even though
203+
/// the exon's terminal base is excluded from the half-open exon interval used
204+
/// elsewhere. An anchor at the CDS terminus (after the last exon on the plus
205+
/// strand, before the first on the minus strand) is UTR, not an internal
206+
/// junction. `cds_segments` is in transcript order, so the terminal exon is
207+
/// the last entry on the plus strand and the first on the minus strand.
208+
pub(crate) fn insertion_at_internal_junction(&self, position: usize) -> bool {
209+
let segment_count = self.cds_segments.len();
210+
self.cds_segments.iter().enumerate().any(|(idx, segment)| {
211+
position == segment.end
212+
&& match self.strand {
213+
Strand::Plus => idx + 1 < segment_count,
214+
Strand::Minus => idx > 0,
215+
}
216+
})
217+
}
218+
}
219+
198220
#[derive(Debug, Serialize, Deserialize, Clone)]
199221
pub struct Snp {
200222
pub index: usize,

0 commit comments

Comments
 (0)