ciri_toolkit/annotation.rs
1//! Annotation module: GTF parsing and exon boundary mapping.
2//!
3//! This module provides the `Annotation` struct for parsing reference GTF (General Transfer Format)
4//! files and providing efficient lookup for exons and gene coordinates.
5//!
6//! It is used to validate BSJ candidates against known transcript boundaries.
7
8use anyhow::Result;
9use std::collections::{HashMap, HashSet};
10use std::fs::File;
11use std::io::{BufRead, BufReader};
12
13/// One gene span on a chromosome, reconstructed from all exon intervals seen in
14/// the GTF.
15///
16/// Java `Summary.java` uses a chromosome-grouped gene interval list as a
17/// fallback when a circRNA does not land exactly on exon boundaries. Rust keeps
18/// the same information so final annotation labels can mirror that fallback.
19pub struct GeneSpan {
20 pub gene_id: String,
21 pub start: i32,
22 pub end: i32,
23}
24
25/// Parsed GTF-derived lookup tables used during BSJ validation and final labeling.
26///
27/// The current representation is intentionally string-keyed because Scan1 hot
28/// paths already build Java-shaped `"chr\tpos"` keys; keeping that contract avoids
29/// repeated format conversions during parity-sensitive checks.
30pub struct Annotation {
31 /// Maps from genomic coordinate string ("Chr\tStart") to its exon metadata ("GeneID\tStrand").
32 pub chr_exon_start_map: HashMap<String, String>,
33 /// Maps from genomic coordinate string ("Chr\tEnd") to its exon metadata ("GeneID\tStrand").
34 pub chr_exon_end_map: HashMap<String, String>,
35 /// Numeric exon-start lookup for post-Summary CIRI-AS boundary correction.
36 ///
37 /// Scan1/Scan2 keep using the Java-shaped string maps above for parity.
38 /// Segments reconstruction tests thousands of nearby splice-site candidates,
39 /// so it uses this side index to avoid formatting `"chr\tpos"` strings in
40 /// the hot loop.
41 pub chr_exon_start_index: HashMap<String, HashMap<i32, (String, char)>>,
42 /// Numeric exon-end lookup for post-Summary CIRI-AS boundary correction.
43 pub chr_exon_end_index: HashMap<String, HashMap<i32, (String, char)>>,
44 /// Maps from Gene ID to a vector of its exons (as start and end pairs).
45 pub gene_exon_map: HashMap<String, Vec<(i32, i32)>>,
46 /// Transcript-consistent exon chains grouped by gene ID.
47 ///
48 /// `gene_exon_map` keeps the historical gene-level union used by Summary
49 /// fallback labels. Full-length sidecar reconstruction needs a stricter
50 /// projection source, because mixing all transcript exons from one gene can
51 /// create impossible exon chains inside unsupported blocks.
52 pub gene_transcript_exon_map: HashMap<String, Vec<Vec<(i32, i32)>>>,
53 /// Transcript-consistent introns keyed as `chr\tleft_exon_end\tright_exon_start\tstrand`.
54 ///
55 /// This is intentionally a side lookup rather than a replacement for the
56 /// Java-shaped exon-boundary maps above: CIRI3 parity paths still need the
57 /// boundary-only behavior, while CIRI-AS read-level segments can use adjacent
58 /// exon pairs from the same transcript to resolve ambiguous splice offsets.
59 pub transcript_splice_map: HashSet<String>,
60 /// Numeric transcript-consistent splice-pair index for CIRI-AS hot loops.
61 pub transcript_splice_index: HashMap<String, HashMap<char, HashSet<(i32, i32)>>>,
62 /// Groups genes by chromosome with their outer exon span for Summary's
63 /// exon/intron/intergenic fallback labeling.
64 pub chr_gene_map: HashMap<String, Vec<GeneSpan>>,
65}
66
67impl Annotation {
68 /// Initializes an empty annotation store.
69 pub fn new() -> Self {
70 Self {
71 chr_exon_start_map: HashMap::new(),
72 chr_exon_end_map: HashMap::new(),
73 chr_exon_start_index: HashMap::new(),
74 chr_exon_end_index: HashMap::new(),
75 gene_exon_map: HashMap::new(),
76 gene_transcript_exon_map: HashMap::new(),
77 transcript_splice_map: HashSet::new(),
78 transcript_splice_index: HashMap::new(),
79 chr_gene_map: HashMap::new(),
80 }
81 }
82
83 /// Reads a GTF file and populates the exon-boundary lookup tables.
84 ///
85 /// Only `exon` features are retained because the rest of the pipeline only
86 /// needs exon boundary membership and gene IDs for parity with CIRI3.
87 ///
88 /// # Arguments
89 /// * `annotation_file` - Path to the input GTF file.
90 pub fn read_gtf(&mut self, annotation_file: &str) -> Result<()> {
91 let file = File::open(annotation_file)?;
92 let reader = BufReader::new(file);
93 let mut gene_bounds: HashMap<(String, String), (i32, i32)> = HashMap::new();
94 let mut transcript_exons: HashMap<(String, String, String), Vec<(i32, i32, String)>> =
95 HashMap::new();
96 for line_res in reader.lines() {
97 let line = line_res?;
98 if line.starts_with('#') {
99 continue;
100 }
101
102 let parts: Vec<&str> = line.split('\t').collect();
103 // Feature type is in the 3rd column (index 2).
104 if parts.len() < 9 || parts[2] != "exon" {
105 continue;
106 }
107
108 let chr = parts[0];
109 let start = parts[3].parse::<i32>()?;
110 let end = parts[4].parse::<i32>()?;
111 let strand = parts[6];
112 let attributes = parts[8];
113
114 // Extract 'gene_id' from the semicolon-separated attributes list.
115 let gene_id = attributes
116 .split(';')
117 .filter(|s| s.trim().starts_with("gene_id"))
118 .next()
119 .and_then(|s| s.split('"').nth(1))
120 .unwrap_or("NA")
121 .to_string();
122 let transcript_id = attributes
123 .split(';')
124 .find(|s| s.trim().starts_with("transcript_id"))
125 .and_then(|s| s.split('"').nth(1))
126 .map(str::to_string);
127
128 let gene_id_key = gene_id.clone();
129 let start_key = format!("{}\t{}", chr, start);
130 let end_key = format!("{}\t{}", chr, end);
131 let value = format!("{}\t{}", gene_id, strand);
132
133 self.chr_exon_start_map.insert(start_key, value.clone());
134 self.chr_exon_end_map.insert(end_key, value);
135 let strand_char = strand.chars().next().unwrap_or('?');
136 self.chr_exon_start_index
137 .entry(chr.to_string())
138 .or_default()
139 .insert(start, (gene_id_key.clone(), strand_char));
140 self.chr_exon_end_index
141 .entry(chr.to_string())
142 .or_default()
143 .insert(end, (gene_id_key.clone(), strand_char));
144 self.gene_exon_map
145 .entry(gene_id)
146 .or_insert_with(Vec::new)
147 .push((start, end));
148 if let Some(transcript_id) = transcript_id {
149 transcript_exons
150 .entry((chr.to_string(), gene_id_key.clone(), transcript_id))
151 .or_default()
152 .push((start, end, strand.to_string()));
153 }
154 let gene_key = (chr.to_string(), gene_id_key);
155 gene_bounds
156 .entry(gene_key)
157 .and_modify(|bounds| {
158 bounds.0 = bounds.0.min(start);
159 bounds.1 = bounds.1.max(end);
160 })
161 .or_insert((start, end));
162 }
163 self.transcript_splice_map.clear();
164 self.transcript_splice_index.clear();
165 self.gene_transcript_exon_map.clear();
166 for ((chr, gene_id, _transcript_id), mut exons) in transcript_exons {
167 exons.sort_by_key(|(start, end, _strand)| (*start, *end));
168 let chain: Vec<(i32, i32)> = exons
169 .iter()
170 .map(|(start, end, _strand)| (*start, *end))
171 .collect();
172 if !chain.is_empty() {
173 self.gene_transcript_exon_map
174 .entry(gene_id)
175 .or_default()
176 .push(chain);
177 }
178 for pair in exons.windows(2) {
179 let left = &pair[0];
180 let right = &pair[1];
181 if left.2 != right.2 {
182 continue;
183 }
184 self.transcript_splice_map
185 .insert(format!("{}\t{}\t{}\t{}", chr, left.1, right.0, left.2));
186 let strand = left.2.chars().next().unwrap_or('?');
187 self.transcript_splice_index
188 .entry(chr.clone())
189 .or_default()
190 .entry(strand)
191 .or_default()
192 .insert((left.1, right.0));
193 }
194 }
195 for chains in self.gene_transcript_exon_map.values_mut() {
196 chains.sort();
197 chains.dedup();
198 }
199 self.chr_gene_map.clear();
200 for ((chr, gene_id), (start, end)) in gene_bounds {
201 self.chr_gene_map.entry(chr).or_default().push(GeneSpan {
202 gene_id,
203 start,
204 end,
205 });
206 }
207 for spans in self.chr_gene_map.values_mut() {
208 spans.sort_by_key(|span| span.start);
209 }
210 Ok(())
211 }
212
213 /// Checks whether a shifted junction pair matches known exon boundaries.
214 ///
215 /// This helper is mainly useful for tests and experiments; Scan1's hot path
216 /// now performs the same lookup inline to stay closer to Java branch order.
217 ///
218 /// # Arguments
219 /// * `chr` - Chromosome name.
220 /// * `tmp_site1` - Predicted upstream junction coordinate.
221 /// * `tmp_site2` - Predicted downstream junction coordinate.
222 /// * `adjt_bp` - Range of shift offsets to check.
223 ///
224 /// # Returns
225 /// `Some(shift, strand, donor_placeholder, acceptor_placeholder)` if a match is found.
226 #[allow(dead_code)]
227 pub fn find_exon_match(
228 &self,
229 chr: &str,
230 tmp_site1: i32,
231 tmp_site2: i32,
232 adjt_bp: i32,
233 ) -> Option<(i32, String, String, String)> {
234 for i in 0..=adjt_bp {
235 let s1 = tmp_site1 + i;
236 let s2 = tmp_site2 + i;
237 let start_key = format!("{}\t{}", chr, s1);
238 let end_key = format!("{}\t{}", chr, s2);
239
240 if let (Some(v1), Some(v2)) = (
241 self.chr_exon_start_map.get(&start_key),
242 self.chr_exon_end_map.get(&end_key),
243 ) {
244 let p1: Vec<&str> = v1.split('\t').collect();
245 let p2: Vec<&str> = v2.split('\t').collect();
246
247 if p1[0] == p2[0] {
248 return Some((i, p1[1].to_string(), "GT".to_string(), "AG".to_string()));
249 }
250 }
251 }
252 None
253 }
254}