Skip to main content

ciri_toolkit/
is_bsj_hg2.rs

1//! Validator module: Reproduces the core validation logic of Java CIRI3.
2//!
3//! This module includes comprehensive sequence validation, canonical splice signal
4//! identification, and linear competition checks to distinguish BSJs from linear splicing noise.
5
6use crate::index_compare::IndexCompare;
7use crate::runtime::{
8    emit_perf_line, emit_trace_line, scan1_profile_enabled, scan2_profile_enabled,
9    trace_hg2_enabled,
10};
11use std::collections::HashMap;
12use std::fmt::Write as FmtWrite;
13use std::sync::atomic::{AtomicU64, Ordering};
14use std::time::Instant;
15
16/// Aggregated profiling counters for the Scan1 validator hot path.
17///
18/// This stays private because the profiler is a maintenance aid, not part of the
19/// validation API surface.
20#[derive(Default)]
21struct HgProfile {
22    hg1_total_ns: AtomicU64,
23    hg1_calls: AtomicU64,
24    index_compare_ns: AtomicU64,
25    index_compare_calls: AtomicU64,
26    exon_lookup_ns: AtomicU64,
27    exon_lookup_calls: AtomicU64,
28    shift_prepare_ns: AtomicU64,
29    shift_prepare_calls: AtomicU64,
30    shift_entries: AtomicU64,
31    linear_check_ns: AtomicU64,
32    linear_check_calls: AtomicU64,
33    circ2_ns: AtomicU64,
34    circ2_calls: AtomicU64,
35    circ3_ns: AtomicU64,
36    circ3_calls: AtomicU64,
37    hg1_hits: AtomicU64,
38    sw_ns: AtomicU64,
39    sw_calls: AtomicU64,
40}
41
42static HG_PROFILE: HgProfile = HgProfile {
43    hg1_total_ns: AtomicU64::new(0),
44    hg1_calls: AtomicU64::new(0),
45    index_compare_ns: AtomicU64::new(0),
46    index_compare_calls: AtomicU64::new(0),
47    exon_lookup_ns: AtomicU64::new(0),
48    exon_lookup_calls: AtomicU64::new(0),
49    shift_prepare_ns: AtomicU64::new(0),
50    shift_prepare_calls: AtomicU64::new(0),
51    shift_entries: AtomicU64::new(0),
52    linear_check_ns: AtomicU64::new(0),
53    linear_check_calls: AtomicU64::new(0),
54    circ2_ns: AtomicU64::new(0),
55    circ2_calls: AtomicU64::new(0),
56    circ3_ns: AtomicU64::new(0),
57    circ3_calls: AtomicU64::new(0),
58    hg1_hits: AtomicU64::new(0),
59    sw_ns: AtomicU64::new(0),
60    sw_calls: AtomicU64::new(0),
61};
62
63/// Aggregated profiling counters for the Scan2 validator hot path.
64#[derive(Default)]
65struct Hg2Profile {
66    hg2_total_ns: AtomicU64,
67    hg2_calls: AtomicU64,
68    sw_ns: AtomicU64,
69    sw_calls: AtomicU64,
70    linear11_ns: AtomicU64,
71    linear11_calls: AtomicU64,
72    linear12_ns: AtomicU64,
73    linear12_calls: AtomicU64,
74    circ2_ns: AtomicU64,
75    circ2_calls: AtomicU64,
76    circ3_ns: AtomicU64,
77    circ3_calls: AtomicU64,
78}
79
80static HG2_PROFILE: Hg2Profile = Hg2Profile {
81    hg2_total_ns: AtomicU64::new(0),
82    hg2_calls: AtomicU64::new(0),
83    sw_ns: AtomicU64::new(0),
84    sw_calls: AtomicU64::new(0),
85    linear11_ns: AtomicU64::new(0),
86    linear11_calls: AtomicU64::new(0),
87    linear12_ns: AtomicU64::new(0),
88    linear12_calls: AtomicU64::new(0),
89    circ2_ns: AtomicU64::new(0),
90    circ2_calls: AtomicU64::new(0),
91    circ3_ns: AtomicU64::new(0),
92    circ3_calls: AtomicU64::new(0),
93};
94
95/// Checks whether verbose `is_bsj_hg2` stage tracing is enabled.
96///
97/// This trace is intentionally separate from `CIRI_TRACE_ALL_CANDS`: the latter
98/// changes candidate traversal so developers can inspect the full search space,
99/// while `--trace` / `CIRI_TRACE_HG2` keep the original traversal and only
100/// annotate which validator branch accepted or rejected a traced candidate.
101fn trace_scan2_hg2_enabled() -> bool {
102    trace_hg2_enabled()
103}
104
105/// Emits one targeted `is_bsj_hg2` branch trace line.
106///
107/// The payload mirrors the Java-shaped `circ_line_arr` contract so a developer
108/// can compare Rust and Java branch decisions without first reconstructing the
109/// candidate fields by hand. This stays off by default because the validator hot
110/// path calls it frequently once tracing is enabled.
111fn trace_scan2_hg2(stage: &str, circ_line_arr: &[String], extra: &str) {
112    if !trace_scan2_hg2_enabled() {
113        return;
114    }
115    emit_trace_line(&format!(
116        "[TRACE_SCAN2_HG2] stage={} type={} strand={} chr={} site1={} site2={} mapq={} s2_ok={} str_len={} pair_len={} str3_len={} {}",
117        stage,
118        circ_line_arr[2],
119        circ_line_arr[0],
120        circ_line_arr[1],
121        circ_line_arr[3],
122        circ_line_arr[4],
123        circ_line_arr[12],
124        circ_line_arr[8],
125        circ_line_arr[5].len(),
126        circ_line_arr[6].len(),
127        circ_line_arr[7].len(),
128        extra,
129    ));
130}
131
132/// Adds elapsed nanoseconds to one profiling counter.
133#[inline]
134fn add_ns(counter: &AtomicU64, started: Instant) {
135    counter.fetch_add(started.elapsed().as_nanos() as u64, Ordering::Relaxed);
136}
137
138/// Finalizes one profiled Scan2 validator call and returns its result.
139#[inline]
140fn finish_scan2_hg2(result: String, started: Option<Instant>) -> String {
141    if let Some(started) = started {
142        add_ns(&HG2_PROFILE.hg2_total_ns, started);
143    }
144    result
145}
146
147/// Prints the collected Scan1 validator profile.
148///
149/// The report is intentionally stable across optimization rounds so release A/B
150/// measurements stay easy to compare.
151pub fn report_scan1_hg_profile() {
152    if !scan1_profile_enabled() {
153        return;
154    }
155    let hg1_total_ns = HG_PROFILE.hg1_total_ns.load(Ordering::Relaxed);
156    let hg1_calls = HG_PROFILE.hg1_calls.load(Ordering::Relaxed);
157    let index_compare_ns = HG_PROFILE.index_compare_ns.load(Ordering::Relaxed);
158    let index_compare_calls = HG_PROFILE.index_compare_calls.load(Ordering::Relaxed);
159    let exon_lookup_ns = HG_PROFILE.exon_lookup_ns.load(Ordering::Relaxed);
160    let exon_lookup_calls = HG_PROFILE.exon_lookup_calls.load(Ordering::Relaxed);
161    let shift_prepare_ns = HG_PROFILE.shift_prepare_ns.load(Ordering::Relaxed);
162    let shift_prepare_calls = HG_PROFILE.shift_prepare_calls.load(Ordering::Relaxed);
163    let shift_entries = HG_PROFILE.shift_entries.load(Ordering::Relaxed);
164    let linear_check_ns = HG_PROFILE.linear_check_ns.load(Ordering::Relaxed);
165    let linear_check_calls = HG_PROFILE.linear_check_calls.load(Ordering::Relaxed);
166    let circ2_ns = HG_PROFILE.circ2_ns.load(Ordering::Relaxed);
167    let circ2_calls = HG_PROFILE.circ2_calls.load(Ordering::Relaxed);
168    let circ3_ns = HG_PROFILE.circ3_ns.load(Ordering::Relaxed);
169    let circ3_calls = HG_PROFILE.circ3_calls.load(Ordering::Relaxed);
170    let hg1_hits = HG_PROFILE.hg1_hits.load(Ordering::Relaxed);
171    let sw_ns = HG_PROFILE.sw_ns.load(Ordering::Relaxed);
172    let sw_calls = HG_PROFILE.sw_calls.load(Ordering::Relaxed);
173    let known_ns = index_compare_ns
174        .saturating_add(exon_lookup_ns)
175        .saturating_add(shift_prepare_ns)
176        .saturating_add(linear_check_ns)
177        .saturating_add(circ2_ns)
178        .saturating_add(circ3_ns);
179    let other_ns = hg1_total_ns.saturating_sub(known_ns);
180    let pct = |part: u64, whole: u64| -> f64 {
181        if whole == 0 {
182            0.0
183        } else {
184            part as f64 * 100.0 / whole as f64
185        }
186    };
187    emit_perf_line(&format!(
188        "[PROFILE_SCAN1_HG1] calls={} hits={} shift_entries={} sw_calls={}",
189        hg1_calls, hg1_hits, shift_entries, sw_calls
190    ));
191    emit_perf_line(&format!(
192        "[PROFILE_SCAN1_HG1] total_ms={:.3} index_compare_ms={:.3} ({:.1}%, calls={}) exon_lookup_ms={:.3} ({:.1}%, calls={}) shift_prepare_ms={:.3} ({:.1}%, calls={}) linear_check_ms={:.3} ({:.1}%, calls={}) circ2_ms={:.3} ({:.1}%, calls={}) circ3_ms={:.3} ({:.1}%, calls={}) other_ms={:.3} ({:.1}%) sw_ms={:.3}",
193        hg1_total_ns as f64 / 1_000_000.0,
194        index_compare_ns as f64 / 1_000_000.0,
195        pct(index_compare_ns, hg1_total_ns),
196        index_compare_calls,
197        exon_lookup_ns as f64 / 1_000_000.0,
198        pct(exon_lookup_ns, hg1_total_ns),
199        exon_lookup_calls,
200        shift_prepare_ns as f64 / 1_000_000.0,
201        pct(shift_prepare_ns, hg1_total_ns),
202        shift_prepare_calls,
203        linear_check_ns as f64 / 1_000_000.0,
204        pct(linear_check_ns, hg1_total_ns),
205        linear_check_calls,
206        circ2_ns as f64 / 1_000_000.0,
207        pct(circ2_ns, hg1_total_ns),
208        circ2_calls,
209        circ3_ns as f64 / 1_000_000.0,
210        pct(circ3_ns, hg1_total_ns),
211        circ3_calls,
212        other_ns as f64 / 1_000_000.0,
213        pct(other_ns, hg1_total_ns),
214        sw_ns as f64 / 1_000_000.0,
215    ));
216}
217
218/// Prints the collected Scan2 validator profile.
219pub fn report_scan2_hg_profile() {
220    if !scan2_profile_enabled() {
221        return;
222    }
223    let hg2_total_ns = HG2_PROFILE.hg2_total_ns.load(Ordering::Relaxed);
224    let hg2_calls = HG2_PROFILE.hg2_calls.load(Ordering::Relaxed);
225    let sw_ns = HG2_PROFILE.sw_ns.load(Ordering::Relaxed);
226    let sw_calls = HG2_PROFILE.sw_calls.load(Ordering::Relaxed);
227    let linear11_ns = HG2_PROFILE.linear11_ns.load(Ordering::Relaxed);
228    let linear11_calls = HG2_PROFILE.linear11_calls.load(Ordering::Relaxed);
229    let linear12_ns = HG2_PROFILE.linear12_ns.load(Ordering::Relaxed);
230    let linear12_calls = HG2_PROFILE.linear12_calls.load(Ordering::Relaxed);
231    let circ2_ns = HG2_PROFILE.circ2_ns.load(Ordering::Relaxed);
232    let circ2_calls = HG2_PROFILE.circ2_calls.load(Ordering::Relaxed);
233    let circ3_ns = HG2_PROFILE.circ3_ns.load(Ordering::Relaxed);
234    let circ3_calls = HG2_PROFILE.circ3_calls.load(Ordering::Relaxed);
235    let known_ns = sw_ns
236        .saturating_add(linear11_ns)
237        .saturating_add(linear12_ns)
238        .saturating_add(circ2_ns)
239        .saturating_add(circ3_ns);
240    let other_ns = hg2_total_ns.saturating_sub(known_ns);
241    let pct = |part: u64, whole: u64| -> f64 {
242        if whole == 0 {
243            0.0
244        } else {
245            part as f64 * 100.0 / whole as f64
246        }
247    };
248    emit_perf_line(&format!(
249        "[PROFILE_SCAN2_HG2] calls={} sw_calls={} linear11_calls={} linear12_calls={} circ2_calls={} circ3_calls={}",
250        hg2_calls, sw_calls, linear11_calls, linear12_calls, circ2_calls, circ3_calls
251    ));
252    emit_perf_line(&format!(
253        "[PROFILE_SCAN2_HG2] total_ms={:.3} sw_ms={:.3} ({:.1}%) linear11_ms={:.3} ({:.1}%) linear12_ms={:.3} ({:.1}%) circ2_ms={:.3} ({:.1}%) circ3_ms={:.3} ({:.1}%) other_ms={:.3} ({:.1}%)",
254        hg2_total_ns as f64 / 1_000_000.0,
255        sw_ns as f64 / 1_000_000.0,
256        pct(sw_ns, hg2_total_ns),
257        linear11_ns as f64 / 1_000_000.0,
258        pct(linear11_ns, hg2_total_ns),
259        linear12_ns as f64 / 1_000_000.0,
260        pct(linear12_ns, hg2_total_ns),
261        circ2_ns as f64 / 1_000_000.0,
262        pct(circ2_ns, hg2_total_ns),
263        circ3_ns as f64 / 1_000_000.0,
264        pct(circ3_ns, hg2_total_ns),
265        other_ns as f64 / 1_000_000.0,
266        pct(other_ns, hg2_total_ns),
267    ));
268}
269
270/// Smith-Waterman local alignment implementation with traceback.
271///
272/// This is kept close to Java's scoring semantics even though more specialized
273/// implementations could be faster, because Scan2 parity depends on matching the
274/// original acceptance thresholds.
275pub struct SmithWaterman {
276    pub match_score: i32,
277    pub mismatch_penalty: i32,
278    pub gap_penalty: i32,
279    pub score: i32,
280    /// Number of steps in the traceback, equivalent to `alignment[1].length()` in Java.
281    pub aligned_len: i32,
282    seq1: String,
283    seq2: String,
284}
285
286impl SmithWaterman {
287    /// Initializes an aligner with Java-compatible scoring parameters.
288    pub fn new(m: i32, mis: i32, gap: i32) -> Self {
289        Self {
290            match_score: m,
291            mismatch_penalty: mis,
292            gap_penalty: gap,
293            score: 0,
294            aligned_len: 0,
295            seq1: String::new(),
296            seq2: String::new(),
297        }
298    }
299
300    /// Sets the sequences to be aligned.
301    ///
302    /// Uppercasing here mirrors Java behavior and keeps later scoring branches
303    /// case-insensitive without adding checks inside the DP loop.
304    pub fn set_seq(&mut self, s1: &str, s2: &str) {
305        self.seq1 = s1.to_uppercase();
306        self.seq2 = s2.to_uppercase();
307    }
308
309    /// Performs local alignment and traceback to calculate the optimal score and
310    /// aligned length.
311    ///
312    /// The implementation favors parity and traceability over algorithmic novelty:
313    /// it uses an explicit DP table and recomputes the final score from traceback,
314    /// matching Java's `getAlignmentScore` behavior.
315    pub fn align(&mut self) {
316        let sw_started = scan1_profile_enabled().then(Instant::now);
317        // Java parity (`smith` package): table rows = seq2, cols = seq1.
318        let cols = self.seq1.len();
319        let rows = self.seq2.len();
320        if cols == 0 || rows == 0 {
321            self.score = 0;
322            self.aligned_len = 0;
323            if let Some(sw_started) = sw_started {
324                HG_PROFILE.sw_calls.fetch_add(1, Ordering::Relaxed);
325                add_ns(&HG_PROFILE.sw_ns, sw_started);
326            }
327            return;
328        }
329
330        let s1 = self.seq1.as_bytes();
331        let s2 = self.seq2.as_bytes();
332        let mut score_table = vec![vec![0i32; cols + 1]; rows + 1];
333        let mut prev: Vec<Vec<Option<(usize, usize)>>> = vec![vec![None; cols + 1]; rows + 1];
334        let mut high_row = 0usize;
335        let mut high_col = 0usize;
336
337        for row in 1..=rows {
338            for col in 1..=cols {
339                let row_space_score = score_table[row - 1][col] + self.gap_penalty;
340                let col_space_score = score_table[row][col - 1] + self.gap_penalty;
341                let mut match_or_mismatch_score = score_table[row - 1][col - 1];
342                if s2[row - 1] == s1[col - 1] {
343                    match_or_mismatch_score += self.match_score;
344                } else {
345                    match_or_mismatch_score += self.mismatch_penalty;
346                }
347
348                let mut cell_score = 0i32;
349                let mut cell_prev: Option<(usize, usize)> = None;
350                if row_space_score >= col_space_score {
351                    if match_or_mismatch_score >= row_space_score {
352                        if match_or_mismatch_score > 0 {
353                            cell_score = match_or_mismatch_score;
354                            cell_prev = Some((row - 1, col - 1));
355                        }
356                    } else if row_space_score > 0 {
357                        cell_score = row_space_score;
358                        cell_prev = Some((row - 1, col));
359                    }
360                } else if match_or_mismatch_score >= col_space_score {
361                    if match_or_mismatch_score > 0 {
362                        cell_score = match_or_mismatch_score;
363                        cell_prev = Some((row - 1, col - 1));
364                    }
365                } else if col_space_score > 0 {
366                    cell_score = col_space_score;
367                    cell_prev = Some((row, col - 1));
368                }
369
370                score_table[row][col] = cell_score;
371                prev[row][col] = cell_prev;
372                if cell_score > score_table[high_row][high_col] {
373                    high_row = row;
374                    high_col = col;
375                }
376            }
377        }
378
379        // Java traceback stops at score == 0.
380        let mut row = high_row;
381        let mut col = high_col;
382        let mut align1: Vec<u8> = Vec::new();
383        let mut align2: Vec<u8> = Vec::new();
384        while score_table[row][col] != 0 {
385            let (pr, pc) = match prev[row][col] {
386                Some(p) => p,
387                None => break,
388            };
389            if row - pr == 1 {
390                align2.push(s2[row - 1]);
391            } else {
392                align2.push(b'-');
393            }
394            if col - pc == 1 {
395                align1.push(s1[col - 1]);
396            } else {
397                align1.push(b'-');
398            }
399            row = pr;
400            col = pc;
401        }
402        self.aligned_len = align2.len() as i32;
403
404        // Java getAlignmentScore recomputes score from traceback alignments.
405        let mut total = 0i32;
406        for i in 0..align1.len() {
407            let c1 = align1[i];
408            let c2 = align2[i];
409            if c1 == b'-' || c2 == b'-' {
410                total += self.gap_penalty;
411            } else if c1 == c2 {
412                total += self.match_score;
413            } else {
414                total += self.mismatch_penalty;
415            }
416        }
417        self.score = total;
418        if let Some(sw_started) = sw_started {
419            HG_PROFILE.sw_calls.fetch_add(1, Ordering::Relaxed);
420            add_ns(&HG_PROFILE.sw_ns, sw_started);
421        }
422    }
423}
424
425/// Shared validator for Scan1 and Scan2 BSJ candidate verification.
426///
427/// The scratch buffers are a retained low-risk optimization from Scan1 tuning:
428/// `is_in_circ_rna_3` is called often enough that reusing window-search buffers
429/// measurably reduces allocation pressure without changing decision logic.
430pub struct IsBSJHg2 {
431    pub linear_range_size_min: i32,
432    pub min_mapq_uni: i32,
433    pub initial_size1: i32,
434    pub aligner: SmithWaterman,
435    window_unit: [i32; 5],
436    query_code_buf: Vec<u64>,
437    circ_pos_buf: Vec<i32>,
438    pem_pos_buf: Vec<i32>,
439}
440
441/// Mirrors Java `String.substring` with explicit bound checks.
442///
443/// Keeping this helper centralizes the end-exclusive indexing contract used
444/// throughout the port and makes parity bugs easier to audit.
445pub fn java_substring(s: &str, start: i32, end: i32) -> &str {
446    let len = s.len() as i32;
447    assert!(
448        start >= 0,
449        "java_substring start<0: start={}, end={}, len={}",
450        start,
451        end,
452        len
453    );
454    assert!(
455        end >= 0,
456        "java_substring end<0: start={}, end={}, len={}",
457        start,
458        end,
459        len
460    );
461    assert!(
462        start <= end,
463        "java_substring start>end: start={}, end={}, len={}",
464        start,
465        end,
466        len
467    );
468    assert!(
469        end <= len,
470        "java_substring end>len: start={}, end={}, len={}",
471        start,
472        end,
473        len
474    );
475    &s[start as usize..end as usize]
476}
477
478impl IsBSJHg2 {
479    /// Creates a new validator with Java-compatible defaults.
480    pub fn new(linear_range_size_min: i32, min_mapq_uni: i32) -> Self {
481        Self {
482            linear_range_size_min,
483            min_mapq_uni,
484            initial_size1: 7,
485            aligner: SmithWaterman::new(1, -1, -1),
486            window_unit: [9, 7, 5, 4, 3],
487            query_code_buf: Vec::with_capacity(64),
488            circ_pos_buf: Vec::with_capacity(64),
489            pem_pos_buf: Vec::with_capacity(64),
490        }
491    }
492
493    /// Shared distance heuristic used by the linear-competition window scans.
494    fn distance_loci_stats(
495        &self,
496        locus_count: usize,
497        locus_sum: i32,
498        locus2_count: usize,
499        locus2_sum: i32,
500        window_step: i32,
501    ) -> i32 {
502        if locus_count < 2 && locus2_count < 2 {
503            return 0;
504        }
505        if locus_sum <= window_step * locus_count as i32 && locus_sum * 20 < locus2_sum {
506            1
507        } else {
508            0
509        }
510    }
511
512    /// Returns the left-side linear competitor window used by Scan1 checks.
513    ///
514    /// This helper exists mostly to document and reuse Java's boundary arithmetic;
515    /// the tiny speedup from avoiding duplicate branch trees is only a secondary
516    /// benefit.
517    #[inline]
518    fn linear_range_left<'a>(&self, chr_taga: &'a str, site1_new: i32, site2_new: i32) -> &'a str {
519        if site2_new - site1_new + 5 >= self.linear_range_size_min {
520            if 2 * site1_new >= site2_new + 6 {
521                java_substring(chr_taga, 2 * site1_new - site2_new - 6, site1_new - 1)
522            } else {
523                java_substring(chr_taga, 0, site1_new - 1)
524            }
525        } else if site1_new >= self.linear_range_size_min + 1 {
526            java_substring(
527                chr_taga,
528                site1_new - self.linear_range_size_min - 1,
529                site1_new - 1,
530            )
531        } else {
532            java_substring(chr_taga, 0, site1_new - 1)
533        }
534    }
535
536    /// Returns the right-side linear competitor window used by Scan1 checks.
537    #[inline]
538    fn linear_range_right<'a>(
539        &self,
540        chr_taga: &'a str,
541        site1_new: i32,
542        site2_new: i32,
543        chr_taga_len: i32,
544    ) -> &'a str {
545        if site2_new - site1_new + 5 >= self.linear_range_size_min {
546            if 2 * site2_new - site1_new + 5 > chr_taga_len {
547                java_substring(chr_taga, site2_new, chr_taga_len)
548            } else {
549                java_substring(chr_taga, site2_new, 2 * site2_new - site1_new + 5)
550            }
551        } else if site2_new + self.linear_range_size_min > chr_taga_len {
552            java_substring(chr_taga, site2_new, chr_taga_len)
553        } else {
554            java_substring(chr_taga, site2_new, site2_new + self.linear_range_size_min)
555        }
556    }
557
558    /// Linear competition check for the left-anchored junction fragment
559    /// (`Phase 1.1` in the original implementation).
560    pub fn is_in_circ_rna_1_1(
561        &mut self,
562        len_str: i32,
563        str_val: &str,
564        circ_range_seq: &str,
565        linear_range: &str,
566    ) -> i32 {
567        for &step in &self.window_unit {
568            if len_str < step * 2 {
569                continue;
570            }
571            let window_size = step * 2;
572            let mut trial = (len_str - window_size) / step;
573            let mut window_count = trial.max(0) as usize + 1;
574            if len_str % step != 0 {
575                window_count += 1;
576            }
577            self.query_code_buf.clear();
578            self.query_code_buf
579                .reserve(window_count.saturating_sub(self.query_code_buf.capacity()));
580            for j in 0..=trial {
581                let s_idx = len_str - j * step - window_size;
582                let e_idx = len_str - j * step;
583                self.query_code_buf.push(Self::encode_window(
584                    &str_val.as_bytes()[s_idx as usize..e_idx as usize],
585                ));
586            }
587            if len_str % step != 0 {
588                self.query_code_buf.push(Self::encode_window(
589                    &str_val.as_bytes()[0..window_size as usize],
590                ));
591            }
592            let mut hash_keys = [u64::MAX; 128];
593            let mut hash_masks = [0u64; 128];
594            let use_hash_fast_path =
595                Self::build_query_hash(&self.query_code_buf, &mut hash_keys, &mut hash_masks);
596            self.circ_pos_buf.clear();
597            self.circ_pos_buf.resize(window_count, -1);
598            Self::scan_window_positions(
599                &self.query_code_buf,
600                circ_range_seq.as_bytes(),
601                &mut self.circ_pos_buf,
602                window_size as usize,
603                true,
604                use_hash_fast_path,
605                &hash_keys,
606                &hash_masks,
607            );
608            self.pem_pos_buf.clear();
609            self.pem_pos_buf.resize(window_count, -1);
610            Self::scan_window_positions(
611                &self.query_code_buf,
612                linear_range.as_bytes(),
613                &mut self.pem_pos_buf,
614                window_size as usize,
615                true,
616                use_hash_fast_path,
617                &hash_keys,
618                &hash_masks,
619            );
620            let mut locus_count = 0usize;
621            let mut locus2_count = 0usize;
622            let mut locus_sum = 0i32;
623            let mut locus2_sum = 0i32;
624            let mut prev_locus: Option<i32> = None;
625            let mut prev_locus2: Option<i32> = None;
626            let mut miss_count = [0, 0, 0];
627            let mut miss_count2_total = 0;
628            if len_str % step != 0 {
629                trial += 1;
630            }
631            for (&pos, &pos2) in self.circ_pos_buf.iter().zip(self.pem_pos_buf.iter()) {
632                if pos >= 0 {
633                    if let Some(prev) = prev_locus {
634                        locus_sum += (pos - prev).abs();
635                    }
636                    prev_locus = Some(pos);
637                    locus_count += 1;
638                    miss_count[0] = 0;
639                } else {
640                    miss_count[1] += 1;
641                    miss_count[0] += 1;
642                    if miss_count[0] > miss_count[2] {
643                        miss_count[2] = miss_count[0];
644                    }
645                }
646                if pos2 >= 0 {
647                    if let Some(prev) = prev_locus2 {
648                        locus2_sum += (pos2 - prev).abs();
649                    }
650                    prev_locus2 = Some(pos2);
651                    locus2_count += 1;
652                } else {
653                    miss_count2_total += 1;
654                }
655            }
656            if miss_count2_total == 0 && miss_count[1] == 0 {
657                if self.distance_loci_stats(locus_count, locus_sum, locus2_count, locus2_sum, step)
658                    == 1
659                {
660                    return 1;
661                } else {
662                    return 0;
663                }
664            } else if miss_count2_total <= miss_count[1] {
665                if locus2_count != 0 {
666                    return 0;
667                }
668            } else if miss_count[2] > 5 || miss_count[1] * 2 > trial {
669                continue;
670            } else {
671                return 1;
672            }
673        }
674        0
675    }
676
677    /// Linear competition check for the right-anchored junction fragment
678    /// (`Phase 1.2` in the original implementation).
679    pub fn is_in_circ_rna_1_2(
680        &mut self,
681        len_str: i32,
682        str_val: &str,
683        circ_range_seq: &str,
684        linear_range: &str,
685    ) -> i32 {
686        for &step in &self.window_unit {
687            if len_str < step * 2 {
688                continue;
689            }
690            let window_size = step * 2;
691            let mut trial = (len_str - window_size) / step;
692            let mut window_count = trial.max(0) as usize + 1;
693            if len_str % step != 0 {
694                window_count += 1;
695            }
696            self.query_code_buf.clear();
697            self.query_code_buf
698                .reserve(window_count.saturating_sub(self.query_code_buf.capacity()));
699            for j in 0..=trial {
700                let s_idx = j * step;
701                let e_idx = j * step + window_size;
702                self.query_code_buf.push(Self::encode_window(
703                    &str_val.as_bytes()[s_idx as usize..e_idx as usize],
704                ));
705            }
706            if len_str % step != 0 {
707                self.query_code_buf.push(Self::encode_window(
708                    &str_val.as_bytes()[(len_str - window_size) as usize..len_str as usize],
709                ));
710            }
711            let mut hash_keys = [u64::MAX; 128];
712            let mut hash_masks = [0u64; 128];
713            let use_hash_fast_path =
714                Self::build_query_hash(&self.query_code_buf, &mut hash_keys, &mut hash_masks);
715            self.circ_pos_buf.clear();
716            self.circ_pos_buf.resize(window_count, -1);
717            Self::scan_window_positions(
718                &self.query_code_buf,
719                circ_range_seq.as_bytes(),
720                &mut self.circ_pos_buf,
721                window_size as usize,
722                false,
723                use_hash_fast_path,
724                &hash_keys,
725                &hash_masks,
726            );
727            self.pem_pos_buf.clear();
728            self.pem_pos_buf.resize(window_count, -1);
729            Self::scan_window_positions(
730                &self.query_code_buf,
731                linear_range.as_bytes(),
732                &mut self.pem_pos_buf,
733                window_size as usize,
734                false,
735                use_hash_fast_path,
736                &hash_keys,
737                &hash_masks,
738            );
739            let mut locus_count = 0usize;
740            let mut locus2_count = 0usize;
741            let mut locus_sum = 0i32;
742            let mut locus2_sum = 0i32;
743            let mut prev_locus: Option<i32> = None;
744            let mut prev_locus2: Option<i32> = None;
745            let mut miss_count = [0, 0, 0];
746            let mut miss_count2_total = 0;
747            if len_str % step != 0 {
748                trial += 1;
749            }
750            for (&pos, &pos2) in self.circ_pos_buf.iter().zip(self.pem_pos_buf.iter()) {
751                if pos >= 0 {
752                    if let Some(prev) = prev_locus {
753                        locus_sum += (pos - prev).abs();
754                    }
755                    prev_locus = Some(pos);
756                    locus_count += 1;
757                    miss_count[0] = 0;
758                } else {
759                    miss_count[1] += 1;
760                    miss_count[0] += 1;
761                    if miss_count[0] > miss_count[2] {
762                        miss_count[2] = miss_count[0];
763                    }
764                }
765                if pos2 >= 0 {
766                    if let Some(prev) = prev_locus2 {
767                        locus2_sum += (pos2 - prev).abs();
768                    }
769                    prev_locus2 = Some(pos2);
770                    locus2_count += 1;
771                } else {
772                    miss_count2_total += 1;
773                }
774            }
775            if miss_count2_total == 0 && miss_count[1] == 0 {
776                if self.distance_loci_stats(locus_count, locus_sum, locus2_count, locus2_sum, step)
777                    == 1
778                {
779                    return 1;
780                } else {
781                    return 0;
782                }
783            } else if miss_count2_total <= miss_count[1] {
784                if locus2_count != 0 {
785                    return 0;
786                }
787            } else if miss_count[2] > 5 || miss_count[1] * 2 > trial {
788                continue;
789            } else {
790                return 1;
791            }
792        }
793        0
794    }
795
796    /// Validates whether the rescued soft/unmapped sequence is still consistent
797    /// with the circ region (`Phase 2`).
798    pub fn is_in_circ_rna_2(&self, unmap_seq: &str, circ_range_seq: &str) -> i32 {
799        let seq_len = unmap_seq.len() as i32;
800        let window_step = 5;
801        let window_size = 10;
802        let mut miss_count3 = [0, 0, 0];
803        let trial = (seq_len - window_size) / window_step;
804        if trial < 0 {
805            return 1;
806        }
807        for j in 0..=trial {
808            let seq = if seq_len <= 10 {
809                unmap_seq
810            } else {
811                &unmap_seq[(j * window_step) as usize..(j * window_step + window_size) as usize]
812            };
813            if circ_range_seq.contains(seq) {
814                miss_count3[0] = 0;
815            } else {
816                miss_count3[1] += 1;
817                miss_count3[0] += 1;
818                if miss_count3[0] > miss_count3[2] {
819                    miss_count3[2] = miss_count3[0];
820                }
821            }
822        }
823        if miss_count3[2] > 5 || (miss_count3[1] - 1) * 2 > trial {
824            return 0;
825        }
826        1
827    }
828
829    /// Encodes one base into the compact 3-bit alphabet used by the rolling
830    /// window matcher in `is_in_circ_rna_3`.
831    #[inline]
832    fn encode_window_base(base: u8) -> u64 {
833        match base {
834            b'A' | b'a' => 0,
835            b'C' | b'c' => 1,
836            b'G' | b'g' => 2,
837            b'T' | b't' => 3,
838            b'N' | b'n' => 4,
839            _ => 7,
840        }
841    }
842
843    /// Encodes a fixed-size sequence window for fast equality checks.
844    #[inline]
845    fn encode_window(seq: &[u8]) -> u64 {
846        let mut code = 0u64;
847        for &base in seq {
848            code = (code << 3) | Self::encode_window_base(base);
849        }
850        code
851    }
852
853    /// Builds the tiny query-code hash table used by the rolling matcher.
854    ///
855    /// The table only helps for the common small-query case; larger query sets
856    /// still fall back to direct comparison to keep the helper simple.
857    fn build_query_hash(
858        query_codes: &[u64],
859        hash_keys: &mut [u64; 128],
860        hash_masks: &mut [u64; 128],
861    ) -> bool {
862        const EMPTY_CODE: u64 = u64::MAX;
863
864        if query_codes.len() > 64 {
865            return false;
866        }
867
868        hash_keys.fill(EMPTY_CODE);
869        hash_masks.fill(0);
870        for (idx, &code) in query_codes.iter().enumerate() {
871            let mut slot = (code as usize).wrapping_mul(0x9E37_79B1usize) & (128 - 1);
872            loop {
873                if hash_keys[slot] == EMPTY_CODE {
874                    hash_keys[slot] = code;
875                    hash_masks[slot] = 1u64 << idx;
876                    break;
877                }
878                if hash_keys[slot] == code {
879                    hash_masks[slot] |= 1u64 << idx;
880                    break;
881                }
882                slot = (slot + 1) & (128 - 1);
883            }
884        }
885        true
886    }
887
888    /// Scans one haystack with a prebuilt matcher and records first/last hits.
889    fn scan_window_positions(
890        query_codes: &[u64],
891        haystack: &[u8],
892        out: &mut [i32],
893        window_size: usize,
894        keep_last: bool,
895        use_hash_fast_path: bool,
896        hash_keys: &[u64; 128],
897        hash_masks: &[u64; 128],
898    ) {
899        const EMPTY_CODE: u64 = u64::MAX;
900
901        if haystack.len() < window_size || query_codes.is_empty() {
902            return;
903        }
904
905        let mut remaining = out.len();
906        let keep_last_bases_mask = (1u64 << ((window_size as u32 - 1) * 3)) - 1;
907        let mut rolling_code = Self::encode_window(&haystack[..window_size]);
908        let last_start = haystack.len() - window_size;
909
910        for start in 0..=last_start {
911            if start != 0 {
912                rolling_code = ((rolling_code & keep_last_bases_mask) << 3)
913                    | Self::encode_window_base(haystack[start + window_size - 1]);
914            }
915
916            if use_hash_fast_path {
917                let mut slot = (rolling_code as usize).wrapping_mul(0x9E37_79B1usize) & (128 - 1);
918                let mut mask = 0u64;
919                loop {
920                    let key = hash_keys[slot];
921                    if key == EMPTY_CODE {
922                        break;
923                    }
924                    if key == rolling_code {
925                        mask = hash_masks[slot];
926                        break;
927                    }
928                    slot = (slot + 1) & (128 - 1);
929                }
930                while mask != 0 {
931                    let idx = mask.trailing_zeros() as usize;
932                    if keep_last {
933                        out[idx] = start as i32;
934                    } else if out[idx] == -1 {
935                        out[idx] = start as i32;
936                        remaining -= 1;
937                    }
938                    mask &= mask - 1;
939                }
940            } else {
941                for (idx, &query_code) in query_codes.iter().enumerate() {
942                    if query_code == rolling_code {
943                        if keep_last {
944                            out[idx] = start as i32;
945                        } else if out[idx] == -1 {
946                            out[idx] = start as i32;
947                            remaining -= 1;
948                        }
949                    }
950                }
951            }
952
953            if !keep_last && remaining == 0 {
954                break;
955            }
956        }
957    }
958
959    /// Validates the mate-support sequence against circ and linear competitor
960    /// regions (`Phase 3`).
961    ///
962    /// The branch logic is still Java-shaped. The hot-path optimizations are
963    /// limited to reusable buffers and a per-call matcher table that is shared
964    /// between circ and linear-region scans.
965    pub fn is_in_circ_rna_3(
966        &mut self,
967        ano_read: &str,
968        pre_judge: &str,
969        circ_range_seq: &str,
970        pem_null_range_seq: &str,
971    ) -> i32 {
972        const WINDOW_STEP: usize = 5;
973        const WINDOW_SIZE: usize = 10;
974        let ano_read_len = ano_read.len() as i32;
975        let trial = (ano_read_len - WINDOW_SIZE as i32) / WINDOW_STEP as i32;
976        if trial < 0 {
977            if !pem_null_range_seq.is_empty() {
978                return -2;
979            }
980            return 1;
981        }
982        let mut miss_count = [0, 0, 0];
983        let mut miss_count2_total = 0;
984        let mut locus_count = 0usize;
985        let mut locus2_count = 0usize;
986        let mut locus_sum = 0i32;
987        let mut locus2_sum = 0i32;
988        let mut prev_locus: Option<i32> = None;
989        let mut prev_locus2: Option<i32> = None;
990        let ano_read_bytes = ano_read.as_bytes();
991        let window_count = trial.max(0) as usize + 1;
992        self.query_code_buf.clear();
993        self.query_code_buf
994            .reserve(window_count.saturating_sub(self.query_code_buf.capacity()));
995        for j in 0..window_count {
996            let start = j * WINDOW_STEP;
997            let end = start + WINDOW_SIZE;
998            self.query_code_buf
999                .push(Self::encode_window(&ano_read_bytes[start..end]));
1000        }
1001
1002        let mut hash_keys = [u64::MAX; 128];
1003        let mut hash_masks = [0u64; 128];
1004        let use_hash_fast_path =
1005            Self::build_query_hash(&self.query_code_buf, &mut hash_keys, &mut hash_masks);
1006
1007        self.circ_pos_buf.clear();
1008        self.circ_pos_buf.resize(window_count, -1);
1009        Self::scan_window_positions(
1010            &self.query_code_buf,
1011            circ_range_seq.as_bytes(),
1012            &mut self.circ_pos_buf,
1013            WINDOW_SIZE,
1014            false,
1015            use_hash_fast_path,
1016            &hash_keys,
1017            &hash_masks,
1018        );
1019
1020        let has_pem_positions = !pem_null_range_seq.is_empty();
1021        if has_pem_positions {
1022            self.pem_pos_buf.clear();
1023            self.pem_pos_buf.resize(window_count, -1);
1024            Self::scan_window_positions(
1025                &self.query_code_buf,
1026                pem_null_range_seq.as_bytes(),
1027                &mut self.pem_pos_buf,
1028                WINDOW_SIZE,
1029                false,
1030                use_hash_fast_path,
1031                &hash_keys,
1032                &hash_masks,
1033            );
1034        }
1035
1036        for j in 0..window_count {
1037            let pos = self.circ_pos_buf[j];
1038            if pos >= 0 {
1039                if let Some(prev) = prev_locus {
1040                    locus_sum += (pos - prev).abs();
1041                }
1042                prev_locus = Some(pos);
1043                locus_count += 1;
1044                miss_count[0] = 0;
1045            } else {
1046                miss_count[1] += 1;
1047                miss_count[0] += 1;
1048                if miss_count[0] > miss_count[2] {
1049                    miss_count[2] = miss_count[0];
1050                }
1051            }
1052
1053            if has_pem_positions {
1054                let pos2 = self.pem_pos_buf[j];
1055                if pos2 >= 0 {
1056                    if let Some(prev) = prev_locus2 {
1057                        locus2_sum += (pos2 - prev).abs();
1058                    }
1059                    prev_locus2 = Some(pos2);
1060                    locus2_count += 1;
1061                } else {
1062                    miss_count2_total += 1;
1063                }
1064            }
1065        }
1066        if !pem_null_range_seq.is_empty() {
1067            if miss_count2_total == 0 && miss_count[1] == 0 {
1068                if self.distance_loci_stats(
1069                    locus_count,
1070                    locus_sum,
1071                    locus2_count,
1072                    locus2_sum,
1073                    WINDOW_STEP as i32,
1074                ) == 1
1075                {
1076                    return 1;
1077                } else {
1078                    return -2;
1079                }
1080            } else if miss_count2_total <= miss_count[1] {
1081                if locus2_count != 0 {
1082                    return -2;
1083                } else {
1084                    return -1;
1085                }
1086            } else if miss_count[1] * 4 > trial * 3 && pre_judge == "0" {
1087                return -2;
1088            } else if miss_count[2] > 5 || miss_count[1] * 2 > trial {
1089                return -1;
1090            } else {
1091                return 1;
1092            }
1093        } else {
1094            if miss_count[1] * 4 > trial * 3 && pre_judge == "0" {
1095                return -2;
1096            } else if miss_count[2] > 5 || miss_count[1] * 2 > trial {
1097                return -1;
1098            }
1099        }
1100        1
1101    }
1102
1103    /// High-level BSJ identification from Scan1 metadata.
1104    ///
1105    /// This is the main Scan1 validator entry. The function remains intentionally
1106    /// close to Java branch order, because any "cleaner" rewrite here tends to
1107    /// introduce subtle parity drift. Retained optimizations are limited to helper
1108    /// extraction and scratch reuse around the hottest inner checks.
1109    pub fn is_bsj_hg1(
1110        &mut self,
1111        circ_line_arr: &mut [String],
1112        chr_taga: &str,
1113        sum_q: i32,
1114        mitochondrion: &str,
1115        sp_label: bool,
1116        chr_exon_start_map: &HashMap<String, String>,
1117        chr_exon_end_map: &HashMap<String, String>,
1118    ) -> Option<String> {
1119        let hg1_started = scan1_profile_enabled().then(Instant::now);
1120        HG_PROFILE.hg1_calls.fetch_add(1, Ordering::Relaxed);
1121        let site1 = circ_line_arr[9].parse::<i32>().unwrap_or(0);
1122        let site2 = circ_line_arr[10].parse::<i32>().unwrap_or(0);
1123        let end_adjt1 = circ_line_arr[11].parse::<i32>().unwrap_or(0);
1124        let end_adjt2 = circ_line_arr[12].parse::<i32>().unwrap_or(0);
1125        let total_adjustment = end_adjt1 + end_adjt2;
1126        let chr_taga_len = chr_taga.len() as i32;
1127
1128        let (end_string1, end_string2, tmp_site1, tmp_site2, adjt_bp);
1129        if end_adjt2 >= 0 {
1130            tmp_site1 = site1 - end_adjt1 - 1;
1131            tmp_site2 = site2 - end_adjt1 - 1;
1132            adjt_bp = 2 + total_adjustment;
1133            if site1 - end_adjt1 - 4 >= 0 {
1134                end_string1 =
1135                    java_substring(chr_taga, site1 - end_adjt1 - 4, end_adjt2 + site1).to_string();
1136            } else {
1137                let n_pad = (0 - (site1 - end_adjt1 - 4)) as usize;
1138                end_string1 = format!(
1139                    "{}{}",
1140                    "N".repeat(n_pad),
1141                    java_substring(chr_taga, 0, end_adjt2 + site1)
1142                );
1143            }
1144            if 3 + end_adjt2 + site2 > chr_taga_len {
1145                end_string2 =
1146                    java_substring(chr_taga, site2 - end_adjt1 - 1, chr_taga_len).to_string();
1147            } else {
1148                end_string2 =
1149                    java_substring(chr_taga, site2 - end_adjt1 - 1, 3 + end_adjt2 + site2)
1150                        .to_string();
1151            }
1152        } else {
1153            tmp_site1 = site1 + end_adjt2 - 1;
1154            tmp_site2 = site2 + end_adjt2 - 1;
1155            adjt_bp = 2 - total_adjustment;
1156            if site1 + end_adjt2 - 4 >= 0 {
1157                end_string1 =
1158                    java_substring(chr_taga, site1 + end_adjt2 - 4, site1 - end_adjt1).to_string();
1159            } else {
1160                let n_pad = (0 - (site1 + end_adjt2 - 4)) as usize;
1161                end_string1 = format!(
1162                    "{}{}",
1163                    "N".repeat(n_pad),
1164                    java_substring(chr_taga, 0, site1 - end_adjt1)
1165                );
1166            }
1167            if 3 + site2 - end_adjt1 > chr_taga_len {
1168                end_string2 =
1169                    java_substring(chr_taga, site2 + end_adjt2 - 1, chr_taga_len).to_string();
1170            } else {
1171                end_string2 =
1172                    java_substring(chr_taga, site2 + end_adjt2 - 1, 3 + site2 - end_adjt1)
1173                        .to_string();
1174            }
1175        }
1176
1177        let index_compare_started = scan1_profile_enabled().then(Instant::now);
1178        let mut index_strand_map = if circ_line_arr[1] == mitochondrion || sp_label {
1179            IndexCompare::index_compare_chrm(&end_string1, &end_string2)
1180        } else {
1181            IndexCompare::index_compare(&end_string1, &end_string2)
1182        };
1183        if let Some(index_compare_started) = index_compare_started {
1184            HG_PROFILE
1185                .index_compare_calls
1186                .fetch_add(1, Ordering::Relaxed);
1187            add_ns(&HG_PROFILE.index_compare_ns, index_compare_started);
1188        }
1189        let chr = circ_line_arr[1].as_str();
1190        let mut start_key = String::with_capacity(chr.len() + 16);
1191        let mut end_key = String::with_capacity(chr.len() + 16);
1192        let exon_lookup_started = scan1_profile_enabled().then(Instant::now);
1193        for i in 0..=adjt_bp {
1194            start_key.clear();
1195            start_key.push_str(chr);
1196            start_key.push('\t');
1197            let _ = write!(&mut start_key, "{}", tmp_site1 + i);
1198            end_key.clear();
1199            end_key.push_str(chr);
1200            end_key.push('\t');
1201            let _ = write!(&mut end_key, "{}", tmp_site2 + i);
1202            if !index_strand_map.contains_key(&i) {
1203                if let (Some(gene_start), Some(gene_end)) = (
1204                    chr_exon_start_map.get(&start_key),
1205                    chr_exon_end_map.get(&end_key),
1206                ) {
1207                    if gene_start == gene_end {
1208                        let mut parts = gene_start.split('\t');
1209                        let _ = parts.next();
1210                        let strand = parts.next().unwrap_or("");
1211                        index_strand_map.insert(
1212                            i,
1213                            format!(
1214                                "{}\t{}\t{}\t{}",
1215                                i,
1216                                strand,
1217                                java_substring(chr_taga, tmp_site1 + i - 3, tmp_site1 + i - 1),
1218                                java_substring(chr_taga, tmp_site2 + i, tmp_site2 + i + 2)
1219                            ),
1220                        );
1221                    }
1222                }
1223            }
1224        }
1225        if let Some(exon_lookup_started) = exon_lookup_started {
1226            HG_PROFILE.exon_lookup_calls.fetch_add(1, Ordering::Relaxed);
1227            add_ns(&HG_PROFILE.exon_lookup_ns, exon_lookup_started);
1228        }
1229
1230        if !index_strand_map.is_empty() {
1231            for (&shift, sig) in &index_strand_map {
1232                HG_PROFILE.shift_entries.fetch_add(1, Ordering::Relaxed);
1233                let mut shift_parts = sig.split('\t');
1234                let _shift_idx = shift_parts.next().unwrap_or("");
1235                let shift_strand = shift_parts.next().unwrap_or("");
1236                let shift_left = shift_parts.next().unwrap_or("");
1237                let shift_right = shift_parts.next().unwrap_or("");
1238                let diff_adjt = if end_adjt2 >= 0 {
1239                    shift - 1 - end_adjt1
1240                } else {
1241                    shift - 1 + total_adjustment - end_adjt1
1242                };
1243                let site1_new = site1 + diff_adjt;
1244                let site2_new = site2 + diff_adjt;
1245                let shift_prepare_started = scan1_profile_enabled().then(Instant::now);
1246                let mut str_new = ["".to_string(), "".to_string()];
1247                if diff_adjt >= 0 {
1248                    let str_adj = java_substring(&circ_line_arr[2], 0, diff_adjt);
1249                    str_new[1] = String::with_capacity(circ_line_arr[3].len() + str_adj.len());
1250                    str_new[1].push_str(&circ_line_arr[3]);
1251                    str_new[1].push_str(str_adj);
1252                    str_new[0] =
1253                        java_substring(&circ_line_arr[2], diff_adjt, circ_line_arr[2].len() as i32)
1254                            .to_string();
1255                } else {
1256                    let str_adj = java_substring(
1257                        &circ_line_arr[3],
1258                        circ_line_arr[3].len() as i32 + diff_adjt,
1259                        circ_line_arr[3].len() as i32,
1260                    );
1261                    str_new[0] = String::with_capacity(str_adj.len() + circ_line_arr[2].len());
1262                    str_new[0].push_str(str_adj);
1263                    str_new[0].push_str(&circ_line_arr[2]);
1264                    str_new[1] = java_substring(
1265                        &circ_line_arr[3],
1266                        0,
1267                        circ_line_arr[3].len() as i32 + diff_adjt,
1268                    )
1269                    .to_string();
1270                }
1271                {
1272                    let mut merged = String::with_capacity(shift_left.len() + str_new[0].len());
1273                    merged.push_str(shift_left);
1274                    merged.push_str(&str_new[0]);
1275                    str_new[0] = merged;
1276                }
1277                str_new[1].push_str(shift_right);
1278                let initial_seq1 = java_substring(&str_new[0], 0, self.initial_size1);
1279                let initial_seq2 = java_substring(
1280                    &str_new[1],
1281                    str_new[1].len() as i32 - self.initial_size1,
1282                    str_new[1].len() as i32,
1283                );
1284                let circ_range_seq = if site1_new - 3 < 0 && site2_new + 2 > chr_taga_len {
1285                    &chr_taga[..]
1286                } else if site1_new - 3 < 0 {
1287                    java_substring(chr_taga, 0, site2_new + 2)
1288                } else if site2_new + 2 > chr_taga_len {
1289                    java_substring(chr_taga, site1_new - 3, chr_taga_len)
1290                } else {
1291                    java_substring(chr_taga, site1_new - 3, site2_new + 2)
1292                };
1293                if let Some(shift_prepare_started) = shift_prepare_started {
1294                    HG_PROFILE
1295                        .shift_prepare_calls
1296                        .fetch_add(1, Ordering::Relaxed);
1297                    add_ns(&HG_PROFILE.shift_prepare_ns, shift_prepare_started);
1298                }
1299                if circ_range_seq.starts_with(initial_seq1)
1300                    && circ_range_seq.ends_with(initial_seq2)
1301                {
1302                    let right_linear_range =
1303                        self.linear_range_right(chr_taga, site1_new, site2_new, chr_taga_len);
1304                    let left_linear_range = self.linear_range_left(chr_taga, site1_new, site2_new);
1305                    if circ_line_arr[6] != "1" {
1306                        let linear_started = scan1_profile_enabled().then(Instant::now);
1307                        circ_line_arr[6] = self
1308                            .is_in_circ_rna_1_2(
1309                                str_new[0].len() as i32,
1310                                &str_new[0],
1311                                circ_range_seq,
1312                                right_linear_range,
1313                            )
1314                            .to_string();
1315                        if let Some(linear_started) = linear_started {
1316                            HG_PROFILE
1317                                .linear_check_calls
1318                                .fetch_add(1, Ordering::Relaxed);
1319                            add_ns(&HG_PROFILE.linear_check_ns, linear_started);
1320                        }
1321                    }
1322                    if circ_line_arr[7] != "1" {
1323                        let linear_started = scan1_profile_enabled().then(Instant::now);
1324                        circ_line_arr[7] = self
1325                            .is_in_circ_rna_1_2(
1326                                str_new[1].len() as i32,
1327                                &str_new[1],
1328                                circ_range_seq,
1329                                left_linear_range,
1330                            )
1331                            .to_string();
1332                        if let Some(linear_started) = linear_started {
1333                            HG_PROFILE
1334                                .linear_check_calls
1335                                .fetch_add(1, Ordering::Relaxed);
1336                            add_ns(&HG_PROFILE.linear_check_ns, linear_started);
1337                        }
1338                    }
1339                    if circ_line_arr[6] == "1" && circ_line_arr[7] == "1" {
1340                        if circ_line_arr[4] != "*" {
1341                            let circ2_started = scan1_profile_enabled().then(Instant::now);
1342                            let circ2_ok = self.is_in_circ_rna_2(&circ_line_arr[4], circ_range_seq);
1343                            if let Some(circ2_started) = circ2_started {
1344                                HG_PROFILE.circ2_calls.fetch_add(1, Ordering::Relaxed);
1345                                add_ns(&HG_PROFILE.circ2_ns, circ2_started);
1346                            }
1347                            if circ2_ok == 0 {
1348                                if let Some(hg1_started) = hg1_started {
1349                                    add_ns(&HG_PROFILE.hg1_total_ns, hg1_started);
1350                                }
1351                                return None;
1352                            }
1353                        }
1354                        let mut tag = 1;
1355                        if circ_line_arr[5].len() > 5 {
1356                            // Java parity: preserve original branch order/conditions.
1357                            let pem_null = if circ_line_arr[0] == "1"
1358                                && site1_new - site1_new + 5 >= self.linear_range_size_min
1359                            {
1360                                if 2 * site1_new >= site2_new + 6 {
1361                                    java_substring(
1362                                        chr_taga,
1363                                        2 * site1_new - site2_new - 6,
1364                                        site1_new - 1,
1365                                    )
1366                                } else {
1367                                    java_substring(chr_taga, 0, site1_new - 1)
1368                                }
1369                            } else if circ_line_arr[0] == "1" {
1370                                if site1_new >= self.linear_range_size_min + 1 {
1371                                    java_substring(
1372                                        chr_taga,
1373                                        site1_new - self.linear_range_size_min - 1,
1374                                        site1_new - 1,
1375                                    )
1376                                } else {
1377                                    java_substring(chr_taga, 0, site1_new - 1)
1378                                }
1379                            } else if circ_line_arr[0] == "0"
1380                                && site2_new - site1_new + 5 > self.linear_range_size_min
1381                            {
1382                                if 2 * site2_new - site1_new + 5 > chr_taga_len {
1383                                    java_substring(chr_taga, site2_new, chr_taga_len)
1384                                } else {
1385                                    java_substring(
1386                                        chr_taga,
1387                                        site2_new,
1388                                        2 * site2_new - site1_new + 5,
1389                                    )
1390                                }
1391                            } else {
1392                                if site2_new + self.linear_range_size_min > chr_taga_len {
1393                                    java_substring(chr_taga, site2_new, chr_taga_len)
1394                                } else {
1395                                    java_substring(
1396                                        chr_taga,
1397                                        site2_new,
1398                                        site2_new + self.linear_range_size_min,
1399                                    )
1400                                }
1401                            };
1402                            let circ3_started = scan1_profile_enabled().then(Instant::now);
1403                            tag = self.is_in_circ_rna_3(
1404                                &circ_line_arr[5],
1405                                &circ_line_arr[8],
1406                                circ_range_seq,
1407                                pem_null,
1408                            );
1409                            if let Some(circ3_started) = circ3_started {
1410                                HG_PROFILE.circ3_calls.fetch_add(1, Ordering::Relaxed);
1411                                add_ns(&HG_PROFILE.circ3_ns, circ3_started);
1412                            }
1413                        }
1414                        HG_PROFILE.hg1_hits.fetch_add(1, Ordering::Relaxed);
1415                        if let Some(hg1_started) = hg1_started {
1416                            add_ns(&HG_PROFILE.hg1_total_ns, hg1_started);
1417                        }
1418                        return Some(format!(
1419                            "{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}",
1420                            tag,
1421                            circ_line_arr[1],
1422                            site1_new,
1423                            site2_new,
1424                            shift_strand,
1425                            shift_left,
1426                            shift_right,
1427                            sum_q
1428                        ));
1429                    }
1430                }
1431            }
1432        }
1433        if let Some(hg1_started) = hg1_started {
1434            add_ns(&HG_PROFILE.hg1_total_ns, hg1_started);
1435        }
1436        None
1437    }
1438
1439    /// Low-level BSJ identification from Scan2 rescue metadata.
1440    ///
1441    /// Scan2 still passes a string-array payload here to match the historical
1442    /// Java data layout. Several attempts to replace that protocol with more typed
1443    /// structures did not deliver reliable speedups, so the current design favors
1444    /// auditability over abstraction.
1445    pub fn is_bsj_hg2(&mut self, circ_line_arr: &[String], chr_taga: &str) -> String {
1446        let hg2_started = scan2_profile_enabled().then(Instant::now);
1447        if hg2_started.is_some() {
1448            HG2_PROFILE.hg2_calls.fetch_add(1, Ordering::Relaxed);
1449        }
1450        trace_scan2_hg2("enter", circ_line_arr, "");
1451        let mut judge_tag = "3".to_string();
1452        let start_site = circ_line_arr[3].parse::<i32>().unwrap_or(0);
1453        let end_site = circ_line_arr[4].parse::<i32>().unwrap_or(0);
1454        let quant = circ_line_arr[12].parse::<i32>().unwrap_or(0);
1455        let chr_taga_len = chr_taga.len() as i32;
1456        let circ_range_seq = if start_site - 3 < 0 && end_site + 2 > chr_taga_len {
1457            &chr_taga[..]
1458        } else if start_site - 3 < 0 {
1459            java_substring(chr_taga, 0, end_site + 2)
1460        } else if end_site + 2 > chr_taga_len {
1461            java_substring(chr_taga, start_site - 3, chr_taga_len)
1462        } else {
1463            java_substring(chr_taga, start_site - 3, end_site + 2)
1464        };
1465        let circ_range_len = circ_range_seq.len() as i32;
1466        let str_full;
1467        let mut pem_null_range_seq = "";
1468        if circ_line_arr[2] == "sm" {
1469            str_full = format!("{}{}", circ_line_arr[5], circ_line_arr[11]);
1470            let len_str = str_full.len() as i32;
1471            if len_str < 7 {
1472                if java_substring(circ_range_seq, circ_range_len - len_str, circ_range_len)
1473                    != str_full
1474                {
1475                    trace_scan2_hg2("sm_short_mismatch", circ_line_arr, "");
1476                    return finish_scan2_hg2("0".to_string(), hg2_started);
1477                } else {
1478                    trace_scan2_hg2("sm_short_exact", circ_line_arr, "");
1479                    return finish_scan2_hg2("2".to_string(), hg2_started);
1480                }
1481            } else {
1482                let s1_part1 =
1483                    java_substring(circ_range_seq, circ_range_len - 4, circ_range_len - 2);
1484                let s1_part2 = java_substring(&str_full, len_str - 4, len_str - 2);
1485                let s2_part1 =
1486                    java_substring(circ_range_seq, circ_range_len - 7, circ_range_len - 4);
1487                let s2_part2 = java_substring(&str_full, len_str - 7, len_str - 4);
1488                if s1_part1 == s1_part2 || s2_part1 == s2_part2 {
1489                    let mut label = true;
1490                    let linear_range;
1491                    if end_site - start_site + 5 >= self.linear_range_size_min {
1492                        if 2 * start_site >= end_site + 6 {
1493                            linear_range = java_substring(
1494                                chr_taga,
1495                                2 * start_site - end_site - 6,
1496                                start_site - 1,
1497                            );
1498                        } else {
1499                            linear_range = java_substring(chr_taga, 0, start_site - 1);
1500                        }
1501                    } else if start_site - 1 >= self.linear_range_size_min {
1502                        linear_range = java_substring(
1503                            chr_taga,
1504                            start_site - self.linear_range_size_min - 1,
1505                            start_site - 1,
1506                        );
1507                    } else {
1508                        linear_range = java_substring(chr_taga, 0, start_site - 1);
1509                    }
1510                    if circ_range_len < len_str {
1511                        return finish_scan2_hg2("2".to_string(), hg2_started);
1512                    }
1513                    let sw_started = scan2_profile_enabled().then(Instant::now);
1514                    self.aligner.set_seq(
1515                        java_substring(circ_range_seq, circ_range_len - len_str, circ_range_len),
1516                        &str_full,
1517                    );
1518                    self.aligner.align();
1519                    if let Some(sw_started) = sw_started {
1520                        HG2_PROFILE.sw_calls.fetch_add(1, Ordering::Relaxed);
1521                        add_ns(&HG2_PROFILE.sw_ns, sw_started);
1522                    }
1523                    if self.aligner.score >= (len_str - (len_str - 2) / 10 * 2) {
1524                        if quant >= self.min_mapq_uni {
1525                            let initial_seq =
1526                                java_substring(&str_full, len_str - self.initial_size1, len_str);
1527                            if java_substring(
1528                                circ_range_seq,
1529                                circ_range_len - self.initial_size1,
1530                                circ_range_len,
1531                            ) == initial_seq
1532                            {
1533                                let linear11_started = scan2_profile_enabled().then(Instant::now);
1534                                let linear11_ok = self.is_in_circ_rna_1_1(
1535                                    len_str,
1536                                    &str_full,
1537                                    circ_range_seq,
1538                                    linear_range,
1539                                ) == 1;
1540                                if let Some(linear11_started) = linear11_started {
1541                                    HG2_PROFILE.linear11_calls.fetch_add(1, Ordering::Relaxed);
1542                                    add_ns(&HG2_PROFILE.linear11_ns, linear11_started);
1543                                }
1544                                if linear11_ok {
1545                                    judge_tag = "2".to_string();
1546                                } else {
1547                                    judge_tag = "3".to_string();
1548                                }
1549                            }
1550                        }
1551                        if circ_line_arr[0] == "1" {
1552                            pem_null_range_seq = linear_range;
1553                        }
1554                        label = false;
1555                    }
1556                    if label {
1557                        let initial_seq =
1558                            java_substring(&str_full, len_str - self.initial_size1, len_str);
1559                        if java_substring(
1560                            circ_range_seq,
1561                            circ_range_len - self.initial_size1,
1562                            circ_range_len,
1563                        ) == initial_seq
1564                        {
1565                            let linear11_started = scan2_profile_enabled().then(Instant::now);
1566                            let linear11_ok = self.is_in_circ_rna_1_1(
1567                                len_str,
1568                                &str_full,
1569                                circ_range_seq,
1570                                linear_range,
1571                            ) == 1;
1572                            if let Some(linear11_started) = linear11_started {
1573                                HG2_PROFILE.linear11_calls.fetch_add(1, Ordering::Relaxed);
1574                                add_ns(&HG2_PROFILE.linear11_ns, linear11_started);
1575                            }
1576                            if linear11_ok {
1577                                if quant >= self.min_mapq_uni {
1578                                    judge_tag = "2".to_string();
1579                                    if circ_line_arr[0] == "1" {
1580                                        pem_null_range_seq = linear_range;
1581                                    }
1582                                } else {
1583                                    trace_scan2_hg2("sm_linear11_low_mapq", circ_line_arr, "");
1584                                    return finish_scan2_hg2("2".to_string(), hg2_started);
1585                                }
1586                            } else {
1587                                trace_scan2_hg2("sm_linear11_fail", circ_line_arr, "");
1588                                return finish_scan2_hg2("0".to_string(), hg2_started);
1589                            }
1590                        } else {
1591                            trace_scan2_hg2("sm_initial_fail", circ_line_arr, "");
1592                            return finish_scan2_hg2("0".to_string(), hg2_started);
1593                        }
1594                    }
1595                } else {
1596                    trace_scan2_hg2("sm_seed_fail", circ_line_arr, "");
1597                    return finish_scan2_hg2("0".to_string(), hg2_started);
1598                }
1599            }
1600        } else {
1601            str_full = format!("{}{}", circ_line_arr[10], circ_line_arr[5]);
1602            let len_str = str_full.len() as i32;
1603            if len_str < 7 {
1604                if java_substring(circ_range_seq, 0, len_str) != str_full {
1605                    trace_scan2_hg2("ms_short_mismatch", circ_line_arr, "");
1606                    return finish_scan2_hg2("0".to_string(), hg2_started);
1607                } else {
1608                    trace_scan2_hg2("ms_short_exact", circ_line_arr, "");
1609                    return finish_scan2_hg2("2".to_string(), hg2_started);
1610                }
1611            } else {
1612                let s1_part1 = java_substring(circ_range_seq, 2, 4);
1613                let s1_part2 = java_substring(&str_full, 2, 4);
1614                let s2_part1 = java_substring(circ_range_seq, 4, 7);
1615                let s2_part2 = java_substring(&str_full, 4, 7);
1616                if s1_part1 == s1_part2 || s2_part1 == s2_part2 {
1617                    let mut label = true;
1618                    let linear_range;
1619                    if end_site - start_site + 5 >= self.linear_range_size_min {
1620                        if 2 * end_site - start_site + 5 > chr_taga_len {
1621                            linear_range = java_substring(chr_taga, end_site, chr_taga_len);
1622                        } else {
1623                            linear_range =
1624                                java_substring(chr_taga, end_site, 2 * end_site - start_site + 5);
1625                        }
1626                    } else if end_site + self.linear_range_size_min > chr_taga_len {
1627                        linear_range = java_substring(chr_taga, end_site, chr_taga_len);
1628                    } else {
1629                        linear_range = java_substring(
1630                            chr_taga,
1631                            end_site,
1632                            end_site + self.linear_range_size_min,
1633                        );
1634                    }
1635                    if circ_range_len < len_str {
1636                        return finish_scan2_hg2("2".to_string(), hg2_started);
1637                    }
1638                    let sw_started = scan2_profile_enabled().then(Instant::now);
1639                    self.aligner
1640                        .set_seq(java_substring(circ_range_seq, 0, len_str), &str_full);
1641                    self.aligner.align();
1642                    if let Some(sw_started) = sw_started {
1643                        HG2_PROFILE.sw_calls.fetch_add(1, Ordering::Relaxed);
1644                        add_ns(&HG2_PROFILE.sw_ns, sw_started);
1645                    }
1646                    if self.aligner.score >= (len_str - (len_str - 2) / 10 * 2) {
1647                        let initial_seq = java_substring(&str_full, 0, self.initial_size1);
1648                        if java_substring(circ_range_seq, 0, self.initial_size1) == initial_seq {
1649                            let linear12_started = scan2_profile_enabled().then(Instant::now);
1650                            let linear12_ok = self.is_in_circ_rna_1_2(
1651                                len_str,
1652                                &str_full,
1653                                circ_range_seq,
1654                                linear_range,
1655                            ) == 1;
1656                            if let Some(linear12_started) = linear12_started {
1657                                HG2_PROFILE.linear12_calls.fetch_add(1, Ordering::Relaxed);
1658                                add_ns(&HG2_PROFILE.linear12_ns, linear12_started);
1659                            }
1660                            if linear12_ok {
1661                                if quant >= self.min_mapq_uni {
1662                                    judge_tag = "2".to_string();
1663                                }
1664                            } else {
1665                                judge_tag = "3".to_string();
1666                            }
1667                        }
1668                        if circ_line_arr[0] == "0" {
1669                            pem_null_range_seq = linear_range;
1670                        }
1671                        label = false;
1672                    }
1673                    if label {
1674                        let initial_seq = java_substring(&str_full, 0, self.initial_size1);
1675                        if java_substring(circ_range_seq, 0, self.initial_size1) == initial_seq {
1676                            let linear12_started = scan2_profile_enabled().then(Instant::now);
1677                            let linear12_ok = self.is_in_circ_rna_1_2(
1678                                len_str,
1679                                &str_full,
1680                                circ_range_seq,
1681                                linear_range,
1682                            ) == 1;
1683                            if let Some(linear12_started) = linear12_started {
1684                                HG2_PROFILE.linear12_calls.fetch_add(1, Ordering::Relaxed);
1685                                add_ns(&HG2_PROFILE.linear12_ns, linear12_started);
1686                            }
1687                            if linear12_ok {
1688                                if quant >= self.min_mapq_uni {
1689                                    judge_tag = "2".to_string();
1690                                    if circ_line_arr[0] == "0" {
1691                                        pem_null_range_seq = linear_range;
1692                                    }
1693                                } else {
1694                                    trace_scan2_hg2("ms_linear12_low_mapq", circ_line_arr, "");
1695                                    return finish_scan2_hg2("2".to_string(), hg2_started);
1696                                }
1697                            } else {
1698                                trace_scan2_hg2("ms_linear12_fail", circ_line_arr, "");
1699                                return finish_scan2_hg2("0".to_string(), hg2_started);
1700                            }
1701                        } else {
1702                            trace_scan2_hg2("ms_initial_fail", circ_line_arr, "");
1703                            return finish_scan2_hg2("0".to_string(), hg2_started);
1704                        }
1705                    }
1706                } else {
1707                    trace_scan2_hg2("ms_seed_fail", circ_line_arr, "");
1708                    return finish_scan2_hg2("0".to_string(), hg2_started);
1709                }
1710            }
1711        }
1712        if circ_line_arr[7] != "*" {
1713            let circ2_started = scan2_profile_enabled().then(Instant::now);
1714            let circ2_ok = self.is_in_circ_rna_2(&circ_line_arr[7], circ_range_seq) != 0;
1715            if let Some(circ2_started) = circ2_started {
1716                HG2_PROFILE.circ2_calls.fetch_add(1, Ordering::Relaxed);
1717                add_ns(&HG2_PROFILE.circ2_ns, circ2_started);
1718            }
1719            if !circ2_ok {
1720                trace_scan2_hg2("circ2_fail", circ_line_arr, "");
1721                return finish_scan2_hg2("0".to_string(), hg2_started);
1722            }
1723        }
1724        if circ_line_arr[6].len() > 5 {
1725            let circ3_started = scan2_profile_enabled().then(Instant::now);
1726            let res = self.is_in_circ_rna_3(
1727                &circ_line_arr[6],
1728                &circ_line_arr[8],
1729                circ_range_seq,
1730                pem_null_range_seq,
1731            );
1732            if let Some(circ3_started) = circ3_started {
1733                HG2_PROFILE.circ3_calls.fetch_add(1, Ordering::Relaxed);
1734                add_ns(&HG2_PROFILE.circ3_ns, circ3_started);
1735            }
1736            trace_scan2_hg2(
1737                "final",
1738                circ_line_arr,
1739                &format!("judge_tag={} circ3_res={}", judge_tag, res),
1740            );
1741            return finish_scan2_hg2(format!("{}{}", res, judge_tag), hg2_started);
1742        }
1743        trace_scan2_hg2(
1744            "final_short_pair",
1745            circ_line_arr,
1746            &format!("judge_tag={}", judge_tag),
1747        );
1748        finish_scan2_hg2(format!("1{}", judge_tag), hg2_started)
1749    }
1750}
1751
1752#[cfg(test)]
1753mod tests {
1754    use super::*;
1755
1756    #[test]
1757    fn test_java_substring() {
1758        assert_eq!(java_substring("ABCDE", 1, 3), "BC");
1759        assert_eq!(java_substring("ABCDE", 0, 5), "ABCDE");
1760    }
1761
1762    #[test]
1763    fn test_smith_waterman_exact() {
1764        let mut sw = SmithWaterman::new(1, -1, -1);
1765        sw.set_seq("ATGC", "ATGC");
1766        sw.align();
1767        assert_eq!(sw.score, 4);
1768        assert_eq!(sw.aligned_len, 4);
1769    }
1770
1771    #[test]
1772    fn test_smith_waterman_mismatch() {
1773        let mut sw = SmithWaterman::new(1, -1, -1);
1774        sw.set_seq("ATGC", "ATGG");
1775        sw.align();
1776        assert_eq!(sw.score, 3);
1777    }
1778
1779    #[test]
1780    fn test_smith_waterman_short() {
1781        let mut sw = SmithWaterman::new(1, -1, -1);
1782        sw.set_seq("GCAT", "GC");
1783        sw.align();
1784        assert_eq!(sw.score, 2);
1785    }
1786}