Skip to main content

ciri_toolkit/
scan1.rs

1//! Scan1: first-pass BSJ candidate discovery.
2//!
3//! This module owns the initial read-group scan over SAM/BAM input and emits the
4//! Java-compatible BSJ intermediate file consumed by Scan2 and Summary.
5//! Performance work here is deliberately limited to ingestion, sharding, and
6//! temporary allocation control; the candidate semantics still follow Java CIRI3.
7
8use crate::annotation::Annotation;
9use crate::is_bsj_hg2::{java_substring, report_scan1_hg_profile, IsBSJHg2};
10use crate::misd::misd;
11use crate::runtime::{emit_perf_line, emit_trace_line, scan1_profile_enabled, should_trace_read};
12use crate::utils::{
13    alignment_short_cs, bam_shard_count, clip_sequence_payload, local_clip_evidence_lines,
14    part_path, AlignmentRecord,
15};
16use anyhow::Result;
17use indicatif::{ProgressBar, ProgressStyle};
18use memmap2::Mmap;
19use noodles::sam::{
20    self,
21    alignment::{
22        record::{
23            data::field::{Tag, Value},
24            Data as _, Sequence as _,
25        },
26        Record as _,
27    },
28};
29use rayon::prelude::*;
30use std::borrow::Cow;
31use std::collections::{HashMap, HashSet};
32use std::fmt::Write as FmtWrite;
33use std::fs::File;
34use std::io::{BufRead, BufReader, BufWriter, Seek, Write};
35use std::sync::atomic::{AtomicU64, Ordering};
36use std::sync::mpsc;
37use std::thread;
38use std::time::{Duration, Instant};
39
40/// First-pass BSJ detector.
41///
42/// Design constraints:
43/// - Behavior is anchored to Java CIRI3, so this type keeps the same two-pass
44///   boundaries and candidate layout.
45/// - Performance work is intentionally concentrated in record ingestion and
46///   `process_group_view`, because profiling showed merge and final write are not
47///   meaningful bottlenecks.
48pub struct Scan1 {
49    pub min_mapq_uni: i32,
50    pub max_circle: i32,
51    pub min_circle: i32,
52    pub linear_range_size_min: i32,
53    pub mem_limit: u64,
54    pub read_len: i32,
55    /// Total read groups observed during Scan1 input traversal.
56    pub mapped_reads: u64,
57    /// Unique read IDs emitted into the merged Scan1 BSJ file.
58    pub bsj1_reads: usize,
59}
60
61type OwnedAlignmentRecord = AlignmentRecord<'static>;
62type OwnedStandMap = HashMap<i32, (char, Cow<'static, str>)>;
63
64/// Returns a stable text payload for one optional `XA:Z` field.
65///
66/// Empty alternatives are written as `*` in the Scan1 sidecar so the protocol
67/// has an explicit missing value while preserving raw BWA payloads unchanged
68/// when post-Summary segment selection needs them.
69fn xa_payload(raw: &str) -> &str {
70    if raw.is_empty() {
71        "*"
72    } else {
73        raw
74    }
75}
76
77/// Extracts a raw BWA `XA:Z` payload from one SAM record.
78///
79/// Scan1's CIRI3 decision logic ignores this optional tag. It is retained only
80/// for the segments sidecar, where a confirmed BSJ read can prefer a
81/// circRNA-compatible alternative over a paralogous primary alignment.
82fn raw_xa_from_sam_record(record: &sam::Record) -> Result<String> {
83    let tag = Tag::new(b'X', b'A');
84    let data = record.data();
85    let Some(value) = data.get(&tag).transpose()? else {
86        return Ok(String::new());
87    };
88    let Value::String(raw) = value else {
89        return Ok(String::new());
90    };
91    let Ok(raw) = std::str::from_utf8(raw.as_ref()) else {
92        return Ok(String::new());
93    };
94    Ok(raw.to_string())
95}
96
97/// Extracts a raw BWA `XA:Z` payload from one BAM record.
98///
99/// This mirrors [`raw_xa_from_sam_record`] for the sharded BAM hot path and
100/// deliberately treats malformed or non-string tags as absent sidecar evidence.
101fn raw_xa_from_bam_record(record: &noodles::bam::Record) -> Result<String> {
102    let tag = Tag::new(b'X', b'A');
103    let data = record.data();
104    let Some(value) = data.get(&tag).transpose()? else {
105        return Ok(String::new());
106    };
107    let Value::String(raw) = value else {
108        return Ok(String::new());
109    };
110    let Ok(raw) = std::str::from_utf8(raw.as_ref()) else {
111        return Ok(String::new());
112    };
113    Ok(raw.to_string())
114}
115
116/// Owned SAM read-group payload passed from the sequential parser thread to the
117/// parallel candidate-evaluation workers.
118///
119/// The SAM path uses this owned form so parsing stays single-source-of-truth
120/// through `noodles`, while the expensive BSJ judgment can still run in parallel.
121struct SamOwnedGroup {
122    read_id: String,
123    group: [Vec<OwnedAlignmentRecord>; 2],
124    stand_map: OwnedStandMap,
125    align_num: usize,
126}
127
128/// Lightweight Scan1 traversal summary used for user-facing stage logs.
129struct Scan1TraversalStats {
130    max_read_len: i32,
131    mapped_reads: u64,
132}
133
134/// One shard-local Scan1 summary.
135struct Scan1ShardStats {
136    max_read_len: i32,
137    mapped_reads: u64,
138}
139
140/// Advises the kernel that an already-processed BAM byte range can be evicted.
141///
142/// The offset is aligned manually because `madvise` requires page alignment.
143/// This is a memory-footprint optimization, not a correctness requirement.
144fn advise_dontneed_aligned(mmap: &Mmap, offset: usize, len: usize) {
145    if len == 0 {
146        return;
147    }
148    // Page alignment is mandatory for madvise (typically 4096 bytes)
149    let page_size = 4096;
150    let aligned_offset = (offset / page_size) * page_size;
151    let aligned_len = ((offset + len + page_size - 1) / page_size) * page_size - aligned_offset;
152
153    unsafe {
154        let ptr = mmap.as_ptr().add(aligned_offset);
155        libc::madvise(ptr as *mut libc::c_void, aligned_len, libc::MADV_DONTNEED);
156    }
157}
158
159/// Emits shard-boundary trace lines for targeted BAM parity debugging.
160///
161/// This stays behind the same targeted read-selection logic used by `--trace`
162/// and `CIRI_TRACE_READS`, so it can remain in the codebase without affecting
163/// normal runs. The output is intentionally narrow: only shard ownership
164/// decisions around read-group boundaries are logged.
165fn trace_bam_shard_event(
166    shard_idx: usize,
167    read_id: &str,
168    stage: &str,
169    start: usize,
170    block_start: usize,
171    end: usize,
172    abs_c_pos: usize,
173) {
174    if should_trace_read(read_id) {
175        emit_trace_line(&format!(
176            "[TRACE_SCAN1_SHARD] shard={} stage={} id={} start={} block_start={} end={} abs_c_pos={}",
177            shard_idx, stage, read_id, start, block_start, end, abs_c_pos
178        ));
179    }
180}
181
182/// Keeps the best representative sequence for one mate inside a read group.
183///
184/// BAM/SAM records that carry hard clips expose a shorter `SEQ` than the
185/// corresponding soft-clipped alignment of the same template. BSJ judgment later
186/// slices the representative read sequence using clip-derived offsets, so taking
187/// the first record verbatim can accidentally anchor Scan1 on a truncated
188/// hard-clipped sequence. To stay compatible with CIRI3's expectation of using
189/// the full read sequence, we retain the longest observed `SEQ` for each mate.
190#[inline]
191fn update_best_stand_seq<'a>(
192    stand_map: &mut HashMap<i32, (char, Cow<'a, str>)>,
193    s_idx: i32,
194    st_c: char,
195    seq: Cow<'a, str>,
196) {
197    match stand_map.get(&s_idx) {
198        Some((_, existing_seq)) if existing_seq.len() >= seq.len() => {}
199        _ => {
200            stand_map.insert(s_idx, (st_c, seq));
201        }
202    }
203}
204
205/// Optional Scan1 profiler used when CLI `--perf` or legacy
206/// `CIRI_PROFILE_SCAN1=1` is enabled.
207///
208/// The counters are coarse on purpose: they are cheap enough to leave in the hot
209/// path, but still isolate whether time is spent in group assembly, BSJ judgment,
210/// or write/merge scaffolding.
211#[derive(Default)]
212struct Scan1Profile {
213    wall_total_ns: AtomicU64,
214    shard_total_ns: AtomicU64,
215    group_process_ns: AtomicU64,
216    bsj_judge_ns: AtomicU64,
217    write_ns: AtomicU64,
218    merge_ns: AtomicU64,
219    records: AtomicU64,
220    groups: AtomicU64,
221    hg1_calls: AtomicU64,
222    hg1_hits: AtomicU64,
223}
224
225impl Scan1Profile {
226    /// Checks whether release profiling is enabled for the current run.
227    fn enabled_from_env() -> bool {
228        scan1_profile_enabled()
229    }
230
231    /// Emits the aggregated Scan1 timing summary.
232    ///
233    /// This keeps the profiler output stable across optimization rounds so A/B
234    /// runs remain comparable.
235    fn report(&self) {
236        let wall_total_ns = self.wall_total_ns.load(Ordering::Relaxed);
237        let shard_total_ns = self.shard_total_ns.load(Ordering::Relaxed);
238        let group_process_ns = self.group_process_ns.load(Ordering::Relaxed);
239        let bsj_judge_ns = self.bsj_judge_ns.load(Ordering::Relaxed);
240        let write_ns = self.write_ns.load(Ordering::Relaxed);
241        let merge_ns = self.merge_ns.load(Ordering::Relaxed);
242        let records = self.records.load(Ordering::Relaxed);
243        let groups = self.groups.load(Ordering::Relaxed);
244        let hg1_calls = self.hg1_calls.load(Ordering::Relaxed);
245        let hg1_hits = self.hg1_hits.load(Ordering::Relaxed);
246        let other_shard_ns =
247            shard_total_ns.saturating_sub(group_process_ns.saturating_add(write_ns));
248        let pct = |part: u64, whole: u64| -> f64 {
249            if whole == 0 {
250                0.0
251            } else {
252                part as f64 * 100.0 / whole as f64
253            }
254        };
255        emit_perf_line(&format!(
256            "[PROFILE_SCAN1] wall_ms={:.3} shard_work_ms={:.3} merge_ms={:.3} records={} groups={} hg1_calls={} hg1_hits={}",
257            wall_total_ns as f64 / 1_000_000.0,
258            shard_total_ns as f64 / 1_000_000.0,
259            merge_ns as f64 / 1_000_000.0,
260            records,
261            groups,
262            hg1_calls,
263            hg1_hits
264        ));
265        emit_perf_line(&format!(
266            "[PROFILE_SCAN1] shard_breakdown_ms group_process={:.3} ({:.1}%) bsj_judge={:.3} ({:.1}% of group) write={:.3} ({:.1}%) other={:.3} ({:.1}%)",
267            group_process_ns as f64 / 1_000_000.0,
268            pct(group_process_ns, shard_total_ns),
269            bsj_judge_ns as f64 / 1_000_000.0,
270            pct(bsj_judge_ns, group_process_ns),
271            write_ns as f64 / 1_000_000.0,
272            pct(write_ns, shard_total_ns),
273            other_shard_ns as f64 / 1_000_000.0,
274            pct(other_shard_ns, shard_total_ns),
275        ));
276        report_scan1_hg_profile();
277    }
278}
279
280impl Scan1 {
281    /// Normalizes the split-CIGAR text that gets written into the persisted BSJ file.
282    ///
283    /// Java's downstream `Summary` and `Misd` code effectively reasons about
284    /// hard clips as soft clips (`H -> S`). Keeping raw `H` in the persisted
285    /// BSJ1 text makes Summary-side CIGAR parsing diverge on real BAMs that
286    /// contain hard-clipped supplementary alignments, even though the hot-path
287    /// validator already treated those clips as soft clips during Scan1. The
288    /// on-disk BSJ1 protocol therefore needs the same normalization.
289    #[inline]
290    fn normalize_bsj_cigar(cigar: &str) -> std::borrow::Cow<'_, str> {
291        if cigar.contains('H') {
292            std::borrow::Cow::Owned(cigar.replace('H', "S"))
293        } else {
294            std::borrow::Cow::Borrowed(cigar)
295        }
296    }
297
298    /// Creates a Scan1 runner with CIRI3-compatible thresholds.
299    pub fn new(
300        min_mapq_uni: i32,
301        min_circle: i32,
302        max_circle: i32,
303        linear_range_size_min: i32,
304    ) -> Self {
305        Self {
306            min_mapq_uni,
307            max_circle,
308            min_circle,
309            linear_range_size_min,
310            mem_limit: 2 * 1024 * 1024 * 1024,
311            read_len: 0,
312            mapped_reads: 0,
313            bsj1_reads: 0,
314        }
315    }
316
317    /// Overrides the per-thread page-cache eviction threshold.
318    pub fn set_mem_limit(&mut self, limit: u64) {
319        self.mem_limit = limit;
320    }
321
322    /// Dispatches to the SAM or BAM implementation while preserving one Scan1 API.
323    pub fn run(
324        &mut self,
325        sam_file: &str,
326        bsj_path: &str,
327        fasta_map: &HashMap<String, String>,
328        annotation: &Annotation,
329    ) -> Result<HashSet<String>> {
330        self.run_with_display(sam_file, bsj_path, None, fasta_map, annotation)
331    }
332
333    /// Dispatches to the SAM or BAM implementation and optionally writes the
334    /// display-only Scan1 rows during the same grouped scan.
335    pub fn run_with_display(
336        &mut self,
337        sam_file: &str,
338        bsj_path: &str,
339        display_path: Option<&str>,
340        fasta_map: &HashMap<String, String>,
341        annotation: &Annotation,
342    ) -> Result<HashSet<String>> {
343        use crate::sam_bam::{detect_format, InputFormat};
344        let format = detect_format(sam_file)?;
345        match format {
346            InputFormat::Sam => {
347                self.run_sam_with_display(sam_file, bsj_path, display_path, fasta_map, annotation)
348            }
349            InputFormat::Bam => {
350                self.run_bam_with_display(sam_file, bsj_path, display_path, fasta_map, annotation)
351            }
352        }
353    }
354
355    /// Runs Scan1 and writes the expanded mate-level `.bsj1` protocol.
356    ///
357    /// The Java-compatible first-hit row is preserved as `priority=1`; extra
358    /// mate-level hits are written as `priority=0` so downstream display and
359    /// internal-splice stages can see them without changing Summary semantics.
360    pub fn run_with_priority(
361        &mut self,
362        sam_file: &str,
363        bsj_path: &str,
364        fasta_map: &HashMap<String, String>,
365        annotation: &Annotation,
366    ) -> Result<HashSet<String>> {
367        self.run_with_priority_and_segments(sam_file, bsj_path, None, fasta_map, annotation)
368    }
369
370    /// Runs Scan1 while writing both Summary-compatible BSJ rows and a segments sidecar.
371    ///
372    /// The sidecar records full read-group alignments only for reads that produced
373    /// Scan1 BSJ evidence. Keeping it out of `.bsj1` preserves CIRI3 parity while
374    /// giving the post-Summary segments stage enough mapper blocks to avoid a
375    /// second BAM/SAM scan for confirmed BSJ reads.
376    pub fn run_with_priority_and_segments(
377        &mut self,
378        sam_file: &str,
379        bsj_path: &str,
380        segments_path: Option<&str>,
381        fasta_map: &HashMap<String, String>,
382        annotation: &Annotation,
383    ) -> Result<HashSet<String>> {
384        use crate::sam_bam::{detect_format, InputFormat};
385        let format = detect_format(sam_file)?;
386        match format {
387            InputFormat::Sam => {
388                self.run_sam_with_priority(sam_file, bsj_path, segments_path, fasta_map, annotation)
389            }
390            InputFormat::Bam => {
391                self.run_bam_with_priority(sam_file, bsj_path, segments_path, fasta_map, annotation)
392            }
393        }
394    }
395
396    /// Runs Scan1 on SAM input.
397    ///
398    /// The current SAM implementation keeps parsing sequential and parallelizes at
399    /// the read-group level. This was chosen deliberately because an earlier
400    /// hand-written text fast path was faster but produced SAM/BAM mismatches.
401    pub fn run_sam(
402        &mut self,
403        sam_file: &str,
404        bsj_path: &str,
405        fasta_map: &HashMap<String, String>,
406        annotation: &Annotation,
407    ) -> Result<HashSet<String>> {
408        self.run_sam_with_display(sam_file, bsj_path, None, fasta_map, annotation)
409    }
410
411    /// Runs Scan1 on SAM input and optionally emits display rows in the same pass.
412    fn run_sam_with_display(
413        &mut self,
414        sam_file: &str,
415        bsj_path: &str,
416        display_path: Option<&str>,
417        fasta_map: &HashMap<String, String>,
418        annotation: &Annotation,
419    ) -> Result<HashSet<String>> {
420        let file_size = std::fs::metadata(sam_file)?.len();
421        let pb = ProgressBar::new(file_size);
422        pb.set_style(
423            ProgressStyle::default_bar()
424                .template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} {percent:>3}% ({eta}) {msg}")?
425                .progress_chars("#>-"),
426        );
427        pb.set_message("");
428        pb.enable_steady_tick(Duration::from_millis(120));
429
430        let shard_out = part_path(bsj_path, 0);
431        let display_shard_out = display_path.map(|path| part_path(path, 0));
432        let stats = self.process_sam_file_to_file(
433            sam_file,
434            fasta_map,
435            annotation,
436            &pb,
437            &shard_out,
438            display_shard_out.as_deref(),
439            None,
440            false,
441        )?;
442        self.read_len = stats.max_read_len;
443        self.mapped_reads = stats.mapped_reads;
444
445        pb.set_style(
446            ProgressStyle::default_bar()
447                .template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} {msg}")?
448                .progress_chars("#>-"),
449        );
450        pb.finish_with_message("");
451        let scan1_ids = self.merge_and_collect_ids(bsj_path, 1)?;
452        if let Some(path) = display_path {
453            let _ = self.merge_display_shards(path, 1)?;
454        }
455        self.bsj1_reads = scan1_ids.len();
456        Ok(scan1_ids)
457    }
458
459    /// Runs the SAM Scan1 path while writing mate-level priority rows directly
460    /// into `.bsj1`.
461    fn run_sam_with_priority(
462        &mut self,
463        sam_file: &str,
464        bsj_path: &str,
465        segments_path: Option<&str>,
466        fasta_map: &HashMap<String, String>,
467        annotation: &Annotation,
468    ) -> Result<HashSet<String>> {
469        let file_size = std::fs::metadata(sam_file)?.len();
470        let pb = ProgressBar::new(file_size);
471        pb.set_style(
472            ProgressStyle::default_bar()
473                .template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} {percent:>3}% ({eta}) {msg}")?
474                .progress_chars("#>-"),
475        );
476        pb.set_message("");
477        pb.enable_steady_tick(Duration::from_millis(120));
478
479        let shard_out = part_path(bsj_path, 0);
480        let segments_shard_out = segments_path.map(|path| part_path(path, 0));
481        let stats = self.process_sam_file_to_file(
482            sam_file,
483            fasta_map,
484            annotation,
485            &pb,
486            &shard_out,
487            None,
488            segments_shard_out.as_deref(),
489            true,
490        )?;
491        self.read_len = stats.max_read_len;
492        self.mapped_reads = stats.mapped_reads;
493
494        pb.set_style(
495            ProgressStyle::default_bar()
496                .template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} {msg}")?
497                .progress_chars("#>-"),
498        );
499        pb.finish_with_message("");
500        let scan1_ids = self.merge_and_collect_ids(bsj_path, 1)?;
501        if let Some(path) = segments_path {
502            self.merge_segment_shards(path, 1)?;
503        }
504        self.bsj1_reads = scan1_ids.len();
505        Ok(scan1_ids)
506    }
507
508    /// Streams a SAM file into one shard output while batching read groups for
509    /// parallel candidate evaluation.
510    ///
511    /// There is only one shard on the SAM path because correctness depends on
512    /// preserving full read-group boundaries from the sequential parser.
513    fn process_sam_file_to_file(
514        &self,
515        sam_file: &str,
516        fasta_map: &HashMap<String, String>,
517        annotation: &Annotation,
518        pb: &ProgressBar,
519        out_path: &str,
520        display_out_path: Option<&str>,
521        segments_out_path: Option<&str>,
522        priority_inline: bool,
523    ) -> Result<Scan1TraversalStats> {
524        let mut writer = BufWriter::with_capacity(256 * 1024, File::create(out_path)?);
525        let mut display_writer = if let Some(path) = display_out_path {
526            Some(BufWriter::with_capacity(256 * 1024, File::create(path)?))
527        } else {
528            None
529        };
530        let mut segments_writer = if let Some(path) = segments_out_path {
531            Some(BufWriter::with_capacity(256 * 1024, File::create(path)?))
532        } else {
533            None
534        };
535        let batch_size = (rayon::current_num_threads().max(1) * 256).max(1024);
536        let (tx, rx) =
537            mpsc::sync_channel::<Vec<SamOwnedGroup>>(rayon::current_num_threads().max(2));
538        let pb_clone = pb.clone();
539
540        let stats = thread::scope(|scope| -> Result<Scan1TraversalStats> {
541            let producer =
542                scope.spawn(|| self.stream_sam_group_batches(sam_file, &pb_clone, tx, batch_size));
543
544            for mut batch in rx {
545                self.process_sam_group_batch(
546                    &mut writer,
547                    display_writer.as_mut(),
548                    segments_writer.as_mut(),
549                    &mut batch,
550                    fasta_map,
551                    annotation,
552                    priority_inline,
553                )?;
554            }
555
556            producer
557                .join()
558                .map_err(|_| anyhow::anyhow!("SAM group producer thread panicked"))?
559        })?;
560
561        writer.flush()?;
562        if let Some(writer) = display_writer.as_mut() {
563            writer.flush()?;
564        }
565        if let Some(writer) = segments_writer.as_mut() {
566            writer.flush()?;
567        }
568        Ok(stats)
569    }
570
571    /// Sequentially parses SAM records and sends owned read groups to workers.
572    ///
573    /// This split keeps all SAM semantics in one place while still allowing the
574    /// heavier BSJ judgment to scale with Rayon.
575    fn stream_sam_group_batches(
576        &self,
577        sam_file: &str,
578        pb: &ProgressBar,
579        tx: mpsc::SyncSender<Vec<SamOwnedGroup>>,
580        batch_size: usize,
581    ) -> Result<Scan1TraversalStats> {
582        let sam_reader = BufReader::with_capacity(1024 * 1024, File::open(sam_file)?);
583        let mut reader = sam::io::Reader::new(sam_reader);
584        let header = reader.read_header()?;
585        let mut record = sam::Record::default();
586        let mut current_id: Vec<u8> = Vec::new();
587        let mut group: [Vec<OwnedAlignmentRecord>; 2] =
588            [Vec::with_capacity(8), Vec::with_capacity(8)];
589        let mut stand_map: OwnedStandMap = HashMap::with_capacity(4);
590        let mut align_num = 0usize;
591        let mut max_read_len = 0i32;
592        let mut cigar_buf = String::with_capacity(64);
593        let mut seq_buf = String::with_capacity(256);
594        let mut batch = Vec::with_capacity(batch_size);
595        let mut records_since_progress = 0usize;
596        let mut last_progress_pos = reader.get_mut().stream_position()?;
597        let mut mapped_reads = 0u64;
598
599        while reader.read_record(&mut record)? != 0 {
600            let read_id = record
601                .name()
602                .ok_or_else(|| anyhow::anyhow!("Missing read name"))?;
603            if read_id != current_id.as_slice() {
604                if !current_id.is_empty() {
605                    self.push_sam_owned_group(
606                        &mut batch,
607                        &current_id,
608                        &mut group,
609                        &mut stand_map,
610                        align_num,
611                    );
612                    mapped_reads += 1;
613                    if batch.len() >= batch_size {
614                        tx.send(std::mem::take(&mut batch))
615                            .map_err(|_| anyhow::anyhow!("SAM group consumer dropped"))?;
616                        batch = Vec::with_capacity(batch_size);
617                    }
618                }
619                current_id.clear();
620                current_id.extend_from_slice(read_id);
621                align_num = 0;
622            }
623
624            let chrom = match record.reference_sequence(&header) {
625                Some(Ok((name, _))) => String::from_utf8_lossy(name).to_string(),
626                _ => "*".to_string(),
627            };
628            let flag = i32::from(u16::from(record.flags()?));
629            let start_pos = record
630                .alignment_start()
631                .transpose()?
632                .map(|p| p.get() as i32)
633                .unwrap_or(0);
634            let mapq = record
635                .mapping_quality()
636                .transpose()?
637                .map(u8::from)
638                .unwrap_or(0) as i32;
639
640            cigar_buf.clear();
641            for result in record.cigar().iter() {
642                let op = result?;
643                use noodles::sam::alignment::record::cigar::op::Kind;
644                let op_char = match op.kind() {
645                    Kind::Match => 'M',
646                    Kind::Insertion => 'I',
647                    Kind::Deletion => 'D',
648                    Kind::Skip => 'N',
649                    Kind::SoftClip => 'S',
650                    Kind::HardClip => 'H',
651                    Kind::Pad => 'P',
652                    Kind::SequenceMatch => '=',
653                    Kind::SequenceMismatch => 'X',
654                };
655                let _ = write!(&mut cigar_buf, "{}{}", op.len(), op_char);
656            }
657
658            seq_buf.clear();
659            for b in record.sequence().iter() {
660                seq_buf.push(char::from(b));
661            }
662            max_read_len = max_read_len.max(seq_buf.len() as i32);
663            let seq = seq_buf.clone();
664            let s_idx = if flag & 0x40 != 0 { 1 } else { 0 };
665            if !seq.is_empty() && seq != "*" {
666                let st_c = if flag & 0x10 != 0 { '1' } else { '0' };
667                update_best_stand_seq(&mut stand_map, s_idx, st_c, Cow::Owned(seq.clone()));
668            }
669            group[s_idx as usize].push(AlignmentRecord {
670                flag,
671                chrom: Cow::Owned(chrom),
672                pos: start_pos,
673                mapq,
674                cigar: Cow::Owned(cigar_buf.clone()),
675                seq: Cow::Owned(seq),
676                xa: Cow::Owned(raw_xa_from_sam_record(&record)?),
677            });
678            align_num += 1;
679            records_since_progress += 1;
680            if records_since_progress >= 4096 {
681                let pos = reader.get_mut().stream_position()?;
682                if pos > last_progress_pos {
683                    pb.inc(pos - last_progress_pos);
684                    last_progress_pos = pos;
685                }
686                records_since_progress = 0;
687            }
688        }
689
690        if !current_id.is_empty() {
691            self.push_sam_owned_group(
692                &mut batch,
693                &current_id,
694                &mut group,
695                &mut stand_map,
696                align_num,
697            );
698            mapped_reads += 1;
699        }
700        if !batch.is_empty() {
701            tx.send(batch)
702                .map_err(|_| anyhow::anyhow!("SAM group consumer dropped"))?;
703        }
704
705        let final_pos = reader.get_mut().stream_position()?;
706        if final_pos > last_progress_pos {
707            pb.inc(final_pos - last_progress_pos);
708        }
709
710        Ok(Scan1TraversalStats {
711            max_read_len,
712            mapped_reads,
713        })
714    }
715
716    /// Moves the currently accumulated SAM read group into the worker batch.
717    ///
718    /// `mem::take` is used to avoid cloning alignment payloads on the hot path.
719    fn push_sam_owned_group(
720        &self,
721        batch: &mut Vec<SamOwnedGroup>,
722        current_id: &[u8],
723        group: &mut [Vec<OwnedAlignmentRecord>; 2],
724        stand_map: &mut OwnedStandMap,
725        align_num: usize,
726    ) {
727        batch.push(SamOwnedGroup {
728            read_id: String::from_utf8_lossy(current_id).into_owned(),
729            group: [std::mem::take(&mut group[0]), std::mem::take(&mut group[1])],
730            stand_map: std::mem::take(stand_map),
731            align_num,
732        });
733        group[0] = Vec::with_capacity(8);
734        group[1] = Vec::with_capacity(8);
735        *stand_map = HashMap::with_capacity(4);
736    }
737
738    /// Evaluates one batch of owned SAM read groups in parallel and writes BSJ1 lines.
739    fn process_sam_group_batch(
740        &self,
741        writer: &mut BufWriter<File>,
742        mut display_writer: Option<&mut BufWriter<File>>,
743        mut segments_writer: Option<&mut BufWriter<File>>,
744        batch: &mut Vec<SamOwnedGroup>,
745        fasta_map: &HashMap<String, String>,
746        annotation: &Annotation,
747        priority_inline: bool,
748    ) -> Result<()> {
749        let groups = std::mem::take(batch);
750        let results: Vec<(Option<(String, String)>, Vec<String>, Vec<String>)> = groups
751            .into_par_iter()
752            .map(|owned| {
753                let non_empty_groups = owned.group.iter().filter(|g| !g.is_empty()).count();
754                if owned.align_num <= 2 && non_empty_groups != 1 {
755                    return (None, Vec::new(), Vec::new());
756                }
757                let mut validator = IsBSJHg2::new(self.linear_range_size_min, self.min_mapq_uni);
758                let main = self
759                    .process_group_view(
760                        &owned.read_id,
761                        &owned.group,
762                        &owned.stand_map,
763                        fasta_map,
764                        annotation,
765                        &mut validator,
766                        None,
767                    )
768                    .map(|(_, mate_label, res_line)| (mate_label, res_line));
769                let mut display_validator =
770                    IsBSJHg2::new(self.linear_range_size_min, self.min_mapq_uni);
771                let display = if priority_inline || display_writer.is_some() {
772                    self.process_group_view_display(
773                        &owned.read_id,
774                        &owned.group,
775                        &owned.stand_map,
776                        fasta_map,
777                        annotation,
778                        &mut display_validator,
779                    )
780                } else {
781                    Vec::new()
782                };
783                let has_bsj = main.is_some() || !display.is_empty();
784                let evidence = if has_bsj {
785                    let mut bsj_lines = Vec::new();
786                    if let Some((_, res_line)) = &main {
787                        bsj_lines.push(res_line.clone());
788                    }
789                    bsj_lines.extend(display.iter().cloned());
790                    Self::segment_evidence_lines_with_local(
791                        &owned.read_id,
792                        "scan1",
793                        "scan1_local",
794                        &owned.group,
795                        &bsj_lines,
796                        fasta_map,
797                    )
798                } else {
799                    Vec::new()
800                };
801                (main, display, evidence)
802            })
803            .collect();
804
805        for (main, display, evidence) in results {
806            if priority_inline {
807                for line in Self::prioritize_scan1_display_lines(main, display) {
808                    writeln!(writer, "{}", line)?;
809                }
810            } else {
811                if let Some((_, res_line)) = main {
812                    writeln!(writer, "{}", res_line)?;
813                }
814                if let Some(display_writer) = display_writer.as_deref_mut() {
815                    for line in display {
816                        writeln!(display_writer, "{}", line)?;
817                    }
818                }
819            }
820            if let Some(segments_writer) = segments_writer.as_deref_mut() {
821                for line in evidence {
822                    writeln!(segments_writer, "{}", line)?;
823                }
824            }
825        }
826
827        Ok(())
828    }
829
830    /// Concatenates per-shard BSJ1 outputs and returns the read IDs already claimed
831    /// by Scan1.
832    ///
833    /// The final `HashSet` is later used by Scan2 to implement Java's "skip
834    /// already-assigned reads" behavior.
835    fn merge_and_collect_ids(&self, bsj_path: &str, num_threads: usize) -> Result<HashSet<String>> {
836        let mut final_writer = BufWriter::with_capacity(1024 * 1024, File::create(bsj_path)?);
837        let mut scan1_id_map = HashSet::new();
838        for i in 0..num_threads {
839            let shard_path = part_path(bsj_path, i);
840            if let Ok(shard_file) = File::open(&shard_path) {
841                let mut reader = BufReader::new(shard_file);
842                let mut line = String::new();
843                loop {
844                    line.clear();
845                    if reader.read_line(&mut line)? == 0 {
846                        break;
847                    }
848                    let trimmed = line.trim_end();
849                    let parts: Vec<&str> = trimmed.split('\t').collect();
850                    if !parts.is_empty() && crate::utils::bsj_is_summary_priority(&parts) {
851                        scan1_id_map.insert(parts[0].to_string());
852                    }
853                    if !trimmed.is_empty() {
854                        final_writer.write_all(line.as_bytes())?;
855                    }
856                }
857            }
858            let _ = std::fs::remove_file(shard_path);
859        }
860        final_writer.flush()?;
861        Ok(scan1_id_map)
862    }
863
864    /// Converts mate-level Scan1 display rows into the persisted `.bsj1` protocol.
865    ///
866    /// The main row is selected by the Java-compatible first-hit path and gets
867    /// `priority=1`. Additional mate rows stay visible for downstream evidence
868    /// collection but are marked `priority=0` so Summary can ignore them.
869    fn prioritize_scan1_display_lines(
870        main: Option<(String, String)>,
871        display: Vec<String>,
872    ) -> Vec<String> {
873        let Some((main_mate, main_line)) = main else {
874            return display
875                .into_iter()
876                .filter_map(|line| Self::with_priority_from_display_line(&line, "0"))
877                .collect();
878        };
879        let Some((main_id, main_payload)) = main_line.split_once('\t') else {
880            return Vec::new();
881        };
882        let main_key = format!("{}\t{}\t{}", main_id, main_mate, main_payload);
883        let mut wrote_main = false;
884        let mut rows = Vec::new();
885        for line in display {
886            if let Some((key, priority_line)) = Self::priority_display_key_and_line(&line, "0") {
887                if key == main_key && !wrote_main {
888                    if let Some(priority_line) = Self::with_priority_from_display_line(&line, "1") {
889                        rows.push(priority_line);
890                    }
891                    wrote_main = true;
892                } else {
893                    rows.push(priority_line);
894                }
895            }
896        }
897        if !wrote_main {
898            rows.push(format!("{}\t{}\t1\t{}", main_id, main_mate, main_payload));
899        }
900        rows
901    }
902
903    /// Adds a `priority` field to a display row of the form
904    /// `read_id, mate_label, legacy_payload...`.
905    fn with_priority_from_display_line(line: &str, priority: &str) -> Option<String> {
906        let mut parts = line.splitn(3, '\t');
907        let read_id = parts.next()?;
908        let mate_label = parts.next()?;
909        let payload = parts.next()?;
910        Some(format!("{read_id}\t{mate_label}\t{priority}\t{payload}"))
911    }
912
913    /// Builds the comparison key used to match the first-hit row to display rows.
914    fn priority_display_key_and_line(line: &str, priority: &str) -> Option<(String, String)> {
915        let mut parts = line.splitn(3, '\t');
916        let read_id = parts.next()?;
917        let mate_label = parts.next()?;
918        let payload = parts.next()?;
919        Some((
920            format!("{read_id}\t{mate_label}\t{payload}"),
921            format!("{read_id}\t{mate_label}\t{priority}\t{payload}"),
922        ))
923    }
924
925    /// Runs Scan1 on BAM input using independent compressed-byte shards.
926    ///
927    /// Unlike SAM, BAM can be safely split near BGZF boundaries, so the parallel
928    /// strategy here is shard-based instead of producer/consumer batching.
929    pub fn run_bam(
930        &mut self,
931        bam_file: &str,
932        bsj_path: &str,
933        fasta_map: &HashMap<String, String>,
934        annotation: &Annotation,
935    ) -> Result<HashSet<String>> {
936        self.run_bam_with_display(bam_file, bsj_path, None, fasta_map, annotation)
937    }
938
939    /// Runs the BAM Scan1 path while writing mate-level priority rows directly
940    /// into `.bsj1`.
941    fn run_bam_with_priority(
942        &mut self,
943        bam_file: &str,
944        bsj_path: &str,
945        segments_path: Option<&str>,
946        fasta_map: &HashMap<String, String>,
947        annotation: &Annotation,
948    ) -> Result<HashSet<String>> {
949        self.run_bam_with_display_impl(
950            bam_file,
951            bsj_path,
952            None,
953            segments_path,
954            fasta_map,
955            annotation,
956            true,
957        )
958    }
959
960    /// Runs Scan1 on BAM input and optionally emits display rows in the same pass.
961    fn run_bam_with_display(
962        &mut self,
963        bam_file: &str,
964        bsj_path: &str,
965        display_path: Option<&str>,
966        fasta_map: &HashMap<String, String>,
967        annotation: &Annotation,
968    ) -> Result<HashSet<String>> {
969        self.run_bam_with_display_impl(
970            bam_file,
971            bsj_path,
972            display_path,
973            None,
974            fasta_map,
975            annotation,
976            false,
977        )
978    }
979
980    fn run_bam_with_display_impl(
981        &mut self,
982        bam_file: &str,
983        bsj_path: &str,
984        display_path: Option<&str>,
985        segments_path: Option<&str>,
986        fasta_map: &HashMap<String, String>,
987        annotation: &Annotation,
988        priority_inline: bool,
989    ) -> Result<HashSet<String>> {
990        use noodles::bam;
991        let run_started = Instant::now();
992        let profile = if Scan1Profile::enabled_from_env() {
993            Some(Scan1Profile::default())
994        } else {
995            None
996        };
997        let profile_ref = profile.as_ref();
998        let file = File::open(bam_file)?;
999        let file_size = std::fs::metadata(bam_file)?.len();
1000        let mmap = unsafe { Mmap::map(&file)? };
1001        let num_threads = bam_shard_count(mmap.len(), rayon::current_num_threads());
1002        let shard_size = mmap.len() / num_threads;
1003        let mut reader = bam::io::Reader::new(file);
1004        let header = reader.read_header()?;
1005
1006        let pb = ProgressBar::new(file_size);
1007        pb.set_style(
1008            ProgressStyle::default_bar()
1009                .template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} {percent:>3}% ({eta}) {msg}")?
1010                .progress_chars("#>-"),
1011        );
1012        pb.set_message("");
1013        pb.enable_steady_tick(Duration::from_millis(120));
1014
1015        unsafe {
1016            libc::madvise(
1017                mmap.as_ptr() as *mut libc::c_void,
1018                mmap.len(),
1019                libc::MADV_SEQUENTIAL,
1020            );
1021        }
1022
1023        let header_ref = &header;
1024        let shard_stats: Vec<Scan1ShardStats> = (0..num_threads)
1025            .into_par_iter()
1026            .map(|i| {
1027                let start = i * shard_size;
1028                let end = if i == num_threads - 1 {
1029                    mmap.len()
1030                } else {
1031                    (i + 1) * shard_size
1032                };
1033                let shard_out = part_path(bsj_path, i);
1034                let display_shard_out = display_path.map(|path| part_path(path, i));
1035                let segments_shard_out = segments_path.map(|path| part_path(path, i));
1036                self.process_bam_shard_to_file(
1037                    i,
1038                    &mmap,
1039                    start,
1040                    end,
1041                    header_ref,
1042                    fasta_map,
1043                    annotation,
1044                    &pb,
1045                    &shard_out,
1046                    display_shard_out.as_deref(),
1047                    segments_shard_out.as_deref(),
1048                    priority_inline,
1049                    profile_ref,
1050                )
1051            })
1052            .collect::<Result<Vec<_>>>()?;
1053
1054        self.read_len = shard_stats
1055            .iter()
1056            .map(|s| s.max_read_len)
1057            .max()
1058            .unwrap_or(0);
1059        self.mapped_reads = shard_stats.iter().map(|s| s.mapped_reads).sum();
1060        pb.set_position(file_size);
1061        pb.set_style(
1062            ProgressStyle::default_bar()
1063                .template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} {msg}")?
1064                .progress_chars("#>-"),
1065        );
1066        pb.finish_with_message("Completed");
1067        let merge_started = Instant::now();
1068        let result = self.merge_and_collect_ids(bsj_path, num_threads);
1069        if let Some(profile) = profile_ref {
1070            profile
1071                .merge_ns
1072                .fetch_add(merge_started.elapsed().as_nanos() as u64, Ordering::Relaxed);
1073            profile
1074                .wall_total_ns
1075                .fetch_add(run_started.elapsed().as_nanos() as u64, Ordering::Relaxed);
1076            profile.report();
1077        }
1078        let scan1_ids = result?;
1079        if let Some(path) = display_path {
1080            let _ = self.merge_display_shards(path, num_threads)?;
1081        }
1082        if let Some(path) = segments_path {
1083            self.merge_segment_shards(path, num_threads)?;
1084        }
1085        self.bsj1_reads = scan1_ids.len();
1086        Ok(scan1_ids)
1087    }
1088
1089    /// Extracts the `(read_id, mate_label)` key from one display row.
1090    fn parse_display_claim(line: &str) -> Option<(&str, &str)> {
1091        let mut parts = line.splitn(3, '\t');
1092        match (parts.next(), parts.next(), parts.next()) {
1093            (Some(read_id), Some(mate), Some(_)) => Some((read_id, mate)),
1094            _ => None,
1095        }
1096    }
1097
1098    /// Merges display shard files in shard order and returns the emitted claims.
1099    fn merge_display_shards(
1100        &self,
1101        output_path: &str,
1102        num_threads: usize,
1103    ) -> Result<HashSet<String>> {
1104        let mut writer = BufWriter::with_capacity(1024 * 1024, File::create(output_path)?);
1105        let mut claims = HashSet::new();
1106        for i in 0..num_threads {
1107            let shard_path = part_path(output_path, i);
1108            if let Ok(shard_file) = File::open(&shard_path) {
1109                let mut reader = BufReader::new(shard_file);
1110                let mut line = String::new();
1111                loop {
1112                    line.clear();
1113                    if reader.read_line(&mut line)? == 0 {
1114                        break;
1115                    }
1116                    if let Some((rid, mate)) = Self::parse_display_claim(line.trim_end()) {
1117                        claims.insert(format!("{rid}\t{mate}"));
1118                    }
1119                    writer.write_all(line.as_bytes())?;
1120                }
1121            }
1122            let _ = std::fs::remove_file(shard_path);
1123        }
1124        writer.flush()?;
1125        Ok(claims)
1126    }
1127
1128    /// Merges shard-local Scan1 segments sidecars without changing row order.
1129    fn merge_segment_shards(&self, output_path: &str, num_threads: usize) -> Result<()> {
1130        let mut writer = BufWriter::with_capacity(1024 * 1024, File::create(output_path)?);
1131        for i in 0..num_threads {
1132            let shard_path = part_path(output_path, i);
1133            if let Ok(shard_file) = File::open(&shard_path) {
1134                let mut reader = BufReader::new(shard_file);
1135                let mut line = String::new();
1136                while reader.read_line(&mut line)? != 0 {
1137                    writer.write_all(line.as_bytes())?;
1138                    line.clear();
1139                }
1140            }
1141            let _ = std::fs::remove_file(shard_path);
1142        }
1143        writer.flush()?;
1144        Ok(())
1145    }
1146
1147    /// Formats read-group alignments for the Scan1 `<prefix>.segments1` sidecar.
1148    ///
1149    /// Rows are intentionally mapper-block evidence, not final segments. The
1150    /// post-Summary segments stage filters by confirmed `.out` read assignments
1151    /// and applies the same chain selection/circ correction used by `.segments`.
1152    fn segment_evidence_lines<'a>(
1153        read_id: &str,
1154        stage: &str,
1155        group: &[Vec<AlignmentRecord<'a>>; 2],
1156        reference: &HashMap<String, String>,
1157    ) -> Vec<String> {
1158        let mut rows = Vec::new();
1159        for bucket in group {
1160            for aln in bucket {
1161                if aln.chrom.as_ref() == "*" || aln.cigar.as_ref() == "*" {
1162                    continue;
1163                }
1164                let mate = if aln.flag & 0x40 != 0 { "R1" } else { "R2" };
1165                let clips = clip_sequence_payload(aln.cigar.as_ref(), aln.seq.as_ref());
1166                let cs = alignment_short_cs(
1167                    aln.cigar.as_ref(),
1168                    aln.seq.as_ref(),
1169                    aln.chrom.as_ref(),
1170                    aln.pos,
1171                    reference,
1172                );
1173                rows.push(format!(
1174                    "{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}",
1175                    read_id,
1176                    stage,
1177                    mate,
1178                    aln.flag,
1179                    aln.chrom,
1180                    aln.pos,
1181                    aln.mapq,
1182                    aln.cigar,
1183                    aln.seq.len(),
1184                    clips,
1185                    cs,
1186                    xa_payload(aln.xa.as_ref())
1187                ));
1188            }
1189        }
1190        rows
1191    }
1192
1193    /// Formats mapper rows plus validator-accepted local clip pseudo rows.
1194    fn segment_evidence_lines_with_local<'a>(
1195        read_id: &str,
1196        stage: &str,
1197        local_stage: &str,
1198        group: &[Vec<AlignmentRecord<'a>>; 2],
1199        bsj_lines: &[String],
1200        fasta_map: &HashMap<String, String>,
1201    ) -> Vec<String> {
1202        let mut rows = Self::segment_evidence_lines(read_id, stage, group, fasta_map);
1203        let alignments: Vec<&AlignmentRecord<'_>> =
1204            group.iter().flat_map(|bucket| bucket.iter()).collect();
1205        rows.extend(local_clip_evidence_lines(
1206            read_id,
1207            local_stage,
1208            &alignments,
1209            bsj_lines,
1210            fasta_map,
1211            10,
1212        ));
1213        rows
1214    }
1215
1216    /// Processes one BAM shard into a temporary BSJ1 shard file.
1217    ///
1218    /// The shard starts at the first BGZF header found at or after `start`; the
1219    /// leading partial read group is skipped for non-zero shards so every read is
1220    /// judged exactly once.
1221    fn process_bam_shard_to_file(
1222        &self,
1223        shard_idx: usize,
1224        mmap: &Mmap,
1225        start: usize,
1226        end: usize,
1227        header: &sam::Header,
1228        fasta_map: &HashMap<String, String>,
1229        annotation: &Annotation,
1230        pb: &ProgressBar,
1231        out_path: &str,
1232        display_out_path: Option<&str>,
1233        segments_out_path: Option<&str>,
1234        priority_inline: bool,
1235        profile: Option<&Scan1Profile>,
1236    ) -> Result<Scan1ShardStats> {
1237        use noodles::bam;
1238        let shard_started = profile.map(|_| Instant::now());
1239        let mut writer = BufWriter::with_capacity(256 * 1024, File::create(out_path)?);
1240        let mut display_writer = if let Some(path) = display_out_path {
1241            Some(BufWriter::with_capacity(256 * 1024, File::create(path)?))
1242        } else {
1243            None
1244        };
1245        let mut segments_writer = if let Some(path) = segments_out_path {
1246            Some(BufWriter::with_capacity(256 * 1024, File::create(path)?))
1247        } else {
1248            None
1249        };
1250        let mut validator = IsBSJHg2::new(self.linear_range_size_min, self.min_mapq_uni);
1251        let mut display_validator = IsBSJHg2::new(self.linear_range_size_min, self.min_mapq_uni);
1252        let block_start = if start == 0 {
1253            0
1254        } else {
1255            let mut found = None;
1256            for i in start..mmap.len().saturating_sub(3) {
1257                if &mmap[i..i + 4] == b"\x1f\x8b\x08\x04" {
1258                    found = Some(i);
1259                    break;
1260                }
1261            }
1262            found.unwrap_or(mmap.len())
1263        };
1264        let pos = if start == 0 {
1265            0
1266        } else {
1267            // Decode from the previous BGZF block so we can decide whether the
1268            // first read group in `block_start` is a continuation from the prior
1269            // shard or a complete new group that should be kept.
1270            let mut found = None;
1271            let mut i = block_start
1272                .saturating_sub(1)
1273                .min(mmap.len().saturating_sub(4));
1274            loop {
1275                if i + 3 < mmap.len() && &mmap[i..i + 4] == b"\x1f\x8b\x08\x04" {
1276                    found = Some(i);
1277                    break;
1278                }
1279                if i == 0 {
1280                    break;
1281                }
1282                i -= 1;
1283            }
1284            found.unwrap_or(0)
1285        };
1286        if pos >= mmap.len() {
1287            return Ok(Scan1ShardStats {
1288                max_read_len: 0,
1289                mapped_reads: 0,
1290            });
1291        }
1292        let mut reader = bam::io::Reader::new(&mmap[pos..]);
1293        if start == 0 {
1294            let _ = reader.read_header()?;
1295        }
1296        let mut record = bam::Record::default();
1297        let mut current_id: Vec<u8> = Vec::new();
1298        let mut group: [Vec<AlignmentRecord>; 2] = [Vec::with_capacity(8), Vec::with_capacity(8)];
1299        let mut stand_map: HashMap<i32, (char, Cow<str>)> = HashMap::with_capacity(4);
1300        let mut align_num = 0usize;
1301        let mut crossed_start = start == 0;
1302        let mut leading_partial_id: Option<Vec<u8>> = None;
1303        let mut last_progress_pos = block_start.saturating_sub(pos);
1304        let mut last_evicted_pos = pos;
1305        let eviction_threshold = 64 * 1024 * 1024;
1306        let mut shard_max_read_len = 0i32;
1307        let mut mapped_reads = 0u64;
1308        let mut cigar_buf = String::with_capacity(64);
1309        let mut seq_buf = String::with_capacity(256);
1310        while reader.read_record(&mut record)? != 0 {
1311            if let Some(profile) = profile {
1312                profile.records.fetch_add(1, Ordering::Relaxed);
1313            }
1314            let curr_c_pos = reader.get_ref().virtual_position().compressed() as usize;
1315            if curr_c_pos > last_progress_pos {
1316                pb.inc((curr_c_pos - last_progress_pos) as u64);
1317                last_progress_pos = curr_c_pos;
1318            }
1319            if curr_c_pos - (last_evicted_pos - pos) > eviction_threshold {
1320                advise_dontneed_aligned(
1321                    mmap,
1322                    last_evicted_pos,
1323                    curr_c_pos - (last_evicted_pos - pos),
1324                );
1325                last_evicted_pos = pos + curr_c_pos;
1326            }
1327            let read_id = record
1328                .name()
1329                .ok_or_else(|| anyhow::anyhow!("Missing read name"))?;
1330            let abs_c_pos = pos + curr_c_pos;
1331            let trace_read_id = String::from_utf8_lossy(read_id);
1332            if !crossed_start {
1333                if abs_c_pos < block_start {
1334                    trace_bam_shard_event(
1335                        shard_idx,
1336                        &trace_read_id,
1337                        "before_block_start",
1338                        start,
1339                        block_start,
1340                        end,
1341                        abs_c_pos,
1342                    );
1343                    leading_partial_id = Some(read_id.to_vec());
1344                    continue;
1345                }
1346                crossed_start = true;
1347            }
1348            if let Some(partial_id) = &leading_partial_id {
1349                if read_id == partial_id.as_slice() {
1350                    // Non-zero shards start decoding from the previous BGZF block,
1351                    // so the first logical read group may already have records in
1352                    // the prior shard. Skip that entire group here and let the
1353                    // earlier shard own it completely.
1354                    trace_bam_shard_event(
1355                        shard_idx,
1356                        &trace_read_id,
1357                        "skip_leading_partial_group",
1358                        start,
1359                        block_start,
1360                        end,
1361                        abs_c_pos,
1362                    );
1363                    continue;
1364                }
1365                trace_bam_shard_event(
1366                    shard_idx,
1367                    &trace_read_id,
1368                    "leading_partial_cleared",
1369                    start,
1370                    block_start,
1371                    end,
1372                    abs_c_pos,
1373                );
1374                leading_partial_id = None;
1375            }
1376            if read_id != current_id.as_slice() {
1377                trace_bam_shard_event(
1378                    shard_idx,
1379                    &trace_read_id,
1380                    "group_boundary",
1381                    start,
1382                    block_start,
1383                    end,
1384                    abs_c_pos,
1385                );
1386                if !current_id.is_empty() {
1387                    mapped_reads += 1;
1388                    let id_str = String::from_utf8_lossy(&current_id);
1389                    let non_empty_groups = group.iter().filter(|g| !g.is_empty()).count();
1390                    if align_num > 2 || non_empty_groups == 1 {
1391                        let main = self.process_group_view(
1392                            &id_str,
1393                            &group,
1394                            &stand_map,
1395                            fasta_map,
1396                            annotation,
1397                            &mut validator,
1398                            profile,
1399                        );
1400                        if let Some((_, _, res_line)) = &main {
1401                            trace_bam_shard_event(
1402                                shard_idx,
1403                                &id_str,
1404                                "write_bsj1",
1405                                start,
1406                                block_start,
1407                                end,
1408                                abs_c_pos,
1409                            );
1410                            let write_started = profile.map(|_| Instant::now());
1411                            if !priority_inline {
1412                                writeln!(writer, "{}", res_line)?;
1413                            }
1414                            if let (Some(profile), Some(write_started)) = (profile, write_started) {
1415                                profile.write_ns.fetch_add(
1416                                    write_started.elapsed().as_nanos() as u64,
1417                                    Ordering::Relaxed,
1418                                );
1419                            }
1420                        }
1421                        if priority_inline || display_writer.is_some() {
1422                            let display_lines = self.process_group_view_display(
1423                                &id_str,
1424                                &group,
1425                                &stand_map,
1426                                fasta_map,
1427                                annotation,
1428                                &mut display_validator,
1429                            );
1430                            let write_segments_evidence =
1431                                main.is_some() || !display_lines.is_empty();
1432                            if let Some(segments_writer) = segments_writer.as_mut() {
1433                                if write_segments_evidence {
1434                                    let mut bsj_lines = Vec::new();
1435                                    if let Some((_, _, res_line)) = &main {
1436                                        bsj_lines.push(res_line.clone());
1437                                    }
1438                                    bsj_lines.extend(display_lines.iter().cloned());
1439                                    for line in Self::segment_evidence_lines_with_local(
1440                                        &id_str,
1441                                        "scan1",
1442                                        "scan1_local",
1443                                        &group,
1444                                        &bsj_lines,
1445                                        fasta_map,
1446                                    ) {
1447                                        writeln!(segments_writer, "{}", line)?;
1448                                    }
1449                                }
1450                            }
1451                            if priority_inline {
1452                                let main =
1453                                    main.map(|(_, mate_label, res_line)| (mate_label, res_line));
1454                                for line in
1455                                    Self::prioritize_scan1_display_lines(main, display_lines)
1456                                {
1457                                    writeln!(writer, "{}", line)?;
1458                                }
1459                            } else if let Some(display_writer) = display_writer.as_mut() {
1460                                for line in display_lines {
1461                                    writeln!(display_writer, "{}", line)?;
1462                                }
1463                            }
1464                        } else if let Some(segments_writer) = segments_writer.as_mut() {
1465                            if main.is_some() {
1466                                let mut bsj_lines = Vec::new();
1467                                if let Some((_, _, res_line)) = &main {
1468                                    bsj_lines.push(res_line.clone());
1469                                }
1470                                for line in Self::segment_evidence_lines_with_local(
1471                                    &id_str,
1472                                    "scan1",
1473                                    "scan1_local",
1474                                    &group,
1475                                    &bsj_lines,
1476                                    fasta_map,
1477                                ) {
1478                                    writeln!(segments_writer, "{}", line)?;
1479                                }
1480                            }
1481                        }
1482                    }
1483                    if pos + curr_c_pos > end {
1484                        current_id.clear();
1485                        break;
1486                    }
1487                }
1488                current_id.clear();
1489                current_id.extend_from_slice(read_id);
1490                group[0].clear();
1491                group[1].clear();
1492                stand_map.clear();
1493                align_num = 0;
1494            }
1495            let chrom = match record.reference_sequence(header) {
1496                Some(Ok((name, _))) => String::from_utf8_lossy(name).to_string(),
1497                _ => "*".to_string(),
1498            };
1499            let flag = i32::from(u16::from(record.flags()));
1500            let start_pos = record
1501                .alignment_start()
1502                .transpose()?
1503                .map(|p| p.get() as i32)
1504                .unwrap_or(0);
1505            let mapq = record.mapping_quality().map(u8::from).unwrap_or(0) as i32;
1506            cigar_buf.clear();
1507            for result in record.cigar().iter() {
1508                let op = result?;
1509                use noodles::sam::alignment::record::cigar::op::Kind;
1510                let op_char = match op.kind() {
1511                    Kind::Match => 'M',
1512                    Kind::Insertion => 'I',
1513                    Kind::Deletion => 'D',
1514                    Kind::Skip => 'N',
1515                    Kind::SoftClip => 'S',
1516                    Kind::HardClip => 'H',
1517                    Kind::Pad => 'P',
1518                    Kind::SequenceMatch => '=',
1519                    Kind::SequenceMismatch => 'X',
1520                };
1521                let _ = write!(&mut cigar_buf, "{}{}", op.len(), op_char);
1522            }
1523            seq_buf.clear();
1524            for b in record.sequence().iter() {
1525                seq_buf.push(char::from(b));
1526            }
1527            shard_max_read_len = shard_max_read_len.max(seq_buf.len() as i32);
1528            let seq = seq_buf.clone();
1529            if !seq.is_empty() && seq != "*" {
1530                let s_idx = if flag & 0x40 != 0 { 1 } else { 0 };
1531                let st_c = if flag & 0x10 != 0 { '1' } else { '0' };
1532                update_best_stand_seq(&mut stand_map, s_idx, st_c, Cow::Owned(seq.clone()));
1533            }
1534            group[if flag & 0x40 != 0 { 1 } else { 0 }].push(AlignmentRecord {
1535                flag,
1536                chrom: Cow::Owned(chrom),
1537                pos: start_pos,
1538                mapq,
1539                cigar: Cow::Owned(cigar_buf.clone()),
1540                seq: Cow::Owned(seq),
1541                xa: Cow::Owned(raw_xa_from_bam_record(&record)?),
1542            });
1543            align_num += 1;
1544        }
1545        if !current_id.is_empty() {
1546            mapped_reads += 1;
1547            let id_str = String::from_utf8_lossy(&current_id);
1548            let main = self.process_group_view(
1549                &id_str,
1550                &group,
1551                &stand_map,
1552                fasta_map,
1553                annotation,
1554                &mut validator,
1555                profile,
1556            );
1557            if let Some((_, _, res_line)) = &main {
1558                trace_bam_shard_event(
1559                    shard_idx,
1560                    &id_str,
1561                    "write_bsj1_tail",
1562                    start,
1563                    block_start,
1564                    end,
1565                    pos + last_progress_pos,
1566                );
1567                let write_started = profile.map(|_| Instant::now());
1568                if !priority_inline {
1569                    writeln!(writer, "{}", res_line)?;
1570                }
1571                if let (Some(profile), Some(write_started)) = (profile, write_started) {
1572                    profile
1573                        .write_ns
1574                        .fetch_add(write_started.elapsed().as_nanos() as u64, Ordering::Relaxed);
1575                }
1576            }
1577            if priority_inline || display_writer.is_some() {
1578                let display_lines = self.process_group_view_display(
1579                    &id_str,
1580                    &group,
1581                    &stand_map,
1582                    fasta_map,
1583                    annotation,
1584                    &mut display_validator,
1585                );
1586                let write_segments_evidence = main.is_some() || !display_lines.is_empty();
1587                if let Some(segments_writer) = segments_writer.as_mut() {
1588                    if write_segments_evidence {
1589                        let mut bsj_lines = Vec::new();
1590                        if let Some((_, _, res_line)) = &main {
1591                            bsj_lines.push(res_line.clone());
1592                        }
1593                        bsj_lines.extend(display_lines.iter().cloned());
1594                        for line in Self::segment_evidence_lines_with_local(
1595                            &id_str,
1596                            "scan1",
1597                            "scan1_local",
1598                            &group,
1599                            &bsj_lines,
1600                            fasta_map,
1601                        ) {
1602                            writeln!(segments_writer, "{}", line)?;
1603                        }
1604                    }
1605                }
1606                if priority_inline {
1607                    let main = main.map(|(_, mate_label, res_line)| (mate_label, res_line));
1608                    for line in Self::prioritize_scan1_display_lines(main, display_lines) {
1609                        writeln!(writer, "{}", line)?;
1610                    }
1611                } else if let Some(display_writer) = display_writer.as_mut() {
1612                    for line in display_lines {
1613                        writeln!(display_writer, "{}", line)?;
1614                    }
1615                }
1616            } else if let Some(segments_writer) = segments_writer.as_mut() {
1617                if main.is_some() {
1618                    let mut bsj_lines = Vec::new();
1619                    if let Some((_, _, res_line)) = &main {
1620                        bsj_lines.push(res_line.clone());
1621                    }
1622                    for line in Self::segment_evidence_lines_with_local(
1623                        &id_str,
1624                        "scan1",
1625                        "scan1_local",
1626                        &group,
1627                        &bsj_lines,
1628                        fasta_map,
1629                    ) {
1630                        writeln!(segments_writer, "{}", line)?;
1631                    }
1632                }
1633            }
1634        }
1635        writer.flush()?;
1636        if let Some(display_writer) = display_writer.as_mut() {
1637            display_writer.flush()?;
1638        }
1639        if let Some(segments_writer) = segments_writer.as_mut() {
1640            segments_writer.flush()?;
1641        }
1642        if let (Some(profile), Some(shard_started)) = (profile, shard_started) {
1643            profile
1644                .shard_total_ns
1645                .fetch_add(shard_started.elapsed().as_nanos() as u64, Ordering::Relaxed);
1646        }
1647        Ok(Scan1ShardStats {
1648            max_read_len: shard_max_read_len,
1649            mapped_reads,
1650        })
1651    }
1652
1653    /// Thin profiling wrapper around the actual read-group judge.
1654    fn process_group_view<'a>(
1655        &self,
1656        read_id: &str,
1657        group: &[Vec<AlignmentRecord<'a>>; 2],
1658        stand_map: &HashMap<i32, (char, Cow<'a, str>)>,
1659        fasta_map: &HashMap<String, String>,
1660        annotation: &Annotation,
1661        validator: &mut IsBSJHg2,
1662        profile: Option<&Scan1Profile>,
1663    ) -> Option<(String, String, String)> {
1664        let started = profile.map(|_| Instant::now());
1665        let result = self.process_group_view_impl(
1666            read_id, group, stand_map, fasta_map, annotation, validator, profile,
1667        );
1668        if let (Some(profile), Some(started)) = (profile, started) {
1669            profile
1670                .group_process_ns
1671                .fetch_add(started.elapsed().as_nanos() as u64, Ordering::Relaxed);
1672            profile.groups.fetch_add(1, Ordering::Relaxed);
1673        }
1674        result
1675    }
1676
1677    /// Reproduces Java Scan1 BSJ candidate assembly for one read group.
1678    ///
1679    /// Most Scan1 CPU time ends up here and inside `IsBSJHg2::is_bsj_hg1`, so the
1680    /// low-risk optimizations in this function are limited to reusing derived
1681    /// values such as `misd` outputs and oriented read sequence strings. The
1682    /// branch structure itself intentionally stays close to Java for parity.
1683    fn process_group_view_impl<'a>(
1684        &self,
1685        read_id: &str,
1686        group: &[Vec<AlignmentRecord<'a>>; 2],
1687        stand_map: &HashMap<i32, (char, Cow<'a, str>)>,
1688        fasta_map: &HashMap<String, String>,
1689        annotation: &Annotation,
1690        validator: &mut IsBSJHg2,
1691        profile: Option<&Scan1Profile>,
1692    ) -> Option<(String, String, String)> {
1693        // Read tracing is intentionally read-scoped to avoid overwhelming output.
1694        let trace_read = should_trace_read(read_id);
1695        let [pair1, pair2] = group;
1696        let group_count =
1697            (if !pair1.is_empty() { 1 } else { 0 }) + (if !pair2.is_empty() { 1 } else { 0 });
1698        if group_count == 0 {
1699            return None;
1700        }
1701        let is_paried = group_count - 1;
1702        for n in 0..=is_paried {
1703            let segments = if n == 0 { pair1 } else { pair2 };
1704            let mate_segments = if n == 0 { pair2 } else { pair1 };
1705            let (read_strand, read_seq) = match stand_map.get(&(n as i32)) {
1706                Some((st, seq)) => (*st, seq.as_ref()),
1707                None => continue,
1708            };
1709            if segments.len() < 2 {
1710                continue;
1711            }
1712            let seq_len = read_seq.len() as i32;
1713            // `misd` is deterministic for a given alignment and sequence length, so
1714            // computing it once per segment is a safe hot-path optimization.
1715            let segment_misds: Vec<[i32; 4]> = segments
1716                .iter()
1717                .map(|aln| misd(&aln.cigar, seq_len))
1718                .collect();
1719            let mate_misds: Vec<[i32; 4]> = mate_segments
1720                .iter()
1721                .map(|aln| misd(&aln.cigar, seq_len))
1722                .collect();
1723            for i in 0..segments.len() {
1724                for j in i + 1..segments.len() {
1725                    let (mut al1, mut al2) = (&segments[i], &segments[j]);
1726                    if al1.chrom != al2.chrom {
1727                        continue;
1728                    }
1729                    // Java parity: skip chrM unless explicitly enabled (currently always disabled).
1730                    if al1.chrom.as_ref() == "chrM" {
1731                        continue;
1732                    }
1733                    // Java parity: two candidate segments must share FLAG bit 0x10.
1734                    if (al1.flag & 0x10) != (al2.flag & 0x10) {
1735                        continue;
1736                    }
1737                    // Java parity: at least one segment MAPQ passes threshold.
1738                    if al1.mapq < self.min_mapq_uni && al2.mapq < self.min_mapq_uni {
1739                        continue;
1740                    }
1741                    if al1.cigar.as_ref() == "*" || al2.cigar.as_ref() == "*" {
1742                        continue;
1743                    }
1744                    let mut c1 = segment_misds[i];
1745                    let mut c2 = segment_misds[j];
1746                    if c1[0] > c2[0] {
1747                        std::mem::swap(&mut c1, &mut c2);
1748                        std::mem::swap(&mut al1, &mut al2);
1749                    }
1750                    let mut identified = false;
1751                    let (mut s1_n, mut s2_n, mut adj1, mut adj2): (i32, i32, i32, i32) =
1752                        (0, 0, 0, 0);
1753                    let (mut str1, mut str2, mut str3, mut str4) =
1754                        (String::new(), String::new(), String::new(), String::new());
1755                    let (mut q1, mut q2, mut sum_q) = (0, 0, 0);
1756                    let al1_strand = if al1.flag & 0x10 != 0 { '1' } else { '0' };
1757                    // Sequence orientation depends on the current alignment strand,
1758                    // not just on the original read slot. Hoisting it here avoids
1759                    // rebuilding the same reverse-complement per candidate branch.
1760                    let seq_oriented: Cow<'_, str> = if al1_strand == read_strand {
1761                        Cow::Borrowed(read_seq)
1762                    } else {
1763                        Cow::Owned(crate::utils::reverse_complement(read_seq))
1764                    };
1765                    if c1[0] * c2[0] == -1 {
1766                        let scale = c1[0] * al1.pos + c1[2] + c2[0] * al2.pos + c2[2];
1767                        if scale > 0
1768                            && (c1[1] - c2[1]).abs() <= 6
1769                            && scale <= self.max_circle
1770                            && scale >= self.min_circle
1771                        {
1772                            adj1 = (c1[1] * c1[0] + c2[1] * c2[0]) / 2;
1773                            adj2 = (c1[1] * c1[0] + c2[1] * c2[0]) - adj1;
1774                            if adj1.abs() <= 4 {
1775                                identified = true;
1776                                s1_n = al1.pos + adj1;
1777                                s2_n = al2.pos + c2[3] - 1 - adj2;
1778                                str2 = java_substring(seq_oriented.as_ref(), 0, c1[1] + adj1)
1779                                    .to_string();
1780                                str1 = java_substring(seq_oriented.as_ref(), c1[1] + adj1, seq_len)
1781                                    .to_string();
1782                                str3 = "*".to_string();
1783                                if al1.mapq >= self.min_mapq_uni && al2.mapq >= self.min_mapq_uni {
1784                                    q1 = 1;
1785                                    q2 = 1;
1786                                    sum_q = 1;
1787                                } else if al1.mapq >= self.min_mapq_uni {
1788                                    q1 = 1;
1789                                } else if al2.mapq >= self.min_mapq_uni {
1790                                    q2 = 1;
1791                                }
1792                            }
1793                        }
1794                    } else if (c1[0] * c2[0]).abs() == 10 {
1795                        if c1[0] == -1 {
1796                            let scale = al2.pos + c2[3] - 1 - al1.pos;
1797                            if scale > 0
1798                                && (seq_len - c2[2] - c1[1]).abs() <= 6
1799                                && scale <= self.max_circle
1800                                && scale >= self.min_circle
1801                            {
1802                                adj1 = (c2[1] + c2[3] - c1[1]) / 2;
1803                                adj2 = (c2[1] + c2[3] - c1[1]) - adj1;
1804                                if adj1.abs() <= 4 {
1805                                    identified = true;
1806                                    s1_n = al1.pos + adj1;
1807                                    s2_n = al2.pos + c2[3] - 1 - adj2;
1808                                    str1 = java_substring(
1809                                        seq_oriented.as_ref(),
1810                                        c1[1] + adj1,
1811                                        seq_len,
1812                                    )
1813                                    .to_string();
1814                                    str2 =
1815                                        java_substring(seq_oriented.as_ref(), c2[1], c1[1] + adj1)
1816                                            .to_string();
1817                                    str3 =
1818                                        java_substring(seq_oriented.as_ref(), 0, c2[1]).to_string();
1819                                    if al1.mapq >= self.min_mapq_uni
1820                                        && al2.mapq >= self.min_mapq_uni
1821                                    {
1822                                        q1 = 1;
1823                                        q2 = 1;
1824                                        sum_q = 1;
1825                                    } else if al1.mapq >= self.min_mapq_uni {
1826                                        q1 = 1;
1827                                    } else if al2.mapq >= self.min_mapq_uni {
1828                                        q2 = 1;
1829                                    }
1830                                }
1831                            }
1832                        } else {
1833                            let scale = al1.pos + c1[3] - 1 - al2.pos;
1834                            if scale > 0
1835                                && (c1[1] - c2[1]).abs() <= 6
1836                                && scale <= self.max_circle
1837                                && scale >= self.min_circle
1838                            {
1839                                adj1 = (c1[1] - c2[1]) / 2;
1840                                adj2 = (c1[1] - c2[1]) - adj1;
1841                                if adj1.abs() <= 4 {
1842                                    identified = true;
1843                                    s1_n = al2.pos + adj1;
1844                                    s2_n = al1.pos + c1[3] - 1 - adj2;
1845                                    str2 = java_substring(seq_oriented.as_ref(), 0, c1[1] - adj2)
1846                                        .to_string();
1847                                    str1 = java_substring(
1848                                        seq_oriented.as_ref(),
1849                                        c1[1] - adj2,
1850                                        seq_len - c2[2],
1851                                    )
1852                                    .to_string();
1853                                    str3 = java_substring(
1854                                        seq_oriented.as_ref(),
1855                                        seq_len - c2[2],
1856                                        seq_len,
1857                                    )
1858                                    .to_string();
1859                                    if al1.mapq >= self.min_mapq_uni
1860                                        && al2.mapq >= self.min_mapq_uni
1861                                    {
1862                                        q1 = 1;
1863                                        q2 = 1;
1864                                        sum_q = 1;
1865                                    } else if al2.mapq >= self.min_mapq_uni {
1866                                        q1 = 1;
1867                                    } else if al1.mapq >= self.min_mapq_uni {
1868                                        q2 = 1;
1869                                    }
1870                                }
1871                            }
1872                        }
1873                    }
1874                    if identified {
1875                        let mut s4_ok = 0;
1876                        if is_paried == 1 {
1877                            if let Some((mate_stand, mate_seq)) = stand_map.get(&((1 - n) as i32)) {
1878                                str4 = if al1_strand != *mate_stand {
1879                                    mate_seq.to_string()
1880                                } else {
1881                                    crate::utils::reverse_complement(mate_seq)
1882                                };
1883                            }
1884                            for (m_aln, mc) in mate_segments.iter().zip(mate_misds.iter()) {
1885                                if m_aln.chrom == al1.chrom && m_aln.mapq >= self.min_mapq_uni {
1886                                    if m_aln.flag & 0x10 != al1.flag & 0x10
1887                                        && m_aln.pos >= s1_n - 6
1888                                        && m_aln.pos + mc[3] - 1 <= s2_n + 6
1889                                    {
1890                                        s4_ok = 1;
1891                                        break;
1892                                    } else {
1893                                        s4_ok = -1;
1894                                        break;
1895                                    }
1896                                }
1897                            }
1898                        } else {
1899                            s4_ok = 1;
1900                            str4 = "".to_string();
1901                        }
1902                        let mut line_arr = [
1903                            if al1.flag & 0x10 != 0 { "1" } else { "0" }.to_string(),
1904                            al1.chrom.to_string(),
1905                            str1,
1906                            str2,
1907                            str3,
1908                            str4,
1909                            q1.to_string(),
1910                            q2.to_string(),
1911                            s4_ok.to_string(),
1912                            s1_n.to_string(),
1913                            s2_n.to_string(),
1914                            adj1.to_string(),
1915                            adj2.to_string(),
1916                        ];
1917                        if trace_read {
1918                            emit_trace_line(&format!(
1919                                "[TRACE_SCAN1_CAND] id={} n={} al1=({}, {}, {}, {}) al2=({}, {}, {}, {}) c1={:?} c2={:?} s1_n={} s2_n={} adj1={} adj2={} q=({},{},{}) s4_ok={} line_arr6_8={}/{}/{}",
1920                                read_id,
1921                                n,
1922                                al1.chrom,
1923                                al1.pos,
1924                                al1.mapq,
1925                                al1.cigar,
1926                                al2.chrom,
1927                                al2.pos,
1928                                al2.mapq,
1929                                al2.cigar,
1930                                c1,
1931                                c2,
1932                                s1_n,
1933                                s2_n,
1934                                adj1,
1935                                adj2,
1936                                q1,
1937                                q2,
1938                                sum_q,
1939                                s4_ok,
1940                                line_arr[6],
1941                                line_arr[7],
1942                                line_arr[8]
1943                            ));
1944                        }
1945                        if let Some(chr_seq) = fasta_map.get(al1.chrom.as_ref()) {
1946                            let hg1_started = profile.map(|_| Instant::now());
1947                            let res = validator.is_bsj_hg1(
1948                                &mut line_arr,
1949                                chr_seq,
1950                                sum_q,
1951                                "chrM",
1952                                false,
1953                                &annotation.chr_exon_start_map,
1954                                &annotation.chr_exon_end_map,
1955                            );
1956                            if let Some(profile) = profile {
1957                                profile.hg1_calls.fetch_add(1, Ordering::Relaxed);
1958                                if let Some(hg1_started) = hg1_started {
1959                                    profile.bsj_judge_ns.fetch_add(
1960                                        hg1_started.elapsed().as_nanos() as u64,
1961                                        Ordering::Relaxed,
1962                                    );
1963                                }
1964                                if res.is_some() {
1965                                    profile.hg1_hits.fetch_add(1, Ordering::Relaxed);
1966                                }
1967                            }
1968                            if trace_read {
1969                                emit_trace_line(&format!(
1970                                    "[TRACE_SCAN1_HG1] id={} result={} post_line_arr6_8={}/{}/{} sites={}->{}",
1971                                    read_id,
1972                                    if res.is_some() { "Some" } else { "None" },
1973                                    line_arr[6],
1974                                    line_arr[7],
1975                                    line_arr[8],
1976                                    line_arr[9],
1977                                    line_arr[10]
1978                                ));
1979                            }
1980                            if let Some(res) = res {
1981                                let cigar1 = Self::normalize_bsj_cigar(al1.cigar.as_ref());
1982                                let cigar2 = Self::normalize_bsj_cigar(al2.cigar.as_ref());
1983                                let mate_label = if n == 1 { "R1" } else { "R2" };
1984                                return Some((
1985                                    read_id.to_string(),
1986                                    mate_label.to_string(),
1987                                    format!("{}\t{};{}\t{}", read_id, cigar1, cigar2, res),
1988                                ));
1989                            }
1990                        }
1991                    }
1992                }
1993            }
1994        }
1995        None
1996    }
1997
1998    /// Enumerates mate-level Scan1 hits for the user-facing post-Summary `.bsj`.
1999    ///
2000    /// This helper intentionally does not participate in the main parity path.
2001    /// It reuses the same BSJ validator, but keeps searching once one mate hits
2002    /// so the final display can distinguish `R1`, `R2`, or both.
2003    fn process_group_view_display<'a>(
2004        &self,
2005        read_id: &str,
2006        group: &[Vec<AlignmentRecord<'a>>; 2],
2007        stand_map: &HashMap<i32, (char, Cow<'a, str>)>,
2008        fasta_map: &HashMap<String, String>,
2009        annotation: &Annotation,
2010        validator: &mut IsBSJHg2,
2011    ) -> Vec<String> {
2012        let trace_read = should_trace_read(read_id);
2013        let [pair1, pair2] = group;
2014        let group_count =
2015            (if !pair1.is_empty() { 1 } else { 0 }) + (if !pair2.is_empty() { 1 } else { 0 });
2016        if group_count == 0 {
2017            return Vec::new();
2018        }
2019        let is_paired = group_count == 2;
2020        let mut results = Vec::new();
2021
2022        'mate: for slot in [1_i32, 0_i32] {
2023            let segments = if slot == 0 { pair1 } else { pair2 };
2024            let mate_segments = if slot == 0 { pair2 } else { pair1 };
2025            let (read_strand, read_seq) = match stand_map.get(&slot) {
2026                Some((st, seq)) => (*st, seq.as_ref()),
2027                None => continue,
2028            };
2029            if segments.len() < 2 {
2030                continue;
2031            }
2032            let seq_len = read_seq.len() as i32;
2033            let segment_misds: Vec<[i32; 4]> = segments
2034                .iter()
2035                .map(|aln| misd(&aln.cigar, seq_len))
2036                .collect();
2037            let mate_misds: Vec<[i32; 4]> = mate_segments
2038                .iter()
2039                .map(|aln| misd(&aln.cigar, seq_len))
2040                .collect();
2041            for i in 0..segments.len() {
2042                for j in i + 1..segments.len() {
2043                    let (mut al1, mut al2) = (&segments[i], &segments[j]);
2044                    if al1.chrom != al2.chrom
2045                        || al1.chrom.as_ref() == "chrM"
2046                        || (al1.flag & 0x10) != (al2.flag & 0x10)
2047                        || (al1.mapq < self.min_mapq_uni && al2.mapq < self.min_mapq_uni)
2048                        || al1.cigar.as_ref() == "*"
2049                        || al2.cigar.as_ref() == "*"
2050                    {
2051                        continue;
2052                    }
2053                    let mut c1 = segment_misds[i];
2054                    let mut c2 = segment_misds[j];
2055                    if c1[0] > c2[0] {
2056                        std::mem::swap(&mut c1, &mut c2);
2057                        std::mem::swap(&mut al1, &mut al2);
2058                    }
2059                    let mut identified = false;
2060                    let (mut s1_n, mut s2_n, mut adj1, mut adj2): (i32, i32, i32, i32) =
2061                        (0, 0, 0, 0);
2062                    let (mut str1, mut str2, mut str3, mut str4) =
2063                        (String::new(), String::new(), String::new(), String::new());
2064                    let (mut q1, mut q2, mut sum_q) = (0, 0, 0);
2065                    let al1_strand = if al1.flag & 0x10 != 0 { '1' } else { '0' };
2066                    let seq_oriented: Cow<'_, str> = if al1_strand == read_strand {
2067                        Cow::Borrowed(read_seq)
2068                    } else {
2069                        Cow::Owned(crate::utils::reverse_complement(read_seq))
2070                    };
2071
2072                    if c1[0] * c2[0] == -1 {
2073                        let scale = c1[0] * al1.pos + c1[2] + c2[0] * al2.pos + c2[2];
2074                        if scale > 0
2075                            && (c1[1] - c2[1]).abs() <= 6
2076                            && scale <= self.max_circle
2077                            && scale >= self.min_circle
2078                        {
2079                            adj1 = (c1[1] * c1[0] + c2[1] * c2[0]) / 2;
2080                            adj2 = (c1[1] * c1[0] + c2[1] * c2[0]) - adj1;
2081                            if adj1.abs() <= 4 {
2082                                identified = true;
2083                                s1_n = al1.pos + adj1;
2084                                s2_n = al2.pos + c2[3] - 1 - adj2;
2085                                str2 = java_substring(seq_oriented.as_ref(), 0, c1[1] + adj1)
2086                                    .to_string();
2087                                str1 = java_substring(seq_oriented.as_ref(), c1[1] + adj1, seq_len)
2088                                    .to_string();
2089                                str3 = "*".to_string();
2090                                if al1.mapq >= self.min_mapq_uni && al2.mapq >= self.min_mapq_uni {
2091                                    q1 = 1;
2092                                    q2 = 1;
2093                                    sum_q = 1;
2094                                } else if al1.mapq >= self.min_mapq_uni {
2095                                    q1 = 1;
2096                                } else if al2.mapq >= self.min_mapq_uni {
2097                                    q2 = 1;
2098                                }
2099                            }
2100                        }
2101                    } else if (c1[0] * c2[0]).abs() == 10 {
2102                        if c1[0] == -1 {
2103                            let scale = al2.pos + c2[3] - 1 - al1.pos;
2104                            if scale > 0
2105                                && (seq_len - c2[2] - c1[1]).abs() <= 6
2106                                && scale <= self.max_circle
2107                                && scale >= self.min_circle
2108                            {
2109                                adj1 = (c2[1] + c2[3] - c1[1]) / 2;
2110                                adj2 = (c2[1] + c2[3] - c1[1]) - adj1;
2111                                if adj1.abs() <= 4 {
2112                                    identified = true;
2113                                    s1_n = al1.pos + adj1;
2114                                    s2_n = al2.pos + c2[3] - 1 - adj2;
2115                                    str1 = java_substring(
2116                                        seq_oriented.as_ref(),
2117                                        c1[1] + adj1,
2118                                        seq_len,
2119                                    )
2120                                    .to_string();
2121                                    str2 =
2122                                        java_substring(seq_oriented.as_ref(), c2[1], c1[1] + adj1)
2123                                            .to_string();
2124                                    str3 =
2125                                        java_substring(seq_oriented.as_ref(), 0, c2[1]).to_string();
2126                                    if al1.mapq >= self.min_mapq_uni
2127                                        && al2.mapq >= self.min_mapq_uni
2128                                    {
2129                                        q1 = 1;
2130                                        q2 = 1;
2131                                        sum_q = 1;
2132                                    } else if al1.mapq >= self.min_mapq_uni {
2133                                        q1 = 1;
2134                                    } else if al2.mapq >= self.min_mapq_uni {
2135                                        q2 = 1;
2136                                    }
2137                                }
2138                            }
2139                        } else {
2140                            let scale = al1.pos + c1[3] - 1 - al2.pos;
2141                            if scale > 0
2142                                && (c1[1] - c2[1]).abs() <= 6
2143                                && scale <= self.max_circle
2144                                && scale >= self.min_circle
2145                            {
2146                                adj1 = (c1[1] - c2[1]) / 2;
2147                                adj2 = (c1[1] - c2[1]) - adj1;
2148                                if adj1.abs() <= 4 {
2149                                    identified = true;
2150                                    s1_n = al2.pos + adj1;
2151                                    s2_n = al1.pos + c1[3] - 1 - adj2;
2152                                    str2 = java_substring(seq_oriented.as_ref(), 0, c1[1] - adj2)
2153                                        .to_string();
2154                                    str1 = java_substring(
2155                                        seq_oriented.as_ref(),
2156                                        c1[1] - adj2,
2157                                        seq_len - c2[2],
2158                                    )
2159                                    .to_string();
2160                                    str3 = java_substring(
2161                                        seq_oriented.as_ref(),
2162                                        seq_len - c2[2],
2163                                        seq_len,
2164                                    )
2165                                    .to_string();
2166                                    if al1.mapq >= self.min_mapq_uni
2167                                        && al2.mapq >= self.min_mapq_uni
2168                                    {
2169                                        q1 = 1;
2170                                        q2 = 1;
2171                                        sum_q = 1;
2172                                    } else if al2.mapq >= self.min_mapq_uni {
2173                                        q1 = 1;
2174                                    } else if al1.mapq >= self.min_mapq_uni {
2175                                        q2 = 1;
2176                                    }
2177                                }
2178                            }
2179                        }
2180                    }
2181                    if !identified {
2182                        continue;
2183                    }
2184
2185                    let mut s4_ok = 0;
2186                    if is_paired {
2187                        if let Some((mate_stand, mate_seq)) = stand_map.get(&(1 - slot)) {
2188                            str4 = if al1_strand != *mate_stand {
2189                                mate_seq.to_string()
2190                            } else {
2191                                crate::utils::reverse_complement(mate_seq)
2192                            };
2193                        }
2194                        for (m_aln, mc) in mate_segments.iter().zip(mate_misds.iter()) {
2195                            if m_aln.chrom == al1.chrom && m_aln.mapq >= self.min_mapq_uni {
2196                                if m_aln.flag & 0x10 != al1.flag & 0x10
2197                                    && m_aln.pos >= s1_n - 6
2198                                    && m_aln.pos + mc[3] - 1 <= s2_n + 6
2199                                {
2200                                    s4_ok = 1;
2201                                    break;
2202                                } else {
2203                                    s4_ok = -1;
2204                                    break;
2205                                }
2206                            }
2207                        }
2208                    } else {
2209                        s4_ok = 1;
2210                        str4.clear();
2211                    }
2212
2213                    let mut line_arr = [
2214                        if al1.flag & 0x10 != 0 { "1" } else { "0" }.to_string(),
2215                        al1.chrom.to_string(),
2216                        str1,
2217                        str2,
2218                        str3,
2219                        str4,
2220                        q1.to_string(),
2221                        q2.to_string(),
2222                        s4_ok.to_string(),
2223                        s1_n.to_string(),
2224                        s2_n.to_string(),
2225                        adj1.to_string(),
2226                        adj2.to_string(),
2227                    ];
2228                    if trace_read {
2229                        emit_trace_line(&format!(
2230                            "[TRACE_SCAN1_DISPLAY] id={} slot={} al1=({}, {}, {}, {}) al2=({}, {}, {}, {}) c1={:?} c2={:?} s1_n={} s2_n={} adj1={} adj2={} q=({},{},{}) s4_ok={}",
2231                            read_id,
2232                            slot,
2233                            al1.chrom,
2234                            al1.pos,
2235                            al1.mapq,
2236                            al1.cigar,
2237                            al2.chrom,
2238                            al2.pos,
2239                            al2.mapq,
2240                            al2.cigar,
2241                            c1,
2242                            c2,
2243                            s1_n,
2244                            s2_n,
2245                            adj1,
2246                            adj2,
2247                            q1,
2248                            q2,
2249                            sum_q,
2250                            s4_ok,
2251                        ));
2252                    }
2253                    if let Some(chr_seq) = fasta_map.get(al1.chrom.as_ref()) {
2254                        let res = validator.is_bsj_hg1(
2255                            &mut line_arr,
2256                            chr_seq,
2257                            sum_q,
2258                            "chrM",
2259                            false,
2260                            &annotation.chr_exon_start_map,
2261                            &annotation.chr_exon_end_map,
2262                        );
2263                        if let Some(res) = res {
2264                            let cigar1 = Self::normalize_bsj_cigar(al1.cigar.as_ref());
2265                            let cigar2 = Self::normalize_bsj_cigar(al2.cigar.as_ref());
2266                            let mate_label = if slot == 1 { "R1" } else { "R2" };
2267                            results.push(format!(
2268                                "{}\t{}\t{};{}\t{}",
2269                                read_id, mate_label, cigar1, cigar2, res
2270                            ));
2271                            continue 'mate;
2272                        }
2273                    }
2274                }
2275            }
2276        }
2277
2278        results
2279    }
2280}