Skip to main content

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}