Skip to main content

ciri_toolkit/
ciri_as.rs

1//! Post-Summary circRNA read reconstruction entry points.
2//!
3//! This module remains intentionally separate from `Scan1 -> Scan2 -> Summary`.
4//! The CIRI3 Rust path is parity-sensitive, so all circRNA internal-structure
5//! work stays in a post-Summary phase that consumes the final circRNA table and
6//! sidecar alignment evidence without feeding evidence back into BSJ detection.
7//! The current deliverable is `<prefix>.segments`: a read-level, splice-aware
8//! representation of confirmed BSJ reads that can be compared directly against
9//! simulator truth before full-length path reconstruction is re-enabled on top
10//! of it. Full-length reconstruction is likewise a sidecar: it consumes the
11//! completed segment rows and writes one rank 1 major isoform per
12//! Summary-confirmed circRNA without changing CIRI3 `.out` or `.bsj`
13//! decisions. Multi-sample major isoform switching can later reuse the same
14//! structure fields without changing the core BSJ contract.
15
16use anyhow::{anyhow, bail, Context, Result};
17use indicatif::{ProgressBar, ProgressStyle};
18use memmap2::Mmap;
19use noodles::bam;
20use noodles::sam::{
21    self,
22    alignment::record::data::field::{Tag, Value},
23};
24use rayon::prelude::*;
25use std::cmp::Ordering;
26use std::collections::{hash_map::DefaultHasher, BTreeMap, HashMap, HashSet};
27use std::fmt::Write as FmtWrite;
28use std::fs::File;
29use std::hash::{Hash, Hasher};
30use std::io::{BufRead, BufReader, BufWriter, Seek, Write};
31use std::sync::atomic::{AtomicU64, Ordering as AtomicOrdering};
32use std::time::{Duration, Instant};
33
34use crate::annotation::Annotation;
35use crate::sam_bam::{detect_format, InputFormat};
36use crate::utils::{
37    bam_shard_count, bsj_payload_start, clip_placement_cigar, clip_sequence_payload,
38    exact_clip_match_positions, is_bsj_mate_label, parse_cigar_ops_basic, parse_clip_payload,
39    part_path, reverse_complement,
40};
41
42const MIN_INTRON: i32 = 70;
43const MIN_EXON_LENGTH: i32 = 20;
44const MAX_EXON_LENGTH: i32 = 2000;
45const MIN_JUNCTION_SUPPORT_SEGMENT_LENGTH: i32 = 10;
46const INTERNAL_SPLICE_CORRECTION_WINDOW: i32 = 4;
47const PARTIAL_LOCAL_SPLICE_CORRECTION_WINDOW: i32 = 16;
48/// Maximum movement allowed when a Summary-confirmed BSJ row is snapped back to
49/// the final circRNA boundary.
50///
51/// This window is intentionally wider than ordinary internal splice correction:
52/// BWA-MEM can extend a BSJ-side block through a few bases of microhomology, but
53/// the Summary circRNA site is already the accepted BSJ evidence for this row.
54const CONFIRMED_BSJ_BOUNDARY_CORRECTION_WINDOW: i32 = 16;
55/// Sequence-score bonus granted to high-confidence junction evidence.
56///
57/// The bonus is small enough that several newly introduced mismatches still
58/// lose, but it keeps exact transcript or read-specific support from being
59/// displaced by one-base microhomology ties.
60const JUNCTION_SEQUENCE_EVIDENCE_TIE_BONUS: i32 = 3;
61const MAPQ_THRES: i32 = 5;
62/// Minimum terminal 3' clip required on both mates for exact-span outward-pair evidence.
63///
64/// BSJ detection relies on roughly 20 bp split anchors. Exact-span outward pairs
65/// have no aligned outward length, so their terminal clip evidence should not be
66/// weaker.
67const OUTWARD_MIN_TERMINAL_CLIP: i32 = 19;
68/// Minimum aligned outward length required on both sides of a non-identical
69/// outward-facing pair. Terminal clips are not required when the aligned spans
70/// already provide this much outward extension.
71const OUTWARD_MIN_PAIR_OFFSET: i32 = 19;
72const MAPQ_UNI: i32 = 0;
73const MAPQ_BOTH: i32 = 0;
74/// Maximum unspliced graph block kept as a single mature exon in isoform output.
75///
76/// This follows the CIRI-AS exon-length scale instead of a permissive genomic
77/// span cutoff. Larger blocks usually mean the segment graph lacks internal
78/// splice evidence; when annotation is available, those blocks are projected to
79/// known exons so intronic genomic span is not reported as mature RNA sequence.
80const MAJOR_MAX_UNSPLICED_EXON_LEN: i32 = MAX_EXON_LENGTH;
81/// Minimum BSJ/backward read-chain support required for every adjacent selected
82/// junction pair in a mature multi-exon isoform.
83///
84/// Outward reads are useful weak completion evidence during path selection, but
85/// they do not localize a BSJ range precisely enough to certify mature phasing.
86const MAJOR_MIN_MATURE_LINK_SUPPORT: f64 = 1.0;
87/// Minimum continuous anchor on both sides of a candidate junction-exclusion span.
88///
89/// A continuous block that merely overruns a splice boundary by a few bases can
90/// be caused by mapper microhomology. Requiring anchors on both sides keeps
91/// junction-exclusive evidence reserved for reads that truly span across the
92/// candidate intron rather than touch both boundary coordinates by a short drift.
93const MAJOR_EXCLUSIVE_JUNCTION_MIN_ANCHOR: i32 = MIN_JUNCTION_SUPPORT_SEGMENT_LENGTH;
94/// Smallest non-BSJ assignment probability retained for isoform graph support.
95///
96/// Backward/outward rows have no exact Summary BSJ. The isoform stage therefore
97/// treats their circRNA membership as probabilistic and leaves weak, internal
98/// placements unassigned instead of hard-counting them for an enclosing locus.
99const MAJOR_MIN_NON_BSJ_ASSIGNMENT_WEIGHT: f64 = 0.001;
100/// Minimum distance scale used when comparing non-BSJ anchors with BSJ sites.
101///
102/// The dynamic scale starts from the read-level aligned bases. This lower bound
103/// keeps normal read-pair jitter from erasing real boundary-adjacent support.
104const MAJOR_NON_BSJ_MIN_DISTANCE_SCALE: f64 = 100.0;
105/// Maximum distance scale used for non-BSJ circRNA assignment.
106///
107/// Long genomic spans often include introns or internal circular wraps; letting
108/// that span define the scale would incorrectly make distant internal reads look
109/// compatible with an outer BSJ.
110const MAJOR_NON_BSJ_MAX_DISTANCE_SCALE: f64 = 1000.0;
111/// Maximum genomic gap used to merge high-confidence read spans into one
112/// estimate anchor.
113///
114/// Paired BSJ/backward mates can bracket a short unsequenced insert inside the
115/// same exon even when the reads do not overlap. Keeping this window small lets
116/// those mate-level anchors guide annotation projection without merging across
117/// the kilobase-scale introns that should still be represented as exon gaps.
118const MAJOR_ESTIMATE_ANCHOR_MERGE_GAP: i32 = 300;
119/// Minimum segment-covered fraction required to trust unannotated long exons.
120///
121/// Annotation can legitimately contain rare mega-exons, but an unannotated
122/// long exon with only tiny read anchors is usually an unresolved structure
123/// estimate rather than a sequence-ready isoform. This threshold only gates
124/// FASTA output for estimates; the GTF audit row is still emitted.
125const MAJOR_MIN_FASTA_SEGMENT_COVERAGE_PCT: f64 = 10.0;
126/// Minimum segment-covered fraction required for estimate FASTA output.
127///
128/// FASTA is intended to be the high-confidence sequence set. Estimates can
129/// remain valuable audit records in GTF, but sequence output should require
130/// broad direct segment support unless the isoform is classified as mature.
131const MAJOR_MIN_TRUSTED_ESTIMATE_SEGMENT_COVERAGE_PCT: f64 = 50.0;
132/// Minimum segment-covered fraction for high-confidence unphased candidates.
133///
134/// These records are not mature because adjacent junction-pair phasing is
135/// incomplete, but a broadly segment-covered chain is still useful enough to
136/// include in the sequence FASTA with its estimate reason preserved.
137const MAJOR_MIN_CANDIDATE_SEGMENT_COVERAGE_PCT: f64 = 90.0;
138/// Minimum sample-level BSJ support required before a different major isoform
139/// can trigger a cohort switching call.
140const COHORT_SWITCHING_MIN_BSJ_READS: f64 = 5.0;
141/// Minimum usage shift required before high-confidence sample-major structures
142/// are reported as a cohort switching event.
143const COHORT_SWITCHING_MIN_USAGE_DELTA: f64 = 0.50;
144/// Minimum aggregate structural support required to seed a cohort alternative.
145const COHORT_MIN_ALT_EDGE_SUPPORT: f64 = 5.0;
146/// Hard cap for per-circRNA alternative-edge probes during cohort co-assembly.
147const COHORT_MAX_ALT_EDGE_PROBES: usize = 64;
148/// Half-window around local anchors searched for non-BSJ clip placement.
149const NON_BSJ_LOCAL_CLIP_ANCHOR_FLANK_MULTIPLIER: i32 = 2;
150/// Maximum local clip pseudo-alignments retained per read group.
151const NON_BSJ_LOCAL_CLIP_MAX_ROWS_PER_READ: usize = 16;
152/// Maximum local clip placements retained for one clipped side before chain ranking.
153const NON_BSJ_LOCAL_CLIP_MAX_PLACEMENTS_PER_SIDE: usize = 4;
154/// Minimum selected XA anchor length accepted by the backward negative filter.
155const XA_REJECT_MIN_ANCHOR_LEN: i32 = MIN_JUNCTION_SUPPORT_SEGMENT_LENGTH;
156/// Minimum span reduction required before XA can reject a backward sidecar row.
157const XA_REJECT_MIN_SPAN_REDUCTION: i32 = 1000;
158/// Read-coordinate tolerance for treating an XA hit as the same aligned slice.
159const XA_SEAMLESS_QUERY_TOLERANCE: i32 = 2;
160/// Maximum exact XA alternatives evaluated per selected alignment record.
161const XA_MAX_ALTERNATIVES_PER_RECORD: usize = 4;
162/// Maximum XA alternatives inspected when repairing a confirmed BSJ row's mate.
163const BSJ_MATE_XA_MAX_ALTERNATIVES_PER_RECORD: usize = 8;
164/// CIRI3 default `-Max/--max_span`; sidecar backward rows should not exceed the
165/// same circRNA spanning range when they are candidates for later graph work.
166const BACKWARD_MAX_SPAN: i32 = 200000;
167
168/// Runtime options for the post-Summary circRNA segments phase.
169///
170/// The fields intentionally reuse the existing CIRI-AS-style second-sweep inputs
171/// so read recognition can keep its current parity-sensitive logic while the
172/// user-facing output is simplified to `<prefix>.segments`.
173pub struct AsConfig<'a> {
174    /// Original queryname-sorted SAM/BAM used by the main CIRI run.
175    pub input_path: &'a str,
176    /// Final CIRI3-style circRNA result table used as CIRI-AS `-C` input.
177    pub circ_path: &'a str,
178    /// Final mate-level BSJ table written after Summary.
179    ///
180    /// This file contains both `priority=1` CIRI3-compatible evidence and
181    /// `priority=0` mate-level evidence. The segments phase uses it only as
182    /// post-Summary read/mate annotation; it never feeds these rows back into
183    /// Summary counts or circRNA discovery.
184    pub bsj_path: Option<&'a str>,
185    /// Scan1/Scan2 segment-evidence sidecars produced before Summary.
186    ///
187    /// When present, the segments phase reads these compact alignment dumps and
188    /// filters them by the final `.out` junction-read assignments. This avoids a
189    /// second BAM/SAM scan for confirmed BSJ reads while keeping the sidecar out
190    /// of Summary's parity-sensitive inputs.
191    pub segment_evidence_paths: Vec<&'a str>,
192    /// Scan2 non-BSJ topology sidecars produced before Summary.
193    ///
194    /// These rows are read-level candidates, not circRNA-level assignments. They
195    /// can represent backward/outward evidence with no known BSJ, so the final
196    /// segments phase skips only reads already assigned as BSJ and then rebuilds
197    /// the supplemental rows from the read chain itself.
198    pub non_bsj_segment_evidence_paths: Vec<&'a str>,
199    /// Output prefix; the current phase writes `<prefix>.segments`.
200    pub out_prefix: &'a str,
201    /// Keep shard-local intermediate files after successful segments finalize.
202    ///
203    /// The CLI uses this for `--debug`. Most retained non-BSJ alignments live in
204    /// temporary shard streams, so deleting them inside this module would make
205    /// the user-facing debug flag incomplete.
206    pub keep_temp_files: bool,
207    /// In-memory reference sequence map loaded by the main CLI.
208    pub reference: &'a HashMap<String, String>,
209    /// Optional exon-boundary annotation used to resolve ambiguous motif offsets.
210    ///
211    /// CIRI-AS v1.2 does not consult annotation during `index_compare`, so Perl
212    /// falls back to hash iteration order when multiple motif offsets are valid.
213    /// The Rust sidecar deliberately uses annotation as a deterministic
214    /// biological tie-break before falling back to the lowest offset.
215    pub annotation: Option<&'a Annotation>,
216    /// Minimum mapping quality shared with the main BSJ scan.
217    ///
218    /// Outward reads are weaker circRNA evidence than BSJ/backward reads, so the
219    /// post-Summary detector uses the same user-facing MAPQ threshold instead
220    /// of the permissive CIRI-AS sidecar constant used for local coverage.
221    pub min_mapq: i32,
222    /// Optional user-facing progress logger supplied by the CLI.
223    ///
224    /// The segments phase owns an internal BAM progress bar, but several
225    /// correctness-preserving steps run immediately after that bar completes.
226    /// Routing status through the caller keeps stdout and `<prefix>.log`
227    /// consistent with Scan1/Scan2 without giving this module direct log-file
228    /// ownership.
229    pub progress_log: Option<&'a mut dyn FnMut(&str, &str) -> Result<()>>,
230}
231
232/// User-facing counts produced by the post-Summary segments phase.
233///
234/// These counts are computed after final segment rows have been selected, so
235/// they reflect the actual `<prefix>.segments` output rather than intermediate
236/// candidate pools.
237#[derive(Debug, Clone, Copy, Default)]
238pub struct SegmentRunSummary {
239    /// Total rows written to `<prefix>.segments`.
240    pub total_segments: usize,
241    /// Confirmed Summary-assigned BSJ rows.
242    pub bsj_segments: usize,
243    /// Non-BSJ mate-chain wrap rows.
244    pub backward_segments: usize,
245    /// Non-BSJ pair-orientation rows.
246    pub outward_segments: usize,
247}
248
249/// User-facing counts produced by the isoform reconstruction stage.
250///
251/// The GTF is the complete per-circRNA audit output: every circRNA has one rank
252/// 1 major record. FASTA is stricter and contains only sequence-ready isoforms,
253/// so both counts remain visible to distinguish "reconstructed structure
254/// exists" from "trusted sequence was emitted".
255#[derive(Debug, Clone, Copy, Default)]
256pub struct IsoformRunSummary {
257    /// Isoform records written to `<prefix>.isoforms.gtf`.
258    pub total_isoforms: usize,
259    /// Distinct circRNAs represented by GTF isoform records.
260    pub circ_rnas: usize,
261    /// Sequence records written to `<prefix>.isoforms.fa`.
262    pub fasta_isoforms: usize,
263    /// Distinct circRNAs represented by FASTA sequence records.
264    pub fasta_circ_rnas: usize,
265}
266
267/// One sample entry consumed by `ciri-assemble`.
268///
269/// The prefix points to completed second-pass outputs. Assembly derives
270/// `<prefix>.out` and `<prefix>.segments` so the manifest stays stable even if
271/// additional review sidecars are present in the same directory.
272#[derive(Debug, Clone)]
273pub struct CohortSampleInput {
274    /// User-facing sample identifier used as a matrix column.
275    pub sample_id: String,
276    /// Output prefix of one completed second-pass CIRI run.
277    pub prefix: String,
278}
279
280/// Configuration for cohort-level isoform assembly from second-pass outputs.
281pub struct CohortAssembleConfig<'a> {
282    /// Headerless manifest entries supplied by `ciri-assemble`.
283    pub samples: Vec<CohortSampleInput>,
284    /// Output prefix for `.isoforms.*` and matrix files.
285    pub out_prefix: &'a str,
286    /// Reference sequences used to materialize FASTA records.
287    pub reference: &'a HashMap<String, String>,
288    /// Optional annotation used by the same major-isoform projection logic as
289    /// single-sample reconstruction.
290    pub annotation: Option<&'a Annotation>,
291}
292
293/// User-facing summary returned by cohort assembly.
294#[derive(Debug, Clone, Copy, Default)]
295pub struct CohortAssembleSummary {
296    /// Number of samples read from the manifest.
297    pub samples: usize,
298    /// Distinct circRNAs represented by the cohort matrix.
299    pub circ_rnas: usize,
300    /// Isoform records written to `<prefix>.isoforms.gtf`.
301    pub isoforms: usize,
302    /// CircRNAs whose supported sample-major structures switched across samples.
303    pub switching_circ_rnas: usize,
304    /// FASTA records emitted after applying the normal sequence-confidence gate.
305    pub fasta_isoforms: usize,
306}
307
308/// Compact alignment representation for CIRI-AS read-group matching.
309///
310/// CIRI-AS only needs a subset of SAM fields. `seq` stores the full read
311/// sequence in the legacy SAM/BAM sweep and the compact sidecar clip payload for
312/// auditability; the default sidecar path materializes validator-accepted local
313/// clips as pseudo-alignment rows before the post-Summary chain builder runs.
314#[derive(Debug, Clone)]
315struct AsAlignment {
316    flag: i32,
317    chr: String,
318    pos: i32,
319    mapq: i32,
320    cigar: String,
321    seq: String,
322    cs: String,
323    from_local_clip: bool,
324    xa_alternatives: Vec<XaAlternative>,
325}
326
327/// One BWA `XA:Z` alternative alignment parsed for sidecar chain rejection.
328///
329/// CIRI3 parity code ignores optional tags. The segments sidecar uses XA only as
330/// a conservative negative signal: if a short ambiguous supplementary block can
331/// be placed by XA into a non-circular chain, the read should not be promoted to
332/// `type=backward` solely because BWA materialized a different equivalent hit.
333#[derive(Debug, Clone, PartialEq, Eq)]
334struct XaAlternative {
335    chr: String,
336    strand: char,
337    pos: i32,
338    cigar: String,
339    edit_distance: i32,
340}
341
342/// Read-level output row written to `<prefix>.segments`.
343///
344/// This is the formal handoff format between the CIRI-AS-style evidence capture
345/// and future full-length reconstruction. The current default CLI path emits
346/// confirmed `bsj` rows, mate-chain `backward` rows, and pair-orientation
347/// `outward` rows; `forward` remains reserved for later circ-span extraction.
348#[derive(Clone)]
349struct SegmentRecord {
350    read_id: String,
351    type_name: &'static str,
352    circ_id: String,
353    chrom: String,
354    start: String,
355    end: String,
356    strand: String,
357    is_circular: usize,
358    is_r1_bsj: usize,
359    is_r2_bsj: usize,
360    r1_align_strand: String,
361    r2_align_strand: String,
362    r1_cigar: String,
363    r1_cs: String,
364    r1_segments: String,
365    r2_cigar: String,
366    r2_cs: String,
367    r2_segments: String,
368}
369
370/// One mate-level BSJ row parsed from the final `<prefix>.bsj` display file.
371///
372/// The final `.bsj` protocol can report both mates for one read. Keeping this
373/// sidecar evidence separate from `junction_read_to_circ` lets segments mark
374/// the R1/R2 mate that CIRI identified as a BSJ while preserving the Summary
375/// rule that only `priority=1` rows define `.out` junction read counts.
376#[derive(Debug, Clone)]
377struct MateBsjEvidence {
378    mate_bucket: usize,
379    chr: String,
380    start: i32,
381    end: i32,
382    strand: String,
383    priority: u8,
384    source_stage: String,
385}
386
387/// One CIGAR operation retained inside a selected segment block.
388///
389/// Segment tokens are reference intervals, but allele-aware `.segments` output
390/// must still know where an insertion or deletion happened inside that
391/// interval. Keeping only block start/end collapses `33M1I116M` into one long
392/// `M` run and shifts every downstream base in the reconstructed `cs` payload.
393#[derive(Debug, Clone, Copy, PartialEq, Eq)]
394struct SegmentCigarOp {
395    len: i32,
396    op: char,
397}
398
399/// One genomic segment block in read order.
400///
401/// CIGAR parsing first walks the alignment in reference order, then flips the
402/// query coordinates for reverse-strand records so downstream chain assembly can
403/// reason in the same read-order protocol used by the simulator truth tables.
404/// `cigar_ops` is retained only while the block boundaries still match the
405/// source alignment; boundary correction clears it when the old operation
406/// offsets would no longer be trustworthy.
407/// `from_local_clip` also covers mapper terminal hard clips because those
408/// supplementary split anchors can be shifted several bases by exon-edge
409/// microhomology before the post-Summary splice correction has seen annotation
410/// or sequence context.
411#[derive(Debug, Clone)]
412struct SegmentBlock {
413    read_start: i32,
414    read_end: i32,
415    ref_start: i32,
416    ref_end: i32,
417    from_local_clip: bool,
418    cigar_ops: Vec<SegmentCigarOp>,
419}
420
421/// One alignment record with CIGAR-derived blocks in read order.
422#[derive(Debug, Clone)]
423struct ParsedAlignment {
424    flag: i32,
425    chrom: String,
426    strand: char,
427    mapq: i32,
428    seq: String,
429    blocks: Vec<SegmentBlock>,
430}
431
432/// A selected segment block plus the full read sequence it came from.
433///
434/// Boundary correction can trim or extend a block by a few bases when
435/// annotation, validated junction support, or circRNA boundaries provide a
436/// better splice coordinate. Keeping the full oriented sequence here lets the
437/// materializer re-slice query bases after those corrections instead of keeping
438/// a stale pre-correction query fragment.
439#[derive(Debug, Clone)]
440struct ChainBlockPayload {
441    block: SegmentBlock,
442    strand: char,
443    source_seq: String,
444}
445
446/// One candidate alignment chain for a single mate.
447///
448/// The chain is built from primary/supplementary alignments first, with
449/// secondary alignments only allowed to replace that chain when they produce a
450/// topology-compatible circRNA explanation. Keeping the chain explicit prevents
451/// us from dumping raw chimeric records that do not reflect the CIRI-AS splice
452/// interpretation.
453#[derive(Debug, Clone)]
454struct MateChain {
455    chrom: String,
456    token_strand: char,
457    blocks: Vec<SegmentBlock>,
458    tokens: Vec<String>,
459    cigar: String,
460    cs: String,
461    is_bsj: bool,
462    is_circular: bool,
463    used_secondary: usize,
464    used_supplementary: usize,
465    query_coverage: i32,
466}
467
468/// One final circRNA row loaded from the CIRI3 Summary output.
469///
470/// The column parser is header-driven because modern CIRI3-style tables include
471/// a trailing `Score` column, while CIRI-AS v1.2 expects junction read IDs to be
472/// the final column. Header lookup lets the sidecar consume the current Rust
473/// output directly and avoids temporary lossy conversion for normal use.
474#[derive(Debug, Clone)]
475struct CircRecord {
476    id: String,
477    chr: String,
478    start: i32,
479    end: i32,
480    junction_read_count: String,
481    gene_id: String,
482    strand: String,
483}
484
485/// One internal splice edge used by the major-isoform graph.
486///
487/// The edge represents a transcript-compatible intron from `donor_end` to
488/// `acceptor_start` in genomic coordinates. It is intentionally independent of
489/// read ID because BSJ, backward, and outward reads are combined as support
490/// tiers after the final segment rows are stable.
491#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
492struct MajorEdge {
493    donor_end: i32,
494    acceptor_start: i32,
495    strand: char,
496}
497
498/// Junction token used to phase the selected major-isoform path.
499///
500/// The implicit BSJ is part of the circular junction chain. Treating it as a
501/// first-class token prevents two-exon circRNAs with an unsupported long block
502/// from being labeled `mature` simply because they have only one internal
503/// splice edge.
504#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
505enum MajorJunction {
506    Bsj,
507    Edge(MajorEdge),
508}
509
510/// Pair of adjacent junctions observed in one read-chain segment.
511///
512/// Individual junction support is not enough to prove a mature full-length
513/// chain: two neighboring junctions can be observed by separate reads while the
514/// block between them remains unphased. Link support records that one read
515/// chain contains both junctions consecutively, including BSJ-to-internal
516/// boundary links.
517#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
518struct MajorJunctionLink {
519    left: MajorJunction,
520    right: MajorJunction,
521}
522
523impl MajorJunctionLink {
524    /// Builds a directional link key from two adjacent junctions in read-chain order.
525    ///
526    /// The two BSJ boundaries around a single internal junction must remain
527    /// distinguishable. Collapsing `(BSJ, edge)` and `(edge, BSJ)` would let a
528    /// read that only covers the short exon between one internal junction and
529    /// one BSJ side certify both circular blocks as mature.
530    fn new(a: MajorJunction, b: MajorJunction) -> Self {
531        Self { left: a, right: b }
532    }
533}
534
535/// Tiered read support for one major-isoform splice edge.
536///
537/// BSJ and backward reads are treated as high-confidence phasing evidence;
538/// outward reads are retained only as weak completion evidence because they
539/// support circular origin but do not localize a BSJ span by themselves.
540#[derive(Debug, Clone, Copy, Default)]
541struct MajorEdgeSupport {
542    bsj: f64,
543    backward: f64,
544    outward: f64,
545}
546
547/// Tiered read support for a neighboring-junction link.
548#[derive(Debug, Clone, Copy, Default)]
549struct MajorLinkSupport {
550    bsj: f64,
551    backward: f64,
552    outward: f64,
553}
554
555/// BSJ-only read observation retained for cohort isoform quantification.
556///
557/// Backward and outward rows are useful for discovering possible structures, but
558/// they do not resolve the BSJ molecule precisely enough for usage estimates.
559/// Cohort usage therefore keeps a separate BSJ read-level view and assigns each
560/// read probabilistically across the already co-assembled candidate isoforms.
561#[derive(Debug, Clone)]
562struct MajorBsjReadObservation {
563    read_id: String,
564    edges: HashSet<MajorEdge>,
565    spans: Vec<(i32, i32)>,
566}
567
568/// Continuous aligned segment used as junction-exclusive evidence.
569///
570/// If a read has one uninterrupted alignment block spanning a candidate intron,
571/// that read is evidence that the candidate splice junction was not used in
572/// this molecule. The signal is only used to keep annotation-only junctions from
573/// over-splitting unphased blocks; read-supported junctions still take priority.
574#[derive(Debug, Clone, Copy)]
575struct MajorAlignedSpan {
576    start: i32,
577    end: i32,
578    bsj: f64,
579    backward: f64,
580    outward: f64,
581}
582
583/// Weighted circRNA assignment for one `<prefix>.segments` row.
584///
585/// BSJ rows keep a single exact assignment with weight 1.0. Backward/outward
586/// rows can be compatible with several confirmed circRNAs or with none; their
587/// weights are probabilities used only by isoform reconstruction so the public
588/// read-level segments remain unmodified.
589#[derive(Debug, Clone, Copy)]
590struct MajorCircAssignment {
591    circ_index: usize,
592    weight: f64,
593}
594
595/// Exact circRNA interval index entry for assigning non-BSJ segment rows.
596///
597/// `max_end_through` enables backward scans over a start-sorted vector to stop
598/// once no earlier interval can contain the query span, which keeps assignment
599/// bounded without retaining read-level state for every circRNA.
600#[derive(Debug, Clone, Copy)]
601struct MajorCircIndexEntry {
602    start: i32,
603    end: i32,
604    circ_index: usize,
605    max_end_through: i32,
606}
607
608/// Major isoform selected for one Summary-confirmed circRNA.
609///
610/// The output remains one record per circRNA. Rank and sample fields stay
611/// internal so sorting and FASTA headers can remain stable while the public GTF
612/// schema stays compact.
613#[derive(Debug, Clone)]
614struct MajorIsoformRecord {
615    circ_id: String,
616    isoform_id: String,
617    sample_id: String,
618    chr: String,
619    start: i32,
620    end: i32,
621    strand: char,
622    source_gene_id: String,
623    exons: Vec<(i32, i32)>,
624    cov: f64,
625    segment_coverage_pct: f64,
626    path_score: f64,
627    bsj_reads: usize,
628    isoform_len: i32,
629    isoform_rank: usize,
630    isoform_class: String,
631    isoform_origin: String,
632    estimate_reason: String,
633}
634
635/// Per-sample support maps used by major-isoform reconstruction.
636#[derive(Default)]
637struct MajorIsoformSupportBundle {
638    edge_support_by_circ: HashMap<usize, HashMap<MajorEdge, MajorEdgeSupport>>,
639    link_support_by_circ: HashMap<usize, HashMap<MajorJunctionLink, MajorLinkSupport>>,
640    link_exclusion_by_circ: HashMap<usize, HashMap<MajorJunctionLink, MajorLinkSupport>>,
641    span_support_by_circ: HashMap<usize, Vec<MajorAlignedSpan>>,
642    bsj_reads_by_circ: HashMap<usize, Vec<MajorBsjReadObservation>>,
643}
644
645/// Matrix-ready circRNA values parsed from one sample `.out`.
646#[derive(Debug, Clone)]
647struct MajorCircSampleValue {
648    circ: CircRecord,
649    bsj_reads: f64,
650    junction_ratio: f64,
651}
652
653/// Per-sample assembly state retained for cohort-level switching and usage.
654struct MajorSampleAssembly {
655    sample_id: String,
656    circ_index_by_id: HashMap<String, usize>,
657    circ_values: HashMap<String, MajorCircSampleValue>,
658    support: MajorIsoformSupportBundle,
659}
660
661/// Structure key used to merge identical isoforms across samples.
662#[derive(Debug, Clone, Eq, PartialEq, Hash, Ord, PartialOrd)]
663struct MajorIsoformStructureKey {
664    chr: String,
665    start: i32,
666    end: i32,
667    strand: char,
668    exons: Vec<(i32, i32)>,
669}
670
671/// Stable circRNA row metadata used by cohort matrices.
672#[derive(Debug, Clone)]
673struct CohortCircInfo {
674    id: String,
675}
676
677/// Result of converting graph blocks into mature exon intervals.
678struct MajorExonBuild {
679    exons: Vec<(i32, i32)>,
680    projected_long_block: bool,
681    unresolved_long_block: bool,
682    unphased_junction_chain: bool,
683    inferred_internal_block: bool,
684    unphased_single_exon_block: bool,
685}
686
687impl MajorExonBuild {
688    /// Returns whether this isoform is fully supported by segment graph blocks.
689    fn origin(&self) -> &'static str {
690        if self.projected_long_block || self.unresolved_long_block {
691            "estimate"
692        } else if self.unphased_junction_chain
693            || self.inferred_internal_block
694            || self.unphased_single_exon_block
695        {
696            "estimate"
697        } else {
698            "mature"
699        }
700    }
701
702    /// Returns a compact reason string for estimate records.
703    fn estimate_reason(&self) -> String {
704        let mut reasons = Vec::new();
705        if self.projected_long_block {
706            reasons.push("gtf_long_block_projection");
707        }
708        if self.unresolved_long_block {
709            reasons.push("unresolved_long_block");
710        }
711        if self.unphased_junction_chain {
712            reasons.push("unphased_junction_chain");
713        }
714        if self.inferred_internal_block {
715            reasons.push("inferred_internal_block");
716        }
717        if self.unphased_single_exon_block {
718            reasons.push("unphased_single_exon_block");
719        }
720        if reasons.is_empty() {
721            "none".to_string()
722        } else {
723            reasons.join(",")
724        }
725    }
726}
727
728/// Per-block result from annotation projection.
729#[derive(Debug, Clone, Copy, Default)]
730struct MajorBlockBuildStatus {
731    projected: bool,
732    unresolved: bool,
733    inferred: bool,
734    unphased_single_exon: bool,
735}
736
737/// Column indexes required to rebuild isoforms from `<prefix>.segments`.
738///
739/// Header-driven lookup keeps the isoform stage coupled to the public segments
740/// contract instead of to the in-memory `SegmentRecord` layout, which is the
741/// interface needed for later standalone and multi-sample processing.
742#[derive(Debug, Clone, Copy)]
743struct MajorSegmentsColumns {
744    read_id: usize,
745    type_name: usize,
746    circ_id: usize,
747    chrom: usize,
748    start: usize,
749    end: usize,
750    r1_segments: usize,
751    r2_segments: usize,
752}
753
754/// CircRNA locus cluster used to define local splice-repair search windows.
755///
756/// Perl CIRI-AS materializes every covered base into a hash. Rust keeps compact
757/// intervals and performs explicit containment tests, which preserves the same
758/// boundary rule (`start - 70 .. end + 70`) without whole-genome per-base maps.
759#[derive(Debug, Clone)]
760struct CircCluster {
761    chr: String,
762    start: i32,
763    end: i32,
764}
765
766/// Exact Summary circRNA span used for local clip and splice-repair context.
767///
768/// Backward/outward rows are read-level topology evidence and do not require a
769/// known BSJ. When a read is close to known circ loci, these compact spans still
770/// provide bounded windows for annotation and splice-signal guided correction.
771#[derive(Debug, Clone, Copy)]
772struct CircSpan {
773    start: i32,
774    end: i32,
775    max_end_through: i32,
776}
777
778/// Local reference window used to place non-BSJ soft clips.
779///
780/// Backward/outward reads are not assigned to one circRNA at read level, so clip
781/// placement cannot be limited to a unique circ span. These windows stay local
782/// to detected circ loci and the read's own mapped blocks, then the normal
783/// chain ranking chooses whether any pseudo-alignment is biologically coherent.
784#[derive(Debug, Clone, PartialEq, Eq, Hash)]
785struct LocalClipWindow {
786    chr: String,
787    start: i32,
788    end: i32,
789}
790
791/// Parsed result of the CIRI-AS `MSID` CIGAR classifier.
792///
793/// `kind`, `clip1`, `clip2`, and `ref_len` map directly to the four values
794/// returned by the Perl routine. Invalid CIGARs keep a negative `ref_len`, which
795/// lets the caller retain Perl's "skip by sentinel" behavior.
796#[derive(Debug, Clone, Copy)]
797struct Msid {
798    kind: i32,
799    clip1: i32,
800    clip2: i32,
801    ref_len: i32,
802}
803
804/// Candidate splice junction before motif validation and clustering.
805#[derive(Debug, Clone)]
806struct PositiveCandidate {
807    read_id: String,
808    index: usize,
809    chr: String,
810    site1: i32,
811    site2: i32,
812    adjust1: i32,
813    adjust2: i32,
814    strand_hint: i32,
815    cigars: [Option<String>; 3],
816}
817
818/// Runs the post-Summary read-level circRNA segments phase.
819///
820/// The default CLI path consumes Scan1/Scan2 sidecar evidence and filters it by
821/// Summary-confirmed junction reads, which avoids re-scanning BAM/SAM while
822/// preserving CIRI3 `.out` parity. If no sidecar paths are supplied, the older
823/// CIRI-AS-style second sweep remains available for focused development and
824/// tests; full-length cirexon/path reconstruction stays dormant in this module.
825pub fn run_ciri_as(mut config: AsConfig<'_>) -> Result<SegmentRunSummary> {
826    let profile = segments_profile_enabled();
827    let phase_started = profile.then(Instant::now);
828    let (circ_records, junction_read_to_circ) = load_circ_records(config.circ_path)?;
829    log_segments_profile(profile, "load_circ_records", phase_started);
830    if circ_records.is_empty() {
831        bail!("No circRNAs were loaded from {}", config.circ_path);
832    }
833    if junction_read_to_circ.is_empty() {
834        bail!(
835            "No circular junction read IDs were loaded from {}",
836            config.circ_path
837        );
838    }
839
840    let phase_started = profile.then(Instant::now);
841    let mate_bsj_evidence = if let Some(path) = config.bsj_path {
842        load_mate_bsj_evidence(path)?
843    } else {
844        HashMap::new()
845    };
846    log_segments_profile(profile, "load_mate_bsj_evidence", phase_started);
847    let phase_started = profile.then(Instant::now);
848    let clusters = build_circ_clusters(&circ_records);
849    log_segments_profile(profile, "build_circ_clusters", phase_started);
850    let phase_started = profile.then(Instant::now);
851    let (bsj_segment_shard_paths, read_len) = if config.segment_evidence_paths.is_empty() {
852        let read_len = infer_read_length(config.input_path)?;
853        if read_len < 40 {
854            bail!(
855                "CIRI-AS requires paired reads with inferred read length >= 40, got {}",
856                read_len
857            );
858        }
859        (Vec::new(), read_len)
860    } else {
861        let (shard_paths, read_len) = load_segment_evidence_spill(
862            &config.segment_evidence_paths,
863            &junction_read_to_circ,
864            config.out_prefix,
865            config.keep_temp_files,
866        )?;
867        if read_len < 40 {
868            bail!(
869                "segments sidecar read length must be >= 40 for CIRI-AS chain assembly, got {}",
870                read_len
871            );
872        }
873        (shard_paths, read_len)
874    };
875    log_segments_profile(profile, "load_segment_evidence", phase_started);
876    let phase_started = profile.then(Instant::now);
877    let mut state = ScanState {
878        circ_by_id: circ_records
879            .iter()
880            .map(|r| (r.id.clone(), r.clone()))
881            .collect(),
882        junction_read_to_circ,
883        mate_bsj_evidence,
884        reference: config.reference,
885        annotation: config.annotation,
886        circ_spans_by_chr: circ_spans_by_chr(&circ_records),
887        clusters_by_chr: clusters_by_chr(clusters),
888        read_len,
889        min_mapq: config.min_mapq,
890        candidates: Vec::new(),
891        outward_read_ids: HashSet::new(),
892        prebuilt_segment_records: Vec::new(),
893        segment_groups: HashMap::new(),
894    };
895    log_segments_profile(profile, "build_scan_state", phase_started);
896
897    let phase_started = profile.then(Instant::now);
898    let non_bsj_rescan_group_indexes = if config.non_bsj_segment_evidence_paths.is_empty() {
899        None
900    } else {
901        log_segments_user_progress(
902            &mut config,
903            "Scanning exons 1/4",
904            "Determining non-BSJ read topology...",
905        )?;
906        let non_bsj_paths = config.non_bsj_segment_evidence_paths.clone();
907        let out_prefix = config.out_prefix;
908        Some(load_non_bsj_segment_evidence(
909            &non_bsj_paths,
910            out_prefix,
911            &mut state,
912            || {
913                log_segments_user_progress(
914                    &mut config,
915                    "Scanning exons 2/4",
916                    "Preparing non-BSJ segments...",
917                )
918            },
919        )?)
920    };
921    log_segments_profile(profile, "load_non_bsj_segment_evidence", phase_started);
922
923    let phase_started = profile.then(Instant::now);
924    let segments = if config.segment_evidence_paths.is_empty() {
925        scan_alignment_groups(config.input_path, &mut state)?;
926        validate_splice_motifs(&mut state.candidates, config.reference, config.annotation)?;
927        build_segment_records(&state)?
928    } else {
929        let rescan_group_shard_paths = if let Some(paths) = non_bsj_rescan_group_indexes {
930            paths
931        } else {
932            let phase_started = profile.then(Instant::now);
933            let rescan_group_shards =
934                scan_backward_alignment_groups(config.input_path, config.out_prefix, &mut state)?;
935            log_segments_profile(profile, "scan_backward_alignment_groups", phase_started);
936            rescan_group_shards.unwrap_or_default()
937        };
938        log_segments_user_progress(
939            &mut config,
940            "Scanning exons 3/4",
941            "Collecting junction support from retained segments...",
942        )?;
943        let phase_started = profile.then(Instant::now);
944        let keep_temp_files = config.keep_temp_files;
945        let records = build_sidecar_segment_records(
946            &mut state,
947            bsj_segment_shard_paths,
948            rescan_group_shard_paths,
949            keep_temp_files,
950            || {
951                log_segments_user_progress(
952                    &mut config,
953                    "Scanning exons 4/4",
954                    "Correcting ambiguous segments with junction support...",
955                )
956            },
957        )?;
958        log_segments_profile(profile, "build_sidecar_segment_records", phase_started);
959        records
960    };
961    log_segments_profile(profile, "build_segments", phase_started);
962    let summary = segment_run_summary(&segments);
963    drop(state);
964    let phase_started = profile.then(Instant::now);
965    let segments_path = format!("{}.segments", config.out_prefix);
966    write_segments(&segments_path, &segments)?;
967    log_segments_profile(profile, "write_segments", phase_started);
968    drop(segments);
969    Ok(summary)
970}
971
972/// Rebuilds major circRNA isoform sidecars from completed `.out` and `.segments` files.
973///
974/// This is the resumable checkpoint used by `ciri --continue` when
975/// `<prefix>.segments` already exists. It deliberately starts from the public
976/// merged segment file instead of any shard-local temporary files, so isoform
977/// debugging can rerun without depending on partially written parallel state.
978pub fn rebuild_major_isoforms_from_segments(
979    circ_path: &str,
980    segments_path: &str,
981    out_prefix: &str,
982    reference: &HashMap<String, String>,
983    annotation: Option<&Annotation>,
984) -> Result<IsoformRunSummary> {
985    let (circ_records, _junction_read_to_circ) = load_circ_records(circ_path)?;
986    build_major_isoforms_from_segments_file(
987        &circ_records,
988        segments_path,
989        out_prefix,
990        reference,
991        annotation,
992    )
993}
994
995/// Runs cohort isoform assembly from completed second-pass sample prefixes.
996///
997/// Each sample contributes its own `.out` BSJ counts and `.segments` structure
998/// evidence. Unlike single-sample output, cohort assembly first merges all
999/// structural reads into one co-assembly graph, then estimates per-sample usage
1000/// for the resulting high-confidence candidate structures. This keeps sample
1001/// labels out of structure discovery so sample-local annotation projection or
1002/// boundary drift cannot by itself create an isoform-switching event.
1003pub fn run_ciri_assemble(config: CohortAssembleConfig<'_>) -> Result<CohortAssembleSummary> {
1004    if config.samples.is_empty() {
1005        bail!("ciri-assemble requires at least one sample");
1006    }
1007    let mut sample_assemblies = Vec::with_capacity(config.samples.len());
1008    for sample in &config.samples {
1009        let out_path = format!("{}.out", sample.prefix);
1010        let segments_path = format!("{}.segments", sample.prefix);
1011        let (circ_records, circ_values) = load_major_circ_sample_values(&out_path)?;
1012        let circ_index_by_id: HashMap<String, usize> = circ_records
1013            .iter()
1014            .enumerate()
1015            .map(|(idx, circ)| (circ.id.clone(), idx))
1016            .collect();
1017        let support = collect_major_isoform_support(&circ_records, &segments_path)?;
1018        sample_assemblies.push(MajorSampleAssembly {
1019            sample_id: sample.sample_id.clone(),
1020            circ_index_by_id,
1021            circ_values,
1022            support,
1023        });
1024    }
1025
1026    let sample_ids: Vec<String> = sample_assemblies
1027        .iter()
1028        .map(|sample| sample.sample_id.clone())
1029        .collect();
1030    let cohort_circ_records = cohort_circ_records(&sample_assemblies);
1031    let cohort_support = cohort_support_bundle(&cohort_circ_records, &sample_assemblies);
1032    let circ_infos = cohort_circ_infos_from_records(&cohort_circ_records);
1033    write_cohort_bsj_matrix(
1034        &format!("{}.bsj.tsv", config.out_prefix),
1035        &sample_ids,
1036        &circ_infos,
1037        &sample_assemblies,
1038    )?;
1039    write_cohort_ratio_matrix(
1040        &format!("{}.ratio.tsv", config.out_prefix),
1041        &sample_ids,
1042        &circ_infos,
1043        &sample_assemblies,
1044    )?;
1045
1046    let cohort_sample_id = major_sample_id(config.out_prefix);
1047    let mut cohort_isoforms = Vec::new();
1048    let mut switching_rows: Vec<(String, Vec<MajorIsoformRecord>)> = Vec::new();
1049    for (circ_index, circ) in cohort_circ_records.iter().enumerate() {
1050        let mut candidates = select_cohort_isoform_candidates(
1051            circ_index,
1052            circ,
1053            &cohort_support,
1054            &cohort_sample_id,
1055            config.annotation,
1056        );
1057        if candidates.is_empty() {
1058            continue;
1059        }
1060        let candidate_support_by_key =
1061            cohort_candidate_assignment_support_by_key(&candidates, &sample_assemblies, &circ.id);
1062        candidates.sort_by(|a, b| {
1063            let support_b = candidate_support_by_key
1064                .get(&major_isoform_structure_key(b))
1065                .copied()
1066                .unwrap_or(0.0);
1067            let support_a = candidate_support_by_key
1068                .get(&major_isoform_structure_key(a))
1069                .copied()
1070                .unwrap_or(0.0);
1071            support_b
1072                .partial_cmp(&support_a)
1073                .unwrap_or(Ordering::Equal)
1074                .then_with(|| major_isoform_structure_key(a).cmp(&major_isoform_structure_key(b)))
1075        });
1076        let switching = candidates.len() > 1
1077            && cohort_usage_shift_passes(&candidates, &sample_assemblies, &circ.id);
1078        let mut selected_records = if switching {
1079            candidates
1080        } else {
1081            candidates.into_iter().take(1).collect()
1082        };
1083        for (rank, record) in selected_records.iter_mut().enumerate() {
1084            major_set_isoform_identity(record, rank + 1);
1085            record.sample_id = cohort_sample_id.clone();
1086            let total_support = candidate_support_by_key
1087                .get(&major_isoform_structure_key(record))
1088                .copied()
1089                .unwrap_or(0.0);
1090            record.path_score = total_support;
1091            record.cov = total_support;
1092            record.bsj_reads = total_support.round().max(0.0) as usize;
1093        }
1094        if switching {
1095            switching_rows.push((circ.id.clone(), selected_records.clone()));
1096        }
1097        cohort_isoforms.extend(selected_records);
1098    }
1099
1100    cohort_isoforms.sort_by(|a, b| {
1101        a.chr
1102            .cmp(&b.chr)
1103            .then_with(|| a.start.cmp(&b.start))
1104            .then_with(|| a.end.cmp(&b.end))
1105            .then_with(|| a.circ_id.cmp(&b.circ_id))
1106            .then_with(|| a.isoform_rank.cmp(&b.isoform_rank))
1107    });
1108    write_major_isoform_gtf(
1109        &format!("{}.isoforms.gtf", config.out_prefix),
1110        &cohort_isoforms,
1111    )?;
1112    let fasta_summary = write_major_isoform_fasta(
1113        &format!("{}.isoforms.fa", config.out_prefix),
1114        &cohort_isoforms,
1115        config.reference,
1116    )?;
1117    write_cohort_usage_matrix(
1118        &format!("{}.usage.tsv", config.out_prefix),
1119        &sample_ids,
1120        &switching_rows,
1121        &sample_assemblies,
1122    )?;
1123
1124    Ok(CohortAssembleSummary {
1125        samples: sample_ids.len(),
1126        circ_rnas: circ_infos.len(),
1127        isoforms: cohort_isoforms.len(),
1128        switching_circ_rnas: switching_rows.len(),
1129        fasta_isoforms: fasta_summary.isoforms,
1130    })
1131}
1132
1133/// Summarizes final segment row counts by evidence type.
1134fn segment_run_summary(records: &[SegmentRecord]) -> SegmentRunSummary {
1135    let mut summary = SegmentRunSummary {
1136        total_segments: records.len(),
1137        ..SegmentRunSummary::default()
1138    };
1139    for record in records {
1140        match record.type_name {
1141            "bsj" => summary.bsj_segments += 1,
1142            "backward" => summary.backward_segments += 1,
1143            "outward" => summary.outward_segments += 1,
1144            _ => {}
1145        }
1146    }
1147    summary
1148}
1149
1150/// Returns whether segments-phase profiling is enabled.
1151///
1152/// This mirrors the existing `CIRI_PROFILE_SCAN1/2` convention: profiling is
1153/// opt-in, has no effect on normal output, and is scoped to post-Summary
1154/// sidecar reconstruction so CIRI3 parity logs remain stable by default.
1155fn segments_profile_enabled() -> bool {
1156    matches!(std::env::var("CIRI_PROFILE_SEGMENTS"), Ok(v) if !v.is_empty() && v != "0")
1157}
1158
1159/// Prints one segments profiling phase when the opt-in switch is enabled.
1160fn log_segments_profile(enabled: bool, label: &str, started: Option<Instant>) {
1161    if let (true, Some(started)) = (enabled, started) {
1162        eprintln!(
1163            "[CIRI_PROFILE_SEGMENTS] {label}: {:.3}s",
1164            started.elapsed().as_secs_f64()
1165        );
1166    }
1167}
1168
1169/// Emits one user-facing segments progress line through the caller's logger.
1170fn log_segments_user_progress(config: &mut AsConfig<'_>, label: &str, message: &str) -> Result<()> {
1171    if let Some(logger) = config.progress_log.as_deref_mut() {
1172        logger(label, message)?;
1173    }
1174    Ok(())
1175}
1176
1177/// Builds a byte-progress bar for retained segment shard passes.
1178///
1179/// The support and correction passes walk temporary `R/A/C/S` shard streams
1180/// instead of the original BAM. Summing shard byte sizes gives users the same
1181/// progress expectation as Scan1/Scan2 without retaining read-level state just
1182/// for reporting.
1183fn retained_segment_progress_bar(
1184    bsj_shards: &[SegmentScanShardPaths],
1185    non_bsj_shards: &[SegmentScanShardPaths],
1186    message: &'static str,
1187) -> Result<Option<ProgressBar>> {
1188    let total_bytes = retained_segment_shard_bytes(bsj_shards, non_bsj_shards)?;
1189    if total_bytes == 0 {
1190        return Ok(None);
1191    }
1192    let pb = ProgressBar::new(total_bytes);
1193    pb.set_style(
1194        ProgressStyle::default_bar()
1195            .template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} {percent:>3}% ({eta}) {msg}")?
1196            .progress_chars("#>-"),
1197    );
1198    pb.set_message(message);
1199    pb.enable_steady_tick(Duration::from_millis(120));
1200    Ok(Some(pb))
1201}
1202
1203/// Returns the total on-disk size of retained segment shards.
1204fn retained_segment_shard_bytes(
1205    bsj_shards: &[SegmentScanShardPaths],
1206    non_bsj_shards: &[SegmentScanShardPaths],
1207) -> Result<u64> {
1208    bsj_shards
1209        .iter()
1210        .chain(non_bsj_shards)
1211        .try_fold(0_u64, |acc, shard| {
1212            Ok(acc + std::fs::metadata(&shard.path)?.len())
1213        })
1214}
1215
1216/// Mutable state accumulated while scanning the original alignment file.
1217///
1218/// Perl stores these as package globals. Rust keeps them in one struct so later
1219/// BAM parallelization can shard the scan and merge candidate/state fragments
1220/// without changing the CIRI-AS decision routines themselves.
1221struct ScanState<'a> {
1222    circ_by_id: HashMap<String, CircRecord>,
1223    junction_read_to_circ: HashMap<String, String>,
1224    mate_bsj_evidence: HashMap<String, Vec<MateBsjEvidence>>,
1225    reference: &'a HashMap<String, String>,
1226    annotation: Option<&'a Annotation>,
1227    circ_spans_by_chr: HashMap<String, Vec<CircSpan>>,
1228    clusters_by_chr: HashMap<String, Vec<CircCluster>>,
1229    read_len: i32,
1230    min_mapq: i32,
1231    candidates: Vec<PositiveCandidate>,
1232    outward_read_ids: HashSet<String>,
1233    prebuilt_segment_records: Vec<SegmentRecord>,
1234    segment_groups: HashMap<String, Vec<AsAlignment>>,
1235}
1236
1237/// Read-only inputs shared by parallel segments BAM shards.
1238///
1239/// The third-pass segments scan needs the same circRNA indexes in every shard,
1240/// but cloning the full `ScanState` would multiply the already-loaded BSJ
1241/// sidecar evidence. This view keeps shard workers read-only and lets each
1242/// worker return only newly discovered backward/outward evidence.
1243struct SegmentScanContext<'a> {
1244    junction_read_to_circ: &'a HashMap<String, String>,
1245    reference: &'a HashMap<String, String>,
1246    annotation: Option<&'a Annotation>,
1247    circ_spans_by_chr: &'a HashMap<String, Vec<CircSpan>>,
1248    clusters_by_chr: &'a HashMap<String, Vec<CircCluster>>,
1249    read_len: i32,
1250    min_mapq: i32,
1251}
1252
1253/// Shard-local mutable output from the segments BAM rescan.
1254///
1255/// Keeping this separate from `ScanState` makes BGZF sharding deterministic:
1256/// every read group is owned by exactly one shard, and the main thread performs
1257/// the only merge into the final state.
1258#[derive(Default)]
1259struct SegmentScanAccum {
1260    candidates: Vec<PositiveCandidate>,
1261    segment_groups: HashMap<String, Vec<AsAlignment>>,
1262    outward_read_ids: HashSet<String>,
1263    prebuilt_segment_records: Vec<SegmentRecord>,
1264}
1265
1266/// Path for one shard-local segments rescan spill.
1267struct SegmentScanShardPaths {
1268    path: String,
1269}
1270
1271/// One read-complete retained segment shard block.
1272///
1273/// The retained `R/A/C/S` protocol is the scalability boundary for segments
1274/// finalize: each block contains the alignments and local splice hints needed
1275/// to rebuild that read without a global read-id index.
1276struct RetainedSegmentGroup {
1277    read_id: String,
1278    records: Vec<AsAlignment>,
1279    candidates: Vec<PositiveCandidate>,
1280    segment_records: Vec<SegmentRecord>,
1281}
1282
1283/// Parsed Scan1/Scan2 segment-evidence row before shard-local aggregation.
1284///
1285/// BSJ sidecars can be large enough that keeping all parsed rows in a global
1286/// read-keyed map dominates RSS. This owned row is written to hash partitions
1287/// first, then each partition is aggregated independently into the same retained
1288/// `R/A` stream used by non-BSJ finalize.
1289struct SegmentEvidenceRow {
1290    read_id: String,
1291    alignment: AsAlignment,
1292    read_len: i32,
1293}
1294
1295/// Streaming writer for one segments rescan shard.
1296///
1297/// Shards can discover many non-BSJ groups on large BAMs. Writing those groups
1298/// immediately follows the Scan1/Scan2 pattern and prevents parallel workers
1299/// from retaining all candidate evidence in memory until the slowest shard
1300/// finishes.
1301struct SegmentScanShardWriter {
1302    writer: BufWriter<File>,
1303}
1304
1305/// Reference and annotation inputs used only when formatting read-level chains.
1306///
1307/// Keeping this context out of Scan1/Scan2 preserves CIRI3 parity: boundary
1308/// correction changes only the final `<prefix>.segments` representation after a
1309/// read has already been assigned to a confirmed circRNA.
1310struct SegmentCorrectionContext<'a> {
1311    reference: &'a HashMap<String, String>,
1312    annotation: Option<&'a Annotation>,
1313    junction_support: Option<&'a JunctionSupportMap>,
1314}
1315
1316type JunctionSupportKey = (i32, i32, char);
1317type JunctionSupportMap = HashMap<String, HashMap<JunctionSupportKey, usize>>;
1318type JunctionSupportIndex = HashMap<String, HashMap<char, HashMap<i32, Vec<i32>>>>;
1319
1320/// Loads the CIRI3 Summary table and maps junction reads back to circRNAs.
1321///
1322/// CIRI-AS v1.2 used positional columns. This parser is stricter and safer by
1323/// using header names, while preserving the Perl overwrite behavior if one read
1324/// ID appears under more than one circRNA: the later row wins.
1325fn load_circ_records(path: &str) -> Result<(Vec<CircRecord>, HashMap<String, String>)> {
1326    let file = File::open(path).with_context(|| format!("open circ table {}", path))?;
1327    let mut lines = BufReader::new(file).lines();
1328    let header = lines
1329        .next()
1330        .transpose()?
1331        .ok_or_else(|| anyhow!("empty circ table: {}", path))?;
1332    let headers: Vec<&str> = header.split('\t').collect();
1333    let idx = |name: &str| -> Result<usize> {
1334        headers
1335            .iter()
1336            .position(|h| *h == name)
1337            .ok_or_else(|| anyhow!("missing `{}` column in {}", name, path))
1338    };
1339    let id_idx = idx("circRNA_ID")?;
1340    let chr_idx = idx("chr")?;
1341    let start_idx = idx("circRNA_start")?;
1342    let end_idx = idx("circRNA_end")?;
1343    let junc_count_idx = idx("#junction_reads")?;
1344    let gene_idx = idx("gene_id")?;
1345    let strand_idx = idx("strand")?;
1346    let read_ids_idx = idx("junction_reads_ID")?;
1347
1348    let mut records = Vec::new();
1349    let mut read_to_circ = HashMap::new();
1350    for line in lines {
1351        let line = line?;
1352        if line.trim().is_empty() {
1353            continue;
1354        }
1355        let cols: Vec<String> = line.split('\t').map(str::to_string).collect();
1356        let get = |i: usize| -> Result<&str> {
1357            cols.get(i)
1358                .map(String::as_str)
1359                .ok_or_else(|| anyhow!("malformed circ row in {}: {}", path, line))
1360        };
1361        let id = get(id_idx)?.to_string();
1362        let chr = get(chr_idx)?.to_string();
1363        let start = get(start_idx)?.parse::<i32>()?;
1364        let end = get(end_idx)?.parse::<i32>()?;
1365        let junction_reads: Vec<String> = get(read_ids_idx)?
1366            .split(',')
1367            .filter(|s| !s.is_empty())
1368            .map(str::to_string)
1369            .collect();
1370        for read in &junction_reads {
1371            read_to_circ.insert(read.clone(), id.clone());
1372        }
1373        records.push(CircRecord {
1374            id,
1375            chr,
1376            start,
1377            end,
1378            junction_read_count: get(junc_count_idx)?.to_string(),
1379            gene_id: get(gene_idx)?.to_string(),
1380            strand: get(strand_idx)?.to_string(),
1381        });
1382    }
1383    Ok((records, read_to_circ))
1384}
1385
1386/// Loads post-Summary mate-level BSJ evidence from the final `.bsj` display file.
1387///
1388/// New `.bsj` rows start with `read_id, mate_label, priority` before the legacy
1389/// CIRI payload and end with `source_stage`. Historical rows without an explicit
1390/// mate label are ignored here because `<prefix>.segments` needs R1/R2-specific
1391/// evidence; Summary compatibility remains handled by the core pipeline.
1392fn load_mate_bsj_evidence(path: &str) -> Result<HashMap<String, Vec<MateBsjEvidence>>> {
1393    let file = File::open(path).with_context(|| format!("open BSJ evidence {}", path))?;
1394    let reader = BufReader::new(file);
1395    let mut out: HashMap<String, Vec<MateBsjEvidence>> = HashMap::new();
1396    for line_res in reader.lines() {
1397        let line = line_res?;
1398        if line.trim().is_empty() {
1399            continue;
1400        }
1401        let parts: Vec<&str> = line.split('\t').collect();
1402        let payload_start = bsj_payload_start(&parts);
1403        if payload_start != 3 || parts.len() < payload_start + 9 {
1404            continue;
1405        }
1406        let Some(read_id) = parts.first().copied() else {
1407            continue;
1408        };
1409        let mate_label = parts[1];
1410        if !is_bsj_mate_label(mate_label) {
1411            continue;
1412        }
1413        let priority = parts[2].parse::<u8>().unwrap_or(1);
1414        let start = parts[payload_start + 3]
1415            .parse::<i32>()
1416            .with_context(|| format!("parse BSJ start from {}", line))?;
1417        let end = parts[payload_start + 4]
1418            .parse::<i32>()
1419            .with_context(|| format!("parse BSJ end from {}", line))?;
1420        out.entry(read_id.to_string())
1421            .or_default()
1422            .push(MateBsjEvidence {
1423                mate_bucket: if mate_label == "R1" { 0 } else { 1 },
1424                chr: parts[payload_start + 2].to_string(),
1425                start,
1426                end,
1427                strand: parts[payload_start + 5].to_string(),
1428                priority,
1429                source_stage: parts.last().copied().unwrap_or("unknown").to_string(),
1430            });
1431    }
1432    for rows in out.values_mut() {
1433        rows.sort_by(|a, b| {
1434            a.mate_bucket
1435                .cmp(&b.mate_bucket)
1436                .then_with(|| a.priority.cmp(&b.priority))
1437                .then_with(|| a.source_stage.cmp(&b.source_stage))
1438                .then_with(|| a.chr.cmp(&b.chr))
1439                .then_with(|| a.start.cmp(&b.start))
1440                .then_with(|| a.end.cmp(&b.end))
1441        });
1442    }
1443    Ok(out)
1444}
1445
1446/// Spills Scan1/Scan2 mapper-block evidence for Summary-confirmed BSJ reads.
1447///
1448/// The sidecar rows are deliberately compact:
1449/// `read_id, stage, mate, flag, chrom, pos, mapq, cigar, read_len, clips,
1450/// alignment_cs, raw_xa`. Only `read_id` and the mapper fields are mandatory;
1451/// `clips` stores compact `L:<seq>,R:<seq>` soft-clip subsequences for
1452/// traceability, while `alignment_cs` keeps allele bases in short-form cs
1453/// without spilling full read sequences. The optional raw BWA `XA:Z` payload is
1454/// used only for post-Summary circ-context chain selection.
1455/// The important memory boundary is that rows are hash-partitioned by read ID
1456/// first, then each partition is aggregated independently. This mirrors the
1457/// Scan1/Scan2 spill pattern and avoids a whole-run
1458/// `HashMap<read_id, Vec<AsAlignment>>` during segments finalize.
1459fn load_segment_evidence_spill(
1460    paths: &[&str],
1461    confirmed_reads: &HashMap<String, String>,
1462    out_prefix: &str,
1463    keep_temp_files: bool,
1464) -> Result<(Vec<SegmentScanShardPaths>, i32)> {
1465    let partition_count = segment_spill_partition_count();
1466    let raw_paths: Vec<SegmentScanShardPaths> = (0..partition_count)
1467        .map(|idx| SegmentScanShardPaths::new_bsj_raw(out_prefix, idx))
1468        .collect();
1469    let mut writers = raw_paths
1470        .iter()
1471        .map(|paths| {
1472            Ok(BufWriter::with_capacity(
1473                512 * 1024,
1474                File::create(&paths.path)?,
1475            ))
1476        })
1477        .collect::<Result<Vec<_>>>()?;
1478    let mut read_len = 0_i32;
1479    for path in paths {
1480        let file = File::open(path).with_context(|| format!("open segment evidence {}", path))?;
1481        let reader = BufReader::new(file);
1482        for line_res in reader.lines() {
1483            let line = line_res?;
1484            if line.trim().is_empty() {
1485                continue;
1486            }
1487            let Some(row) = parse_segment_evidence_row(&line, confirmed_reads)? else {
1488                continue;
1489            };
1490            read_len = read_len.max(row.read_len);
1491            let idx = read_id_partition(&row.read_id, partition_count);
1492            write_segment_evidence_raw_row(&mut writers[idx], &row)?;
1493        }
1494    }
1495    for writer in &mut writers {
1496        writer.flush()?;
1497    }
1498    drop(writers);
1499
1500    let shard_paths = raw_paths
1501        .par_iter()
1502        .enumerate()
1503        .map(|(idx, raw_path)| {
1504            let retained_path = SegmentScanShardPaths::new_bsj(out_prefix, idx);
1505            aggregate_segment_evidence_partition(&raw_path.path, &retained_path)?;
1506            if !keep_temp_files {
1507                let _ = std::fs::remove_file(&raw_path.path);
1508            }
1509            Ok(retained_path)
1510        })
1511        .collect::<Result<Vec<_>>>()?;
1512    Ok((shard_paths, read_len))
1513}
1514
1515/// Chooses how many bounded BSJ sidecar partitions to use.
1516fn segment_spill_partition_count() -> usize {
1517    rayon::current_num_threads().saturating_mul(4).clamp(1, 256)
1518}
1519
1520/// Returns the stable hash partition for one read ID.
1521fn read_id_partition(read_id: &str, partition_count: usize) -> usize {
1522    let mut hasher = DefaultHasher::new();
1523    read_id.hash(&mut hasher);
1524    (hasher.finish() as usize) % partition_count.max(1)
1525}
1526
1527/// Parses one Scan1/Scan2 segment-evidence line.
1528fn parse_segment_evidence_row(
1529    line: &str,
1530    confirmed_reads: &HashMap<String, String>,
1531) -> Result<Option<SegmentEvidenceRow>> {
1532    let parts: Vec<&str> = line.split('\t').collect();
1533    if parts.len() < 9 {
1534        return Ok(None);
1535    }
1536    let read_id = parts[0];
1537    if !confirmed_reads.contains_key(read_id) {
1538        return Ok(None);
1539    }
1540    let flag = parts[3]
1541        .parse::<i32>()
1542        .with_context(|| format!("parse segment evidence flag from {}", line))?;
1543    let pos = parts[5]
1544        .parse::<i32>()
1545        .with_context(|| format!("parse segment evidence position from {}", line))?;
1546    let mapq = parts[6]
1547        .parse::<i32>()
1548        .with_context(|| format!("parse segment evidence MAPQ from {}", line))?;
1549    let read_len = parts[8]
1550        .parse::<i32>()
1551        .with_context(|| format!("parse segment evidence read length from {}", line))?;
1552    let seq = parts.get(9).copied().unwrap_or("*");
1553    let cs = parts.get(10).copied().unwrap_or("*");
1554    let xa = parts.get(11).copied().unwrap_or("*");
1555    Ok(Some(SegmentEvidenceRow {
1556        read_id: read_id.to_string(),
1557        alignment: AsAlignment {
1558            flag,
1559            chr: parts[4].to_string(),
1560            pos,
1561            mapq,
1562            cigar: parts[7].to_string(),
1563            seq: if seq == "*" {
1564                String::new()
1565            } else {
1566                seq.to_string()
1567            },
1568            cs: cs.to_string(),
1569            from_local_clip: parts[1].contains("_local"),
1570            xa_alternatives: parse_xa_tag(xa),
1571        },
1572        read_len,
1573    }))
1574}
1575
1576/// Writes one parsed BSJ segment-evidence row to its raw hash partition.
1577fn write_segment_evidence_raw_row(
1578    writer: &mut BufWriter<File>,
1579    row: &SegmentEvidenceRow,
1580) -> Result<()> {
1581    writeln!(
1582        writer,
1583        "{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}",
1584        row.read_id,
1585        row.alignment.flag,
1586        row.alignment.chr,
1587        row.alignment.pos,
1588        row.alignment.mapq,
1589        row.alignment.cigar,
1590        if row.alignment.seq.is_empty() {
1591            "*"
1592        } else {
1593            &row.alignment.seq
1594        },
1595        u8::from(row.alignment.from_local_clip),
1596        row.alignment.cs,
1597        format_xa_alternatives(&row.alignment.xa_alternatives),
1598        row.read_len
1599    )?;
1600    Ok(())
1601}
1602
1603/// Aggregates one raw BSJ hash partition into a retained `R/A` shard.
1604fn aggregate_segment_evidence_partition(
1605    raw_path: &str,
1606    retained_path: &SegmentScanShardPaths,
1607) -> Result<()> {
1608    let file = File::open(raw_path)
1609        .with_context(|| format!("open segment evidence partition {raw_path}"))?;
1610    let reader = BufReader::new(file);
1611    let mut groups: HashMap<String, Vec<AsAlignment>> = HashMap::new();
1612    for line_res in reader.lines() {
1613        let line = line_res?;
1614        if line.trim().is_empty() {
1615            continue;
1616        }
1617        let mut parts = line.split('\t');
1618        let Some(read_id) = parts.next() else {
1619            continue;
1620        };
1621        let Some(flag) = parts.next().and_then(|value| value.parse::<i32>().ok()) else {
1622            continue;
1623        };
1624        let Some(chr) = parts.next() else {
1625            continue;
1626        };
1627        let Some(pos) = parts.next().and_then(|value| value.parse::<i32>().ok()) else {
1628            continue;
1629        };
1630        let Some(mapq) = parts.next().and_then(|value| value.parse::<i32>().ok()) else {
1631            continue;
1632        };
1633        let Some(cigar) = parts.next() else {
1634            continue;
1635        };
1636        let Some(seq) = parts.next() else {
1637            continue;
1638        };
1639        let from_local_clip = parts.next().is_some_and(|value| value == "1");
1640        let cs = parts.next().unwrap_or("*");
1641        let xa = parts.next().unwrap_or("*");
1642        groups
1643            .entry(read_id.to_string())
1644            .or_default()
1645            .push(AsAlignment {
1646                flag,
1647                chr: chr.to_string(),
1648                pos,
1649                mapq,
1650                cigar: cigar.to_string(),
1651                seq: if seq == "*" {
1652                    String::new()
1653                } else {
1654                    seq.to_string()
1655                },
1656                cs: cs.to_string(),
1657                from_local_clip,
1658                xa_alternatives: parse_xa_alternatives_field(xa),
1659            });
1660    }
1661    let mut writer = SegmentScanShardWriter::new(retained_path)?;
1662    for (read_id, mut records) in groups {
1663        records.sort_by(|a, b| {
1664            mate_bucket(a.flag)
1665                .cmp(&mate_bucket(b.flag))
1666                .then_with(|| as_alignment_order_key(a).cmp(&as_alignment_order_key(b)))
1667                .then_with(|| a.chr.cmp(&b.chr))
1668                .then_with(|| a.pos.cmp(&b.pos))
1669                .then_with(|| a.cigar.cmp(&b.cigar))
1670        });
1671        // Duplicate mapper rows are local to a read group. Keeping one global
1672        // string key per sidecar row can dominate RSS on large BAMs, so dedupe
1673        // after grouping with a short-lived per-read key set instead.
1674        let mut seen = HashSet::with_capacity(records.len());
1675        records.retain(|record| {
1676            seen.insert((
1677                record.flag,
1678                record.chr.clone(),
1679                record.pos,
1680                record.mapq,
1681                record.cigar.clone(),
1682                record.seq.clone(),
1683                record.cs.clone(),
1684                format_xa_alternatives(&record.xa_alternatives),
1685            ))
1686        });
1687        writer.write_read_result(&read_id, &records, false, &[], &[])?;
1688    }
1689    writer.flush()?;
1690    Ok(())
1691}
1692
1693/// Loads Scan2 read-level non-BSJ topology candidates.
1694///
1695/// These rows are deliberately re-evaluated after Summary instead of trusted as
1696/// final calls. Scan2 only knows read-level topology; this loader skips final
1697/// BSJ reads and then runs the same backward/outward materializer used by the
1698/// legacy third BAM scan. The sequence column is the compact `L:/R:` soft-clip
1699/// payload, matching the BSJ sidecar strategy to keep I/O bounded.
1700fn load_non_bsj_segment_evidence(
1701    paths: &[&str],
1702    out_prefix: &str,
1703    state: &mut ScanState,
1704    mut on_spill_done: impl FnMut() -> Result<()>,
1705) -> Result<Vec<SegmentScanShardPaths>> {
1706    let profile = segments_profile_enabled().then(NonBsjSidecarProfile::default);
1707    let shard_paths = {
1708        let ctx = SegmentScanContext::from_state(state);
1709        let grouped_inputs = paths
1710            .iter()
1711            .map(|path| non_bsj_sidecar_is_grouped(path))
1712            .collect::<Result<Vec<_>>>()?;
1713        if paths.len() > 1 && grouped_inputs.iter().all(|is_grouped| *is_grouped) {
1714            spill_grouped_non_bsj_segment_evidence_shards(
1715                paths,
1716                out_prefix,
1717                0,
1718                &ctx,
1719                profile.as_ref(),
1720            )?
1721        } else {
1722            let mut shard_paths = Vec::new();
1723            for (path, is_grouped) in paths.iter().zip(grouped_inputs) {
1724                if is_grouped {
1725                    let mut paths = spill_grouped_non_bsj_segment_evidence_mmap(
1726                        path,
1727                        out_prefix,
1728                        shard_paths.len(),
1729                        &ctx,
1730                        profile.as_ref(),
1731                    )?;
1732                    shard_paths.append(&mut paths);
1733                } else {
1734                    let mut paths = spill_legacy_non_bsj_segment_evidence(
1735                        path,
1736                        out_prefix,
1737                        shard_paths.len(),
1738                        &ctx,
1739                    )?;
1740                    shard_paths.append(&mut paths);
1741                }
1742            }
1743            shard_paths
1744        }
1745    };
1746    if let Some(profile) = &profile {
1747        profile.report();
1748    }
1749    on_spill_done()?;
1750    Ok(shard_paths)
1751}
1752
1753/// Tests whether a non-BSJ sidecar uses the current read-group line protocol.
1754fn non_bsj_sidecar_is_grouped(path: &str) -> Result<bool> {
1755    let file =
1756        File::open(path).with_context(|| format!("open non-BSJ segment evidence {}", path))?;
1757    let reader = BufReader::new(file);
1758    for line_res in reader.lines() {
1759        let line = line_res?;
1760        if line.trim().is_empty() {
1761            continue;
1762        }
1763        return Ok(line.split('\t').nth(1).is_some_and(is_non_bsj_group_stage));
1764    }
1765    Ok(true)
1766}
1767
1768/// Returns true for current compact and older verbose non-BSJ group stages.
1769fn is_non_bsj_group_stage(stage: &str) -> bool {
1770    stage == "N" || stage == "scan2_non_bsj_group"
1771}
1772
1773/// Loads the current one-line-per-read non-BSJ sidecar with sharded mmap parsing.
1774///
1775/// Whole-genome sidecars can be tens of GiB. Mapping the file keeps memory
1776/// bounded by page cache instead of copying the file into heap memory, while
1777/// newline-aligned shards let read groups be parsed and materialized in parallel.
1778fn spill_grouped_non_bsj_segment_evidence_mmap(
1779    path: &str,
1780    out_prefix: &str,
1781    shard_offset: usize,
1782    ctx: &SegmentScanContext<'_>,
1783    profile: Option<&NonBsjSidecarProfile>,
1784) -> Result<Vec<SegmentScanShardPaths>> {
1785    let file =
1786        File::open(path).with_context(|| format!("open non-BSJ segment evidence {}", path))?;
1787    let mmap = unsafe { Mmap::map(&file)? };
1788    if mmap.is_empty() {
1789        return Ok(Vec::new());
1790    }
1791    unsafe {
1792        libc::madvise(
1793            mmap.as_ptr() as *mut libc::c_void,
1794            mmap.len(),
1795            libc::MADV_SEQUENTIAL,
1796        );
1797    }
1798    let pb = segment_scan_progress_bar(path)?;
1799    pb.set_message("");
1800    let ranges = line_aligned_shard_ranges(&mmap, rayon::current_num_threads().max(1));
1801    let shard_paths = ranges
1802        .into_par_iter()
1803        .enumerate()
1804        .map(|(idx, (start, end))| {
1805            let paths = SegmentScanShardPaths::new(out_prefix, shard_offset + idx);
1806            process_grouped_non_bsj_sidecar_shard(&mmap, start, end, ctx, &pb, &paths, profile)?;
1807            Ok(paths)
1808        })
1809        .collect::<Result<Vec<_>>>()?;
1810    pb.set_position(mmap.len() as u64);
1811
1812    pb.set_style(
1813        ProgressStyle::default_bar()
1814            .template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} {msg}")?
1815            .progress_chars("#>-"),
1816    );
1817    pb.finish_with_message("Completed");
1818    Ok(shard_paths)
1819}
1820
1821/// Spills already sharded grouped non-BSJ sidecars into retained read streams.
1822///
1823/// Scan2 writes one sidecar per BAM shard. Each source shard is already
1824/// read-group complete, so finalize can process one raw shard into one retained
1825/// `R/A/C/S` shard without first merging the raw 10+ GiB sidecar or keeping the
1826/// retained alignment groups in heap memory. The later merge stage loads only
1827/// preliminary `R/C/S` metadata and seeks back to `A` rows for the small
1828/// ambiguous subset that needs support-aware correction.
1829fn spill_grouped_non_bsj_segment_evidence_shards(
1830    paths: &[&str],
1831    out_prefix: &str,
1832    shard_offset: usize,
1833    ctx: &SegmentScanContext<'_>,
1834    profile: Option<&NonBsjSidecarProfile>,
1835) -> Result<Vec<SegmentScanShardPaths>> {
1836    let total_bytes = paths.iter().try_fold(0_u64, |acc, path| {
1837        Ok::<u64, anyhow::Error>(acc + std::fs::metadata(path)?.len())
1838    })?;
1839    let pb = ProgressBar::new(total_bytes);
1840    pb.set_style(
1841        ProgressStyle::default_bar()
1842            .template(
1843                "{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} {percent:>3}% ({eta}) {msg}",
1844            )?
1845            .progress_chars("#>-"),
1846    );
1847    pb.set_message("");
1848
1849    let shard_paths = paths
1850        .par_iter()
1851        .enumerate()
1852        .map(|(idx, path)| {
1853            let output_paths = SegmentScanShardPaths::new(out_prefix, shard_offset + idx);
1854            process_grouped_non_bsj_sidecar_file(path, ctx, &pb, &output_paths, profile)?;
1855            Ok(output_paths)
1856        })
1857        .collect::<Result<Vec<_>>>()?;
1858    pb.set_position(total_bytes);
1859
1860    pb.set_style(
1861        ProgressStyle::default_bar()
1862            .template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} {msg}")?
1863            .progress_chars("#>-"),
1864    );
1865    pb.finish_with_message("Completed");
1866    Ok(shard_paths)
1867}
1868
1869/// Streams one grouped non-BSJ Scan2 sidecar into a retained segment shard.
1870///
1871/// Scan2 already writes these inputs as read-complete shard files. Re-mapping
1872/// every 10+ GiB source shard in parallel can make RSS look like heap growth on
1873/// real BAMs because the kernel charges touched mapped pages to the process.
1874/// A buffered line reader keeps the same one-read-at-a-time semantics while
1875/// bounding resident memory to the reader and writer buffers for each worker.
1876fn process_grouped_non_bsj_sidecar_file(
1877    path: &str,
1878    ctx: &SegmentScanContext<'_>,
1879    pb: &ProgressBar,
1880    paths: &SegmentScanShardPaths,
1881    profile: Option<&NonBsjSidecarProfile>,
1882) -> Result<()> {
1883    let file = File::open(path).with_context(|| format!("open non-BSJ segment evidence {path}"))?;
1884    if file.metadata()?.len() == 0 {
1885        SegmentScanShardWriter::new(paths)?.flush()?;
1886        return Ok(());
1887    }
1888
1889    let mut reader = BufReader::with_capacity(8 * 1024 * 1024, file);
1890    let mut writer = SegmentScanShardWriter::new(paths)?;
1891    let mut line = String::new();
1892    let mut pending_progress = 0usize;
1893    loop {
1894        line.clear();
1895        let bytes = reader.read_line(&mut line)?;
1896        if bytes == 0 {
1897            break;
1898        }
1899        pending_progress += bytes;
1900        let line = line.trim_end_matches(['\n', '\r']);
1901        if !line.is_empty() {
1902            if let Some((read_id, payload)) = non_bsj_group_payload(line, ctx) {
1903                process_borrowed_non_bsj_group_to_writer(
1904                    read_id,
1905                    payload,
1906                    line,
1907                    ctx,
1908                    &mut writer,
1909                    profile,
1910                )?;
1911            }
1912        }
1913        if pending_progress >= 4 * 1024 * 1024 {
1914            pb.inc(pending_progress as u64);
1915            pending_progress = 0;
1916        }
1917    }
1918    if pending_progress > 0 {
1919        pb.inc(pending_progress as u64);
1920    }
1921    writer.flush()?;
1922    Ok(())
1923}
1924
1925/// Splits a line-oriented mmap into non-overlapping newline-aligned ranges.
1926fn line_aligned_shard_ranges(mmap: &Mmap, wanted_shards: usize) -> Vec<(usize, usize)> {
1927    let shard_count = wanted_shards.max(1).min(mmap.len().max(1));
1928    let mut starts = Vec::with_capacity(shard_count + 1);
1929    starts.push(0);
1930    for idx in 1..shard_count {
1931        let raw = idx * mmap.len() / shard_count;
1932        starts.push(next_line_start(mmap, raw));
1933    }
1934    starts.push(mmap.len());
1935    starts.dedup();
1936    starts
1937        .windows(2)
1938        .filter_map(|pair| (pair[0] < pair[1]).then_some((pair[0], pair[1])))
1939        .collect()
1940}
1941
1942/// Returns the first byte after the line containing `pos`.
1943fn next_line_start(mmap: &Mmap, pos: usize) -> usize {
1944    if pos == 0 || pos >= mmap.len() {
1945        return pos.min(mmap.len());
1946    }
1947    if mmap[pos - 1] == b'\n' {
1948        return pos;
1949    }
1950    let mut idx = pos;
1951    while idx < mmap.len() {
1952        if mmap[idx] == b'\n' {
1953            return idx + 1;
1954        }
1955        idx += 1;
1956    }
1957    mmap.len()
1958}
1959
1960#[derive(Default)]
1961struct NonBsjSidecarProfile {
1962    groups: AtomicU64,
1963    parsed_alignments: AtomicU64,
1964    topology_groups: AtomicU64,
1965    backward_shape_groups: AtomicU64,
1966    backward_chain_shape_groups: AtomicU64,
1967    outward_groups: AtomicU64,
1968    raw_candidate_groups: AtomicU64,
1969    raw_candidate_count: AtomicU64,
1970    retained_groups: AtomicU64,
1971    validated_candidate_groups: AtomicU64,
1972    validated_candidate_count: AtomicU64,
1973    materialized_groups: AtomicU64,
1974    materialized_alignments: AtomicU64,
1975    drop_no_route: AtomicU64,
1976    drop_no_raw_candidate: AtomicU64,
1977    drop_no_valid_candidate: AtomicU64,
1978    drop_no_segment_record: AtomicU64,
1979    backward_attempt_groups: AtomicU64,
1980    outward_attempt_groups: AtomicU64,
1981    backward_retained_groups: AtomicU64,
1982    outward_retained_groups: AtomicU64,
1983}
1984
1985impl NonBsjSidecarProfile {
1986    fn add(&self, counter: &AtomicU64, value: u64) {
1987        counter.fetch_add(value, AtomicOrdering::Relaxed);
1988    }
1989
1990    fn report(&self) {
1991        eprintln!(
1992            "[CIRI_PROFILE_SEGMENTS] non_bsj_sidecar_groups: groups={} parsed_alignments={} topology={} backward_shape={} outward={} raw_candidate_groups={} raw_candidate_count={} validated_candidate_groups={} validated_candidate_count={} materialized_groups={} retained={} materialized_alignments={}",
1993            self.groups.load(AtomicOrdering::Relaxed),
1994            self.parsed_alignments.load(AtomicOrdering::Relaxed),
1995            self.topology_groups.load(AtomicOrdering::Relaxed),
1996            self.backward_shape_groups.load(AtomicOrdering::Relaxed),
1997            self.outward_groups.load(AtomicOrdering::Relaxed),
1998            self.raw_candidate_groups.load(AtomicOrdering::Relaxed),
1999            self.raw_candidate_count.load(AtomicOrdering::Relaxed),
2000            self.validated_candidate_groups.load(AtomicOrdering::Relaxed),
2001            self.validated_candidate_count.load(AtomicOrdering::Relaxed),
2002            self.materialized_groups.load(AtomicOrdering::Relaxed),
2003            self.retained_groups.load(AtomicOrdering::Relaxed),
2004            self.materialized_alignments.load(AtomicOrdering::Relaxed),
2005        );
2006        eprintln!(
2007            "[CIRI_PROFILE_SEGMENTS] non_bsj_sidecar_routes: backward_chain_shape={} backward_attempt={} outward_attempt={} backward_retained={} outward_retained={} drop_no_route={} drop_no_raw_candidate={} drop_no_valid_candidate={} drop_no_segment_record={}",
2008            self.backward_chain_shape_groups.load(AtomicOrdering::Relaxed),
2009            self.backward_attempt_groups.load(AtomicOrdering::Relaxed),
2010            self.outward_attempt_groups.load(AtomicOrdering::Relaxed),
2011            self.backward_retained_groups.load(AtomicOrdering::Relaxed),
2012            self.outward_retained_groups.load(AtomicOrdering::Relaxed),
2013            self.drop_no_route.load(AtomicOrdering::Relaxed),
2014            self.drop_no_raw_candidate.load(AtomicOrdering::Relaxed),
2015            self.drop_no_valid_candidate.load(AtomicOrdering::Relaxed),
2016            self.drop_no_segment_record.load(AtomicOrdering::Relaxed),
2017        );
2018    }
2019}
2020
2021/// Processes one mmap shard of grouped non-BSJ read evidence.
2022fn process_grouped_non_bsj_sidecar_shard(
2023    mmap: &Mmap,
2024    start: usize,
2025    end: usize,
2026    ctx: &SegmentScanContext<'_>,
2027    pb: &ProgressBar,
2028    paths: &SegmentScanShardPaths,
2029    profile: Option<&NonBsjSidecarProfile>,
2030) -> Result<()> {
2031    let mut writer = SegmentScanShardWriter::new(paths)?;
2032    let mut pos = start;
2033    let mut last_progress = start;
2034    while pos < end {
2035        let line_start = pos;
2036        while pos < end && mmap[pos] != b'\n' {
2037            pos += 1;
2038        }
2039        let mut line_end = pos;
2040        if line_end > line_start && mmap[line_end - 1] == b'\r' {
2041            line_end -= 1;
2042        }
2043        if pos < end {
2044            pos += 1;
2045        }
2046        if line_end > line_start {
2047            let line = std::str::from_utf8(&mmap[line_start..line_end]).with_context(|| {
2048                format!("parse UTF-8 non-BSJ sidecar line at byte {line_start}")
2049            })?;
2050            if let Some((read_id, payload)) = non_bsj_group_payload(line, ctx) {
2051                process_borrowed_non_bsj_group_to_writer(
2052                    read_id,
2053                    payload,
2054                    line,
2055                    ctx,
2056                    &mut writer,
2057                    profile,
2058                )?;
2059            }
2060        }
2061        if pos.saturating_sub(last_progress) >= 4 * 1024 * 1024 {
2062            pb.inc((pos - last_progress) as u64);
2063            last_progress = pos;
2064        }
2065    }
2066    if end > last_progress {
2067        pb.inc((end - last_progress) as u64);
2068    }
2069    writer.flush()?;
2070    advise_segment_mmap_dontneed(mmap, start, end - start);
2071    Ok(())
2072}
2073
2074/// Returns the read ID and encoded payload for a current non-BSJ group line.
2075///
2076/// The grouped sidecar can contain tens of GiB on real data. Separating this
2077/// lightweight header parse from full alignment materialization lets the hot
2078/// path reject ordinary linear read groups before allocating owned `String`
2079/// fields for every alignment.
2080fn non_bsj_group_payload<'a>(
2081    line: &'a str,
2082    ctx: &SegmentScanContext<'_>,
2083) -> Option<(&'a str, &'a str)> {
2084    let mut parts = line.splitn(3, '\t');
2085    let read_id = parts.next()?;
2086    let stage = parts.next()?;
2087    let payload = parts.next()?;
2088    if !is_non_bsj_group_stage(stage) || ctx.junction_read_to_circ.contains_key(read_id) {
2089        return None;
2090    }
2091    Some((read_id, payload))
2092}
2093
2094/// Borrowed alignment fields used by the non-BSJ group retention gate.
2095///
2096/// This mirrors the compact sidecar payload without taking ownership. Stage 2
2097/// keeps topology and motif checks on these borrowed fields and only converts
2098/// them into owned `AsAlignment` records after a group is known to be retained.
2099#[derive(Clone, Copy)]
2100struct NonBsjAlignmentView<'a> {
2101    flag: i32,
2102    chr: &'a str,
2103    pos: i32,
2104    mapq: i32,
2105    cigar: &'a str,
2106    seq: &'a str,
2107    cs: &'a str,
2108}
2109
2110/// Parses compact non-BSJ alignment fields as borrowed views.
2111fn parse_non_bsj_group_views<'a>(
2112    payload: &'a str,
2113    line: &str,
2114) -> Result<Vec<NonBsjAlignmentView<'a>>> {
2115    let mut records = Vec::new();
2116    let mut seen = HashSet::new();
2117    for encoded in payload.split(';') {
2118        if encoded.is_empty() {
2119            continue;
2120        }
2121        let mut fields = [None; 9];
2122        let mut field_count = 0usize;
2123        for field in encoded.split('|') {
2124            if field_count < fields.len() {
2125                fields[field_count] = Some(field);
2126            }
2127            field_count += 1;
2128            if field_count >= fields.len() {
2129                break;
2130            }
2131        }
2132        let (flag_idx, chrom_idx, pos_idx, mapq_idx, cigar_idx, seq_idx, cs_idx) = match field_count
2133        {
2134            0..=5 => continue,
2135            6 => (0, 1, 2, 3, 4, 5, None),
2136            7 => (0, 1, 2, 3, 4, 5, Some(6)),
2137            _ => (1, 2, 3, 4, 5, 7, None),
2138        };
2139        let field = |idx: usize| fields[idx].expect("validated non-BSJ sidecar field");
2140        let chr = field(chrom_idx);
2141        let cigar = field(cigar_idx);
2142        let seq = field(seq_idx);
2143        let cs = cs_idx
2144            .and_then(|idx| fields[idx])
2145            .filter(|value| !value.is_empty())
2146            .unwrap_or("*");
2147        let flag = field(flag_idx)
2148            .parse::<i32>()
2149            .with_context(|| format!("parse non-BSJ grouped flag from {}", line))?;
2150        let pos = field(pos_idx)
2151            .parse::<i32>()
2152            .with_context(|| format!("parse non-BSJ grouped position from {}", line))?;
2153        let mapq = field(mapq_idx)
2154            .parse::<i32>()
2155            .with_context(|| format!("parse non-BSJ grouped MAPQ from {}", line))?;
2156        let key = (flag, chr, pos, mapq, cigar, seq, cs);
2157        if !seen.insert(key) {
2158            continue;
2159        }
2160        records.push(NonBsjAlignmentView {
2161            flag,
2162            chr,
2163            pos,
2164            mapq,
2165            cigar,
2166            seq,
2167            cs,
2168        });
2169    }
2170    Ok(records)
2171}
2172
2173/// Streams one borrowed non-BSJ group into retained shard rows when it survives.
2174///
2175/// The compact Scan2 sidecar is large enough that parsing it twice dominates
2176/// `Scanning exons 1/4` on real data. This path performs all cheap topology and
2177/// splice-candidate checks on borrowed fields, and only allocates owned
2178/// `AsAlignment` records for groups that will actually be retained.
2179fn process_borrowed_non_bsj_group_to_writer(
2180    read_id: &str,
2181    payload: &str,
2182    line: &str,
2183    ctx: &SegmentScanContext<'_>,
2184    writer: &mut SegmentScanShardWriter,
2185    profile: Option<&NonBsjSidecarProfile>,
2186) -> Result<()> {
2187    if let Some(profile) = profile {
2188        profile.add(&profile.groups, 1);
2189    }
2190    let views = parse_non_bsj_group_views(payload, line)?;
2191    if let Some(profile) = profile {
2192        profile.add(&profile.parsed_alignments, views.len() as u64);
2193    }
2194    if views.is_empty() {
2195        return Ok(());
2196    }
2197    let is_outward = is_outward_pair_group_view(&views, ctx);
2198    let may_backward = may_have_backward_candidate_shape_view(&views);
2199    let may_backward_chain =
2200        may_backward && borrowed_group_has_backward_chain_topology(&views, ctx.read_len);
2201    if !is_outward && !may_backward_chain {
2202        if let Some(profile) = profile {
2203            profile.add(&profile.drop_no_route, 1);
2204        }
2205        return Ok(());
2206    }
2207    if let Some(profile) = profile {
2208        profile.add(&profile.topology_groups, 1);
2209        if may_backward {
2210            profile.add(&profile.backward_shape_groups, 1);
2211        }
2212        if may_backward_chain {
2213            profile.add(&profile.backward_chain_shape_groups, 1);
2214        }
2215        if is_outward {
2216            profile.add(&profile.outward_groups, 1);
2217        }
2218    }
2219
2220    // Outward groups keep the old backward-candidate check so a possible
2221    // backward interpretation still wins the same priority as before. The new
2222    // chain-shape gate is used only to drop non-outward groups that cannot form
2223    // a circular read-chain topology.
2224    let candidates = if may_backward_chain || (is_outward && may_backward) {
2225        mapping_check1_backward_candidate_views(read_id, &views, ctx)
2226    } else {
2227        Vec::new()
2228    };
2229    if let Some(profile) = profile {
2230        if !candidates.is_empty() {
2231            profile.add(&profile.raw_candidate_groups, 1);
2232            profile.add(&profile.raw_candidate_count, candidates.len() as u64);
2233        } else if (may_backward_chain || is_outward) && !is_outward {
2234            profile.add(&profile.drop_no_raw_candidate, 1);
2235        }
2236    }
2237    let candidates = validate_splice_candidates(candidates, ctx.reference, ctx.annotation);
2238    if let Some(profile) = profile {
2239        if !candidates.is_empty() {
2240            profile.add(&profile.validated_candidate_groups, 1);
2241            profile.add(&profile.validated_candidate_count, candidates.len() as u64);
2242        } else if (may_backward_chain || is_outward) && !is_outward {
2243            profile.add(&profile.drop_no_valid_candidate, 1);
2244        }
2245    }
2246    if !is_outward && candidates.is_empty() {
2247        return Ok(());
2248    }
2249
2250    let records = materialize_non_bsj_group_records(&views);
2251    if let Some(profile) = profile {
2252        profile.add(&profile.materialized_groups, 1);
2253        if !candidates.is_empty() {
2254            profile.add(&profile.backward_attempt_groups, 1);
2255        }
2256        if is_outward {
2257            profile.add(&profile.outward_attempt_groups, 1);
2258        }
2259    }
2260    let (segment_records, enriched_records) =
2261        prebuild_non_bsj_segment_records(read_id, &records, is_outward, &candidates, ctx);
2262    if segment_records.is_empty() {
2263        if let Some(profile) = profile {
2264            profile.add(&profile.drop_no_segment_record, 1);
2265        }
2266        return Ok(());
2267    }
2268    if let Some(profile) = profile {
2269        profile.add(&profile.retained_groups, 1);
2270        profile.add(&profile.materialized_alignments, records.len() as u64);
2271        if segment_records
2272            .iter()
2273            .any(|record| record.type_name == "backward")
2274        {
2275            profile.add(&profile.backward_retained_groups, 1);
2276        }
2277        if segment_records
2278            .iter()
2279            .any(|record| record.type_name == "outward")
2280        {
2281            profile.add(&profile.outward_retained_groups, 1);
2282        }
2283    }
2284    if is_outward {
2285        let retained_records = enriched_records.as_deref().unwrap_or(&records);
2286        writer.write_read_result(
2287            read_id,
2288            retained_records,
2289            true,
2290            &candidates,
2291            &segment_records,
2292        )?;
2293    } else {
2294        writer.write_read_result(read_id, &records, false, &candidates, &segment_records)?;
2295    }
2296    Ok(())
2297}
2298
2299/// Materializes sidecar views only after the group is known to be retained.
2300fn materialize_non_bsj_group_records(views: &[NonBsjAlignmentView<'_>]) -> Vec<AsAlignment> {
2301    views
2302        .iter()
2303        .map(|view| AsAlignment {
2304            flag: view.flag,
2305            chr: view.chr.to_string(),
2306            pos: view.pos,
2307            mapq: view.mapq,
2308            cigar: view.cigar.to_string(),
2309            seq: if view.seq == "*" {
2310                String::new()
2311            } else {
2312                view.seq.to_string()
2313            },
2314            cs: view.cs.to_string(),
2315            from_local_clip: false,
2316            xa_alternatives: Vec::new(),
2317        })
2318        .collect()
2319}
2320
2321struct BorrowedChainBlock {
2322    read_start: i32,
2323    read_end: i32,
2324    ref_start: i32,
2325}
2326
2327struct BorrowedParsedAlignment<'a> {
2328    flag: i32,
2329    chr: &'a str,
2330    strand: char,
2331    mapq: i32,
2332    blocks: Vec<BorrowedChainBlock>,
2333}
2334
2335/// Tests the final backward-chain topology condition on borrowed CIGAR blocks.
2336///
2337/// This mirrors the inexpensive parts of `build_pair_chains` before allocating
2338/// owned `AsAlignment` records. Non-outward groups that fail this route check
2339/// cannot produce a backward `SegmentRecord`, while outward groups remain on
2340/// the legacy backward-candidate path to preserve backward-over-outward
2341/// priority when both interpretations are possible.
2342fn borrowed_group_has_backward_chain_topology(
2343    records: &[NonBsjAlignmentView<'_>],
2344    read_len: i32,
2345) -> bool {
2346    let parsed = records
2347        .iter()
2348        .filter_map(|record| {
2349            let blocks = parse_borrowed_chain_blocks(record, read_len)?;
2350            Some(BorrowedParsedAlignment {
2351                flag: record.flag,
2352                chr: record.chr,
2353                strand: strand_char(record.flag),
2354                mapq: record.mapq,
2355                blocks,
2356            })
2357        })
2358        .collect::<Vec<_>>();
2359    for mate in [0usize, 1usize] {
2360        let reverse_chain_order = mate == 1;
2361        for record in parsed
2362            .iter()
2363            .filter(|record| mate_bucket(record.flag) == mate)
2364        {
2365            if borrowed_pool_has_backward_wrap(
2366                &parsed,
2367                mate,
2368                record.chr,
2369                record.strand,
2370                false,
2371                reverse_chain_order,
2372            ) || borrowed_pool_has_backward_wrap(
2373                &parsed,
2374                mate,
2375                record.chr,
2376                record.strand,
2377                true,
2378                reverse_chain_order,
2379            ) {
2380                return true;
2381            }
2382        }
2383    }
2384    false
2385}
2386
2387fn borrowed_pool_has_backward_wrap(
2388    parsed: &[BorrowedParsedAlignment<'_>],
2389    mate: usize,
2390    chr: &str,
2391    strand: char,
2392    include_secondary: bool,
2393    reverse_chain_order: bool,
2394) -> bool {
2395    let mut group = parsed
2396        .iter()
2397        .filter(|record| {
2398            mate_bucket(record.flag) == mate
2399                && record.chr == chr
2400                && record.strand == strand
2401                && (include_secondary || !is_secondary(record.flag))
2402        })
2403        .collect::<Vec<_>>();
2404    if group.is_empty() {
2405        return false;
2406    }
2407    group.sort_by_key(|record| {
2408        (
2409            record
2410                .blocks
2411                .first()
2412                .map(|block| block.read_start)
2413                .unwrap_or(i32::MAX),
2414            usize::from(is_secondary(record.flag)),
2415            usize::from(is_supplementary(record.flag)),
2416            -record.mapq,
2417        )
2418    });
2419    let mut selected: Vec<&BorrowedParsedAlignment<'_>> = Vec::new();
2420    for record in group {
2421        if let Some(last) = selected.last_mut() {
2422            if borrowed_query_overlap(last, record) > 6 {
2423                if borrowed_parsed_alignment_rank(record) > borrowed_parsed_alignment_rank(last) {
2424                    *last = record;
2425                }
2426                continue;
2427            }
2428        }
2429        selected.push(record);
2430    }
2431    let mut blocks = selected
2432        .iter()
2433        .flat_map(|record| record.blocks.iter())
2434        .collect::<Vec<_>>();
2435    blocks.sort_by_key(|block| (block.read_start, block.read_end, block.ref_start));
2436    let order_strand = if reverse_chain_order {
2437        blocks.reverse();
2438        opposite_strand(strand)
2439    } else {
2440        strand
2441    };
2442    blocks
2443        .windows(2)
2444        .any(|pair| borrowed_wraps_in_read_order(pair[0], pair[1], order_strand))
2445}
2446
2447fn parse_borrowed_chain_blocks(
2448    record: &NonBsjAlignmentView<'_>,
2449    read_len: i32,
2450) -> Option<Vec<BorrowedChainBlock>> {
2451    if record.cigar == "*" || record.cigar.is_empty() || record.chr == "*" {
2452        return None;
2453    }
2454    let mut ref_pos = record.pos;
2455    let mut read_pos = 1;
2456    let mut blocks = Vec::new();
2457    let mut current: Option<BorrowedChainBlock> = None;
2458    let mut count = 0i32;
2459    let mut has_count = false;
2460    for mut op in record.cigar.chars() {
2461        if op.is_ascii_digit() {
2462            count = count
2463                .checked_mul(10)?
2464                .checked_add(op.to_digit(10)? as i32)?;
2465            has_count = true;
2466            continue;
2467        }
2468        if !has_count {
2469            return None;
2470        }
2471        if op == 'H' {
2472            op = 'S';
2473        }
2474        match op {
2475            'M' | '=' | 'X' => {
2476                let block = current.get_or_insert(BorrowedChainBlock {
2477                    read_start: read_pos,
2478                    read_end: read_pos + count - 1,
2479                    ref_start: ref_pos,
2480                });
2481                block.read_end = read_pos + count - 1;
2482                read_pos += count;
2483                ref_pos += count;
2484            }
2485            'I' => {
2486                if let Some(block) = current.as_mut() {
2487                    block.read_end += count;
2488                }
2489                read_pos += count;
2490            }
2491            'D' => {
2492                ref_pos += count;
2493            }
2494            'N' => {
2495                if let Some(block) = current.take() {
2496                    blocks.push(block);
2497                }
2498                ref_pos += count;
2499            }
2500            'S' | 'H' => {
2501                if let Some(block) = current.take() {
2502                    blocks.push(block);
2503                }
2504                read_pos += count;
2505            }
2506            'P' => {}
2507            _ => return None,
2508        }
2509        count = 0;
2510        has_count = false;
2511    }
2512    if has_count {
2513        return None;
2514    }
2515    if let Some(block) = current.take() {
2516        blocks.push(block);
2517    }
2518    if blocks.is_empty() {
2519        return None;
2520    }
2521    if record.flag & 0x10 != 0 {
2522        for block in &mut blocks {
2523            let flipped_start = read_len - block.read_end + 1;
2524            let flipped_end = read_len - block.read_start + 1;
2525            block.read_start = flipped_start;
2526            block.read_end = flipped_end;
2527        }
2528        blocks.reverse();
2529    }
2530    Some(blocks)
2531}
2532
2533fn borrowed_query_overlap(
2534    left: &BorrowedParsedAlignment<'_>,
2535    right: &BorrowedParsedAlignment<'_>,
2536) -> i32 {
2537    let left_start = left
2538        .blocks
2539        .first()
2540        .map(|block| block.read_start)
2541        .unwrap_or(0);
2542    let left_end = left.blocks.last().map(|block| block.read_end).unwrap_or(0);
2543    let right_start = right
2544        .blocks
2545        .first()
2546        .map(|block| block.read_start)
2547        .unwrap_or(0);
2548    let right_end = right.blocks.last().map(|block| block.read_end).unwrap_or(0);
2549    (left_end.min(right_end) - left_start.max(right_start) + 1).max(0)
2550}
2551
2552fn borrowed_parsed_alignment_rank(record: &BorrowedParsedAlignment<'_>) -> (i32, i32, i32, i32) {
2553    (
2554        i32::from(!is_secondary(record.flag)),
2555        i32::from(!is_supplementary(record.flag)),
2556        record.mapq,
2557        record
2558            .blocks
2559            .iter()
2560            .map(|block| block.read_end - block.read_start + 1)
2561            .sum(),
2562    )
2563}
2564
2565fn borrowed_wraps_in_read_order(
2566    prev: &BorrowedChainBlock,
2567    next: &BorrowedChainBlock,
2568    strand: char,
2569) -> bool {
2570    match strand {
2571        '+' => next.ref_start < prev.ref_start,
2572        '-' => next.ref_start > prev.ref_start,
2573        _ => false,
2574    }
2575}
2576
2577/// Borrowed-form equivalent of the backward candidate shape prefilter.
2578fn may_have_backward_candidate_shape_view(records: &[NonBsjAlignmentView<'_>]) -> bool {
2579    let mut buckets: HashMap<(&str, i32), (usize, bool)> = HashMap::new();
2580    for record in records {
2581        if record.chr == "*" || record.cigar == "*" {
2582            continue;
2583        }
2584        let key = (record.chr, reverse_bit(record.flag));
2585        let entry = buckets.entry(key).or_insert((0, false));
2586        entry.0 += 1;
2587        entry.1 |= record.cigar.contains('S') || record.cigar.contains('H');
2588        if entry.0 >= 2 && entry.1 {
2589            return true;
2590        }
2591    }
2592    false
2593}
2594
2595/// Borrowed-form equivalent of outward pair detection for the prefilter.
2596fn is_outward_pair_group_view(
2597    records: &[NonBsjAlignmentView<'_>],
2598    ctx: &SegmentScanContext<'_>,
2599) -> bool {
2600    let Some((r1, r2)) = primary_mate_pair_view(records) else {
2601        return false;
2602    };
2603    if r1.chr != r2.chr || r1.chr == "*" || r1.mapq < ctx.min_mapq || r2.mapq < ctx.min_mapq {
2604        return false;
2605    }
2606    if has_linear_mate_pair_mapping_view(records, ctx) {
2607        return false;
2608    }
2609    let Some(r1_span) = alignment_ref_span_view(r1) else {
2610        return false;
2611    };
2612    let Some(r2_span) = alignment_ref_span_view(r2) else {
2613        return false;
2614    };
2615    if !has_3p_outward_pair_geometry_view(r1, r1_span, r2, r2_span) {
2616        return false;
2617    }
2618    let span_start = r1_span.0.min(r2_span.0);
2619    let span_end = r1_span.1.max(r2_span.1);
2620    span_end - span_start + 1 <= BACKWARD_MAX_SPAN
2621}
2622
2623/// Borrowed-form equivalent of the linear mate-pair negative filter.
2624fn has_linear_mate_pair_mapping_view(
2625    records: &[NonBsjAlignmentView<'_>],
2626    ctx: &SegmentScanContext<'_>,
2627) -> bool {
2628    for i in 0..records.len() {
2629        let left = &records[i];
2630        if left.flag & 0x4 != 0 || left.chr == "*" || left.mapq < ctx.min_mapq {
2631            continue;
2632        }
2633        for right in &records[i + 1..] {
2634            if right.flag & 0x4 != 0
2635                || right.chr == "*"
2636                || right.mapq < ctx.min_mapq
2637                || left.chr != right.chr
2638                || mate_bucket(left.flag) == mate_bucket(right.flag)
2639            {
2640                continue;
2641            }
2642            let Some(left_span) = alignment_ref_span_view(left) else {
2643                continue;
2644            };
2645            let Some(right_span) = alignment_ref_span_view(right) else {
2646                continue;
2647            };
2648            let span = left_span.0.min(right_span.0)..=left_span.1.max(right_span.1);
2649            if *span.end() - *span.start() + 1 <= BACKWARD_MAX_SPAN
2650                && has_linear_pair_geometry_view(left, left_span, right, right_span)
2651            {
2652                return true;
2653            }
2654        }
2655    }
2656    false
2657}
2658
2659/// Borrowed-form equivalent of ordinary linear mate orientation.
2660fn has_linear_pair_geometry_view(
2661    left: &NonBsjAlignmentView<'_>,
2662    left_span: (i32, i32),
2663    right: &NonBsjAlignmentView<'_>,
2664    right_span: (i32, i32),
2665) -> bool {
2666    let left_reverse = is_reverse_strand(left.flag);
2667    let right_reverse = is_reverse_strand(right.flag);
2668    if left_reverse == right_reverse {
2669        return false;
2670    }
2671    let (forward_span, reverse_span) = if left_reverse {
2672        (right_span, left_span)
2673    } else {
2674        (left_span, right_span)
2675    };
2676    forward_span != reverse_span
2677        && forward_span.0 <= reverse_span.0
2678        && forward_span.1 <= reverse_span.1
2679}
2680
2681/// Borrowed-form equivalent of broad outward-facing geometry.
2682fn has_3p_outward_pair_geometry_view(
2683    r1: &NonBsjAlignmentView<'_>,
2684    r1_span: (i32, i32),
2685    r2: &NonBsjAlignmentView<'_>,
2686    r2_span: (i32, i32),
2687) -> bool {
2688    let r1_reverse = is_reverse_strand(r1.flag);
2689    let r2_reverse = is_reverse_strand(r2.flag);
2690    if r1_reverse == r2_reverse {
2691        return false;
2692    }
2693    let (reverse_span, forward_span) = if r1_reverse {
2694        (r1_span, r2_span)
2695    } else {
2696        (r2_span, r1_span)
2697    };
2698    let (reverse_record, forward_record) = if r1_reverse { (r1, r2) } else { (r2, r1) };
2699    if reverse_span == forward_span {
2700        return has_paired_outward_terminal_clip_view(reverse_record, forward_record);
2701    }
2702    forward_span.0 - reverse_span.0 >= OUTWARD_MIN_PAIR_OFFSET
2703        && forward_span.1 - reverse_span.1 >= OUTWARD_MIN_PAIR_OFFSET
2704}
2705
2706/// Borrowed-form equivalent of paired outward terminal clip evidence.
2707fn has_paired_outward_terminal_clip_view(
2708    reverse_record: &NonBsjAlignmentView<'_>,
2709    forward_record: &NonBsjAlignmentView<'_>,
2710) -> bool {
2711    let Some((reverse_5p, reverse_3p)) = terminal_query_clips_cigar(reverse_record.cigar) else {
2712        return false;
2713    };
2714    let Some((forward_5p, forward_3p)) = terminal_query_clips_cigar(forward_record.cigar) else {
2715        return false;
2716    };
2717    reverse_3p >= OUTWARD_MIN_TERMINAL_CLIP
2718        && forward_3p >= OUTWARD_MIN_TERMINAL_CLIP
2719        && reverse_3p - forward_5p >= OUTWARD_MIN_TERMINAL_CLIP
2720        && forward_3p - reverse_5p >= OUTWARD_MIN_TERMINAL_CLIP
2721}
2722
2723/// Selects the single primary R1/R2 pair from borrowed sidecar fields.
2724fn primary_mate_pair_view<'a>(
2725    records: &'a [NonBsjAlignmentView<'a>],
2726) -> Option<(&'a NonBsjAlignmentView<'a>, &'a NonBsjAlignmentView<'a>)> {
2727    let mut r1: Option<&'a NonBsjAlignmentView<'a>> = None;
2728    let mut r2: Option<&'a NonBsjAlignmentView<'a>> = None;
2729    for record in records {
2730        if is_secondary(record.flag) || is_supplementary(record.flag) || record.flag & 0x4 != 0 {
2731            continue;
2732        }
2733        match mate_bucket(record.flag) {
2734            0 if r1.is_none() => r1 = Some(record),
2735            0 => return None,
2736            1 if r2.is_none() => r2 = Some(record),
2737            1 => return None,
2738            _ => return None,
2739        }
2740    }
2741    Some((r1?, r2?))
2742}
2743
2744/// Returns the reference span implied by borrowed CIGAR fields.
2745fn alignment_ref_span_view(record: &NonBsjAlignmentView<'_>) -> Option<(i32, i32)> {
2746    if record.cigar == "*" || record.cigar.is_empty() || record.chr == "*" {
2747        return None;
2748    }
2749    let mut ref_pos = record.pos;
2750    let mut start = i32::MAX;
2751    let mut end = i32::MIN;
2752    let mut count = 0i32;
2753    let mut has_count = false;
2754    let mut saw_block = false;
2755    for op in record.cigar.chars() {
2756        if op.is_ascii_digit() {
2757            count = count
2758                .checked_mul(10)?
2759                .checked_add(op.to_digit(10)? as i32)?;
2760            has_count = true;
2761            continue;
2762        }
2763        if !has_count {
2764            return None;
2765        }
2766        match op {
2767            'M' | '=' | 'X' => {
2768                start = start.min(ref_pos);
2769                end = end.max(ref_pos + count - 1);
2770                ref_pos += count;
2771                saw_block = true;
2772            }
2773            'D' | 'N' => {
2774                if saw_block {
2775                    end = end.max(ref_pos + count - 1);
2776                }
2777                ref_pos += count;
2778            }
2779            'I' | 'S' | 'H' | 'P' => {}
2780            _ => return None,
2781        }
2782        count = 0;
2783        has_count = false;
2784    }
2785    if has_count || !saw_block {
2786        return None;
2787    }
2788    Some((start, end))
2789}
2790
2791/// Returns terminal query clips directly from a CIGAR string.
2792fn terminal_query_clips_cigar(cigar: &str) -> Option<(i32, i32)> {
2793    let ops = parse_cigar_ops_basic(cigar)?;
2794    let five_prime = ops
2795        .first()
2796        .copied()
2797        .filter(|(_, op)| matches!(op, 'S' | 'H'))
2798        .map_or(0, |(len, _)| len);
2799    let three_prime = ops
2800        .last()
2801        .copied()
2802        .filter(|(_, op)| matches!(op, 'S' | 'H'))
2803        .map_or(0, |(len, _)| len);
2804    Some((five_prime, three_prime))
2805}
2806
2807/// Loads legacy one-alignment-per-line non-BSJ sidecars with visible progress.
2808fn spill_legacy_non_bsj_segment_evidence(
2809    path: &str,
2810    out_prefix: &str,
2811    shard_idx: usize,
2812    ctx: &SegmentScanContext<'_>,
2813) -> Result<Vec<SegmentScanShardPaths>> {
2814    let file =
2815        File::open(path).with_context(|| format!("open non-BSJ segment evidence {}", path))?;
2816    let mut reader = BufReader::new(file);
2817    let pb = segment_scan_progress_bar(path)?;
2818    pb.set_message("");
2819    let paths = SegmentScanShardPaths::new(out_prefix, shard_idx);
2820    let mut writer = SegmentScanShardWriter::new(&paths)?;
2821    let mut last_progress_pos = 0_u64;
2822    let mut line = String::new();
2823    let mut bytes_since_progress = 0_u64;
2824    {
2825        let mut current_read_id = String::new();
2826        let mut current_records = Vec::with_capacity(8);
2827        let mut seen_in_group = HashSet::new();
2828        loop {
2829            line.clear();
2830            let bytes = reader.read_line(&mut line)?;
2831            if bytes == 0 {
2832                break;
2833            }
2834            bytes_since_progress += bytes as u64;
2835            if bytes_since_progress >= 4 * 1024 * 1024 {
2836                last_progress_pos += bytes_since_progress;
2837                pb.inc(bytes_since_progress);
2838                bytes_since_progress = 0;
2839            }
2840            let line = line.trim_end_matches(['\n', '\r']);
2841            if line.is_empty() {
2842                continue;
2843            }
2844            let parts: Vec<&str> = line.split('\t').collect();
2845            if parts.len() >= 3 && is_non_bsj_group_stage(parts[1]) {
2846                if !current_read_id.is_empty() {
2847                    process_backward_group_to_writer(
2848                        &current_read_id,
2849                        &current_records,
2850                        ctx,
2851                        &mut writer,
2852                    )?;
2853                    current_read_id.clear();
2854                    current_records.clear();
2855                    seen_in_group.clear();
2856                }
2857                let read_id = parts[0];
2858                if ctx.junction_read_to_circ.contains_key(read_id) {
2859                    continue;
2860                }
2861                let records = parse_non_bsj_group_records(parts[2], &line)?;
2862                process_backward_group_to_writer(read_id, &records, ctx, &mut writer)?;
2863                continue;
2864            }
2865            if parts.len() < 10 {
2866                continue;
2867            }
2868            let read_id = parts[0];
2869            if ctx.junction_read_to_circ.contains_key(read_id) {
2870                continue;
2871            }
2872            if !current_read_id.is_empty() && read_id != current_read_id {
2873                process_backward_group_to_writer(
2874                    &current_read_id,
2875                    &current_records,
2876                    ctx,
2877                    &mut writer,
2878                )?;
2879                current_records.clear();
2880                seen_in_group.clear();
2881            }
2882            if current_read_id != read_id {
2883                current_read_id.clear();
2884                current_read_id.push_str(read_id);
2885            }
2886
2887            let flag = parts[3]
2888                .parse::<i32>()
2889                .with_context(|| format!("parse non-BSJ segment flag from {}", line))?;
2890            let chrom = parts[4];
2891            let pos = parts[5]
2892                .parse::<i32>()
2893                .with_context(|| format!("parse non-BSJ segment position from {}", line))?;
2894            let mapq = parts[6]
2895                .parse::<i32>()
2896                .with_context(|| format!("parse non-BSJ segment MAPQ from {}", line))?;
2897            let cigar = parts[7];
2898            let seq = parts[9];
2899            let key = format!("{flag}\t{chrom}\t{pos}\t{mapq}\t{cigar}\t{seq}");
2900            if !seen_in_group.insert(key) {
2901                continue;
2902            }
2903            current_records.push(AsAlignment {
2904                flag,
2905                chr: chrom.to_string(),
2906                pos,
2907                mapq,
2908                cigar: cigar.to_string(),
2909                seq: if seq == "*" {
2910                    String::new()
2911                } else {
2912                    seq.to_string()
2913                },
2914                cs: "*".to_string(),
2915                from_local_clip: parts[1].contains("_local"),
2916                xa_alternatives: Vec::new(),
2917            });
2918        }
2919        if !current_read_id.is_empty() {
2920            process_backward_group_to_writer(&current_read_id, &current_records, ctx, &mut writer)?;
2921        }
2922    }
2923    writer.flush()?;
2924    if bytes_since_progress > 0 {
2925        last_progress_pos += bytes_since_progress;
2926        pb.inc(bytes_since_progress);
2927    }
2928    let total = std::fs::metadata(path)?.len();
2929    if total > last_progress_pos {
2930        pb.inc(total - last_progress_pos);
2931    }
2932    pb.set_style(
2933        ProgressStyle::default_bar()
2934            .template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} {msg}")?
2935            .progress_chars("#>-"),
2936    );
2937    pb.finish_with_message("Completed");
2938    Ok(vec![paths])
2939}
2940
2941/// Parses the compact one-line Scan2 non-BSJ read-group sidecar encoding.
2942fn parse_non_bsj_group_records(payload: &str, line: &str) -> Result<Vec<AsAlignment>> {
2943    let mut records = Vec::new();
2944    let mut seen = HashSet::new();
2945    for encoded in payload.split(';') {
2946        if encoded.is_empty() {
2947            continue;
2948        }
2949        // This parser is on the 10+ GiB non-BSJ sidecar path. The current
2950        // compact protocol has seven fields, while older compact sidecars have
2951        // six and the older verbose protocol has eight. Keeping the first nine
2952        // slots on the stack avoids one short Vec allocation for every
2953        // alignment payload while leaving room for the optional cs field.
2954        let mut fields = [None; 9];
2955        let mut field_count = 0usize;
2956        for field in encoded.split('|') {
2957            if field_count < fields.len() {
2958                fields[field_count] = Some(field);
2959            }
2960            field_count += 1;
2961            if field_count >= fields.len() {
2962                break;
2963            }
2964        }
2965        let (flag_idx, chrom_idx, pos_idx, mapq_idx, cigar_idx, seq_idx, cs_idx) = match field_count
2966        {
2967            0..=5 => continue,
2968            6 => (0, 1, 2, 3, 4, 5, None),
2969            7 => (0, 1, 2, 3, 4, 5, Some(6)),
2970            _ => (1, 2, 3, 4, 5, 7, None),
2971        };
2972        let field = |idx: usize| fields[idx].expect("validated non-BSJ sidecar field");
2973        let flag_text = field(flag_idx);
2974        let chrom = field(chrom_idx);
2975        let pos_text = field(pos_idx);
2976        let mapq_text = field(mapq_idx);
2977        let cigar = field(cigar_idx);
2978        let seq = field(seq_idx);
2979        let cs = cs_idx
2980            .and_then(|idx| fields[idx])
2981            .filter(|value| !value.is_empty())
2982            .unwrap_or("*");
2983        let flag = flag_text
2984            .parse::<i32>()
2985            .with_context(|| format!("parse non-BSJ grouped flag from {}", line))?;
2986        let pos = pos_text
2987            .parse::<i32>()
2988            .with_context(|| format!("parse non-BSJ grouped position from {}", line))?;
2989        let mapq = mapq_text
2990            .parse::<i32>()
2991            .with_context(|| format!("parse non-BSJ grouped MAPQ from {}", line))?;
2992        let key = (flag, chrom, pos, mapq, cigar, seq, cs);
2993        if !seen.insert(key) {
2994            continue;
2995        }
2996        records.push(AsAlignment {
2997            flag,
2998            chr: chrom.to_string(),
2999            pos,
3000            mapq,
3001            cigar: cigar.to_string(),
3002            seq: if seq == "*" {
3003                String::new()
3004            } else {
3005                seq.to_string()
3006            },
3007            cs: cs.to_string(),
3008            from_local_clip: false,
3009            xa_alternatives: Vec::new(),
3010        });
3011    }
3012    Ok(records)
3013}
3014
3015/// Returns recoverable soft-clip payload for one segments alignment.
3016fn segment_clip_payload(record: &AsAlignment) -> String {
3017    if record.seq == "*" || record.seq.starts_with("L:") || record.seq.starts_with("R:") {
3018        record.seq.clone()
3019    } else {
3020        clip_sequence_payload(&record.cigar, &record.seq)
3021    }
3022}
3023
3024/// Builds CIRI-AS circRNA locus clusters.
3025///
3026/// The merge condition intentionally uses `< cluster_end + min_intron * 2 + 1`,
3027/// matching the Perl expression instead of a more conventional interval-overlap
3028/// predicate. That off-by-one-looking boundary is part of the legacy contract.
3029fn build_circ_clusters(records: &[CircRecord]) -> Vec<CircCluster> {
3030    let mut by_chr: BTreeMap<&str, Vec<&CircRecord>> = BTreeMap::new();
3031    for record in records {
3032        by_chr.entry(record.chr.as_str()).or_default().push(record);
3033    }
3034
3035    let mut clusters = Vec::new();
3036    for (chr, mut chr_records) in by_chr {
3037        chr_records.sort_by_key(|r| (r.start, r.end));
3038        let Some(first) = chr_records.first() else {
3039            continue;
3040        };
3041        let mut cluster_start = first.start;
3042        let mut cluster_end = first.end;
3043        for record in chr_records.into_iter().skip(1) {
3044            if record.start < cluster_end + MIN_INTRON * 2 + 1 {
3045                cluster_end = cluster_end.max(record.end);
3046            } else {
3047                clusters.push(CircCluster {
3048                    chr: chr.to_string(),
3049                    start: cluster_start,
3050                    end: cluster_end,
3051                });
3052                cluster_start = record.start;
3053                cluster_end = record.end;
3054            }
3055        }
3056        clusters.push(CircCluster {
3057            chr: chr.to_string(),
3058            start: cluster_start,
3059            end: cluster_end,
3060        });
3061    }
3062    clusters
3063}
3064
3065/// Indexes exact Summary circRNA spans by chromosome for outward-pair gating.
3066fn circ_spans_by_chr(records: &[CircRecord]) -> HashMap<String, Vec<CircSpan>> {
3067    let mut map: HashMap<String, Vec<CircSpan>> = HashMap::new();
3068    for record in records {
3069        map.entry(record.chr.clone()).or_default().push(CircSpan {
3070            start: record.start,
3071            end: record.end,
3072            max_end_through: record.end,
3073        });
3074    }
3075    for spans in map.values_mut() {
3076        spans.sort_by_key(|span| (span.start, span.end));
3077        let mut max_end = i32::MIN;
3078        for span in spans {
3079            max_end = max_end.max(span.end);
3080            span.max_end_through = max_end;
3081        }
3082    }
3083    map
3084}
3085
3086/// Indexes compact circ clusters by chromosome for fast overlap checks.
3087fn clusters_by_chr(clusters: Vec<CircCluster>) -> HashMap<String, Vec<CircCluster>> {
3088    let mut map: HashMap<String, Vec<CircCluster>> = HashMap::new();
3089    for cluster in clusters {
3090        map.entry(cluster.chr.clone()).or_default().push(cluster);
3091    }
3092    for clusters in map.values_mut() {
3093        clusters.sort_by_key(|c| (c.start, c.end));
3094    }
3095    map
3096}
3097
3098/// Infers the global read length using the CIRI-AS sampling rule.
3099///
3100/// Perl samples the first 100 unique read IDs for each mate flag and requires
3101/// both mate categories to agree on one length. Counting unique IDs is important:
3102/// supplementary hard-clipped alignments can have short SEQ fields and must not
3103/// bias the global `read_length` used by `MSID`.
3104fn infer_read_length(path: &str) -> Result<i32> {
3105    let mut seen: [HashSet<String>; 2] = [HashSet::new(), HashSet::new()];
3106    let mut length_types: [HashMap<usize, usize>; 2] = [HashMap::new(), HashMap::new()];
3107    let mut fallback = None;
3108    for_group_records(path, false, |read_id, records| {
3109        for record in records {
3110            if record.seq.is_empty() || record.seq == "*" {
3111                continue;
3112            }
3113            fallback.get_or_insert(record.seq.len() as i32);
3114            let idx = if record.flag & 0x40 != 0 { 1 } else { 0 };
3115            if seen[idx].insert(read_id.to_string()) {
3116                if seen[idx].len() > 100 {
3117                    seen[idx].remove(read_id);
3118                    return Ok(true);
3119                }
3120                *length_types[idx].entry(record.seq.len()).or_insert(0) += 1;
3121            }
3122        }
3123        Ok(false)
3124    })?;
3125    if seen[0].len() >= 100
3126        && seen[1].len() >= 100
3127        && length_types[0].len() == 1
3128        && length_types[1].len() == 1
3129    {
3130        let len0 = *length_types[0].keys().next().unwrap();
3131        let len1 = *length_types[1].keys().next().unwrap();
3132        if len0 == len1 {
3133            return Ok(len0 as i32);
3134        }
3135    }
3136    fallback.ok_or_else(|| anyhow!("could not infer read length from {}", path))
3137}
3138
3139/// Scans queryname-sorted SAM/BAM groups and records CIRI-AS splice candidates.
3140fn scan_alignment_groups(path: &str, state: &mut ScanState) -> Result<()> {
3141    for_group_records(path, true, |read_id, records| {
3142        process_group(read_id, records, state)?;
3143        Ok(false)
3144    })
3145}
3146
3147/// Scans only non-BSJ read groups for backward/circular candidates.
3148///
3149/// The default segments path already receives Summary-confirmed BSJ mapper
3150/// blocks from Scan1/Scan2 sidecars. This pass deliberately skips those read IDs
3151/// so it can supplement `<prefix>.segments` with `type=backward` rows without
3152/// overwriting the richer BSJ sidecar evidence or changing Summary parity. BAM
3153/// shards are returned before candidate merging so the CLI can report the
3154/// post-progress finalize phase immediately after the scan progress bar ends.
3155fn scan_backward_alignment_groups(
3156    path: &str,
3157    out_prefix: &str,
3158    state: &mut ScanState,
3159) -> Result<Option<Vec<SegmentScanShardPaths>>> {
3160    let ctx = SegmentScanContext::from_state(state);
3161    match detect_format(path)? {
3162        InputFormat::Sam => {
3163            let mut accum = SegmentScanAccum::default();
3164            for_group_records(path, true, |read_id, records| {
3165                process_backward_group(read_id, records, &ctx, &mut accum)?;
3166                Ok(false)
3167            })?;
3168            merge_segment_scan_accum(state, accum);
3169            Ok(None)
3170        }
3171        InputFormat::Bam => {
3172            let shard_paths = scan_backward_bam_groups_parallel(path, out_prefix, &ctx)?;
3173            Ok(Some(shard_paths))
3174        }
3175    }
3176}
3177
3178/// Iterates queryname-sorted alignment groups from either SAM or BAM.
3179///
3180/// Both formats feed the same lightweight `AsAlignment` records so the CIRI-AS
3181/// decision code remains format-agnostic. BAM support is sequential in this first
3182/// parity stage; the state boundary above is deliberately ready for sharded
3183/// merging once splice output is aligned to the Perl reference.
3184fn for_group_records<F>(path: &str, show_progress: bool, mut on_group: F) -> Result<()>
3185where
3186    F: FnMut(&str, &[AsAlignment]) -> Result<bool>,
3187{
3188    let pb = if show_progress {
3189        Some(segment_scan_progress_bar(path)?)
3190    } else {
3191        None
3192    };
3193    let result = match detect_format(path)? {
3194        InputFormat::Sam => scan_sam_groups(path, &mut on_group, pb.as_ref()),
3195        InputFormat::Bam => scan_bam_groups(path, &mut on_group, pb.as_ref()),
3196    };
3197    if let Some(pb) = pb {
3198        if result.is_ok() {
3199            if let Some(len) = pb.length() {
3200                pb.set_position(len);
3201            }
3202            pb.set_style(
3203                ProgressStyle::default_bar()
3204                    .template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} {msg}")?
3205                    .progress_chars("#>-"),
3206            );
3207            pb.finish_with_message("Completed");
3208        } else {
3209            pb.abandon();
3210        }
3211    }
3212    result
3213}
3214
3215/// Creates the byte-progress display used by the segments BAM/SAM sweep.
3216///
3217/// This sweep can dominate whole-genome runtime because it re-reads the original
3218/// queryname-sorted alignment file to collect non-BSJ backward/outward evidence.
3219/// Reusing the Scan1/Scan2 progress style makes that cost visible without
3220/// changing the parser or evidence semantics.
3221fn segment_scan_progress_bar(path: &str) -> Result<ProgressBar> {
3222    let file_size = std::fs::metadata(path)?.len();
3223    let pb = ProgressBar::new(file_size);
3224    pb.set_style(
3225        ProgressStyle::default_bar()
3226            .template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} {percent:>3}% ({eta}) {msg}")?
3227            .progress_chars("#>-"),
3228    );
3229    pb.set_message("");
3230    pb.enable_steady_tick(Duration::from_millis(120));
3231    Ok(pb)
3232}
3233
3234/// Updates an optional byte-progress bar from the reader position.
3235fn update_segment_progress<R: Seek>(
3236    reader: &mut R,
3237    pb: Option<&ProgressBar>,
3238    last_progress_pos: &mut u64,
3239) -> Result<()> {
3240    let pos = reader.stream_position()?;
3241    update_segment_progress_position(pos, pb, last_progress_pos);
3242    Ok(())
3243}
3244
3245/// Applies a monotonic byte-position update to an optional progress bar.
3246fn update_segment_progress_position(
3247    pos: u64,
3248    pb: Option<&ProgressBar>,
3249    last_progress_pos: &mut u64,
3250) {
3251    if let Some(pb) = pb {
3252        if pos > *last_progress_pos {
3253            pb.inc(pos - *last_progress_pos);
3254            *last_progress_pos = pos;
3255        }
3256    }
3257}
3258
3259/// Finishes any remaining byte-progress after the stream reaches EOF.
3260fn finish_segment_progress<R: Seek>(
3261    reader: &mut R,
3262    pb: Option<&ProgressBar>,
3263    last_progress_pos: &mut u64,
3264) -> Result<()> {
3265    update_segment_progress(reader, pb, last_progress_pos)
3266}
3267
3268/// Iterates queryname-sorted SAM groups.
3269fn scan_sam_groups<F>(path: &str, on_group: &mut F, pb: Option<&ProgressBar>) -> Result<()>
3270where
3271    F: FnMut(&str, &[AsAlignment]) -> Result<bool>,
3272{
3273    let file = File::open(path).with_context(|| format!("open SAM {}", path))?;
3274    let mut reader = BufReader::new(file);
3275    let mut line = String::new();
3276    let mut current_id = String::new();
3277    let mut group = Vec::with_capacity(8);
3278    let mut records_since_progress = 0usize;
3279    let mut last_progress_pos = reader.stream_position()?;
3280    while reader.read_line(&mut line)? != 0 {
3281        if line.is_empty() || line.starts_with('@') {
3282            line.clear();
3283            continue;
3284        }
3285        let record_line = line.trim_end_matches(&['\r', '\n'][..]);
3286        let mut cols = record_line.split('\t');
3287        let read_id = cols.next().unwrap_or_default();
3288        if !current_id.is_empty() && read_id != current_id {
3289            if on_group(&current_id, &group)? {
3290                return Ok(());
3291            }
3292            group.clear();
3293        }
3294        records_since_progress += 1;
3295        if records_since_progress >= 4096 {
3296            update_segment_progress(&mut reader, pb, &mut last_progress_pos)?;
3297            records_since_progress = 0;
3298        }
3299        current_id.clear();
3300        current_id.push_str(read_id);
3301        let flag = cols.next().unwrap_or("0").parse::<i32>().unwrap_or(0);
3302        let chr = cols.next().unwrap_or("*").to_string();
3303        let pos = cols.next().unwrap_or("0").parse::<i32>().unwrap_or(0);
3304        let mapq = cols.next().unwrap_or("0").parse::<i32>().unwrap_or(0);
3305        let cigar = cols.next().unwrap_or("*").to_string();
3306        cols.next();
3307        cols.next();
3308        cols.next();
3309        let seq = cols.next().unwrap_or("*").to_string();
3310        cols.next();
3311        let xa_alternatives = parse_xa_from_sam_fields(cols);
3312        group.push(AsAlignment {
3313            flag,
3314            chr,
3315            pos,
3316            mapq,
3317            cigar,
3318            seq,
3319            cs: "*".to_string(),
3320            from_local_clip: false,
3321            xa_alternatives,
3322        });
3323        line.clear();
3324    }
3325    if !group.is_empty() {
3326        on_group(&current_id, &group)?;
3327    }
3328    finish_segment_progress(&mut reader, pb, &mut last_progress_pos)?;
3329    Ok(())
3330}
3331
3332/// Iterates queryname-sorted BAM groups through noodles.
3333fn scan_bam_groups<F>(path: &str, on_group: &mut F, pb: Option<&ProgressBar>) -> Result<()>
3334where
3335    F: FnMut(&str, &[AsAlignment]) -> Result<bool>,
3336{
3337    use noodles::sam::alignment::Record as _;
3338
3339    let file = File::open(path).with_context(|| format!("open BAM {}", path))?;
3340    let mut reader = bam::io::Reader::new(file);
3341    let header = reader.read_header()?;
3342    let mut record = bam::Record::default();
3343    let mut current_id: Vec<u8> = Vec::new();
3344    let mut group = Vec::with_capacity(8);
3345    let mut cigar_buf = String::with_capacity(64);
3346    let mut seq_buf = String::with_capacity(256);
3347    let mut records_since_progress = 0usize;
3348    let mut last_progress_pos = reader.get_mut().position();
3349    while reader.read_record(&mut record)? != 0 {
3350        let read_id = record
3351            .name()
3352            .ok_or_else(|| anyhow!("Missing read name in {}", path))?;
3353        if !current_id.is_empty() && read_id != current_id.as_slice() {
3354            let id = String::from_utf8_lossy(&current_id);
3355            if on_group(&id, &group)? {
3356                return Ok(());
3357            }
3358            group.clear();
3359        }
3360        records_since_progress += 1;
3361        if records_since_progress >= 4096 {
3362            update_segment_progress_position(
3363                reader.get_mut().position(),
3364                pb,
3365                &mut last_progress_pos,
3366            );
3367            records_since_progress = 0;
3368        }
3369        current_id.clear();
3370        current_id.extend_from_slice(read_id);
3371        let chr = match record.reference_sequence(&header) {
3372            Some(Ok((name, _))) => String::from_utf8_lossy(name).to_string(),
3373            _ => "*".to_string(),
3374        };
3375        let flag = i32::from(u16::from(record.flags()));
3376        let pos = record
3377            .alignment_start()
3378            .transpose()?
3379            .map(|p| p.get() as i32)
3380            .unwrap_or(0);
3381        let mapq = record.mapping_quality().map(u8::from).unwrap_or(0) as i32;
3382        cigar_buf.clear();
3383        for result in record.cigar().iter() {
3384            let op = result?;
3385            use noodles::sam::alignment::record::cigar::op::Kind;
3386            let op_char = match op.kind() {
3387                Kind::Match => 'M',
3388                Kind::Insertion => 'I',
3389                Kind::Deletion => 'D',
3390                Kind::Skip => 'N',
3391                Kind::SoftClip => 'S',
3392                Kind::HardClip => 'H',
3393                Kind::Pad => 'P',
3394                Kind::SequenceMatch => '=',
3395                Kind::SequenceMismatch => 'X',
3396            };
3397            let _ = write!(&mut cigar_buf, "{}{}", op.len(), op_char);
3398        }
3399        seq_buf.clear();
3400        for b in record.sequence().iter() {
3401            seq_buf.push(char::from(b));
3402        }
3403        let xa_alternatives = parse_xa_from_bam_record(&record)?;
3404        group.push(AsAlignment {
3405            flag,
3406            chr,
3407            pos,
3408            mapq,
3409            cigar: cigar_buf.clone(),
3410            seq: seq_buf.clone(),
3411            cs: "*".to_string(),
3412            from_local_clip: false,
3413            xa_alternatives,
3414        });
3415    }
3416    if !group.is_empty() {
3417        let id = String::from_utf8_lossy(&current_id);
3418        on_group(&id, &group)?;
3419    }
3420    update_segment_progress_position(reader.get_mut().position(), pb, &mut last_progress_pos);
3421    Ok(())
3422}
3423
3424impl<'a> SegmentScanContext<'a> {
3425    /// Borrows the read-only indexes needed by the non-BSJ segments rescan.
3426    fn from_state(state: &'a ScanState<'a>) -> Self {
3427        Self {
3428            junction_read_to_circ: &state.junction_read_to_circ,
3429            reference: state.reference,
3430            annotation: state.annotation,
3431            circ_spans_by_chr: &state.circ_spans_by_chr,
3432            clusters_by_chr: &state.clusters_by_chr,
3433            read_len: state.read_len,
3434            min_mapq: state.min_mapq,
3435        }
3436    }
3437}
3438
3439/// Merges one completed segments scan accumulator into the main state.
3440fn merge_segment_scan_accum(state: &mut ScanState, accum: SegmentScanAccum) {
3441    merge_segment_scan_accum_fields(
3442        &mut state.candidates,
3443        &mut state.segment_groups,
3444        &mut state.outward_read_ids,
3445        &mut state.prebuilt_segment_records,
3446        accum,
3447    );
3448}
3449
3450/// Merges one accumulator into disjoint mutable state fields.
3451///
3452/// This path is still used by the legacy BAM rescan fallback, where workers
3453/// return only retained non-BSJ groups. The Scan2 sidecar path now prefers
3454/// shard-local spill files so large whole-genome inputs do not keep alignment
3455/// groups in memory before the support-aware second pass knows which reads are
3456/// actually needed.
3457fn merge_segment_scan_accum_fields(
3458    candidates: &mut Vec<PositiveCandidate>,
3459    segment_groups: &mut HashMap<String, Vec<AsAlignment>>,
3460    outward_read_ids: &mut HashSet<String>,
3461    prebuilt_segment_records: &mut Vec<SegmentRecord>,
3462    accum: SegmentScanAccum,
3463) {
3464    candidates.extend(accum.candidates);
3465    outward_read_ids.extend(accum.outward_read_ids);
3466    prebuilt_segment_records.extend(accum.prebuilt_segment_records);
3467    for (read_id, records) in accum.segment_groups {
3468        segment_groups.entry(read_id).or_insert(records);
3469    }
3470}
3471
3472impl SegmentScanShardPaths {
3473    /// Builds deterministic shard spill paths beside the final `.segments` file.
3474    fn new(out_prefix: &str, shard_idx: usize) -> Self {
3475        Self {
3476            path: part_path(&format!("{out_prefix}.segments"), shard_idx),
3477        }
3478    }
3479
3480    /// Builds shard spill paths for Summary-confirmed BSJ read evidence.
3481    fn new_bsj(out_prefix: &str, shard_idx: usize) -> Self {
3482        Self {
3483            path: part_path(&format!("{out_prefix}.segments.bsj"), shard_idx),
3484        }
3485    }
3486
3487    /// Builds raw hash-partition paths used while aggregating BSJ sidecars.
3488    fn new_bsj_raw(out_prefix: &str, shard_idx: usize) -> Self {
3489        Self {
3490            path: part_path(&format!("{out_prefix}.segments.bsj.raw"), shard_idx),
3491        }
3492    }
3493}
3494
3495impl SegmentScanShardWriter {
3496    /// Opens the shard-local read-result stream for segments rescan evidence.
3497    fn new(paths: &SegmentScanShardPaths) -> Result<Self> {
3498        Ok(Self {
3499            writer: BufWriter::with_capacity(512 * 1024, File::create(&paths.path)?),
3500        })
3501    }
3502
3503    /// Writes all retained results for one non-BSJ read group.
3504    ///
3505    /// The tagged stream keeps evidence read-local while still allowing a cheap
3506    /// two-pass merge: `R` and `C` rows are loaded before motif validation, and
3507    /// `A` rows are loaded afterward only for read IDs that survived validation
3508    /// or were classified as outward.
3509    fn write_read_result(
3510        &mut self,
3511        read_id: &str,
3512        records: &[AsAlignment],
3513        outward: bool,
3514        candidates: &[PositiveCandidate],
3515        segment_records: &[SegmentRecord],
3516    ) -> Result<()> {
3517        writeln!(self.writer, "R\t{read_id}\t{}", u8::from(outward))?;
3518        for record in records {
3519            writeln!(
3520                self.writer,
3521                "A\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}",
3522                read_id,
3523                record.flag,
3524                record.chr,
3525                record.pos,
3526                record.mapq,
3527                record.cigar,
3528                if record.seq.is_empty() {
3529                    "*"
3530                } else {
3531                    &record.seq
3532                },
3533                u8::from(record.from_local_clip),
3534                record.cs,
3535                format_xa_alternatives(&record.xa_alternatives)
3536            )?;
3537        }
3538        for candidate in candidates {
3539            writeln!(
3540                self.writer,
3541                "C\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}",
3542                candidate.read_id,
3543                candidate.chr,
3544                candidate.site2,
3545                candidate.site1,
3546                candidate.adjust1,
3547                candidate.adjust2,
3548                candidate.strand_hint,
3549                option_field(candidate.cigars[0].as_deref()),
3550                option_field(candidate.cigars[1].as_deref()),
3551                option_field(candidate.cigars[2].as_deref())
3552            )?;
3553        }
3554        for record in segment_records {
3555            writeln!(
3556                self.writer,
3557                "S\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}",
3558                record.read_id,
3559                record.type_name,
3560                record.circ_id,
3561                record.chrom,
3562                record.start,
3563                record.end,
3564                record.strand,
3565                record.is_circular,
3566                record.is_r1_bsj,
3567                record.is_r2_bsj,
3568                record.r1_align_strand,
3569                record.r2_align_strand,
3570                record.r1_cigar,
3571                record.r1_cs,
3572                record.r1_segments,
3573                record.r2_cigar,
3574                record.r2_cs,
3575                record.r2_segments
3576            )?;
3577        }
3578        Ok(())
3579    }
3580
3581    /// Flushes all shard-local spill writers before the main thread merges them.
3582    fn flush(&mut self) -> Result<()> {
3583        self.writer.flush()?;
3584        Ok(())
3585    }
3586}
3587
3588/// Formats an optional TSV field without introducing empty-column ambiguity.
3589fn option_field(value: Option<&str>) -> &str {
3590    value.filter(|value| !value.is_empty()).unwrap_or("*")
3591}
3592
3593/// Serializes XA alternatives into a shard-local sidecar field.
3594fn format_xa_alternatives(alternatives: &[XaAlternative]) -> String {
3595    let mut out = String::new();
3596    for alternative in alternatives {
3597        let _ = write!(
3598            out,
3599            "{},{},{},{},{};",
3600            alternative.chr,
3601            alternative.strand,
3602            alternative.pos,
3603            alternative.cigar,
3604            alternative.edit_distance
3605        );
3606    }
3607    out
3608}
3609
3610/// Parses XA alternatives previously written by a segments rescan shard.
3611fn parse_xa_alternatives_field(raw: &str) -> Vec<XaAlternative> {
3612    raw.split(';')
3613        .filter_map(|entry| {
3614            if entry.is_empty() {
3615                return None;
3616            }
3617            let mut parts = entry.split(',');
3618            Some(XaAlternative {
3619                chr: parts.next()?.to_string(),
3620                strand: parts.next()?.chars().next()?,
3621                pos: parts.next()?.parse().ok()?,
3622                cigar: parts.next()?.to_string(),
3623                edit_distance: parts.next()?.parse().ok()?,
3624            })
3625        })
3626        .collect()
3627}
3628
3629/// Streams retained read blocks from one segment shard.
3630///
3631/// This is the preferred large-data access pattern for finalize. It keeps only
3632/// one read group in memory, preserving the read-local `A/C/S` payload needed
3633/// for support-aware correction without building a global read-id offset map.
3634fn for_each_retained_segment_group<F>(
3635    path: &str,
3636    pb: Option<&ProgressBar>,
3637    mut on_group: F,
3638) -> Result<()>
3639where
3640    F: FnMut(RetainedSegmentGroup) -> Result<()>,
3641{
3642    let file = File::open(path).with_context(|| format!("open segments retained shard {path}"))?;
3643    let mut reader = BufReader::with_capacity(8 * 1024 * 1024, file);
3644    let mut line = String::new();
3645    let mut current: Option<RetainedSegmentGroup> = None;
3646    let mut pending_progress = 0usize;
3647    loop {
3648        line.clear();
3649        let bytes = reader.read_line(&mut line)?;
3650        if bytes == 0 {
3651            break;
3652        }
3653        pending_progress += bytes;
3654        let line = line.trim_end_matches(['\n', '\r']);
3655        if line.is_empty() {
3656            if pending_progress >= 4 * 1024 * 1024 {
3657                if let Some(pb) = pb {
3658                    pb.inc(pending_progress as u64);
3659                }
3660                pending_progress = 0;
3661            }
3662            continue;
3663        }
3664        let parts: Vec<&str> = line.split('\t').collect();
3665        match parts.first().copied() {
3666            Some("R") if parts.len() >= 2 => {
3667                if let Some(group) = current.take() {
3668                    on_group(group)?;
3669                }
3670                current = Some(RetainedSegmentGroup {
3671                    read_id: parts[1].to_string(),
3672                    records: Vec::new(),
3673                    candidates: Vec::new(),
3674                    segment_records: Vec::new(),
3675                });
3676            }
3677            Some("A") if parts.len() >= 10 => {
3678                if let Some(group) = current.as_mut() {
3679                    let has_cs = parts.len() >= 11;
3680                    group.records.push(AsAlignment {
3681                        flag: parts[2].parse().unwrap_or(0),
3682                        chr: parts[3].to_string(),
3683                        pos: parts[4].parse().unwrap_or(0),
3684                        mapq: parts[5].parse().unwrap_or(0),
3685                        cigar: parts[6].to_string(),
3686                        seq: if parts[7] == "*" {
3687                            String::new()
3688                        } else {
3689                            parts[7].to_string()
3690                        },
3691                        cs: if has_cs {
3692                            parts[9].to_string()
3693                        } else {
3694                            "*".to_string()
3695                        },
3696                        from_local_clip: parts[8] == "1",
3697                        xa_alternatives: parse_xa_alternatives_field(if has_cs {
3698                            parts[10]
3699                        } else {
3700                            parts[9]
3701                        }),
3702                    });
3703                }
3704            }
3705            Some("C") if parts.len() >= 11 => {
3706                if let Some(group) = current.as_mut() {
3707                    group.candidates.push(PositiveCandidate {
3708                        read_id: parts[1].to_string(),
3709                        index: 0,
3710                        chr: parts[2].to_string(),
3711                        site2: parts[3].parse().unwrap_or(0),
3712                        site1: parts[4].parse().unwrap_or(0),
3713                        adjust1: parts[5].parse().unwrap_or(0),
3714                        adjust2: parts[6].parse().unwrap_or(0),
3715                        strand_hint: parts[7].parse().unwrap_or(0),
3716                        cigars: [
3717                            parse_optional_field(parts[8]),
3718                            parse_optional_field(parts[9]),
3719                            parse_optional_field(parts[10]),
3720                        ],
3721                    });
3722                }
3723            }
3724            Some("S") if parts.len() >= 15 => {
3725                if let (Some(group), Some(record)) = (
3726                    current.as_mut(),
3727                    segment_record_from_shard_fields(&parts[1..]),
3728                ) {
3729                    group.segment_records.push(record);
3730                }
3731            }
3732            _ => {}
3733        }
3734        if pending_progress >= 4 * 1024 * 1024 {
3735            if let Some(pb) = pb {
3736                pb.inc(pending_progress as u64);
3737            }
3738            pending_progress = 0;
3739        }
3740    }
3741    if let Some(group) = current {
3742        on_group(group)?;
3743    }
3744    if pending_progress > 0 {
3745        if let Some(pb) = pb {
3746            pb.inc(pending_progress as u64);
3747        }
3748    }
3749    Ok(())
3750}
3751
3752/// Parses one shard-local preliminary segment row.
3753fn segment_record_from_shard_fields(parts: &[&str]) -> Option<SegmentRecord> {
3754    let has_alignment_strands = parts.len() >= 16;
3755    let has_cs = parts.len() >= 18;
3756    let (
3757        r1_align_strand,
3758        r2_align_strand,
3759        r1_cigar_idx,
3760        r1_cs_idx,
3761        r1_segments_idx,
3762        r2_cigar_idx,
3763        r2_cs_idx,
3764        r2_segments_idx,
3765    ) = if has_alignment_strands {
3766        (
3767            parts.get(10)?.to_string(),
3768            parts.get(11)?.to_string(),
3769            12usize,
3770            if has_cs { Some(13usize) } else { None },
3771            if has_cs { 14usize } else { 13usize },
3772            if has_cs { 15usize } else { 14usize },
3773            if has_cs { Some(16usize) } else { None },
3774            if has_cs { 17usize } else { 15usize },
3775        )
3776    } else {
3777        (
3778            "NA".to_string(),
3779            "NA".to_string(),
3780            10usize,
3781            None,
3782            11usize,
3783            12usize,
3784            None,
3785            13usize,
3786        )
3787    };
3788    Some(SegmentRecord {
3789        read_id: parts.first()?.to_string(),
3790        type_name: match *parts.get(1)? {
3791            "backward" => "backward",
3792            "outward" => "outward",
3793            _ => return None,
3794        },
3795        circ_id: parts.get(2)?.to_string(),
3796        chrom: parts.get(3)?.to_string(),
3797        start: parts.get(4)?.to_string(),
3798        end: parts.get(5)?.to_string(),
3799        strand: parts.get(6)?.to_string(),
3800        is_circular: parts.get(7)?.parse().ok()?,
3801        is_r1_bsj: parts.get(8)?.parse().ok()?,
3802        is_r2_bsj: parts.get(9)?.parse().ok()?,
3803        r1_align_strand,
3804        r2_align_strand,
3805        r1_cigar: parts.get(r1_cigar_idx)?.to_string(),
3806        r1_cs: r1_cs_idx
3807            .and_then(|idx| parts.get(idx))
3808            .copied()
3809            .unwrap_or("NA")
3810            .to_string(),
3811        r1_segments: parts.get(r1_segments_idx)?.to_string(),
3812        r2_cigar: parts.get(r2_cigar_idx)?.to_string(),
3813        r2_cs: r2_cs_idx
3814            .and_then(|idx| parts.get(idx))
3815            .copied()
3816            .unwrap_or("NA")
3817            .to_string(),
3818        r2_segments: parts.get(r2_segments_idx)?.to_string(),
3819    })
3820}
3821
3822/// Parses a shard-local optional text field.
3823fn parse_optional_field(value: &str) -> Option<String> {
3824    (value != "*").then(|| value.to_string())
3825}
3826
3827/// Processes the segments BAM rescan in BGZF shards.
3828///
3829/// This mirrors the Scan1/Scan2 ownership rule: a non-zero shard starts
3830/// decoding from the previous BGZF block, skips the leading partial read group,
3831/// and then owns each subsequent queryname group until it crosses the shard end.
3832fn scan_backward_bam_groups_parallel(
3833    path: &str,
3834    out_prefix: &str,
3835    ctx: &SegmentScanContext<'_>,
3836) -> Result<Vec<SegmentScanShardPaths>> {
3837    let file = File::open(path).with_context(|| format!("open BAM {}", path))?;
3838    let file_size = std::fs::metadata(path)?.len();
3839    let mmap = unsafe { Mmap::map(&file)? };
3840    let num_threads = bam_shard_count(mmap.len(), rayon::current_num_threads());
3841    let shard_size = mmap.len() / num_threads;
3842    let mut reader = bam::io::Reader::new(file);
3843    let header = reader.read_header()?;
3844    let pb = segment_scan_progress_bar(path)?;
3845
3846    unsafe {
3847        libc::madvise(
3848            mmap.as_ptr() as *mut libc::c_void,
3849            mmap.len(),
3850            libc::MADV_SEQUENTIAL,
3851        );
3852    }
3853
3854    let header_ref = &header;
3855    let shard_paths: Vec<SegmentScanShardPaths> = (0..num_threads)
3856        .into_par_iter()
3857        .map(|i| {
3858            let start = i * shard_size;
3859            let end = if i == num_threads - 1 {
3860                mmap.len()
3861            } else {
3862                (i + 1) * shard_size
3863            };
3864            let paths = SegmentScanShardPaths::new(out_prefix, i);
3865            process_backward_bam_shard(i, &mmap, start, end, header_ref, ctx, &pb, &paths)?;
3866            Ok(paths)
3867        })
3868        .collect::<Result<Vec<_>>>()?;
3869
3870    pb.set_position(file_size);
3871    pb.set_style(
3872        ProgressStyle::default_bar()
3873            .template(
3874                "{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} {msg}",
3875            )?
3876            .progress_chars("#>-"),
3877    );
3878    pb.finish_with_message("Completed");
3879
3880    Ok(shard_paths)
3881}
3882
3883/// Processes one compressed-byte shard for the segments BAM rescan.
3884fn process_backward_bam_shard(
3885    shard_idx: usize,
3886    mmap: &Mmap,
3887    start: usize,
3888    end: usize,
3889    header: &sam::Header,
3890    ctx: &SegmentScanContext<'_>,
3891    pb: &ProgressBar,
3892    paths: &SegmentScanShardPaths,
3893) -> Result<()> {
3894    let block_start = if start == 0 {
3895        0
3896    } else {
3897        find_next_bgzf_block(mmap, start).unwrap_or(mmap.len())
3898    };
3899    let pos = if start == 0 {
3900        0
3901    } else {
3902        find_previous_bgzf_block(mmap, block_start).unwrap_or(0)
3903    };
3904    if pos >= mmap.len() {
3905        let mut writer = SegmentScanShardWriter::new(paths)?;
3906        writer.flush()?;
3907        return Ok(());
3908    }
3909
3910    let mut reader = bam::io::Reader::new(&mmap[pos..]);
3911    if start == 0 {
3912        let _ = reader.read_header()?;
3913    }
3914    let mut record = bam::Record::default();
3915    let mut current_id: Vec<u8> = Vec::new();
3916    let mut group = Vec::with_capacity(8);
3917    let mut crossed_start = start == 0;
3918    let mut leading_partial_id: Option<Vec<u8>> = None;
3919    let mut last_progress_pos = block_start.saturating_sub(pos);
3920    let mut last_evicted_pos = pos;
3921    let eviction_threshold = 64 * 1024 * 1024;
3922    let mut cigar_buf = String::with_capacity(64);
3923    let mut seq_buf = String::with_capacity(256);
3924    let mut writer = SegmentScanShardWriter::new(paths)?;
3925
3926    while reader.read_record(&mut record)? != 0 {
3927        let curr_c_pos = reader.get_ref().virtual_position().compressed() as usize;
3928        if curr_c_pos > last_progress_pos {
3929            pb.inc((curr_c_pos - last_progress_pos) as u64);
3930            last_progress_pos = curr_c_pos;
3931        }
3932        if curr_c_pos > last_evicted_pos.saturating_sub(pos) + eviction_threshold {
3933            let evicted_rel = last_evicted_pos - pos;
3934            advise_segment_mmap_dontneed(mmap, last_evicted_pos, curr_c_pos - evicted_rel);
3935            last_evicted_pos = pos + curr_c_pos;
3936        }
3937
3938        let read_id = record
3939            .name()
3940            .ok_or_else(|| anyhow!("Missing read name in segments BAM shard {shard_idx}"))?;
3941        let abs_c_pos = pos + curr_c_pos;
3942        if !crossed_start {
3943            if abs_c_pos < block_start {
3944                leading_partial_id = Some(read_id.to_vec());
3945                continue;
3946            }
3947            crossed_start = true;
3948        }
3949        if let Some(partial_id) = &leading_partial_id {
3950            if read_id == partial_id.as_slice() {
3951                continue;
3952            }
3953            leading_partial_id = None;
3954        }
3955        if read_id != current_id.as_slice() {
3956            if !current_id.is_empty() {
3957                let id = String::from_utf8_lossy(&current_id);
3958                process_backward_group_to_writer(&id, &group, ctx, &mut writer)?;
3959                if pos + curr_c_pos > end {
3960                    current_id.clear();
3961                    break;
3962                }
3963                group.clear();
3964            }
3965            current_id.clear();
3966            current_id.extend_from_slice(read_id);
3967        }
3968        group.push(bam_record_to_as_alignment(
3969            &record,
3970            header,
3971            &mut cigar_buf,
3972            &mut seq_buf,
3973        )?);
3974    }
3975    if !current_id.is_empty() {
3976        let id = String::from_utf8_lossy(&current_id);
3977        process_backward_group_to_writer(&id, &group, ctx, &mut writer)?;
3978    }
3979    writer.flush()?;
3980    let processed_end = (pos + last_progress_pos).min(mmap.len());
3981    advise_segment_mmap_dontneed(
3982        mmap,
3983        last_evicted_pos,
3984        processed_end.saturating_sub(last_evicted_pos),
3985    );
3986    Ok(())
3987}
3988
3989/// Returns the first BGZF block header at or after `start`.
3990fn find_next_bgzf_block(mmap: &Mmap, start: usize) -> Option<usize> {
3991    (start..mmap.len().saturating_sub(3)).find(|&i| &mmap[i..i + 4] == b"\x1f\x8b\x08\x04")
3992}
3993
3994/// Returns the nearest BGZF block header at or before `start`.
3995fn find_previous_bgzf_block(mmap: &Mmap, start: usize) -> Option<usize> {
3996    let mut i = start.saturating_sub(1).min(mmap.len().saturating_sub(4));
3997    loop {
3998        if i + 3 < mmap.len() && &mmap[i..i + 4] == b"\x1f\x8b\x08\x04" {
3999            return Some(i);
4000        }
4001        if i == 0 {
4002            return None;
4003        }
4004        i -= 1;
4005    }
4006}
4007
4008/// Advises the kernel that an already-processed segments shard range is cold.
4009fn advise_segment_mmap_dontneed(mmap: &Mmap, offset: usize, len: usize) {
4010    if len == 0 {
4011        return;
4012    }
4013    let page_size = 4096usize;
4014    let aligned_offset = (offset / page_size) * page_size;
4015    let aligned_len = ((offset + len + page_size - 1) / page_size) * page_size - aligned_offset;
4016    unsafe {
4017        let ptr = mmap.as_ptr().add(aligned_offset);
4018        libc::madvise(ptr as *mut libc::c_void, aligned_len, libc::MADV_DONTNEED);
4019    }
4020}
4021
4022/// Converts one BAM record into the lightweight segments alignment form.
4023fn bam_record_to_as_alignment(
4024    record: &bam::Record,
4025    header: &sam::Header,
4026    cigar_buf: &mut String,
4027    seq_buf: &mut String,
4028) -> Result<AsAlignment> {
4029    use noodles::sam::alignment::Record as _;
4030
4031    let chr = match record.reference_sequence(header) {
4032        Some(Ok((name, _))) => String::from_utf8_lossy(name).to_string(),
4033        _ => "*".to_string(),
4034    };
4035    let flag = i32::from(u16::from(record.flags()));
4036    let pos = record
4037        .alignment_start()
4038        .transpose()?
4039        .map(|p| p.get() as i32)
4040        .unwrap_or(0);
4041    let mapq = record.mapping_quality().map(u8::from).unwrap_or(0) as i32;
4042    cigar_buf.clear();
4043    for result in record.cigar().iter() {
4044        let op = result?;
4045        use noodles::sam::alignment::record::cigar::op::Kind;
4046        let op_char = match op.kind() {
4047            Kind::Match => 'M',
4048            Kind::Insertion => 'I',
4049            Kind::Deletion => 'D',
4050            Kind::Skip => 'N',
4051            Kind::SoftClip => 'S',
4052            Kind::HardClip => 'H',
4053            Kind::Pad => 'P',
4054            Kind::SequenceMatch => '=',
4055            Kind::SequenceMismatch => 'X',
4056        };
4057        let _ = write!(cigar_buf, "{}{}", op.len(), op_char);
4058    }
4059    seq_buf.clear();
4060    for b in record.sequence().iter() {
4061        seq_buf.push(char::from(b));
4062    }
4063    Ok(AsAlignment {
4064        flag,
4065        chr,
4066        pos,
4067        mapq,
4068        cigar: cigar_buf.clone(),
4069        seq: seq_buf.clone(),
4070        cs: "*".to_string(),
4071        from_local_clip: false,
4072        xa_alternatives: parse_xa_from_bam_record(record)?,
4073    })
4074}
4075
4076/// Parses `XA:Z` alternatives from raw SAM optional columns.
4077///
4078/// The parser intentionally accepts only BWA's four-field entries
4079/// `chr,[+-]pos,cigar,NM;`. Malformed entries are ignored because XA is a
4080/// sidecar-only negative signal and must never make alignment parsing fail.
4081fn parse_xa_from_sam_fields<'a>(fields: impl Iterator<Item = &'a str>) -> Vec<XaAlternative> {
4082    for field in fields {
4083        if let Some(raw) = field.strip_prefix("XA:Z:") {
4084            return parse_xa_tag(raw);
4085        }
4086    }
4087    Vec::new()
4088}
4089
4090/// Parses `XA:Z` alternatives from one BAM record.
4091fn parse_xa_from_bam_record(record: &bam::Record) -> Result<Vec<XaAlternative>> {
4092    let tag = Tag::new(b'X', b'A');
4093    let data = record.data();
4094    let Some(value) = data.get(&tag).transpose()? else {
4095        return Ok(Vec::new());
4096    };
4097    let Value::String(raw) = value else {
4098        return Ok(Vec::new());
4099    };
4100    let Ok(raw) = std::str::from_utf8(raw.as_ref()) else {
4101        return Ok(Vec::new());
4102    };
4103    Ok(parse_xa_tag(raw))
4104}
4105
4106/// Parses one BWA `XA:Z` payload into alternative alignment records.
4107fn parse_xa_tag(raw: &str) -> Vec<XaAlternative> {
4108    raw.split(';')
4109        .filter_map(|entry| {
4110            if entry.is_empty() {
4111                return None;
4112            }
4113            let mut parts = entry.split(',');
4114            let chr = parts.next()?.to_string();
4115            let signed_pos = parts.next()?;
4116            let mut chars = signed_pos.chars();
4117            let strand = chars.next()?;
4118            if !matches!(strand, '+' | '-') {
4119                return None;
4120            }
4121            let pos = chars.as_str().parse::<i32>().ok()?.abs();
4122            let cigar = parts.next()?.to_string();
4123            let edit_distance = parts.next()?.parse::<i32>().ok()?;
4124            Some(XaAlternative {
4125                chr,
4126                strand,
4127                pos,
4128                cigar,
4129                edit_distance,
4130            })
4131        })
4132        .collect()
4133}
4134
4135/// Processes one queryname group through CIRI-AS known/non-known read paths.
4136fn process_group(read_id: &str, records: &[AsAlignment], state: &mut ScanState) -> Result<()> {
4137    if records.is_empty() {
4138        return Ok(());
4139    }
4140    if state.junction_read_to_circ.contains_key(read_id) {
4141        state
4142            .segment_groups
4143            .insert(read_id.to_string(), records.to_vec());
4144        mapping_check1(read_id, records, true, state)?;
4145    } else if records.len() > 2 && overlaps_any_circ_cluster(records, state) {
4146        state
4147            .segment_groups
4148            .insert(read_id.to_string(), records.to_vec());
4149        mapping_check1(read_id, records, false, state)?;
4150    }
4151    Ok(())
4152}
4153
4154/// Processes one non-BSJ read group for backward candidate discovery.
4155///
4156/// This is the sidecar-mode counterpart of the non-BSJ branch in
4157/// `process_group`. Confirmed BSJ reads are skipped because their final chains
4158/// must come from `.segments1/2`; non-BSJ groups are only retained when they
4159/// show read-level backward or outward topology. These supplemental rows do not
4160/// require a known circRNA span or a matching BSJ; downstream graph construction
4161/// can later project them onto compatible circRNA hypotheses.
4162fn process_backward_group(
4163    read_id: &str,
4164    records: &[AsAlignment],
4165    ctx: &SegmentScanContext<'_>,
4166    accum: &mut SegmentScanAccum,
4167) -> Result<()> {
4168    if records.is_empty() || ctx.junction_read_to_circ.contains_key(read_id) {
4169        return Ok(());
4170    }
4171    let is_outward = is_outward_pair_group_ctx(records, ctx);
4172    let may_backward = may_have_backward_candidate_shape(records);
4173    if !is_outward && !may_backward {
4174        return Ok(());
4175    }
4176
4177    let candidates = if may_backward {
4178        mapping_check1_backward_candidates(read_id, records, ctx)
4179    } else {
4180        Vec::new()
4181    };
4182    let mut candidates = validate_splice_candidates(candidates, ctx.reference, ctx.annotation);
4183    let (segment_records, enriched_records) =
4184        prebuild_non_bsj_segment_records(read_id, records, is_outward, &candidates, ctx);
4185    if is_outward {
4186        accum
4187            .segment_groups
4188            .entry(read_id.to_string())
4189            .or_insert_with(|| enriched_records.unwrap_or_else(|| records.to_vec()));
4190        accum.outward_read_ids.insert(read_id.to_string());
4191    }
4192    if !candidates.is_empty() {
4193        accum
4194            .segment_groups
4195            .entry(read_id.to_string())
4196            .or_insert_with(|| records.to_vec());
4197        accum.candidates.append(&mut candidates);
4198    }
4199    accum.prebuilt_segment_records.extend(segment_records);
4200    Ok(())
4201}
4202
4203/// Streams one non-BSJ read group into shard-local spill files.
4204fn process_backward_group_to_writer(
4205    read_id: &str,
4206    records: &[AsAlignment],
4207    ctx: &SegmentScanContext<'_>,
4208    writer: &mut SegmentScanShardWriter,
4209) -> Result<()> {
4210    if records.is_empty() || ctx.junction_read_to_circ.contains_key(read_id) {
4211        return Ok(());
4212    }
4213    let is_outward = is_outward_pair_group_ctx(records, ctx);
4214    let may_backward = may_have_backward_candidate_shape(records);
4215    if !is_outward && !may_backward {
4216        return Ok(());
4217    }
4218
4219    let candidates = if may_backward {
4220        mapping_check1_backward_candidates(read_id, records, ctx)
4221    } else {
4222        Vec::new()
4223    };
4224    let candidates = validate_splice_candidates(candidates, ctx.reference, ctx.annotation);
4225    let (segment_records, enriched_records) =
4226        prebuild_non_bsj_segment_records(read_id, records, is_outward, &candidates, ctx);
4227    if is_outward {
4228        let retained_records = enriched_records.as_deref().unwrap_or(records);
4229        writer.write_read_result(
4230            read_id,
4231            retained_records,
4232            true,
4233            &candidates,
4234            &segment_records,
4235        )?;
4236    } else if !candidates.is_empty() {
4237        writer.write_read_result(read_id, records, false, &candidates, &segment_records)?;
4238    }
4239    Ok(())
4240}
4241
4242/// Cheap read-level prefilter for CIRI-AS-style backward split evidence.
4243///
4244/// This intentionally does not consult circRNA spans. It only asks whether the
4245/// group has at least two same-strand same-chromosome alignments with a clipped
4246/// CIGAR shape that could later produce a backward junction candidate.
4247fn may_have_backward_candidate_shape(records: &[AsAlignment]) -> bool {
4248    let mut buckets: HashMap<(&str, i32), (usize, bool)> = HashMap::new();
4249    for record in records {
4250        if record.chr == "*" || record.cigar == "*" {
4251            continue;
4252        }
4253        let key = (record.chr.as_str(), reverse_bit(record.flag));
4254        let entry = buckets.entry(key).or_insert((0, false));
4255        entry.0 += 1;
4256        entry.1 |= record.cigar.contains('S') || record.cigar.contains('H');
4257        if entry.0 >= 2 && entry.1 {
4258            return true;
4259        }
4260    }
4261    false
4262}
4263
4264/// Validates one read group's splice candidates before shard-local materialization.
4265fn validate_splice_candidates(
4266    candidates: Vec<PositiveCandidate>,
4267    reference: &HashMap<String, String>,
4268    annotation: Option<&Annotation>,
4269) -> Vec<PositiveCandidate> {
4270    candidates
4271        .into_iter()
4272        .enumerate()
4273        .filter_map(|(idx, mut candidate)| {
4274            candidate.index = idx;
4275            validate_splice_candidate(candidate, reference, annotation)
4276        })
4277        .collect()
4278}
4279
4280/// Builds preliminary non-BSJ segment rows inside the owning BAM shard.
4281///
4282/// This moves the read-local backward/outward first pass into the parallel scan
4283/// while leaving global junction-support correction for the later build phase.
4284fn prebuild_non_bsj_segment_records(
4285    read_id: &str,
4286    records: &[AsAlignment],
4287    is_outward: bool,
4288    candidates: &[PositiveCandidate],
4289    ctx: &SegmentScanContext<'_>,
4290) -> (Vec<SegmentRecord>, Option<Vec<AsAlignment>>) {
4291    let correction = SegmentCorrectionContext {
4292        reference: ctx.reference,
4293        annotation: ctx.annotation,
4294        junction_support: None,
4295    };
4296    let junction_hints = local_junction_hints(candidates);
4297    let strand_hint = local_strand_hint(candidates);
4298    let enriched_records = is_outward
4299        .then(|| try_add_non_bsj_local_clip_alignments_ctx(records, ctx))
4300        .flatten();
4301    let materialization_records = enriched_records.as_deref().unwrap_or(records);
4302
4303    if !candidates.is_empty() {
4304        if let Some(record) = build_backward_segment_record_from_group_ctx(
4305            read_id,
4306            materialization_records,
4307            ctx,
4308            &correction,
4309            strand_hint,
4310            &junction_hints,
4311        ) {
4312            return (vec![record], enriched_records);
4313        }
4314    }
4315    if is_outward {
4316        if let Some(record) = build_outward_segment_record(
4317            read_id,
4318            materialization_records,
4319            Some(&correction),
4320            ctx.read_len,
4321            ctx.min_mapq,
4322        ) {
4323            return (vec![record], enriched_records);
4324        }
4325    }
4326    (Vec::new(), enriched_records)
4327}
4328
4329/// Builds one backward row using only shard-local read context.
4330fn build_backward_segment_record_from_group_ctx(
4331    read_id: &str,
4332    records: &[AsAlignment],
4333    ctx: &SegmentScanContext<'_>,
4334    correction: &SegmentCorrectionContext<'_>,
4335    strand_hint: Option<char>,
4336    junction_hints: &[(i32, i32)],
4337) -> Option<SegmentRecord> {
4338    let base = build_backward_segment_record(
4339        read_id,
4340        records,
4341        strand_hint,
4342        junction_hints,
4343        Some(correction),
4344        ctx.read_len,
4345    )?;
4346    // Outward groups may have already been materialized with local-clip rows by
4347    // the caller; running the rescue again would rebuild the same read-local
4348    // windows and can duplicate pseudo-alignments in the non-BSJ hot path.
4349    if records.iter().any(|record| record.from_local_clip) {
4350        return Some(base);
4351    }
4352    let Some(enriched_records) = try_add_non_bsj_local_clip_alignments_ctx(records, ctx) else {
4353        return Some(base);
4354    };
4355    build_backward_segment_record(
4356        read_id,
4357        &enriched_records,
4358        strand_hint,
4359        junction_hints,
4360        Some(correction),
4361        ctx.read_len,
4362    )
4363    .or(Some(base))
4364}
4365
4366/// Returns a unique RNA-strand hint from one read's validated candidates.
4367fn local_strand_hint(candidates: &[PositiveCandidate]) -> Option<char> {
4368    let mut hint = 0;
4369    for candidate in candidates {
4370        if candidate.strand_hint == 0 {
4371            continue;
4372        }
4373        if hint != 0 && hint != candidate.strand_hint {
4374            return None;
4375        }
4376        hint = candidate.strand_hint;
4377    }
4378    match hint {
4379        -1 => Some('+'),
4380        1 => Some('-'),
4381        _ => None,
4382    }
4383}
4384
4385/// Returns corrected internal junction hints for one read.
4386fn local_junction_hints(candidates: &[PositiveCandidate]) -> Vec<(i32, i32)> {
4387    candidates
4388        .iter()
4389        .map(|candidate| (candidate.site2, candidate.site1))
4390        .collect()
4391}
4392
4393/// Adds local soft-clip pseudo-alignments for non-BSJ read groups.
4394///
4395/// This mirrors the BSJ sidecar idea without assuming a read-level circRNA
4396/// assignment. Candidate placements are searched in local windows around
4397/// detected circ loci and the read's mapped blocks; the final backward/outward
4398/// chain builder still decides whether a placement improves the selected
4399/// topology. The original mapper rows are always retained first so these local
4400/// rows cannot erase the primary evidence.
4401fn add_non_bsj_local_clip_alignments(
4402    records: &[AsAlignment],
4403    state: &ScanState,
4404) -> Vec<AsAlignment> {
4405    add_non_bsj_local_clip_alignments_ctx(records, &SegmentScanContext::from_state(state))
4406}
4407
4408/// Adds local soft-clip pseudo-alignments using the shared scan context.
4409fn add_non_bsj_local_clip_alignments_ctx(
4410    records: &[AsAlignment],
4411    ctx: &SegmentScanContext<'_>,
4412) -> Vec<AsAlignment> {
4413    try_add_non_bsj_local_clip_alignments_ctx(records, ctx).unwrap_or_else(|| records.to_vec())
4414}
4415
4416/// Adds local soft-clip pseudo-alignments only when the rescue creates rows.
4417///
4418/// Most non-BSJ groups have no usable soft clip placement. Returning `None` for
4419/// those cases avoids repeatedly cloning the read group while preserving the old
4420/// materialized output through `add_non_bsj_local_clip_alignments_ctx`.
4421fn try_add_non_bsj_local_clip_alignments_ctx(
4422    records: &[AsAlignment],
4423    ctx: &SegmentScanContext<'_>,
4424) -> Option<Vec<AsAlignment>> {
4425    if !records.iter().any(|record| {
4426        !record.from_local_clip
4427            && record.cigar.contains('S')
4428            && !record.seq.is_empty()
4429            && record.seq != "*"
4430    }) {
4431        return None;
4432    }
4433    let windows = non_bsj_local_clip_windows_ctx(records, ctx);
4434    if windows.is_empty() {
4435        return None;
4436    }
4437    let mut local = non_bsj_local_clip_alignments(records, &windows, ctx);
4438    if local.is_empty() {
4439        return None;
4440    }
4441    local.sort_by(|a, b| {
4442        mate_bucket(a.flag)
4443            .cmp(&mate_bucket(b.flag))
4444            .then_with(|| a.chr.cmp(&b.chr))
4445            .then_with(|| a.pos.cmp(&b.pos))
4446            .then_with(|| a.cigar.cmp(&b.cigar))
4447    });
4448    local.truncate(NON_BSJ_LOCAL_CLIP_MAX_ROWS_PER_READ);
4449    let mut out = records.to_vec();
4450    out.extend(local);
4451    Some(out)
4452}
4453
4454/// Builds bounded local reference windows for non-BSJ clip placement.
4455///
4456/// The search space deliberately includes circ-cluster and read-neighborhood
4457/// anchors, not only exact Summary circ spans. Windows are centered on likely
4458/// junction anchors rather than whole loci, which keeps this read-local rescue
4459/// bounded while still allowing clips to place just outside a particular circ
4460/// span when that is the most coherent chain explanation.
4461/// Builds bounded local reference windows from the shared scan context.
4462fn non_bsj_local_clip_windows_ctx(
4463    records: &[AsAlignment],
4464    ctx: &SegmentScanContext<'_>,
4465) -> Vec<LocalClipWindow> {
4466    let mut by_chr: HashMap<&str, (i32, i32)> = HashMap::new();
4467    let mut anchors_by_chr: HashMap<&str, Vec<i32>> = HashMap::new();
4468    let anchor_flank = ctx.read_len * NON_BSJ_LOCAL_CLIP_ANCHOR_FLANK_MULTIPLIER;
4469    for record in records {
4470        if record.chr == "*" {
4471            continue;
4472        }
4473        let Some((start, end)) = alignment_ref_span(record, ctx.read_len) else {
4474            continue;
4475        };
4476        by_chr
4477            .entry(record.chr.as_str())
4478            .and_modify(|span| {
4479                span.0 = span.0.min(start);
4480                span.1 = span.1.max(end);
4481            })
4482            .or_insert((start, end));
4483        anchors_by_chr
4484            .entry(record.chr.as_str())
4485            .or_default()
4486            .extend([start, end]);
4487    }
4488
4489    let mut windows = Vec::new();
4490    let mut seen = HashSet::new();
4491    for (chr, (group_start, group_end)) in by_chr {
4492        if let Some(spans) = ctx.circ_spans_by_chr.get(chr) {
4493            for span in
4494                overlapping_circ_spans(spans, group_start - MIN_INTRON, group_end + MIN_INTRON)
4495            {
4496                push_local_clip_anchor_window(
4497                    &mut windows,
4498                    &mut seen,
4499                    ctx.reference,
4500                    chr,
4501                    span.start,
4502                    anchor_flank,
4503                );
4504                push_local_clip_anchor_window(
4505                    &mut windows,
4506                    &mut seen,
4507                    ctx.reference,
4508                    chr,
4509                    span.end,
4510                    anchor_flank,
4511                );
4512            }
4513        }
4514        if let Some(clusters) = ctx.clusters_by_chr.get(chr) {
4515            for cluster in overlapping_circ_clusters(
4516                clusters,
4517                group_start - MIN_INTRON,
4518                group_end + MIN_INTRON,
4519            ) {
4520                push_local_clip_anchor_window(
4521                    &mut windows,
4522                    &mut seen,
4523                    ctx.reference,
4524                    chr,
4525                    cluster.start,
4526                    anchor_flank,
4527                );
4528                push_local_clip_anchor_window(
4529                    &mut windows,
4530                    &mut seen,
4531                    ctx.reference,
4532                    chr,
4533                    cluster.end,
4534                    anchor_flank,
4535                );
4536            }
4537        }
4538        push_local_clip_anchor_window(
4539            &mut windows,
4540            &mut seen,
4541            ctx.reference,
4542            chr,
4543            group_start,
4544            anchor_flank,
4545        );
4546        push_local_clip_anchor_window(
4547            &mut windows,
4548            &mut seen,
4549            ctx.reference,
4550            chr,
4551            group_end,
4552            anchor_flank,
4553        );
4554        if let Some(anchors) = anchors_by_chr.get(chr) {
4555            for anchor in anchors {
4556                push_local_clip_anchor_window(
4557                    &mut windows,
4558                    &mut seen,
4559                    ctx.reference,
4560                    chr,
4561                    *anchor,
4562                    anchor_flank,
4563                );
4564            }
4565        }
4566    }
4567    windows
4568}
4569
4570/// Returns circ spans overlapping a query interval using the sorted span index.
4571fn overlapping_circ_spans(
4572    spans: &[CircSpan],
4573    query_start: i32,
4574    query_end: i32,
4575) -> impl Iterator<Item = &CircSpan> {
4576    let right = spans.partition_point(|span| span.start <= query_end);
4577    spans[..right]
4578        .iter()
4579        .rev()
4580        .take_while(move |span| span.max_end_through >= query_start)
4581        .filter(move |span| span.end >= query_start)
4582}
4583
4584/// Returns circ clusters overlapping a query interval using sorted disjoint clusters.
4585fn overlapping_circ_clusters(
4586    clusters: &[CircCluster],
4587    query_start: i32,
4588    query_end: i32,
4589) -> impl Iterator<Item = &CircCluster> {
4590    let right = clusters.partition_point(|cluster| cluster.start <= query_end);
4591    clusters[..right]
4592        .iter()
4593        .rev()
4594        .take_while(move |cluster| cluster.end >= query_start)
4595}
4596
4597/// Appends one clamped anchor-centered local clip window if it is non-duplicate.
4598fn push_local_clip_anchor_window(
4599    windows: &mut Vec<LocalClipWindow>,
4600    seen: &mut HashSet<(String, i32, i32)>,
4601    reference: &HashMap<String, String>,
4602    chr: &str,
4603    anchor: i32,
4604    flank: i32,
4605) {
4606    let Some(chr_seq) = reference.get(chr) else {
4607        return;
4608    };
4609    let chr_len = chr_seq.len() as i32;
4610    let start = (anchor - flank).max(1);
4611    let end = (anchor + flank).min(chr_len);
4612    if start > end {
4613        return;
4614    }
4615    let key = (chr.to_string(), start, end);
4616    if seen.insert(key.clone()) {
4617        windows.push(LocalClipWindow {
4618            chr: key.0,
4619            start,
4620            end,
4621        });
4622    }
4623}
4624
4625/// Generates pseudo-alignment rows by full-exact soft-clip placement.
4626///
4627/// BSJ sidecars can afford longest-partial local search because they run only
4628/// after a BSJ validator accepts one read group. Backward/outward candidates are
4629/// more numerous, so this stage keeps only full-clip exact placements and leaves
4630/// partial/approximate clip rescue for a later indexed or circRNA-level pass.
4631fn non_bsj_local_clip_alignments(
4632    records: &[AsAlignment],
4633    windows: &[LocalClipWindow],
4634    ctx: &SegmentScanContext<'_>,
4635) -> Vec<AsAlignment> {
4636    let mut out = Vec::new();
4637    let mut seen = HashSet::new();
4638    let min_clip_len = MIN_JUNCTION_SUPPORT_SEGMENT_LENGTH as usize;
4639    for record in records {
4640        if record.from_local_clip
4641            || !record.cigar.contains('S')
4642            || record.seq.is_empty()
4643            || record.seq == "*"
4644            || record.chr == "*"
4645        {
4646            continue;
4647        }
4648        let payload = segment_clip_payload(record);
4649        if payload == "*" {
4650            continue;
4651        }
4652        for (side, clip_seq) in parse_clip_payload(&payload) {
4653            if clip_seq.len() < min_clip_len || clip_seq.contains('N') {
4654                continue;
4655            }
4656            let query = clip_seq.to_ascii_uppercase();
4657            if query.contains('N') {
4658                continue;
4659            }
4660            let mut candidates: Vec<(i32, AsAlignment)> = Vec::new();
4661            for window in windows.iter().filter(|window| window.chr == record.chr) {
4662                let Some(chr_seq) = ctx.reference.get(&window.chr) else {
4663                    continue;
4664                };
4665                let Some(local_seq) = chr_seq.get((window.start - 1) as usize..window.end as usize)
4666                else {
4667                    continue;
4668                };
4669                let mut positions =
4670                    exact_clip_match_positions(local_seq, &query, window.start, window.end);
4671                positions.truncate(NON_BSJ_LOCAL_CLIP_MAX_PLACEMENTS_PER_SIDE);
4672                for pos in positions {
4673                    let read_len = record.seq.len() as i32;
4674                    let Some(cigar) =
4675                        clip_placement_cigar(side, clip_seq.len(), 0, clip_seq.len(), read_len)
4676                    else {
4677                        continue;
4678                    };
4679                    let flag = record.flag | 0x800;
4680                    let key = (flag, window.chr.clone(), pos, cigar.clone(), side);
4681                    if !seen.insert(key) {
4682                        continue;
4683                    }
4684                    let distance =
4685                        local_clip_distance_to_group(records, &window.chr, pos, ctx.read_len);
4686                    candidates.push((
4687                        -((clip_seq.len() as i32) * 1000) + distance,
4688                        AsAlignment {
4689                            flag,
4690                            chr: window.chr.clone(),
4691                            pos,
4692                            mapq: record.mapq.saturating_sub(1),
4693                            cigar,
4694                            seq: String::new(),
4695                            // Local clip placements are admitted only as full
4696                            // exact matches against the reference window, so a
4697                            // short-form match run is enough for later BAM
4698                            // sequence reconstruction without storing the full
4699                            // read.
4700                            cs: format!(":{}", clip_seq.len()),
4701                            from_local_clip: true,
4702                            xa_alternatives: Vec::new(),
4703                        },
4704                    ));
4705                }
4706            }
4707            candidates.sort_by(|a, b| a.0.cmp(&b.0));
4708            out.extend(
4709                candidates
4710                    .into_iter()
4711                    .take(NON_BSJ_LOCAL_CLIP_MAX_PLACEMENTS_PER_SIDE)
4712                    .map(|(_, alignment)| alignment),
4713            );
4714        }
4715    }
4716    out
4717}
4718
4719/// Scores how close a local clip placement is to the read group's mapped blocks.
4720fn local_clip_distance_to_group(
4721    records: &[AsAlignment],
4722    chr: &str,
4723    pos: i32,
4724    read_len: i32,
4725) -> i32 {
4726    records
4727        .iter()
4728        .filter(|record| record.chr == chr)
4729        .filter_map(|record| alignment_ref_span(record, read_len))
4730        .map(|(start, end)| (pos - start).abs().min((pos - end).abs()))
4731        .min()
4732        .unwrap_or(i32::MAX / 4)
4733}
4734
4735/// Tests membership in Perl's materialized `circ_cluster_range` hash.
4736fn point_in_cluster_range(pos: i32, cluster: &CircCluster) -> bool {
4737    pos >= cluster.start - MIN_INTRON && pos <= cluster.end + MIN_INTRON
4738}
4739
4740/// True if any alignment start/end falls inside a clustered circRNA locus.
4741fn overlaps_any_circ_cluster(records: &[AsAlignment], state: &ScanState) -> bool {
4742    overlaps_any_circ_cluster_ctx(records, &SegmentScanContext::from_state(state))
4743}
4744
4745/// True if any alignment start/end falls inside a clustered circRNA locus.
4746fn overlaps_any_circ_cluster_ctx(records: &[AsAlignment], ctx: &SegmentScanContext<'_>) -> bool {
4747    for record in records {
4748        if record.mapq < MAPQ_THRES {
4749            continue;
4750        }
4751        let msid = msid(&record.cigar, ctx.read_len);
4752        if msid.ref_len < 0 {
4753            continue;
4754        }
4755        let map_end = record.pos + msid.ref_len - 1;
4756        if let Some(clusters) = ctx.clusters_by_chr.get(&record.chr) {
4757            for cluster in overlapping_circ_clusters(
4758                clusters,
4759                record.pos.min(map_end) - MIN_INTRON,
4760                record.pos.max(map_end) + MIN_INTRON,
4761            ) {
4762                if point_in_cluster_range(record.pos, cluster)
4763                    || point_in_cluster_range(map_end, cluster)
4764                {
4765                    return true;
4766                }
4767            }
4768        }
4769    }
4770    false
4771}
4772
4773/// Returns whether one non-BSJ read group is pair-level outward circ evidence.
4774///
4775/// Unlike `type=backward`, this detector does not require a mate-internal read
4776/// chain wrap. It keeps primary-pair geometry where the reverse mate and
4777/// forward mate face outward by coordinate order. Gap length is intentionally
4778/// not capped because a gap-facing pair can still become useful once projected
4779/// onto confirmed BSJ loci; exact same-span pairs require 3' terminal clipping
4780/// on both mates so fully overlapping artifacts are not promoted. The row
4781/// remains `circ_id=NA`; later graph construction may project it onto every
4782/// compatible circRNA instead of forcing a read-level unique assignment.
4783#[cfg(test)]
4784fn is_outward_pair_group(records: &[AsAlignment], state: &ScanState) -> bool {
4785    is_outward_pair_group_ctx(records, &SegmentScanContext::from_state(state))
4786}
4787
4788/// Shared-context form of outward pair detection for parallel segments shards.
4789fn is_outward_pair_group_ctx(records: &[AsAlignment], ctx: &SegmentScanContext<'_>) -> bool {
4790    let Some((r1, r2)) = primary_mate_pair(records) else {
4791        return false;
4792    };
4793    if r1.chr != r2.chr || r1.chr == "*" || r1.mapq < ctx.min_mapq || r2.mapq < ctx.min_mapq {
4794        return false;
4795    }
4796    if has_linear_mate_pair_mapping(records, ctx) {
4797        return false;
4798    }
4799    let Some((r1_start, r1_end)) = alignment_ref_span(r1, ctx.read_len) else {
4800        return false;
4801    };
4802    let Some((r2_start, r2_end)) = alignment_ref_span(r2, ctx.read_len) else {
4803        return false;
4804    };
4805    if !has_3p_outward_pair_geometry(r1, (r1_start, r1_end), r2, (r2_start, r2_end)) {
4806        return false;
4807    }
4808    let span_start = r1_start.min(r2_start);
4809    let span_end = r1_end.max(r2_end);
4810    span_end - span_start + 1 <= BACKWARD_MAX_SPAN
4811}
4812
4813/// Returns whether any R1/R2 alignment pair can explain the read as linear.
4814///
4815/// Outward is weaker than backward because it is inferred from mate orientation
4816/// rather than a read-internal wrap. Before accepting the primary outward pair,
4817/// inspect all retained mapper alignments, including secondary/supplementary
4818/// alternatives, and reject the read if any mate pair forms a conventional
4819/// forward-left / reverse-right linear mapping within the allowed span.
4820fn has_linear_mate_pair_mapping(records: &[AsAlignment], ctx: &SegmentScanContext<'_>) -> bool {
4821    for i in 0..records.len() {
4822        let left = &records[i];
4823        if left.flag & 0x4 != 0 || left.chr == "*" || left.mapq < ctx.min_mapq {
4824            continue;
4825        }
4826        for right in &records[i + 1..] {
4827            if right.flag & 0x4 != 0
4828                || right.chr == "*"
4829                || right.mapq < ctx.min_mapq
4830                || left.chr != right.chr
4831                || mate_bucket(left.flag) == mate_bucket(right.flag)
4832            {
4833                continue;
4834            }
4835            let Some(left_span) = alignment_ref_span(left, ctx.read_len) else {
4836                continue;
4837            };
4838            let Some(right_span) = alignment_ref_span(right, ctx.read_len) else {
4839                continue;
4840            };
4841            let span = left_span.0.min(right_span.0)..=left_span.1.max(right_span.1);
4842            if *span.end() - *span.start() + 1 <= BACKWARD_MAX_SPAN
4843                && has_linear_pair_geometry(left, left_span, right, right_span)
4844            {
4845                return true;
4846            }
4847        }
4848    }
4849    false
4850}
4851
4852/// Tests whether two mate alignments look like an ordinary linear pair.
4853fn has_linear_pair_geometry(
4854    left: &AsAlignment,
4855    left_span: (i32, i32),
4856    right: &AsAlignment,
4857    right_span: (i32, i32),
4858) -> bool {
4859    let left_reverse = is_reverse_strand(left.flag);
4860    let right_reverse = is_reverse_strand(right.flag);
4861    if left_reverse == right_reverse {
4862        return false;
4863    }
4864    let (forward_span, reverse_span) = if left_reverse {
4865        (right_span, left_span)
4866    } else {
4867        (left_span, right_span)
4868    };
4869    forward_span != reverse_span
4870        && forward_span.0 <= reverse_span.0
4871        && forward_span.1 <= reverse_span.1
4872}
4873
4874/// Tests broad outward-facing geometry for a primary R1/R2 pair.
4875///
4876/// A valid pair has one reverse and one forward primary alignment. The reverse
4877/// mate must sit to the left of the forward mate by both start and end
4878/// coordinates. Non-identical spans are accepted when both aligned outward
4879/// extensions are at least 19 bp; terminal clips are not required in that case.
4880/// Exact same-span pairs have zero aligned outward length, so they still need
4881/// paired 3' terminal clip evidence, with the opposite mate's 5' clip
4882/// subtracted, to avoid promoting fully overlapping artifacts.
4883fn has_3p_outward_pair_geometry(
4884    r1: &AsAlignment,
4885    r1_span: (i32, i32),
4886    r2: &AsAlignment,
4887    r2_span: (i32, i32),
4888) -> bool {
4889    let r1_reverse = is_reverse_strand(r1.flag);
4890    let r2_reverse = is_reverse_strand(r2.flag);
4891    if r1_reverse == r2_reverse {
4892        return false;
4893    }
4894    let (reverse_span, forward_span) = if r1_reverse {
4895        (r1_span, r2_span)
4896    } else {
4897        (r2_span, r1_span)
4898    };
4899    let (reverse_record, forward_record) = if r1_reverse { (r1, r2) } else { (r2, r1) };
4900    if reverse_span == forward_span {
4901        return has_paired_outward_terminal_clip(reverse_record, forward_record);
4902    }
4903    if forward_span.0 - reverse_span.0 >= OUTWARD_MIN_PAIR_OFFSET
4904        && forward_span.1 - reverse_span.1 >= OUTWARD_MIN_PAIR_OFFSET
4905    {
4906        return true;
4907    }
4908    false
4909}
4910
4911/// Returns whether both mates carry outward-specific terminal clip evidence.
4912///
4913/// SAM CIGAR is query-order regardless of mapper strand, so the first and last
4914/// operations represent the sequenced 5' and 3' read ends. A real outward pair
4915/// should have strong 3' tails on both mates; if the opposite mate has a similar
4916/// 5' clip, that tail can be explained as unaligned mate overlap and is not
4917/// strong outward evidence.
4918fn has_paired_outward_terminal_clip(
4919    reverse_record: &AsAlignment,
4920    forward_record: &AsAlignment,
4921) -> bool {
4922    let Some((reverse_5p, reverse_3p)) = terminal_query_clips(reverse_record) else {
4923        return false;
4924    };
4925    let Some((forward_5p, forward_3p)) = terminal_query_clips(forward_record) else {
4926        return false;
4927    };
4928    reverse_3p >= OUTWARD_MIN_TERMINAL_CLIP
4929        && forward_3p >= OUTWARD_MIN_TERMINAL_CLIP
4930        && reverse_3p - forward_5p >= OUTWARD_MIN_TERMINAL_CLIP
4931        && forward_3p - reverse_5p >= OUTWARD_MIN_TERMINAL_CLIP
4932}
4933
4934/// Returns 5' and 3' terminal query clip lengths from a CIGAR string.
4935fn terminal_query_clips(record: &AsAlignment) -> Option<(i32, i32)> {
4936    let ops = parse_cigar_ops_basic(&record.cigar)?;
4937    let five_prime = ops
4938        .first()
4939        .copied()
4940        .filter(|(_, op)| matches!(op, 'S' | 'H'))
4941        .map_or(0, |(len, _)| len);
4942    let three_prime = ops
4943        .last()
4944        .copied()
4945        .filter(|(_, op)| matches!(op, 'S' | 'H'))
4946        .map_or(0, |(len, _)| len);
4947    Some((five_prime, three_prime))
4948}
4949
4950/// Selects the single primary R1/R2 pair used for outward orientation checks.
4951fn primary_mate_pair(records: &[AsAlignment]) -> Option<(&AsAlignment, &AsAlignment)> {
4952    let mut r1: Option<&AsAlignment> = None;
4953    let mut r2: Option<&AsAlignment> = None;
4954    for record in records {
4955        if is_secondary(record.flag) || is_supplementary(record.flag) || record.flag & 0x4 != 0 {
4956            continue;
4957        }
4958        match mate_bucket(record.flag) {
4959            0 if r1.is_none() => r1 = Some(record),
4960            0 => return None,
4961            1 if r2.is_none() => r2 = Some(record),
4962            1 => return None,
4963            _ => return None,
4964        }
4965    }
4966    Some((r1?, r2?))
4967}
4968
4969/// Returns the primary mapper alignment strand for R1 and R2.
4970///
4971/// Segment token strands describe RNA/circRNA interpretation and can remain
4972/// unknown for pair-orientation-only `outward` rows. These fields expose the
4973/// raw primary alignment orientation used to audit outward geometry without
4974/// changing the existing read-chain segment semantics.
4975fn primary_mate_alignment_strands(records: &[AsAlignment]) -> (String, String) {
4976    let mut strands: [Option<char>; 2] = [None, None];
4977    for record in records {
4978        if record.flag & 0x4 != 0 || is_secondary(record.flag) || is_supplementary(record.flag) {
4979            continue;
4980        }
4981        let bucket = mate_bucket(record.flag);
4982        if strands[bucket].is_none() {
4983            strands[bucket] = Some(strand_char(record.flag));
4984        }
4985    }
4986    (
4987        strands[0].map_or_else(|| "NA".to_string(), |strand| strand.to_string()),
4988        strands[1].map_or_else(|| "NA".to_string(), |strand| strand.to_string()),
4989    )
4990}
4991
4992/// Returns the reference span covered by one alignment's retained blocks.
4993fn alignment_ref_span(record: &AsAlignment, read_len: i32) -> Option<(i32, i32)> {
4994    let blocks = parse_alignment_blocks(record, read_len)?;
4995    let start = blocks.iter().map(|block| block.ref_start).min()?;
4996    let end = blocks.iter().map(|block| block.ref_end).max()?;
4997    Some((start, end))
4998}
4999
5000/// Splits one read group by reverse-strand flag and runs CIRI-AS pair matching.
5001///
5002/// The bucket is SAM flag bit 0x10, exactly Perl `ten2b(flag, 5)`. It is easy to
5003/// confuse with first/second mate bits because CIRI3's Scan1 grouping is mate
5004/// oriented; CIRI-AS splice detection is alignment-strand oriented.
5005fn mapping_check1(
5006    read_id: &str,
5007    records: &[AsAlignment],
5008    from_bsj: bool,
5009    state: &mut ScanState,
5010) -> Result<()> {
5011    let mut by_reverse: [Vec<&AsAlignment>; 2] = [Vec::new(), Vec::new()];
5012    for record in records {
5013        let idx = if record.flag & 0x10 != 0 { 1 } else { 0 };
5014        by_reverse[idx].push(record);
5015    }
5016    mapping_check2(read_id, &by_reverse[1], from_bsj, state)?;
5017    mapping_check2(read_id, &by_reverse[0], from_bsj, state)?;
5018    Ok(())
5019}
5020
5021/// Runs the non-BSJ mapping check against shard-local segments state.
5022///
5023/// In sidecar mode BSJ reads already came from Scan1/Scan2 segment evidence, so
5024/// the parallel rescan only needs the `from_bsj=false` branch. Keeping this
5025/// accumulator-specific copy avoids cloning the full `ScanState` per BAM shard.
5026fn mapping_check1_backward_candidates(
5027    read_id: &str,
5028    records: &[AsAlignment],
5029    ctx: &SegmentScanContext<'_>,
5030) -> Vec<PositiveCandidate> {
5031    let mut by_reverse: [Vec<&AsAlignment>; 2] = [Vec::new(), Vec::new()];
5032    for record in records {
5033        let idx = if record.flag & 0x10 != 0 { 1 } else { 0 };
5034        by_reverse[idx].push(record);
5035    }
5036    let mut candidates = Vec::new();
5037    mapping_check2_backward(read_id, &by_reverse[1], ctx, &mut candidates);
5038    mapping_check2_backward(read_id, &by_reverse[0], ctx, &mut candidates);
5039    candidates
5040}
5041
5042/// Borrowed sidecar counterpart of `mapping_check1_backward_candidates`.
5043///
5044/// Keeping this path allocation-light matters because most compact non-BSJ
5045/// groups never survive motif validation. It intentionally mirrors the owned
5046/// implementation and only allocates the candidate rows that can pass through
5047/// to validation and retained shard writing.
5048fn mapping_check1_backward_candidate_views(
5049    read_id: &str,
5050    records: &[NonBsjAlignmentView<'_>],
5051    ctx: &SegmentScanContext<'_>,
5052) -> Vec<PositiveCandidate> {
5053    let mut by_reverse: [Vec<&NonBsjAlignmentView<'_>>; 2] = [Vec::new(), Vec::new()];
5054    for record in records {
5055        let idx = if record.flag & 0x10 != 0 { 1 } else { 0 };
5056        by_reverse[idx].push(record);
5057    }
5058    let mut candidates = Vec::new();
5059    mapping_check2_backward_views(read_id, &by_reverse[1], ctx, &mut candidates);
5060    mapping_check2_backward_views(read_id, &by_reverse[0], ctx, &mut candidates);
5061    candidates
5062}
5063
5064/// Accumulator-backed form of CIRI-AS `mapping_check2_add`.
5065fn mapping_check2_backward(
5066    read_id: &str,
5067    records: &[&AsAlignment],
5068    ctx: &SegmentScanContext<'_>,
5069    candidates: &mut Vec<PositiveCandidate>,
5070) {
5071    if records.len() < 2 {
5072        return;
5073    }
5074
5075    let mut matches = Vec::with_capacity(records.len());
5076    for record in records {
5077        matches.push(msid(&record.cigar, ctx.read_len));
5078    }
5079
5080    let initial_len = candidates.len();
5081    let mut candidate_chr: Option<String> = None;
5082    let mut na_tag = false;
5083    'read: for i in 0..records.len() - 1 {
5084        for j in i + 1..records.len() {
5085            let ri = records[i];
5086            let rj = records[j];
5087            if ri.chr != rj.chr || reverse_bit(ri.flag) != reverse_bit(rj.flag) {
5088                continue;
5089            }
5090            if let Some(existing_chr) = &candidate_chr {
5091                if existing_chr != &ri.chr {
5092                    na_tag = true;
5093                    break 'read;
5094                }
5095            }
5096            if let Some(candidate) = candidate_from_pair(
5097                read_id,
5098                ri,
5099                rj,
5100                matches[i],
5101                matches[j],
5102                i,
5103                j,
5104                None,
5105                ctx.read_len,
5106            ) {
5107                candidate_chr.get_or_insert_with(|| candidate.chr.clone());
5108                candidates.push(candidate);
5109            }
5110        }
5111    }
5112
5113    if na_tag {
5114        candidates.truncate(initial_len);
5115    }
5116}
5117
5118/// Borrowed sidecar form of CIRI-AS `mapping_check2_add`.
5119fn mapping_check2_backward_views(
5120    read_id: &str,
5121    records: &[&NonBsjAlignmentView<'_>],
5122    ctx: &SegmentScanContext<'_>,
5123    candidates: &mut Vec<PositiveCandidate>,
5124) {
5125    if records.len() < 2 {
5126        return;
5127    }
5128
5129    let mut matches = Vec::with_capacity(records.len());
5130    for record in records {
5131        matches.push(msid(record.cigar, ctx.read_len));
5132    }
5133
5134    let initial_len = candidates.len();
5135    let mut candidate_chr: Option<String> = None;
5136    let mut na_tag = false;
5137    'read: for i in 0..records.len() - 1 {
5138        for j in i + 1..records.len() {
5139            let ri = records[i];
5140            let rj = records[j];
5141            if ri.chr != rj.chr || reverse_bit(ri.flag) != reverse_bit(rj.flag) {
5142                continue;
5143            }
5144            if let Some(existing_chr) = &candidate_chr {
5145                if existing_chr != ri.chr {
5146                    na_tag = true;
5147                    break 'read;
5148                }
5149            }
5150            if let Some(candidate) = candidate_from_view_pair(
5151                read_id,
5152                ri,
5153                rj,
5154                matches[i],
5155                matches[j],
5156                i,
5157                j,
5158                ctx.read_len,
5159            ) {
5160                candidate_chr.get_or_insert_with(|| candidate.chr.clone());
5161                candidates.push(candidate);
5162            }
5163        }
5164    }
5165
5166    if na_tag {
5167        candidates.truncate(initial_len);
5168    }
5169}
5170
5171/// Mirrors CIRI-AS `mapping_check2` and `mapping_check2_add`.
5172///
5173/// The two Perl routines differ only by circRNA boundary checks and counters.
5174/// `from_bsj=true` enforces the original circRNA interval for known junction
5175/// reads; non-BSJ add-on reads are only pre-gated by clustered circ overlap.
5176fn mapping_check2(
5177    read_id: &str,
5178    records: &[&AsAlignment],
5179    from_bsj: bool,
5180    state: &mut ScanState,
5181) -> Result<()> {
5182    if records.len() < 2 {
5183        return Ok(());
5184    }
5185    let circ = if from_bsj {
5186        let circ_id = state
5187            .junction_read_to_circ
5188            .get(read_id)
5189            .ok_or_else(|| anyhow!("missing circ for junction read {}", read_id))?;
5190        Some(
5191            state
5192                .circ_by_id
5193                .get(circ_id)
5194                .ok_or_else(|| anyhow!("missing circ record {}", circ_id))?,
5195        )
5196    } else {
5197        None
5198    };
5199
5200    let mut matches = Vec::with_capacity(records.len());
5201    for record in records {
5202        matches.push(msid(&record.cigar, state.read_len));
5203    }
5204
5205    let initial_len = state.candidates.len();
5206    let mut candidate_chr: Option<String> = None;
5207    let mut na_tag = false;
5208    'read: for i in 0..records.len() - 1 {
5209        for j in i + 1..records.len() {
5210            let ri = records[i];
5211            let rj = records[j];
5212            if ri.chr != rj.chr {
5213                continue;
5214            }
5215            if let Some(circ) = circ {
5216                if ri.chr != circ.chr {
5217                    continue;
5218                }
5219            }
5220            if reverse_bit(ri.flag) != reverse_bit(rj.flag) {
5221                continue;
5222            }
5223            if let Some(existing_chr) = &candidate_chr {
5224                if existing_chr != &ri.chr {
5225                    na_tag = true;
5226                    break 'read;
5227                }
5228            }
5229            if let Some(candidate) = candidate_from_pair(
5230                read_id,
5231                ri,
5232                rj,
5233                matches[i],
5234                matches[j],
5235                i,
5236                j,
5237                circ,
5238                state.read_len,
5239            ) {
5240                candidate_chr.get_or_insert_with(|| candidate.chr.clone());
5241                state.candidates.push(candidate);
5242            }
5243        }
5244    }
5245
5246    if na_tag {
5247        state.candidates.truncate(initial_len);
5248    }
5249    Ok(())
5250}
5251
5252/// Builds one candidate from a pair of CIRI-AS classified alignments.
5253fn candidate_from_pair(
5254    read_id: &str,
5255    ri: &AsAlignment,
5256    rj: &AsAlignment,
5257    mi: Msid,
5258    mj: Msid,
5259    i: usize,
5260    j: usize,
5261    circ: Option<&CircRecord>,
5262    read_len: i32,
5263) -> Option<PositiveCandidate> {
5264    let product = mi.kind * mj.kind;
5265    if product == -1 {
5266        let cir_scale = mi.kind * (ri.pos + mi.clip2) + mj.kind * (rj.pos + mj.clip2);
5267        if (mi.clip1 - mj.clip1).abs() <= 6
5268            && cir_scale < 0
5269            && boundary_ok(ri, mi, circ)
5270            && boundary_ok(rj, mj, circ)
5271            && ri.mapq >= MAPQ_UNI
5272            && rj.mapq >= MAPQ_UNI
5273            && ri.mapq + rj.mapq >= MAPQ_BOTH
5274        {
5275            let mut pos_com = [ri.pos + mi.clip2, rj.pos + mj.clip2];
5276            pos_com.sort_unstable();
5277            let end_adjustment1 = div_trunc(mi.clip1 * mi.kind + mj.clip1 * mj.kind, 2);
5278            let end_adjustment2 = mi.clip1 * mi.kind + mj.clip1 * mj.kind - end_adjustment1;
5279            let (x_record, y_record) = if mi.kind.cmp(&mj.kind) == Ordering::Greater {
5280                (rj, ri)
5281            } else {
5282                (ri, rj)
5283            };
5284            return Some(PositiveCandidate {
5285                read_id: read_id.to_string(),
5286                index: 0,
5287                chr: ri.chr.clone(),
5288                site2: pos_com[0] - end_adjustment2,
5289                site1: pos_com[1] + end_adjustment1,
5290                adjust1: end_adjustment1,
5291                adjust2: end_adjustment2,
5292                strand_hint: 0,
5293                cigars: [
5294                    Some(x_record.cigar.clone()),
5295                    Some(y_record.cigar.clone()),
5296                    None,
5297                ],
5298            });
5299        }
5300    } else if product.abs() == 10 {
5301        let (x, y, mx, my, rx, ry) = if mi.kind <= mj.kind {
5302            (i, j, mi, mj, ri, rj)
5303        } else {
5304            (j, i, mj, mi, rj, ri)
5305        };
5306        let _ = (x, y);
5307        if mx.kind == -1 {
5308            let cir_scale = ry.pos + my.ref_len - 1 - rx.pos;
5309            if (read_len - my.clip2 - mx.clip1).abs() <= 6
5310                && cir_scale < 0
5311                && boundary_start_ok(ry, circ)
5312                && boundary_end_ok(rx, mx, circ)
5313                && ri.mapq >= MAPQ_UNI
5314                && rj.mapq >= MAPQ_UNI
5315                && ri.mapq + rj.mapq >= MAPQ_BOTH
5316            {
5317                let end_adjustment1 = div_trunc(my.clip1 + my.ref_len - mx.clip1, 2);
5318                let end_adjustment2 = my.clip1 + my.ref_len - mx.clip1 - end_adjustment1;
5319                return Some(PositiveCandidate {
5320                    read_id: read_id.to_string(),
5321                    index: 0,
5322                    chr: rx.chr.clone(),
5323                    site2: ry.pos + my.ref_len - 1 - end_adjustment2,
5324                    site1: rx.pos + end_adjustment1,
5325                    adjust1: end_adjustment1,
5326                    adjust2: end_adjustment2,
5327                    strand_hint: 0,
5328                    cigars: [Some(rx.cigar.clone()), None, Some(ry.cigar.clone())],
5329                });
5330            }
5331        } else {
5332            let cir_scale = rx.pos + mx.ref_len - 1 - ry.pos;
5333            if (mx.clip1 - my.clip1).abs() <= 6
5334                && cir_scale < 0
5335                && boundary_start_ok(rx, circ)
5336                && boundary_end_ok(ry, my, circ)
5337                && ri.mapq >= MAPQ_UNI
5338                && rj.mapq >= MAPQ_UNI
5339                && ri.mapq + rj.mapq >= MAPQ_BOTH
5340            {
5341                let end_adjustment1 = div_trunc(mx.clip1 - my.clip1, 2);
5342                let end_adjustment2 = mx.clip1 - my.clip1 - end_adjustment1;
5343                return Some(PositiveCandidate {
5344                    read_id: read_id.to_string(),
5345                    index: 0,
5346                    chr: rx.chr.clone(),
5347                    site2: rx.pos + mx.ref_len - 1 - end_adjustment2,
5348                    site1: ry.pos + end_adjustment1,
5349                    adjust1: end_adjustment1,
5350                    adjust2: end_adjustment2,
5351                    strand_hint: 0,
5352                    cigars: [None, Some(rx.cigar.clone()), Some(ry.cigar.clone())],
5353                });
5354            }
5355        }
5356    }
5357    None
5358}
5359
5360/// Builds one candidate from a borrowed compact sidecar alignment pair.
5361fn candidate_from_view_pair(
5362    read_id: &str,
5363    ri: &NonBsjAlignmentView<'_>,
5364    rj: &NonBsjAlignmentView<'_>,
5365    mi: Msid,
5366    mj: Msid,
5367    i: usize,
5368    j: usize,
5369    read_len: i32,
5370) -> Option<PositiveCandidate> {
5371    let product = mi.kind * mj.kind;
5372    if product == -1 {
5373        let cir_scale = mi.kind * (ri.pos + mi.clip2) + mj.kind * (rj.pos + mj.clip2);
5374        if (mi.clip1 - mj.clip1).abs() <= 6
5375            && cir_scale < 0
5376            && ri.mapq >= MAPQ_UNI
5377            && rj.mapq >= MAPQ_UNI
5378            && ri.mapq + rj.mapq >= MAPQ_BOTH
5379        {
5380            let mut pos_com = [ri.pos + mi.clip2, rj.pos + mj.clip2];
5381            pos_com.sort_unstable();
5382            let end_adjustment1 = div_trunc(mi.clip1 * mi.kind + mj.clip1 * mj.kind, 2);
5383            let end_adjustment2 = mi.clip1 * mi.kind + mj.clip1 * mj.kind - end_adjustment1;
5384            let (x_record, y_record) = if mi.kind.cmp(&mj.kind) == Ordering::Greater {
5385                (rj, ri)
5386            } else {
5387                (ri, rj)
5388            };
5389            return Some(PositiveCandidate {
5390                read_id: read_id.to_string(),
5391                index: 0,
5392                chr: ri.chr.to_string(),
5393                site2: pos_com[0] - end_adjustment2,
5394                site1: pos_com[1] + end_adjustment1,
5395                adjust1: end_adjustment1,
5396                adjust2: end_adjustment2,
5397                strand_hint: 0,
5398                cigars: [
5399                    Some(x_record.cigar.to_string()),
5400                    Some(y_record.cigar.to_string()),
5401                    None,
5402                ],
5403            });
5404        }
5405    } else if product.abs() == 10 {
5406        let (x, y, mx, my, rx, ry) = if mi.kind <= mj.kind {
5407            (i, j, mi, mj, ri, rj)
5408        } else {
5409            (j, i, mj, mi, rj, ri)
5410        };
5411        let _ = (x, y);
5412        if mx.kind == -1 {
5413            let cir_scale = ry.pos + my.ref_len - 1 - rx.pos;
5414            if (read_len - my.clip2 - mx.clip1).abs() <= 6
5415                && cir_scale < 0
5416                && ri.mapq >= MAPQ_UNI
5417                && rj.mapq >= MAPQ_UNI
5418                && ri.mapq + rj.mapq >= MAPQ_BOTH
5419            {
5420                let end_adjustment1 = div_trunc(my.clip1 + my.ref_len - mx.clip1, 2);
5421                let end_adjustment2 = my.clip1 + my.ref_len - mx.clip1 - end_adjustment1;
5422                return Some(PositiveCandidate {
5423                    read_id: read_id.to_string(),
5424                    index: 0,
5425                    chr: rx.chr.to_string(),
5426                    site2: ry.pos + my.ref_len - 1 - end_adjustment2,
5427                    site1: rx.pos + end_adjustment1,
5428                    adjust1: end_adjustment1,
5429                    adjust2: end_adjustment2,
5430                    strand_hint: 0,
5431                    cigars: [Some(rx.cigar.to_string()), None, Some(ry.cigar.to_string())],
5432                });
5433            }
5434        } else {
5435            let cir_scale = rx.pos + mx.ref_len - 1 - ry.pos;
5436            if (mx.clip1 - my.clip1).abs() <= 6
5437                && cir_scale < 0
5438                && ri.mapq >= MAPQ_UNI
5439                && rj.mapq >= MAPQ_UNI
5440                && ri.mapq + rj.mapq >= MAPQ_BOTH
5441            {
5442                let end_adjustment1 = div_trunc(mx.clip1 - my.clip1, 2);
5443                let end_adjustment2 = mx.clip1 - my.clip1 - end_adjustment1;
5444                return Some(PositiveCandidate {
5445                    read_id: read_id.to_string(),
5446                    index: 0,
5447                    chr: rx.chr.to_string(),
5448                    site2: rx.pos + mx.ref_len - 1 - end_adjustment2,
5449                    site1: ry.pos + end_adjustment1,
5450                    adjust1: end_adjustment1,
5451                    adjust2: end_adjustment2,
5452                    strand_hint: 0,
5453                    cigars: [None, Some(rx.cigar.to_string()), Some(ry.cigar.to_string())],
5454                });
5455            }
5456        }
5457    }
5458    None
5459}
5460
5461/// Perl-style integer division truncating toward zero.
5462fn div_trunc(value: i32, divisor: i32) -> i32 {
5463    value / divisor
5464}
5465
5466/// Returns SAM flag bit 0x10 as CIRI-AS `ten2b(flag, 5)`.
5467fn reverse_bit(flag: i32) -> i32 {
5468    if flag & 0x10 != 0 {
5469        1
5470    } else {
5471        0
5472    }
5473}
5474
5475/// Returns whether the SAM reverse-complement flag is set.
5476fn is_reverse_strand(flag: i32) -> bool {
5477    flag & 0x10 != 0
5478}
5479
5480/// Checks the full circ-boundary predicate used by the product `-1` branch.
5481fn boundary_ok(record: &AsAlignment, msid: Msid, circ: Option<&CircRecord>) -> bool {
5482    circ.is_none_or(|circ| {
5483        record.pos >= circ.start - 6 && record.pos + msid.ref_len - 1 <= circ.end + 6
5484    })
5485}
5486
5487/// Checks start-side circ-boundary predicates from CIRI-AS branch `abs(product)==10`.
5488fn boundary_start_ok(record: &AsAlignment, circ: Option<&CircRecord>) -> bool {
5489    circ.is_none_or(|circ| record.pos >= circ.start - 6)
5490}
5491
5492/// Checks end-side circ-boundary predicates from CIRI-AS branch `abs(product)==10`.
5493fn boundary_end_ok(record: &AsAlignment, msid: Msid, circ: Option<&CircRecord>) -> bool {
5494    circ.is_none_or(|circ| record.pos + msid.ref_len - 1 <= circ.end + 6)
5495}
5496
5497/// Mirrors the CIRI-AS `MSID` CIGAR classifier.
5498///
5499/// Hard clips are normalized to soft clips because the Perl routine performs
5500/// `s/H/S/g` before classifying. The returned values intentionally retain
5501/// legacy sentinels `-1` and `-2` for unsupported full-length and malformed CIGAR
5502/// patterns.
5503fn msid(cigar: &str, read_len: i32) -> Msid {
5504    if cigar == "*" || cigar.is_empty() {
5505        return Msid {
5506            kind: 0,
5507            clip1: 0,
5508            clip2: 0,
5509            ref_len: -1,
5510        };
5511    }
5512    let mut counts = Vec::new();
5513    let mut styles = Vec::new();
5514    let mut number = String::new();
5515    for ch in cigar.chars() {
5516        if ch.is_ascii_digit() {
5517            number.push(ch);
5518        } else {
5519            counts.push(number.parse::<i32>().unwrap_or(0));
5520            number.clear();
5521            styles.push(if ch == 'H' { 'S' } else { ch });
5522        }
5523    }
5524    if styles.is_empty() {
5525        return Msid {
5526            kind: 0,
5527            clip1: 0,
5528            clip2: 0,
5529            ref_len: -2,
5530        };
5531    }
5532    if counts.len() == 1 {
5533        return if cigar == format!("{}M", read_len) {
5534            Msid {
5535                kind: 0,
5536                clip1: 0,
5537                clip2: 0,
5538                ref_len: read_len,
5539            }
5540        } else {
5541            Msid {
5542                kind: 0,
5543                clip1: 0,
5544                clip2: 0,
5545                ref_len: -1,
5546            }
5547        };
5548    }
5549
5550    match counts.len() {
5551        2 => match (styles[0], styles[1]) {
5552            ('M', 'S') => Msid {
5553                kind: 1,
5554                clip1: counts[0],
5555                clip2: counts[0] - 1,
5556                ref_len: counts[0],
5557            },
5558            ('S', 'M') => Msid {
5559                kind: -1,
5560                clip1: counts[0],
5561                clip2: 0,
5562                ref_len: counts[1],
5563            },
5564            _ => invalid_msid(-2),
5565        },
5566        3 => match (styles[0], styles[1], styles[2]) {
5567            ('S', _, 'S') => Msid {
5568                kind: 10,
5569                clip1: counts[0],
5570                clip2: counts[2],
5571                ref_len: counts[1],
5572            },
5573            ('M', 'D', 'M') => Msid {
5574                kind: 0,
5575                clip1: 0,
5576                clip2: 0,
5577                ref_len: read_len + counts[1],
5578            },
5579            ('M', 'I', 'M') => Msid {
5580                kind: 0,
5581                clip1: 0,
5582                clip2: 0,
5583                ref_len: read_len - counts[1],
5584            },
5585            _ => invalid_msid(-2),
5586        },
5587        _ if styles[0] == 'M' && *styles.last().unwrap() == 'S' => {
5588            let (m_sum, d_sum) = sum_md(&styles[..styles.len() - 1], &counts[..counts.len() - 1]);
5589            Msid {
5590                kind: 1,
5591                clip1: read_len - counts[counts.len() - 1],
5592                clip2: m_sum + d_sum - 1,
5593                ref_len: m_sum + d_sum,
5594            }
5595        }
5596        _ if styles[0] == 'S' && *styles.last().unwrap() == 'M' => {
5597            let (m_sum, d_sum) = sum_md(&styles[1..], &counts[1..]);
5598            Msid {
5599                kind: -1,
5600                clip1: counts[0],
5601                clip2: 0,
5602                ref_len: m_sum + d_sum,
5603            }
5604        }
5605        _ if styles[0] == 'M' && *styles.last().unwrap() == 'M' => {
5606            let (m_sum, d_sum) = sum_md(&styles, &counts);
5607            Msid {
5608                kind: 0,
5609                clip1: 0,
5610                clip2: 0,
5611                ref_len: m_sum + d_sum,
5612            }
5613        }
5614        _ if styles[0] == 'S' && *styles.last().unwrap() == 'S' => {
5615            let (m_sum, d_sum) = sum_md(&styles[1..styles.len() - 1], &counts[1..counts.len() - 1]);
5616            Msid {
5617                kind: 10,
5618                clip1: counts[0],
5619                clip2: counts[counts.len() - 1],
5620                ref_len: m_sum + d_sum,
5621            }
5622        }
5623        _ => invalid_msid(-2),
5624    }
5625}
5626
5627/// Returns an invalid `MSID` sentinel.
5628fn invalid_msid(ref_len: i32) -> Msid {
5629    Msid {
5630        kind: 0,
5631        clip1: 0,
5632        clip2: 0,
5633        ref_len,
5634    }
5635}
5636
5637/// Sums reference-consuming `M` and `D` operations for `MSID`.
5638fn sum_md(styles: &[char], counts: &[i32]) -> (i32, i32) {
5639    let mut m_sum = 0;
5640    let mut d_sum = 0;
5641    for (style, count) in styles.iter().zip(counts) {
5642        match style {
5643            'M' => m_sum += count,
5644            'D' => d_sum += count,
5645            _ => {}
5646        }
5647    }
5648    (m_sum, d_sum)
5649}
5650
5651/// Applies CIRI-AS splice-signal motif checking and coordinate adjustment.
5652fn validate_splice_motifs(
5653    candidates: &mut Vec<PositiveCandidate>,
5654    reference: &HashMap<String, String>,
5655    annotation: Option<&Annotation>,
5656) -> Result<()> {
5657    // Motif validation is read-local and reference/annotation are immutable, so
5658    // this can run on Rayon workers without changing the final CIRI-AS ordering.
5659    let mut validated: Vec<(usize, PositiveCandidate)> = std::mem::take(candidates)
5660        .into_par_iter()
5661        .enumerate()
5662        .filter_map(|(idx, candidate)| {
5663            validate_splice_candidate(candidate, reference, annotation)
5664                .map(|candidate| (idx, candidate))
5665        })
5666        .collect();
5667    validated.sort_by_key(|(idx, _)| *idx);
5668    let mut validated: Vec<PositiveCandidate> = validated
5669        .into_iter()
5670        .map(|(_, candidate)| candidate)
5671        .collect();
5672    for (idx, candidate) in validated.iter_mut().enumerate() {
5673        candidate.index = idx;
5674    }
5675    *candidates = validated;
5676    Ok(())
5677}
5678
5679/// Validates and motif-adjusts one splice candidate independently.
5680///
5681/// This helper keeps the per-candidate work separate from collection ordering so
5682/// callers can run it in parallel while retaining the serial output contract.
5683fn validate_splice_candidate(
5684    mut candidate: PositiveCandidate,
5685    reference: &HashMap<String, String>,
5686    annotation: Option<&Annotation>,
5687) -> Option<PositiveCandidate> {
5688    let chr_seq = reference.get(&candidate.chr)?;
5689    let total_adjustment = candidate.adjust1 + candidate.adjust2;
5690    let (end_string1, end_string2) = if candidate.adjust2 >= 0 {
5691        (
5692            perl_substr(
5693                chr_seq,
5694                candidate.site1 - candidate.adjust1 - 4,
5695                4 + total_adjustment,
5696            ),
5697            perl_substr(
5698                chr_seq,
5699                candidate.site2 - candidate.adjust1 - 1,
5700                4 + total_adjustment,
5701            ),
5702        )
5703    } else {
5704        (
5705            perl_substr(
5706                chr_seq,
5707                candidate.site1 + candidate.adjust2 - 4,
5708                4 - total_adjustment,
5709            ),
5710            perl_substr(
5711                chr_seq,
5712                candidate.site2 + candidate.adjust2 - 1,
5713                4 - total_adjustment,
5714            ),
5715        )
5716    };
5717    let (hint, index) = index_compare(&end_string1, &end_string2, &candidate, annotation);
5718    if hint == 0 {
5719        return None;
5720    }
5721    let diff_adjt = if candidate.adjust2 >= 0 {
5722        index - 1 - candidate.adjust1
5723    } else {
5724        index - 1 + candidate.adjust2
5725    };
5726    candidate.strand_hint = hint;
5727    candidate.adjust1 += diff_adjt;
5728    candidate.adjust2 = total_adjustment - candidate.adjust1;
5729    candidate.site1 += diff_adjt;
5730    candidate.site2 += diff_adjt;
5731    Some(candidate)
5732}
5733
5734/// Perl-compatible positive-offset substring on ASCII FASTA sequences.
5735fn perl_substr(seq: &str, start: i32, len: i32) -> String {
5736    if len <= 0 || start >= seq.len() as i32 {
5737        return String::new();
5738    }
5739    let start = start.max(0) as usize;
5740    let end = (start + len as usize).min(seq.len());
5741    seq[start..end].to_string()
5742}
5743
5744/// Matches splice motifs and resolves ambiguous strand/offset choices.
5745///
5746/// CIRI-AS v1.2 uses `if AC/CT ... elsif AG/GT`, so ambiguous windows are decided
5747/// by branch order and Perl hash iteration. Rust keeps that behavior only as the
5748/// fallback. When annotation is available, both negative-strand (`AC/CT`) and
5749/// positive-strand (`AG/GT`) motif explanations are scored against known exon
5750/// boundaries so strand and offset are chosen by biological support first.
5751fn index_compare(
5752    left: &str,
5753    right: &str,
5754    candidate: &PositiveCandidate,
5755    annotation: Option<&Annotation>,
5756) -> (i32, i32) {
5757    let left = left.to_ascii_uppercase();
5758    let right = right.to_ascii_uppercase();
5759    let has_negative_motifs = left.contains("AC") && right.contains("CT");
5760    let has_positive_motifs = left.contains("AG") && right.contains("GT");
5761    let negative = shared_motif_indices(&left, "AC", &right, "CT");
5762    let positive = shared_motif_indices(&left, "AG", &right, "GT");
5763
5764    if annotation.is_some() {
5765        if let Some(choice) = choose_annotated_motif(&negative, &positive, candidate, annotation) {
5766            return choice;
5767        }
5768    }
5769
5770    if has_negative_motifs {
5771        if negative.is_empty() {
5772            return (0, 0);
5773        }
5774        return (1, choose_motif_index(&negative, 1, candidate, annotation));
5775    }
5776    if has_positive_motifs && !positive.is_empty() {
5777        return (-1, choose_motif_index(&positive, -1, candidate, annotation));
5778    }
5779    (0, 0)
5780}
5781
5782/// Finds all shared motif offsets between two splice windows.
5783fn shared_motif_indices(left: &str, motif_left: &str, right: &str, motif_right: &str) -> Vec<i32> {
5784    let left_indices: HashSet<usize> = left.match_indices(motif_left).map(|(i, _)| i).collect();
5785    let mut indices: Vec<i32> = right
5786        .match_indices(motif_right)
5787        .map(|(i, _)| i)
5788        .filter(|i| left_indices.contains(i))
5789        .map(|i| i as i32)
5790        .collect();
5791    indices.sort_unstable();
5792    indices
5793}
5794
5795/// Chooses a motif offset using exon-boundary support before stable fallback.
5796fn choose_motif_index(
5797    indices: &[i32],
5798    hint: i32,
5799    candidate: &PositiveCandidate,
5800    annotation: Option<&Annotation>,
5801) -> i32 {
5802    indices
5803        .iter()
5804        .copied()
5805        .max_by_key(|&index| {
5806            (
5807                annotation_offset_score(candidate, index, hint, annotation),
5808                std::cmp::Reverse(index),
5809            )
5810        })
5811        .unwrap_or(0)
5812}
5813
5814/// Chooses between negative- and positive-strand motif explanations.
5815///
5816/// Annotation can disambiguate both offset and strand. If both strands receive
5817/// the same support score, this falls back to CIRI-AS v1.2 branch order
5818/// (`AC/CT` before `AG/GT`) so uninformative annotation does not invent a new
5819/// strand convention.
5820fn choose_annotated_motif(
5821    negative: &[i32],
5822    positive: &[i32],
5823    candidate: &PositiveCandidate,
5824    annotation: Option<&Annotation>,
5825) -> Option<(i32, i32)> {
5826    let mut best: Option<(i32, i32, i32, i32)> = None;
5827    for &(hint, branch_rank, indices) in &[(1, 0, negative), (-1, 1, positive)] {
5828        for &index in indices {
5829            let score = annotation_offset_score(candidate, index, hint, annotation);
5830            if score == 0 {
5831                continue;
5832            }
5833            let candidate_key = (score, -branch_rank, -index, hint);
5834            if best.is_none_or(|current| candidate_key > current) {
5835                best = Some(candidate_key);
5836            }
5837        }
5838    }
5839    if let Some((_, _, neg_index, hint)) = best {
5840        return Some((hint, -neg_index));
5841    }
5842    None
5843}
5844
5845/// Scores one possible motif offset by known exon-boundary support.
5846///
5847/// CIRI-AS later treats `site1` as a candidate exon start and `site2` as a
5848/// candidate exon end. The tie-break follows that downstream contract: exact
5849/// `site1 -> exon_start` plus `site2 -> exon_end` support is strongest, with a
5850/// smaller bonus when the motif-implied strand agrees with annotation.
5851fn annotation_offset_score(
5852    candidate: &PositiveCandidate,
5853    index: i32,
5854    hint: i32,
5855    annotation: Option<&Annotation>,
5856) -> i32 {
5857    let Some(annotation) = annotation else {
5858        return 0;
5859    };
5860    let diff_adjt = if candidate.adjust2 >= 0 {
5861        index - 1 - candidate.adjust1
5862    } else {
5863        index - 1 + candidate.adjust2
5864    };
5865    let site1 = candidate.site1 + diff_adjt;
5866    let site2 = candidate.site2 + diff_adjt;
5867    let start_key = format!("{}\t{}", candidate.chr, site1);
5868    let end_key = format!("{}\t{}", candidate.chr, site2);
5869    let start_hit = annotation.chr_exon_start_map.get(&start_key);
5870    let end_hit = annotation.chr_exon_end_map.get(&end_key);
5871    let mut score = match (start_hit, end_hit) {
5872        (Some(start), Some(end)) => {
5873            let start_gene = start.split('\t').next().unwrap_or("");
5874            let end_gene = end.split('\t').next().unwrap_or("");
5875            if start_gene == end_gene {
5876                100
5877            } else {
5878                80
5879            }
5880        }
5881        (Some(_), None) | (None, Some(_)) => 20,
5882        (None, None) => 0,
5883    };
5884    let motif_strand = if hint > 0 { "-" } else { "+" };
5885    if let Some(start) = start_hit {
5886        if start.split('\t').nth(1) == Some(motif_strand) {
5887            score += 5;
5888        }
5889    }
5890    if let Some(end) = end_hit {
5891        if end.split('\t').nth(1) == Some(motif_strand) {
5892            score += 5;
5893        }
5894    }
5895    score
5896}
5897
5898/// Converts validated read classes into `<prefix>.segments` rows.
5899///
5900/// The current phase emits only BSJ reads and non-BSJ backward reads. The
5901/// classification itself still comes from the existing CIRI-AS-style scan; this
5902/// helper is only responsible for selecting the best alignment chain and
5903/// materializing it as simulator-compatible segments.
5904fn build_segment_records(state: &ScanState) -> Result<Vec<SegmentRecord>> {
5905    let profile = segments_profile_enabled();
5906    let backward_ids: HashSet<&str> = state
5907        .candidates
5908        .iter()
5909        .map(|candidate| candidate.read_id.as_str())
5910        .filter(|read_id| !state.junction_read_to_circ.contains_key(*read_id))
5911        .collect();
5912    let read_strand_hints = build_read_strand_hints(state);
5913    let read_junction_hints = build_read_junction_hints(state);
5914    let correction = SegmentCorrectionContext {
5915        reference: state.reference,
5916        annotation: state.annotation,
5917        junction_support: None,
5918    };
5919    let mut read_ids: Vec<&str> = state.segment_groups.keys().map(String::as_str).collect();
5920    read_ids.sort_unstable();
5921
5922    let mut out = Vec::new();
5923    for read_id in read_ids {
5924        let Some(records) = state.segment_groups.get(read_id) else {
5925            continue;
5926        };
5927        if let Some(circ_id) = state.junction_read_to_circ.get(read_id) {
5928            let circ = state
5929                .circ_by_id
5930                .get(circ_id)
5931                .ok_or_else(|| anyhow!("missing circ record {}", circ_id))?;
5932            if let Some(record) = build_bsj_segment_record(
5933                read_id,
5934                records,
5935                circ,
5936                state
5937                    .mate_bsj_evidence
5938                    .get(read_id)
5939                    .map(Vec::as_slice)
5940                    .unwrap_or(&[]),
5941                read_junction_hints
5942                    .get(read_id)
5943                    .map(Vec::as_slice)
5944                    .unwrap_or(&[]),
5945                Some(&correction),
5946                state.read_len,
5947            ) {
5948                out.push(record);
5949            }
5950        } else if backward_ids.contains(read_id) {
5951            if let Some(record) = build_backward_segment_record(
5952                read_id,
5953                records,
5954                read_strand_hints.get(read_id).copied().flatten(),
5955                read_junction_hints
5956                    .get(read_id)
5957                    .map(Vec::as_slice)
5958                    .unwrap_or(&[]),
5959                Some(&correction),
5960                state.read_len,
5961            ) {
5962                out.push(record);
5963            }
5964        }
5965    }
5966    let phase_started = profile.then(Instant::now);
5967    sort_segment_records(&mut out);
5968    log_segments_profile(profile, "sort_segment_records", phase_started);
5969    Ok(out)
5970}
5971
5972/// Builds the default sidecar-mode `<prefix>.segments` rows.
5973///
5974/// Confirmed BSJ reads are materialized from Scan1/Scan2 sidecar alignments so
5975/// the output keeps local clip pseudo rows captured during validation. Backward
5976/// and outward rows come from the supplemental non-BSJ scan and intentionally
5977/// carry no circRNA assignment. All three evidence classes share the same
5978/// internal-junction support pass; only the confirmed BSJ gap itself remains
5979/// type-specific.
5980fn build_sidecar_segment_records(
5981    state: &mut ScanState,
5982    bsj_segment_shard_paths: Vec<SegmentScanShardPaths>,
5983    rescan_group_shard_paths: Vec<SegmentScanShardPaths>,
5984    keep_temp_files: bool,
5985    mut on_correction_start: impl FnMut() -> Result<()>,
5986) -> Result<Vec<SegmentRecord>> {
5987    let profile = segments_profile_enabled();
5988    let first_pass_correction = SegmentCorrectionContext {
5989        reference: state.reference,
5990        annotation: state.annotation,
5991        junction_support: None,
5992    };
5993
5994    let phase_started = profile.then(Instant::now);
5995    let junction_support = collect_sidecar_junction_support_from_shards(
5996        state,
5997        &bsj_segment_shard_paths,
5998        &rescan_group_shard_paths,
5999        &first_pass_correction,
6000    )?;
6001    log_segments_profile(profile, "unified_collect_junction_support", phase_started);
6002    let phase_started = profile.then(Instant::now);
6003    let junction_support_index = build_junction_support_index(&junction_support);
6004    log_segments_profile(
6005        profile,
6006        "unified_build_junction_support_index",
6007        phase_started,
6008    );
6009    on_correction_start()?;
6010    let phase_started = profile.then(Instant::now);
6011    let correction = SegmentCorrectionContext {
6012        reference: state.reference,
6013        annotation: state.annotation,
6014        junction_support: Some(&junction_support),
6015    };
6016    let mut out = build_corrected_sidecar_records_from_shards(
6017        state,
6018        &bsj_segment_shard_paths,
6019        &rescan_group_shard_paths,
6020        &junction_support_index,
6021        &first_pass_correction,
6022        &correction,
6023    )?;
6024    let ambiguous_records = out
6025        .iter()
6026        .filter(|record| segment_record_has_supported_alternative(record, &junction_support_index))
6027        .count();
6028    log_segments_profile(profile, "unified_selective_second_pass", phase_started);
6029    if profile {
6030        eprintln!(
6031            "[CIRI_PROFILE_SEGMENTS] unified_second_pass_records: {ambiguous_records}/{}",
6032            out.len()
6033        );
6034    }
6035    let phase_started = profile.then(Instant::now);
6036    sort_segment_records(&mut out);
6037    log_segments_profile(profile, "sort_segment_records", phase_started);
6038    if !keep_temp_files {
6039        cleanup_segment_shards(&bsj_segment_shard_paths);
6040        cleanup_segment_shards(&rescan_group_shard_paths);
6041    }
6042    Ok(out)
6043}
6044
6045/// Collects preliminary junction support by streaming retained segment shards.
6046///
6047/// Only the support count is global. Preliminary rows and read-local alignments
6048/// are rebuilt from each shard block and dropped immediately, avoiding the old
6049/// `Vec<SegmentRecord>` plus read-id offset index during the first pass.
6050fn collect_sidecar_junction_support_from_shards(
6051    state: &ScanState,
6052    bsj_shards: &[SegmentScanShardPaths],
6053    non_bsj_shards: &[SegmentScanShardPaths],
6054    correction: &SegmentCorrectionContext<'_>,
6055) -> Result<JunctionSupportMap> {
6056    let mut support = HashMap::new();
6057    let pb = retained_segment_progress_bar(bsj_shards, non_bsj_shards, "")?;
6058    let pb_ref = pb.as_ref();
6059    let bsj_support = bsj_shards
6060        .par_iter()
6061        .map(|shard| collect_bsj_junction_support_from_shard(state, shard, pb_ref, correction))
6062        .collect::<Result<Vec<_>>>()?;
6063    for local in bsj_support {
6064        merge_junction_support(&mut support, local);
6065    }
6066
6067    let non_bsj_support = non_bsj_shards
6068        .par_iter()
6069        .map(|shard| collect_non_bsj_junction_support_from_shard(shard, pb_ref))
6070        .collect::<Result<Vec<_>>>()?;
6071    for local in non_bsj_support {
6072        merge_junction_support(&mut support, local);
6073    }
6074    if let Some(pb) = pb {
6075        pb.set_style(
6076            ProgressStyle::default_bar()
6077                .template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} {msg}")?
6078                .progress_chars("#>-"),
6079        );
6080        pb.finish_with_message("Completed");
6081    }
6082    Ok(support)
6083}
6084
6085/// Adds shard-local support counts into the global support table.
6086fn merge_junction_support(target: &mut JunctionSupportMap, source: JunctionSupportMap) {
6087    for (chrom, chrom_support) in source {
6088        let target_chrom = target.entry(chrom).or_default();
6089        for (key, count) in chrom_support {
6090            *target_chrom.entry(key).or_insert(0) += count;
6091        }
6092    }
6093}
6094
6095/// Builds support counts for one BSJ retained shard in parallel finalize.
6096fn collect_bsj_junction_support_from_shard(
6097    state: &ScanState,
6098    shard: &SegmentScanShardPaths,
6099    pb: Option<&ProgressBar>,
6100    correction: &SegmentCorrectionContext<'_>,
6101) -> Result<JunctionSupportMap> {
6102    let mut support = HashMap::new();
6103    for_each_retained_segment_group(&shard.path, pb, |group| {
6104        if let Some(record) =
6105            build_bsj_segment_record_from_retained_group(state, &group, correction, &[])?
6106        {
6107            collect_junction_support_from_record(&record, &mut support);
6108        }
6109        Ok(())
6110    })?;
6111    Ok(support)
6112}
6113
6114/// Builds support counts for one non-BSJ retained shard in parallel finalize.
6115fn collect_non_bsj_junction_support_from_shard(
6116    shard: &SegmentScanShardPaths,
6117    pb: Option<&ProgressBar>,
6118) -> Result<JunctionSupportMap> {
6119    let mut support = HashMap::new();
6120    for_each_retained_segment_group(&shard.path, pb, |group| {
6121        for record in &group.segment_records {
6122            collect_junction_support_from_record(record, &mut support);
6123        }
6124        Ok(())
6125    })?;
6126    Ok(support)
6127}
6128
6129/// Builds final segment rows by streaming retained shards after support is known.
6130fn build_corrected_sidecar_records_from_shards(
6131    state: &ScanState,
6132    bsj_shards: &[SegmentScanShardPaths],
6133    non_bsj_shards: &[SegmentScanShardPaths],
6134    support_index: &JunctionSupportIndex,
6135    first_pass_correction: &SegmentCorrectionContext<'_>,
6136    correction: &SegmentCorrectionContext<'_>,
6137) -> Result<Vec<SegmentRecord>> {
6138    let pb = retained_segment_progress_bar(bsj_shards, non_bsj_shards, "")?;
6139    let pb_ref = pb.as_ref();
6140    let bsj_records = bsj_shards
6141        .par_iter()
6142        .map(|shard| {
6143            build_corrected_bsj_records_from_shard(
6144                state,
6145                shard,
6146                support_index,
6147                first_pass_correction,
6148                correction,
6149                pb_ref,
6150            )
6151        })
6152        .collect::<Result<Vec<_>>>()?;
6153    let non_bsj_records = non_bsj_shards
6154        .par_iter()
6155        .map(|shard| {
6156            build_corrected_non_bsj_records_from_shard(
6157                state,
6158                shard,
6159                support_index,
6160                correction,
6161                pb_ref,
6162            )
6163        })
6164        .collect::<Result<Vec<_>>>()?;
6165    if let Some(pb) = pb {
6166        pb.set_style(
6167            ProgressStyle::default_bar()
6168                .template("{spinner:.green} [{elapsed_precise}] [{bar:40.cyan/blue}] {bytes}/{total_bytes} {msg}")?
6169                .progress_chars("#>-"),
6170        );
6171        pb.finish_with_message("Completed");
6172    }
6173    let total_records = bsj_records
6174        .iter()
6175        .chain(&non_bsj_records)
6176        .map(Vec::len)
6177        .sum();
6178    let mut out = Vec::with_capacity(total_records);
6179    for mut records in bsj_records {
6180        out.append(&mut records);
6181    }
6182    for mut records in non_bsj_records {
6183        out.append(&mut records);
6184    }
6185    Ok(out)
6186}
6187
6188/// Builds corrected BSJ records for one retained shard.
6189///
6190/// Each shard is read-complete and the correction inputs are immutable, so this
6191/// can run on Rayon workers while the caller keeps the final sort as the stable
6192/// output-order boundary.
6193fn build_corrected_bsj_records_from_shard(
6194    state: &ScanState,
6195    shard: &SegmentScanShardPaths,
6196    support_index: &JunctionSupportIndex,
6197    first_pass_correction: &SegmentCorrectionContext<'_>,
6198    correction: &SegmentCorrectionContext<'_>,
6199    pb: Option<&ProgressBar>,
6200) -> Result<Vec<SegmentRecord>> {
6201    let mut out = Vec::new();
6202    for_each_retained_segment_group(&shard.path, pb, |group| {
6203        let Some(preliminary) = build_bsj_segment_record_from_retained_group(
6204            state,
6205            &group,
6206            first_pass_correction,
6207            &[],
6208        )?
6209        else {
6210            return Ok(());
6211        };
6212        let record = if segment_record_has_supported_alternative(&preliminary, support_index) {
6213            build_bsj_segment_record_from_retained_group(state, &group, correction, &[])?
6214                .unwrap_or(preliminary)
6215        } else {
6216            preliminary
6217        };
6218        out.push(record);
6219        Ok(())
6220    })?;
6221    Ok(out)
6222}
6223
6224/// Builds corrected non-BSJ records for one retained shard.
6225///
6226/// Non-BSJ correction remains read-local except for the immutable junction
6227/// support table, which allows shard-level parallelism without changing the
6228/// preliminary `R/A/C/S` spill protocol or keeping all retained groups resident.
6229fn build_corrected_non_bsj_records_from_shard(
6230    state: &ScanState,
6231    shard: &SegmentScanShardPaths,
6232    support_index: &JunctionSupportIndex,
6233    correction: &SegmentCorrectionContext<'_>,
6234    pb: Option<&ProgressBar>,
6235) -> Result<Vec<SegmentRecord>> {
6236    let mut out = Vec::new();
6237    for_each_retained_segment_group(&shard.path, pb, |group| {
6238        let strand_hint = read_strand_hint_from_candidates(&group.candidates);
6239        let junction_hints = read_junction_hints_from_candidates(&group.candidates);
6240        for preliminary in &group.segment_records {
6241            let record = if segment_record_has_supported_alternative(preliminary, support_index) {
6242                rebuild_segment_record_from_retained_group(
6243                    preliminary,
6244                    &group,
6245                    state,
6246                    correction,
6247                    strand_hint,
6248                    &junction_hints,
6249                )
6250                .unwrap_or_else(|| preliminary.clone())
6251            } else {
6252                preliminary.clone()
6253            };
6254            out.push(record);
6255        }
6256        Ok(())
6257    })?;
6258    Ok(out)
6259}
6260
6261/// Builds one confirmed BSJ row from a retained read block.
6262fn build_bsj_segment_record_from_retained_group(
6263    state: &ScanState,
6264    group: &RetainedSegmentGroup,
6265    correction: &SegmentCorrectionContext<'_>,
6266    junction_hints: &[(i32, i32)],
6267) -> Result<Option<SegmentRecord>> {
6268    let read_id = group.read_id.as_str();
6269    let Some(circ_id) = state.junction_read_to_circ.get(read_id) else {
6270        return Ok(None);
6271    };
6272    let circ = state
6273        .circ_by_id
6274        .get(circ_id)
6275        .ok_or_else(|| anyhow!("missing circ record {}", circ_id))?;
6276    Ok(build_bsj_segment_record(
6277        read_id,
6278        &group.records,
6279        circ,
6280        state
6281            .mate_bsj_evidence
6282            .get(read_id)
6283            .map(Vec::as_slice)
6284            .unwrap_or(&[]),
6285        junction_hints,
6286        Some(correction),
6287        state.read_len,
6288    ))
6289}
6290
6291/// Rebuilds one retained non-BSJ preliminary row with support-aware correction.
6292fn rebuild_segment_record_from_retained_group(
6293    record: &SegmentRecord,
6294    group: &RetainedSegmentGroup,
6295    state: &ScanState,
6296    correction: &SegmentCorrectionContext<'_>,
6297    strand_hint: Option<char>,
6298    junction_hints: &[(i32, i32)],
6299) -> Option<SegmentRecord> {
6300    match record.type_name {
6301        "backward" => build_backward_segment_record_from_records(
6302            group.read_id.as_str(),
6303            &group.records,
6304            state,
6305            correction,
6306            strand_hint,
6307            junction_hints,
6308        ),
6309        "outward" => build_outward_segment_record(
6310            group.read_id.as_str(),
6311            &group.records,
6312            Some(correction),
6313            state.read_len,
6314            state.min_mapq,
6315        ),
6316        _ => None,
6317    }
6318}
6319
6320/// Deletes retained segment shard files after streamed finalize is complete.
6321fn cleanup_segment_shards(shards: &[SegmentScanShardPaths]) {
6322    for shard in shards {
6323        let _ = std::fs::remove_file(&shard.path);
6324    }
6325}
6326
6327/// Builds one backward row from a streamed retained group and local hints.
6328fn build_backward_segment_record_from_records(
6329    read_id: &str,
6330    records: &[AsAlignment],
6331    state: &ScanState,
6332    correction: &SegmentCorrectionContext<'_>,
6333    strand_hint: Option<char>,
6334    junction_hints: &[(i32, i32)],
6335) -> Option<SegmentRecord> {
6336    let base = build_backward_segment_record(
6337        read_id,
6338        records,
6339        strand_hint,
6340        junction_hints,
6341        Some(correction),
6342        state.read_len,
6343    )?;
6344    let enriched_records = add_non_bsj_local_clip_alignments(records, state);
6345    if enriched_records.len() == records.len() {
6346        return Some(base);
6347    }
6348    build_backward_segment_record(
6349        read_id,
6350        &enriched_records,
6351        strand_hint,
6352        junction_hints,
6353        Some(correction),
6354        state.read_len,
6355    )
6356    .or(Some(base))
6357}
6358
6359/// Adds one preliminary segment row to the global junction-support map.
6360fn collect_junction_support_from_record(record: &SegmentRecord, support: &mut JunctionSupportMap) {
6361    let chrom_support = support.entry(record.chrom.clone()).or_default();
6362    collect_junction_support_from_segments(&record.r1_segments, chrom_support);
6363    collect_junction_support_from_segments(&record.r2_segments, chrom_support);
6364}
6365
6366/// Adds retained read-chain `N` junctions from one mate's segment string.
6367fn collect_junction_support_from_segments(
6368    segments: &str,
6369    chrom_support: &mut HashMap<JunctionSupportKey, usize>,
6370) {
6371    let mut previous: Option<(i32, i32, char)> = None;
6372    let mut pending_bsj = false;
6373    for token in segments.split('|') {
6374        if token == "<bsj>" {
6375            pending_bsj = previous.is_some();
6376            continue;
6377        }
6378        let Some((start, end, strand)) = parse_segment_token(token) else {
6379            pending_bsj = false;
6380            continue;
6381        };
6382        if end - start + 1 < MIN_JUNCTION_SUPPORT_SEGMENT_LENGTH {
6383            continue;
6384        }
6385        if let Some((prev_start, prev_end, prev_strand)) = previous {
6386            if !pending_bsj && prev_strand == strand {
6387                let (site2, site1) = if prev_start <= start {
6388                    (prev_end, start)
6389                } else {
6390                    (end, prev_start)
6391                };
6392                *chrom_support.entry((site2, site1, strand)).or_insert(0) += 1;
6393            }
6394        }
6395        previous = Some((start, end, strand));
6396        pending_bsj = false;
6397    }
6398}
6399
6400/// Builds a sparse lookup for finding nearby supported junction alternatives.
6401///
6402/// The ranking path still needs exact `(end, start, strand)` counts, so the
6403/// support map above remains authoritative. This auxiliary index avoids scanning
6404/// every coordinate inside a correction window for every confirmed BSJ read.
6405fn build_junction_support_index(support: &JunctionSupportMap) -> JunctionSupportIndex {
6406    let mut index = HashMap::new();
6407    for (chrom, chrom_support) in support {
6408        let chrom_index = index.entry(chrom.clone()).or_insert_with(HashMap::new);
6409        for (&(end, start, strand), &count) in chrom_support {
6410            if count == 0 {
6411                continue;
6412            }
6413            chrom_index
6414                .entry(strand)
6415                .or_insert_with(HashMap::new)
6416                .entry(end)
6417                .or_insert_with(Vec::new)
6418                .push(start);
6419        }
6420    }
6421    index
6422}
6423
6424/// Tests whether preliminary segments have a nearby supported splice alternative.
6425///
6426/// The first pass already applies annotation and motif correction. A support
6427/// aware rebuild is only useful when another preliminary BSJ read supports a
6428/// different internal `N` boundary near the current one. This keeps the second
6429/// pass focused on ambiguous internal splice sites instead of materializing all
6430/// confirmed BSJ reads twice.
6431fn segment_record_has_supported_alternative(
6432    record: &SegmentRecord,
6433    support: &JunctionSupportIndex,
6434) -> bool {
6435    let Some(chrom_support) = support.get(&record.chrom) else {
6436        return false;
6437    };
6438    segments_have_supported_alternative(&record.r1_segments, chrom_support)
6439        || segments_have_supported_alternative(&record.r2_segments, chrom_support)
6440}
6441
6442/// Scans one mate's read-chain segments for nearby non-BSJ supported alternatives.
6443fn segments_have_supported_alternative(
6444    segments: &str,
6445    chrom_support: &HashMap<char, HashMap<i32, Vec<i32>>>,
6446) -> bool {
6447    let mut previous: Option<(i32, i32, char)> = None;
6448    let mut pending_bsj = false;
6449    for token in segments.split('|') {
6450        if token == "<bsj>" {
6451            pending_bsj = previous.is_some();
6452            continue;
6453        }
6454        let Some((start, end, strand)) = parse_segment_token(token) else {
6455            previous = None;
6456            pending_bsj = false;
6457            continue;
6458        };
6459        if let Some((prev_start, prev_end, prev_strand)) = previous {
6460            if !pending_bsj && prev_strand == strand {
6461                let (left_start, left_end, right_start, right_end) = if prev_start <= start {
6462                    (prev_start, prev_end, start, end)
6463                } else {
6464                    (start, end, prev_start, prev_end)
6465                };
6466                let left_len = left_end - left_start + 1;
6467                let right_len = right_end - right_start + 1;
6468                if supported_alternative_near_junction(
6469                    left_end,
6470                    right_start,
6471                    left_len,
6472                    right_len,
6473                    strand,
6474                    chrom_support,
6475                ) {
6476                    return true;
6477                }
6478            }
6479        }
6480        previous = Some((start, end, strand));
6481        pending_bsj = false;
6482    }
6483    false
6484}
6485
6486/// Looks for a different supported junction in the correction search window.
6487fn supported_alternative_near_junction(
6488    current_end: i32,
6489    current_start: i32,
6490    left_len: i32,
6491    right_len: i32,
6492    strand: char,
6493    chrom_support: &HashMap<char, HashMap<i32, Vec<i32>>>,
6494) -> bool {
6495    let Some(strand_support) = chrom_support.get(&strand) else {
6496        return false;
6497    };
6498    let base_window = if left_len <= MIN_EXON_LENGTH || right_len <= MIN_EXON_LENGTH {
6499        PARTIAL_LOCAL_SPLICE_CORRECTION_WINDOW
6500    } else {
6501        INTERNAL_SPLICE_CORRECTION_WINDOW
6502    };
6503    // The preliminary pass may already have moved one side by `base_window`.
6504    // Doubling the lookup window keeps the selective second pass accurate while
6505    // materialization itself now runs in parallel, so this broader prefilter is
6506    // no longer the dominant cost.
6507    let search_window = base_window * 2;
6508    for end in current_end - search_window..=current_end + search_window {
6509        let Some(starts) = strand_support.get(&end) else {
6510            continue;
6511        };
6512        for &start in starts {
6513            if end == current_end && start == current_start {
6514                continue;
6515            }
6516            if start < current_start - search_window || start > current_start + search_window {
6517                continue;
6518            }
6519            if end < start {
6520                return true;
6521            }
6522        }
6523    }
6524    false
6525}
6526
6527/// Parses one `start-end:strand` segment token.
6528fn parse_segment_token(token: &str) -> Option<(i32, i32, char)> {
6529    let (range, strand_text) = token.rsplit_once(':')?;
6530    let (start, end) = range.split_once('-')?;
6531    let strand = strand_text.chars().next()?;
6532    Some((start.parse().ok()?, end.parse().ok()?, strand))
6533}
6534
6535/// Builds one `type=bsj` output row.
6536fn build_bsj_segment_record(
6537    read_id: &str,
6538    records: &[AsAlignment],
6539    circ: &CircRecord,
6540    mate_bsj_evidence: &[MateBsjEvidence],
6541    junction_hints: &[(i32, i32)],
6542    correction: Option<&SegmentCorrectionContext<'_>>,
6543    read_len: i32,
6544) -> Option<SegmentRecord> {
6545    let token_strand = circ
6546        .strand
6547        .chars()
6548        .next()
6549        .filter(|strand| matches!(strand, '+' | '-'))
6550        .unwrap_or('?');
6551    let mut bsj_row_junction_hints = junction_hints.to_vec();
6552    if mate_bsj_evidence
6553        .iter()
6554        .any(|evidence| evidence_matches_circ(evidence, circ))
6555        && !bsj_row_junction_hints
6556            .iter()
6557            .any(|&(site2, site1)| site2 == -circ.end && site1 == -circ.start)
6558    {
6559        bsj_row_junction_hints.push((-circ.end, -circ.start));
6560    }
6561    let mut chains = build_pair_chains(
6562        records,
6563        read_len,
6564        Some(circ),
6565        "bsj",
6566        token_strand,
6567        &bsj_row_junction_hints,
6568        correction,
6569    );
6570    repair_bsj_non_bsj_mates_by_xa(
6571        &mut chains,
6572        records,
6573        circ,
6574        read_len,
6575        token_strand,
6576        &bsj_row_junction_hints,
6577        correction,
6578    );
6579    repair_bsj_non_bsj_mates_by_circ_records(
6580        &mut chains,
6581        records,
6582        circ,
6583        read_len,
6584        token_strand,
6585        &bsj_row_junction_hints,
6586        correction,
6587    );
6588    repair_bsj_mates_by_confirmed_site(
6589        &mut chains,
6590        records,
6591        circ,
6592        mate_bsj_evidence,
6593        read_len,
6594        token_strand,
6595        &bsj_row_junction_hints,
6596        correction,
6597    );
6598    apply_mate_bsj_evidence(&mut chains, mate_bsj_evidence, circ);
6599    if chains[0].is_none() && chains[1].is_none() {
6600        return None;
6601    }
6602    let (r1_align_strand, r2_align_strand) = primary_mate_alignment_strands(records);
6603    let (r1_segments, r1_cigar, r1_cs, is_r1_bsj) = chain_text(chains[0].as_ref());
6604    let (r2_segments, r2_cigar, r2_cs, is_r2_bsj) = chain_text(chains[1].as_ref());
6605    Some(SegmentRecord {
6606        read_id: read_id.to_string(),
6607        type_name: "bsj",
6608        circ_id: circ.id.clone(),
6609        chrom: circ.chr.clone(),
6610        start: circ.start.to_string(),
6611        end: circ.end.to_string(),
6612        strand: circ.strand.clone(),
6613        is_circular: 1,
6614        is_r1_bsj,
6615        is_r2_bsj,
6616        r1_align_strand,
6617        r2_align_strand,
6618        r1_cigar,
6619        r1_cs,
6620        r1_segments,
6621        r2_cigar,
6622        r2_cs,
6623        r2_segments,
6624    })
6625}
6626
6627/// Builds one `type=backward` output row.
6628fn build_backward_segment_record(
6629    read_id: &str,
6630    records: &[AsAlignment],
6631    inferred_strand: Option<char>,
6632    junction_hints: &[(i32, i32)],
6633    correction: Option<&SegmentCorrectionContext<'_>>,
6634    read_len: i32,
6635) -> Option<SegmentRecord> {
6636    let token_strand = inferred_strand.unwrap_or('?');
6637    let mut chains = build_pair_chains(
6638        records,
6639        read_len,
6640        None,
6641        "backward",
6642        token_strand,
6643        junction_hints,
6644        correction,
6645    );
6646    let is_initial_circular = usize::from(
6647        chains
6648            .iter()
6649            .flatten()
6650            .any(|chain| chain.is_circular && !chain.is_bsj),
6651    );
6652    if is_initial_circular == 0 {
6653        return None;
6654    }
6655    match resolve_backward_records_by_xa(
6656        records,
6657        &chains,
6658        read_len,
6659        token_strand,
6660        junction_hints,
6661        correction,
6662    ) {
6663        XaBackwardResolution::Keep => {}
6664        XaBackwardResolution::Reject => return None,
6665        XaBackwardResolution::Repair(repaired_chains) => chains = repaired_chains,
6666    }
6667    let is_circular = usize::from(
6668        chains
6669            .iter()
6670            .flatten()
6671            .any(|chain| chain.is_circular && !chain.is_bsj),
6672    );
6673    if is_circular == 0 {
6674        return None;
6675    }
6676    let chrom = selected_chain_chrom(&chains)?;
6677    let (span_start, span_end) = selected_chain_span(&chains)?;
6678    if span_end - span_start + 1 > BACKWARD_MAX_SPAN {
6679        return None;
6680    }
6681    let (r1_align_strand, r2_align_strand) = primary_mate_alignment_strands(records);
6682    let (r1_segments, r1_cigar, r1_cs, _) = chain_text(chains[0].as_ref());
6683    let (r2_segments, r2_cigar, r2_cs, _) = chain_text(chains[1].as_ref());
6684    Some(SegmentRecord {
6685        read_id: read_id.to_string(),
6686        type_name: "backward",
6687        circ_id: "NA".to_string(),
6688        chrom,
6689        start: span_start.to_string(),
6690        end: span_end.to_string(),
6691        strand: "NA".to_string(),
6692        is_circular,
6693        is_r1_bsj: 0,
6694        is_r2_bsj: 0,
6695        r1_align_strand,
6696        r2_align_strand,
6697        r1_cigar,
6698        r1_cs,
6699        r1_segments,
6700        r2_cigar,
6701        r2_cs,
6702        r2_segments,
6703    })
6704}
6705
6706/// Builds one `type=outward` row from a primary outward R1/R2 pair.
6707///
6708/// This materializes only ordinary linear mate chains. Pair orientation is the
6709/// circular signal, so the row must not write `<bsj>` or `B`; any internal `N`
6710/// operators come solely from the original mate CIGAR and can support graph
6711/// exon-exon edges independently of the outward pair-level evidence.
6712fn build_outward_segment_record(
6713    read_id: &str,
6714    records: &[AsAlignment],
6715    correction: Option<&SegmentCorrectionContext<'_>>,
6716    read_len: i32,
6717    min_mapq: i32,
6718) -> Option<SegmentRecord> {
6719    let (r1, r2) = primary_mate_pair(records)?;
6720    if r1.mapq < min_mapq || r2.mapq < min_mapq {
6721        return None;
6722    }
6723    let mut chains = build_pair_chains(records, read_len, None, "outward", '?', &[], correction);
6724    if chains.iter().flatten().any(|chain| chain.is_circular) {
6725        let selected = [r1.clone(), r2.clone()];
6726        chains = build_pair_chains(&selected, read_len, None, "outward", '?', &[], correction);
6727    }
6728    if chains.iter().flatten().any(|chain| chain.is_circular) {
6729        return None;
6730    }
6731    let token_strand = infer_outward_token_strand(&chains, correction).unwrap_or('?');
6732    if token_strand != '?' {
6733        chains = build_pair_chains(
6734            records,
6735            read_len,
6736            None,
6737            "outward",
6738            token_strand,
6739            &[],
6740            correction,
6741        );
6742        if chains.iter().flatten().any(|chain| chain.is_circular) {
6743            let selected = [r1.clone(), r2.clone()];
6744            chains = build_pair_chains(
6745                &selected,
6746                read_len,
6747                None,
6748                "outward",
6749                token_strand,
6750                &[],
6751                correction,
6752            );
6753        }
6754        if chains.iter().flatten().any(|chain| chain.is_circular) {
6755            return None;
6756        }
6757    }
6758    let chrom = selected_chain_chrom(&chains)?;
6759    let (span_start, span_end) = selected_chain_span(&chains)?;
6760    if span_end - span_start + 1 > BACKWARD_MAX_SPAN {
6761        return None;
6762    }
6763    let r1_span = chain_ref_span(chains[0].as_ref())?;
6764    let r2_span = chain_ref_span(chains[1].as_ref())?;
6765    if !has_3p_outward_pair_geometry(r1, r1_span, r2, r2_span) {
6766        return None;
6767    }
6768    let (r1_align_strand, r2_align_strand) = primary_mate_alignment_strands(records);
6769    let (r1_segments, r1_cigar, r1_cs, _) = chain_text(chains[0].as_ref());
6770    let (r2_segments, r2_cigar, r2_cs, _) = chain_text(chains[1].as_ref());
6771    Some(SegmentRecord {
6772        read_id: read_id.to_string(),
6773        type_name: "outward",
6774        circ_id: "NA".to_string(),
6775        chrom,
6776        start: span_start.to_string(),
6777        end: span_end.to_string(),
6778        strand: token_strand.to_string().replace('?', "NA"),
6779        is_circular: 1,
6780        is_r1_bsj: 0,
6781        is_r2_bsj: 0,
6782        r1_align_strand,
6783        r2_align_strand,
6784        r1_cigar,
6785        r1_cs,
6786        r1_segments,
6787        r2_cigar,
6788        r2_cs,
6789        r2_segments,
6790    })
6791}
6792
6793/// Infers an outward row's RNA strand from mate-internal splice junctions.
6794///
6795/// Outward evidence itself is pair-orientation based and therefore does not
6796/// imply a transcript strand. When one mate already contains ordinary `N`
6797/// junctions, however, annotation or splice motifs can provide the same strand
6798/// evidence used by internal-boundary correction. The helper accepts only a
6799/// unique non-conflicting strand so alignment orientation is never reported as
6800/// biological strand by fallback.
6801fn infer_outward_token_strand(
6802    chains: &[Option<MateChain>; 2],
6803    correction: Option<&SegmentCorrectionContext<'_>>,
6804) -> Option<char> {
6805    let correction = correction?;
6806    let mut inferred: Option<char> = None;
6807    for chain in chains.iter().flatten() {
6808        for pair in chain.blocks.windows(2) {
6809            let (left, right) = if pair[0].ref_start <= pair[1].ref_start {
6810                (&pair[0], &pair[1])
6811            } else {
6812                (&pair[1], &pair[0])
6813            };
6814            let end = left.ref_end;
6815            let start = right.ref_start;
6816            if end >= start {
6817                continue;
6818            }
6819            let plus = splice_strand_evidence_score(correction, &chain.chrom, end, start, '+');
6820            let minus = splice_strand_evidence_score(correction, &chain.chrom, end, start, '-');
6821            let strand = match plus.cmp(&minus) {
6822                std::cmp::Ordering::Greater if plus > 0 => '+',
6823                std::cmp::Ordering::Less if minus > 0 => '-',
6824                _ => continue,
6825            };
6826            match inferred {
6827                Some(existing) if existing != strand => return None,
6828                Some(_) => {}
6829                None => inferred = Some(strand),
6830            }
6831        }
6832    }
6833    inferred
6834}
6835
6836/// Scores one exact internal junction as strand evidence.
6837///
6838/// Transcript-consistent annotation is strongest, boundary-level annotation is
6839/// next, and de novo splice motifs are used only when annotation is absent.
6840/// Scores are comparable only between `+` and `-` for the same junction.
6841fn splice_strand_evidence_score(
6842    ctx: &SegmentCorrectionContext<'_>,
6843    chrom: &str,
6844    end: i32,
6845    start: i32,
6846    strand: char,
6847) -> i32 {
6848    let (transcript_score, annotation_score) =
6849        annotation_splice_pair_score(ctx.annotation, chrom, end, start, strand).unwrap_or((0, 0));
6850    let motif_score = ctx
6851        .reference
6852        .get(chrom)
6853        .and_then(|seq| splice_motif_score(seq, end, start, strand))
6854        .unwrap_or(0);
6855    transcript_score * 10_000 + annotation_score * 100 + motif_score
6856}
6857
6858/// Result of topology-neutral XA selection for a candidate backward read.
6859enum XaBackwardResolution {
6860    Keep,
6861    Repair([Option<MateChain>; 2]),
6862    Reject,
6863}
6864
6865/// Resolves weak backward chains by ranking seamless XA alternatives first.
6866///
6867/// BWA-MEM emits `XA` alternatives without splice-aware context. For segments
6868/// sidecar output we can safely compare read-slice-compatible XA hits before
6869/// applying the max-span guard. The ranking is topology-neutral and does not use
6870/// XA edit distance: linear and circular replacements compete by strand/query
6871/// compatibility and genomic spanning size first, then the selected best chain
6872/// decides whether the read is still a `type=backward` row. This keeps CIRI3
6873/// Scan1/Scan2 parity untouched while handling both `sim:3966179`-style
6874/// circular repair and `sim:432652`-style linear rejection.
6875fn resolve_backward_records_by_xa(
6876    records: &[AsAlignment],
6877    chains: &[Option<MateChain>; 2],
6878    read_len: i32,
6879    token_strand: char,
6880    junction_hints: &[(i32, i32)],
6881    correction: Option<&SegmentCorrectionContext<'_>>,
6882) -> XaBackwardResolution {
6883    let Some(current_rank) = xa_alignment_rank(chains) else {
6884        return XaBackwardResolution::Keep;
6885    };
6886    let Some((current_start, current_end)) = selected_chain_span(chains) else {
6887        return XaBackwardResolution::Keep;
6888    };
6889    let current_span = current_end - current_start + 1;
6890    let current_coverage = selected_chain_query_coverage(chains);
6891    let candidate_sets = xa_candidate_sets(records, chains, read_len);
6892    if candidate_sets.is_empty() {
6893        return XaBackwardResolution::Keep;
6894    }
6895
6896    let mut best: Option<((i32, i32, i32, i32, i32), [Option<MateChain>; 2])> = None;
6897    let mut candidate_records = records.to_vec();
6898    enumerate_xa_candidate_sets(
6899        records,
6900        &candidate_sets,
6901        0,
6902        &mut candidate_records,
6903        read_len,
6904        token_strand,
6905        junction_hints,
6906        correction,
6907        current_span,
6908        current_coverage,
6909        current_rank,
6910        &mut best,
6911    );
6912
6913    if let Some((_, best_chains)) = best {
6914        if chain_has_backward_topology(&best_chains) {
6915            XaBackwardResolution::Repair(best_chains)
6916        } else {
6917            XaBackwardResolution::Reject
6918        }
6919    } else {
6920        XaBackwardResolution::Keep
6921    }
6922}
6923
6924/// One selected alignment plus its seamless XA replacements.
6925struct XaCandidateSet {
6926    record_index: usize,
6927    alternatives: Vec<AsAlignment>,
6928}
6929
6930/// Builds XA choices from all selected MAPQ=0 records, not only supplementary ones.
6931///
6932/// Some BWA-MEM records keep the more local splice-aware placement as `XA` on a
6933/// primary MAPQ=0 record. Restricting the sidecar to supplementary records leaves
6934/// false backward rows such as `sim:4599148`; therefore every selected low-MAPQ
6935/// record used by the current chain can contribute exact same-slice alternatives.
6936fn xa_candidate_sets(
6937    records: &[AsAlignment],
6938    chains: &[Option<MateChain>; 2],
6939    read_len: i32,
6940) -> Vec<XaCandidateSet> {
6941    records
6942        .iter()
6943        .enumerate()
6944        .filter_map(|(record_index, record)| {
6945            if !ambiguous_alignment_with_xa(record, read_len)
6946                || !chains_use_record_as_backward(chains, record, read_len)
6947            {
6948                return None;
6949            }
6950            let alternatives: Vec<AsAlignment> = record
6951                .xa_alternatives
6952                .iter()
6953                .filter(|alternative| xa_replacement_is_seamless(record, alternative, read_len))
6954                .take(XA_MAX_ALTERNATIVES_PER_RECORD)
6955                .map(|alternative| alignment_from_xa(record, alternative))
6956                .collect();
6957            (!alternatives.is_empty()).then_some(XaCandidateSet {
6958                record_index,
6959                alternatives,
6960            })
6961        })
6962        .collect()
6963}
6964
6965/// Enumerates joint XA replacements across selected records.
6966///
6967/// Greedy single-record replacement misses cases where R1 and R2 each carry one
6968/// remote equivalent hit: replacing either record alone does not reduce the
6969/// pair-level span, but replacing both reveals a linear local interpretation.
6970fn enumerate_xa_candidate_sets(
6971    original_records: &[AsAlignment],
6972    candidate_sets: &[XaCandidateSet],
6973    set_idx: usize,
6974    candidate_records: &mut Vec<AsAlignment>,
6975    read_len: i32,
6976    token_strand: char,
6977    junction_hints: &[(i32, i32)],
6978    correction: Option<&SegmentCorrectionContext<'_>>,
6979    current_span: i32,
6980    current_coverage: i32,
6981    current_rank: (i32, i32, i32, i32, i32),
6982    best: &mut Option<((i32, i32, i32, i32, i32), [Option<MateChain>; 2])>,
6983) {
6984    if set_idx == candidate_sets.len() {
6985        if candidate_records
6986            .iter()
6987            .zip(original_records.iter())
6988            .all(|(candidate, original)| {
6989                candidate.pos == original.pos
6990                    && candidate.flag == original.flag
6991                    && candidate.cigar == original.cigar
6992                    && candidate.chr == original.chr
6993            })
6994        {
6995            return;
6996        }
6997        let candidate_chains = build_pair_chains(
6998            candidate_records,
6999            read_len,
7000            None,
7001            "backward",
7002            token_strand,
7003            junction_hints,
7004            correction,
7005        );
7006        if selected_chain_query_coverage(&candidate_chains)
7007            < current_coverage - XA_SEAMLESS_QUERY_TOLERANCE
7008        {
7009            return;
7010        }
7011        let Some((candidate_start, candidate_end)) = selected_chain_span(&candidate_chains) else {
7012            return;
7013        };
7014        let candidate_span = candidate_end - candidate_start + 1;
7015        if current_span - candidate_span < XA_REJECT_MIN_SPAN_REDUCTION {
7016            return;
7017        }
7018        let Some(rank) = xa_alignment_rank(&candidate_chains) else {
7019            return;
7020        };
7021        if rank <= current_rank {
7022            return;
7023        }
7024        if best.as_ref().is_none_or(|(best_rank, _)| rank > *best_rank) {
7025            *best = Some((rank, candidate_chains));
7026        }
7027        return;
7028    }
7029
7030    let candidate_set = &candidate_sets[set_idx];
7031    enumerate_xa_candidate_sets(
7032        original_records,
7033        candidate_sets,
7034        set_idx + 1,
7035        candidate_records,
7036        read_len,
7037        token_strand,
7038        junction_hints,
7039        correction,
7040        current_span,
7041        current_coverage,
7042        current_rank,
7043        best,
7044    );
7045    let original = candidate_records[candidate_set.record_index].clone();
7046    for alternative in &candidate_set.alternatives {
7047        candidate_records[candidate_set.record_index] = alternative.clone();
7048        enumerate_xa_candidate_sets(
7049            original_records,
7050            candidate_sets,
7051            set_idx + 1,
7052            candidate_records,
7053            read_len,
7054            token_strand,
7055            junction_hints,
7056            correction,
7057            current_span,
7058            current_coverage,
7059            current_rank,
7060            best,
7061        );
7062    }
7063    candidate_records[candidate_set.record_index] = original;
7064}
7065
7066/// Ranks one XA-derived alignment interpretation without using topology.
7067///
7068/// Exact same-slice XA hits are alternative mapper placements, so the sidecar
7069/// first asks which placement is the most compatible with the read pair's
7070/// genomic span. Topology is intentionally excluded here; the caller handles a
7071/// linear best chain by dropping the `type=backward` row.
7072fn xa_alignment_rank(chains: &[Option<MateChain>; 2]) -> Option<(i32, i32, i32, i32, i32)> {
7073    selected_chain_chrom(chains)?;
7074    let (start, end) = selected_chain_span(chains)?;
7075    let span = end - start + 1;
7076    let used_supplementary: usize = chains
7077        .iter()
7078        .flatten()
7079        .map(|chain| chain.used_supplementary)
7080        .sum();
7081    let used_secondary: usize = chains
7082        .iter()
7083        .flatten()
7084        .map(|chain| chain.used_secondary)
7085        .sum();
7086    Some((
7087        i32::from(span <= BACKWARD_MAX_SPAN),
7088        -span,
7089        selected_chain_query_coverage(chains),
7090        -(used_supplementary as i32),
7091        -(used_secondary as i32),
7092    ))
7093}
7094
7095/// Returns whether any selected mate chain still supports backward topology.
7096fn chain_has_backward_topology(chains: &[Option<MateChain>; 2]) -> bool {
7097    chains
7098        .iter()
7099        .flatten()
7100        .any(|chain| chain.is_circular && !chain.is_bsj)
7101}
7102
7103/// Returns whether the selected backward chain currently uses this alignment.
7104fn chains_use_record_as_backward(
7105    chains: &[Option<MateChain>; 2],
7106    record: &AsAlignment,
7107    read_len: i32,
7108) -> bool {
7109    if !chains
7110        .iter()
7111        .flatten()
7112        .any(|chain| chain.is_circular && !chain.is_bsj)
7113    {
7114        return false;
7115    }
7116    chains
7117        .iter()
7118        .flatten()
7119        .any(|chain| chain_uses_record(chain, record, read_len))
7120}
7121
7122/// Identifies ambiguous alignments that should not define backward alone.
7123///
7124/// The CIRI3 parity path does not inspect XA tags, but this sidecar resolver
7125/// uses them to choose a better read-level chain. We require a minimum mapped
7126/// anchor so tiny clips do not drive the decision, and deliberately avoid both
7127/// MAPQ and maximum-anchor limits because exact XA alternatives can also
7128/// explain longer repetitive supplementary blocks that BWA scored confidently
7129/// in a non-splice-aware placement.
7130fn ambiguous_alignment_with_xa(record: &AsAlignment, read_len: i32) -> bool {
7131    if record.xa_alternatives.is_empty() || record.from_local_clip {
7132        return false;
7133    }
7134    alignment_mapped_len(record, read_len).is_some_and(|len| len >= XA_REJECT_MIN_ANCHOR_LEN)
7135}
7136
7137/// Tests whether an XA hit can replace the same read slice as one SAM record.
7138///
7139/// The replacement is intentionally strict: it must stay on the same alignment
7140/// strand, parse into the same number of blocks, and cover nearly the same
7141/// query coordinates. BWA already marked these as alternatives, but this
7142/// read-slice check keeps the sidecar from swapping unrelated clipped pieces.
7143fn xa_replacement_is_seamless(
7144    record: &AsAlignment,
7145    alternative: &XaAlternative,
7146    read_len: i32,
7147) -> bool {
7148    if alternative.strand != strand_char(record.flag) {
7149        return false;
7150    }
7151    let alt_record = alignment_from_xa(record, alternative);
7152    let Some(source_blocks) = parse_alignment_blocks(record, read_len) else {
7153        return false;
7154    };
7155    let Some(alt_blocks) = parse_alignment_blocks(&alt_record, read_len) else {
7156        return false;
7157    };
7158    if source_blocks.len() != alt_blocks.len() {
7159        return false;
7160    }
7161    source_blocks
7162        .iter()
7163        .zip(alt_blocks.iter())
7164        .all(|(source, alt)| {
7165            (source.read_start - alt.read_start).abs() <= XA_SEAMLESS_QUERY_TOLERANCE
7166                && (source.read_end - alt.read_end).abs() <= XA_SEAMLESS_QUERY_TOLERANCE
7167                && (source.read_end - source.read_start + 1) >= XA_REJECT_MIN_ANCHOR_LEN
7168                && (alt.read_end - alt.read_start + 1) >= XA_REJECT_MIN_ANCHOR_LEN
7169        })
7170}
7171
7172/// Returns whether a selected chain contains one block from the source record.
7173fn chain_uses_record(chain: &MateChain, record: &AsAlignment, read_len: i32) -> bool {
7174    let Some(blocks) = parse_alignment_blocks(record, read_len) else {
7175        return false;
7176    };
7177    blocks.iter().any(|source| {
7178        chain.blocks.iter().any(|selected| {
7179            source.read_start == selected.read_start
7180                && source.read_end == selected.read_end
7181                && source.ref_start <= selected.ref_end
7182                && selected.ref_start <= source.ref_end
7183        })
7184    })
7185}
7186
7187/// Returns total selected query coverage across both mates.
7188fn selected_chain_query_coverage(chains: &[Option<MateChain>; 2]) -> i32 {
7189    chains
7190        .iter()
7191        .flatten()
7192        .map(|chain| chain.query_coverage)
7193        .sum()
7194}
7195
7196/// Returns the aligned query length represented by one record.
7197fn alignment_mapped_len(record: &AsAlignment, read_len: i32) -> Option<i32> {
7198    parse_alignment_blocks(record, read_len).map(|blocks| {
7199        blocks
7200            .iter()
7201            .map(|block| block.read_end - block.read_start + 1)
7202            .sum()
7203    })
7204}
7205
7206/// Converts a BWA XA hit into an alternative alignment for sidecar ranking.
7207fn alignment_from_xa(record: &AsAlignment, alternative: &XaAlternative) -> AsAlignment {
7208    let mut flag = record.flag;
7209    if alternative.strand == '-' {
7210        flag |= 0x10;
7211    } else {
7212        flag &= !0x10;
7213    }
7214    AsAlignment {
7215        flag,
7216        chr: alternative.chr.clone(),
7217        pos: alternative.pos,
7218        mapq: 0,
7219        cigar: alternative.cigar.clone(),
7220        seq: record.seq.clone(),
7221        cs: "*".to_string(),
7222        from_local_clip: false,
7223        xa_alternatives: Vec::new(),
7224    }
7225}
7226
7227/// Sorts final read-level segment rows by their graph-construction coordinates.
7228///
7229/// Each row already contains both mates, so row-level genomic sorting does not
7230/// break read-pair phasing. The ordering makes later circRNA-level graph
7231/// construction stream mostly contiguous BSJ/backward evidence instead of
7232/// holding every read in a read-ID keyed map.
7233fn sort_segment_records(records: &mut [SegmentRecord]) {
7234    records.sort_by_cached_key(|record| {
7235        (
7236            record.chrom.clone(),
7237            sort_position(&record.start),
7238            sort_position(&record.end),
7239            type_sort_rank(record.type_name),
7240            record.circ_id.clone(),
7241            record.read_id.clone(),
7242        )
7243    });
7244}
7245
7246/// Parses a segment row coordinate for stable output ordering.
7247fn sort_position(value: &str) -> i32 {
7248    value.parse::<i32>().unwrap_or(i32::MAX)
7249}
7250
7251/// Keeps confirmed BSJ rows ahead of supplemental rows at identical loci.
7252fn type_sort_rank(type_name: &str) -> u8 {
7253    match type_name {
7254        "bsj" => 0,
7255        "backward" => 1,
7256        "outward" => 2,
7257        "forward" => 3,
7258        _ => 4,
7259    }
7260}
7261
7262/// Returns the single chromosome shared by selected mate chains.
7263///
7264/// Backward rows have no circRNA assignment yet, but downstream candidate
7265/// clustering still needs chromosome context. The row-level schema has one
7266/// `chrom` column, so mixed-chromosome mate chains are filtered out instead of
7267/// being reported as circular segments with ambiguous reference context.
7268fn selected_chain_chrom(chains: &[Option<MateChain>; 2]) -> Option<String> {
7269    let mut chrom: Option<&str> = None;
7270    for chain in chains.iter().flatten() {
7271        match chrom {
7272            Some(existing) if existing != chain.chrom => return None,
7273            Some(_) => {}
7274            None => chrom = Some(chain.chrom.as_str()),
7275        }
7276    }
7277    chrom.map(str::to_string)
7278}
7279
7280/// Returns the genomic span covered by all selected mate-chain blocks.
7281///
7282/// Backward rows are not assigned to a candidate BSJ at read-level output time:
7283/// one backward read can be compatible with multiple later BSJ/circRNA groups.
7284/// The sortable coordinates therefore describe the retained alignment span
7285/// across the selected R1/R2 chains, not a putative circRNA boundary.
7286fn selected_chain_span(chains: &[Option<MateChain>; 2]) -> Option<(i32, i32)> {
7287    let mut start = i32::MAX;
7288    let mut end = i32::MIN;
7289    for block in chains.iter().flatten().flat_map(|chain| &chain.blocks) {
7290        start = start.min(block.ref_start);
7291        end = end.max(block.ref_end);
7292    }
7293    (start <= end).then_some((start, end))
7294}
7295
7296/// Returns the genomic span covered by one selected mate chain.
7297fn chain_ref_span(chain: Option<&MateChain>) -> Option<(i32, i32)> {
7298    let chain = chain?;
7299    let mut start = i32::MAX;
7300    let mut end = i32::MIN;
7301    for block in &chain.blocks {
7302        start = start.min(block.ref_start);
7303        end = end.max(block.ref_end);
7304    }
7305    (start <= end).then_some((start, end))
7306}
7307
7308/// Selects one best chain for each mate of a read pair.
7309fn build_pair_chains(
7310    records: &[AsAlignment],
7311    read_len: i32,
7312    circ: Option<&CircRecord>,
7313    type_name: &str,
7314    token_strand: char,
7315    junction_hints: &[(i32, i32)],
7316    correction: Option<&SegmentCorrectionContext<'_>>,
7317) -> [Option<MateChain>; 2] {
7318    let mut buckets: [Vec<&AsAlignment>; 2] = [Vec::new(), Vec::new()];
7319    for record in records {
7320        buckets[mate_bucket(record.flag)].push(record);
7321    }
7322    [
7323        select_best_chain_for_mate(
7324            &buckets[0],
7325            read_len,
7326            circ,
7327            type_name,
7328            token_strand,
7329            junction_hints,
7330            correction,
7331            false,
7332        ),
7333        select_best_chain_for_mate(
7334            &buckets[1],
7335            read_len,
7336            circ,
7337            type_name,
7338            token_strand,
7339            junction_hints,
7340            correction,
7341            true,
7342        ),
7343    ]
7344}
7345
7346/// Chooses the best chain for one mate.
7347///
7348/// Primary plus supplementary alignments are always considered first. Secondary
7349/// alignments are only allowed to win if they produce a better circRNA-consistent
7350/// chain than the primary/supplementary pool.
7351fn select_best_chain_for_mate(
7352    records: &[&AsAlignment],
7353    read_len: i32,
7354    circ: Option<&CircRecord>,
7355    type_name: &str,
7356    token_strand: char,
7357    junction_hints: &[(i32, i32)],
7358    correction: Option<&SegmentCorrectionContext<'_>>,
7359    reverse_chain_order: bool,
7360) -> Option<MateChain> {
7361    if records.is_empty() {
7362        return None;
7363    }
7364    let non_secondary: Vec<&AsAlignment> = records
7365        .iter()
7366        .copied()
7367        .filter(|record| !is_secondary(record.flag))
7368        .collect();
7369    let primary_chain = build_chain_from_pool(
7370        &non_secondary,
7371        read_len,
7372        circ,
7373        type_name,
7374        token_strand,
7375        junction_hints,
7376        correction,
7377        reverse_chain_order,
7378    );
7379    let fallback_chain = build_chain_from_pool(
7380        records,
7381        read_len,
7382        circ,
7383        type_name,
7384        token_strand,
7385        junction_hints,
7386        correction,
7387        reverse_chain_order,
7388    );
7389    better_chain(primary_chain, fallback_chain, type_name, circ)
7390}
7391
7392/// Picks the better of the primary/supplementary and fallback chain candidates.
7393fn better_chain(
7394    left: Option<MateChain>,
7395    right: Option<MateChain>,
7396    type_name: &str,
7397    circ: Option<&CircRecord>,
7398) -> Option<MateChain> {
7399    match (left, right) {
7400        (Some(left), Some(right)) => {
7401            if chain_rank(&right, type_name, circ) > chain_rank(&left, type_name, circ) {
7402                Some(right)
7403            } else {
7404                Some(left)
7405            }
7406        }
7407        (Some(left), None) => Some(left),
7408        (None, Some(right)) => Some(right),
7409        (None, None) => None,
7410    }
7411}
7412
7413/// Builds one best-effort chain from a pool of candidate alignments.
7414fn build_chain_from_pool(
7415    records: &[&AsAlignment],
7416    read_len: i32,
7417    circ: Option<&CircRecord>,
7418    type_name: &str,
7419    token_strand: char,
7420    junction_hints: &[(i32, i32)],
7421    correction: Option<&SegmentCorrectionContext<'_>>,
7422    reverse_chain_order: bool,
7423) -> Option<MateChain> {
7424    let mut by_key: BTreeMap<(String, char), Vec<ParsedAlignment>> = BTreeMap::new();
7425    for record in records {
7426        let Some(blocks) = parse_alignment_blocks(record, read_len) else {
7427            continue;
7428        };
7429        if blocks.is_empty() || record.chr == "*" {
7430            continue;
7431        }
7432        let strand = strand_char(record.flag);
7433        by_key
7434            .entry((record.chr.clone(), strand))
7435            .or_default()
7436            .push(ParsedAlignment {
7437                flag: record.flag,
7438                chrom: record.chr.clone(),
7439                strand,
7440                mapq: record.mapq,
7441                seq: oriented_chain_sequence(
7442                    record,
7443                    read_len,
7444                    correction.and_then(|ctx| ctx.reference.get(&record.chr)),
7445                ),
7446                blocks,
7447            });
7448    }
7449
7450    let mut best: Option<MateChain> = None;
7451    for ((_chrom, _strand), mut group) in by_key {
7452        fill_incomplete_segment_sequences(&mut group, read_len);
7453        group.sort_by_key(|record| {
7454            (
7455                record
7456                    .blocks
7457                    .first()
7458                    .map(|block| block.read_start)
7459                    .unwrap_or(i32::MAX),
7460                usize::from(is_secondary(record.flag)),
7461                usize::from(is_supplementary(record.flag)),
7462                -record.mapq,
7463            )
7464        });
7465
7466        let mut selected: Vec<ParsedAlignment> = Vec::new();
7467        for record in group {
7468            if let Some(last) = selected.last_mut() {
7469                if query_overlap(last, &record) > 6 {
7470                    if parsed_alignment_rank(&record) > parsed_alignment_rank(last) {
7471                        *last = record;
7472                    }
7473                    continue;
7474                }
7475            }
7476            selected.push(record);
7477        }
7478
7479        let chain = materialize_chain(
7480            &selected,
7481            read_len,
7482            circ,
7483            token_strand,
7484            junction_hints,
7485            correction,
7486            reverse_chain_order,
7487        )?;
7488        if best.as_ref().is_none_or(|current| {
7489            chain_generic_rank(&chain, type_name, circ)
7490                > chain_generic_rank(current, type_name, circ)
7491        }) {
7492            best = Some(chain);
7493        }
7494    }
7495    best
7496}
7497
7498/// Restores full-read sequence for incomplete sidecar blocks.
7499///
7500/// BWA-MEM often stores only the aligned slice in hard-clipped supplementary
7501/// SAM records. Read-chain materialization still addresses every selected block
7502/// in full read coordinates, so shorter records must borrow the lowest-`N` full
7503/// sequence in the same mate/strand group before splice-boundary scoring.
7504fn fill_incomplete_segment_sequences(group: &mut [ParsedAlignment], read_len: i32) {
7505    let Some(full_seq) = group
7506        .iter()
7507        .filter(|record| record.seq.len() as i32 >= read_len)
7508        .min_by_key(|record| (sequence_n_count(&record.seq), -(record.seq.len() as isize)))
7509        .map(|record| record.seq.clone())
7510    else {
7511        return;
7512    };
7513    for record in group {
7514        if (record.seq.len() as i32) < read_len {
7515            record.seq = full_seq.clone();
7516        }
7517    }
7518}
7519
7520/// Counts ambiguous query bases in a sidecar sequence.
7521///
7522/// Local pseudo-alignments can be full-length only because missing clipped bases
7523/// were padded with `N`; preferring the lowest-`N` copy preserves real read
7524/// sequence whenever an original primary alignment is available in the group.
7525fn sequence_n_count(seq: &str) -> usize {
7526    seq.bytes()
7527        .filter(|base| matches!(base.to_ascii_uppercase(), b'N'))
7528        .count()
7529}
7530
7531/// Replaces N-padded local pseudo sequences only after boundary selection.
7532///
7533/// Local pseudo-alignments reconstructed from short `cs` can be full-length
7534/// strings whose clipped read positions are synthetic `N`. Those `N` bases are
7535/// useful during sequence-aware boundary scoring because they prevent missing
7536/// clip payload from over-promoting a shifted junction. Once the boundary has
7537/// been selected, the final `.segments` cs and `.segments.bam` SEQ fields should
7538/// use the best real full-read sequence available in the same selected chain.
7539fn replace_incomplete_payload_sequences_for_output(
7540    payloads: &mut [ChainBlockPayload],
7541    read_len: i32,
7542) {
7543    let Some((full_seq, full_n_count)) = payloads
7544        .iter()
7545        .filter(|payload| payload.source_seq.len() as i32 >= read_len)
7546        .min_by_key(|payload| {
7547            (
7548                sequence_n_count(&payload.source_seq),
7549                -(payload.source_seq.len() as isize),
7550            )
7551        })
7552        .map(|payload| {
7553            (
7554                payload.source_seq.clone(),
7555                sequence_n_count(&payload.source_seq),
7556            )
7557        })
7558    else {
7559        return;
7560    };
7561    for payload in payloads {
7562        if payload.source_seq.len() as i32 >= read_len
7563            && sequence_n_count(&payload.source_seq) > full_n_count
7564        {
7565            payload.source_seq = full_seq.clone();
7566        }
7567    }
7568}
7569
7570/// Returns the SAM-oriented read sequence used by mapper tags.
7571///
7572/// Full SAM/BAM records and minimap2-style `cs` tags both use the SAM sequence
7573/// orientation: for reverse-strand records, the aligned SEQ substring already
7574/// matches the forward reference. Reverse-strand block coordinates are flipped
7575/// separately for read-chain ordering, so query slicing converts those
7576/// read-order coordinates back to SAM coordinates instead of reverse
7577/// complementing the sequence here.
7578fn oriented_chain_sequence(
7579    record: &AsAlignment,
7580    read_len: i32,
7581    reference_seq: Option<&String>,
7582) -> String {
7583    if record.seq.is_empty()
7584        || record.seq == "*"
7585        || record.seq.starts_with("L:")
7586        || record.seq.starts_with("R:")
7587    {
7588        let Some(seq) = sequence_from_short_cs(
7589            &record.cigar,
7590            &record.cs,
7591            record.pos,
7592            read_len,
7593            reference_seq,
7594            if record.seq.starts_with("L:") || record.seq.starts_with("R:") {
7595                Some(record.seq.as_str())
7596            } else {
7597                None
7598            },
7599        ) else {
7600            return String::new();
7601        };
7602        return seq.to_ascii_uppercase();
7603    }
7604    record.seq.to_ascii_uppercase()
7605}
7606
7607/// Extracts a 1-based inclusive query slice from an already oriented sequence.
7608fn query_subseq(seq: &str, read_start: i32, read_end: i32) -> String {
7609    if seq.is_empty() || read_start <= 0 || read_end < read_start {
7610        return String::new();
7611    }
7612    let start = (read_start - 1) as usize;
7613    let end = read_end as usize;
7614    seq.get(start..end).unwrap_or("").to_string()
7615}
7616
7617/// Reconstructs a full-length query skeleton from short-form cs and CIGAR.
7618///
7619/// Scan1/Scan2 sidecars now store cs instead of full read sequence to keep temp
7620/// I/O bounded. Soft-clipped positions are filled with `N`; aligned positions
7621/// are reconstructed from `:`, `*`, `+`, and reference-backed match runs so
7622/// downstream block slicing can still materialize chain-level cs.
7623fn sequence_from_short_cs(
7624    cigar: &str,
7625    cs: &str,
7626    pos: i32,
7627    read_len: i32,
7628    reference_seq: Option<&String>,
7629    clip_payload: Option<&str>,
7630) -> Option<String> {
7631    if cs == "*" || cs == "NA" || read_len <= 0 || pos <= 0 {
7632        return None;
7633    }
7634    let reference_seq = reference_seq?;
7635    let mut out = vec![b'N'; read_len as usize];
7636    let mut read_idx = 0usize;
7637    let mut ref_idx = (pos - 1) as usize;
7638    let mut cs_ops = CsOpStream::new(cs);
7639    for (len, op) in parse_cigar_ops_basic(cigar)? {
7640        let len = len as usize;
7641        match op {
7642            'M' | '=' | 'X' => {
7643                let mut filled = 0usize;
7644                while filled < len {
7645                    match cs_ops.next_op()? {
7646                        CsOp::Match(count) => {
7647                            let take = count.min(len - filled);
7648                            let ref_start = ref_idx;
7649                            let ref_end = ref_start + take;
7650                            let dst_start = read_idx + filled;
7651                            let dst_end = dst_start + take;
7652                            out.get_mut(dst_start..dst_end)?
7653                                .copy_from_slice(reference_seq.get(ref_start..ref_end)?.as_bytes());
7654                            ref_idx += take;
7655                            filled += take;
7656                            if count > take {
7657                                cs_ops.push_front(CsOp::Match(count - take));
7658                            }
7659                        }
7660                        CsOp::Sub(query) => {
7661                            *out.get_mut(read_idx + filled)? = query.to_ascii_uppercase() as u8;
7662                            ref_idx += 1;
7663                            filled += 1;
7664                        }
7665                        CsOp::Ins(seq) => {
7666                            let bytes = seq.as_bytes();
7667                            let dst_start = read_idx + filled;
7668                            let dst_end = dst_start + bytes.len();
7669                            out.get_mut(dst_start..dst_end)?.copy_from_slice(bytes);
7670                            filled += bytes.len();
7671                        }
7672                        CsOp::Del(seq) => {
7673                            ref_idx += seq.len();
7674                        }
7675                        CsOp::Skip(len) => {
7676                            ref_idx += len;
7677                        }
7678                    }
7679                }
7680                read_idx += len;
7681            }
7682            'I' => {
7683                if let CsOp::Ins(seq) = cs_ops.next_op()? {
7684                    let bytes = seq.as_bytes();
7685                    let take = bytes.len().min(len);
7686                    out.get_mut(read_idx..read_idx + take)?
7687                        .copy_from_slice(&bytes[..take]);
7688                }
7689                read_idx += len;
7690            }
7691            'S' | 'H' => read_idx += len,
7692            'D' => {
7693                let _ = cs_ops.next_op()?;
7694                ref_idx += len;
7695            }
7696            'N' => {
7697                let _ = cs_ops.next_op()?;
7698                ref_idx += len;
7699            }
7700            'P' => {}
7701            _ => return None,
7702        }
7703    }
7704    if let Some(payload) = clip_payload {
7705        fill_soft_clips_from_payload(cigar, &mut out, payload)?;
7706    }
7707    String::from_utf8(out).ok()
7708}
7709
7710/// Restores sidecar-retained soft clips into a SAM-oriented query skeleton.
7711///
7712/// Short-form cs intentionally omits soft-clipped bases. Scan1/Scan2 keep those
7713/// bases in a compact `L:<seq>,R:<seq>` payload, still in SAM record
7714/// orientation. Filling them before any `0x10` reverse-complement step prevents
7715/// reverse-strand chains from being materialized with `N` bases at the BSJ
7716/// flank.
7717fn fill_soft_clips_from_payload(cigar: &str, out: &mut [u8], payload: &str) -> Option<()> {
7718    if payload == "*" || payload.is_empty() {
7719        return Some(());
7720    }
7721    let ops = parse_cigar_ops_basic(cigar)?;
7722    for (side, seq) in parse_clip_payload(payload) {
7723        let bytes = seq.as_bytes();
7724        match side {
7725            'L' => {
7726                let (len, op) = ops.first().copied()?;
7727                if op != 'S' || len as usize != bytes.len() || bytes.len() > out.len() {
7728                    return None;
7729                }
7730                out.get_mut(..bytes.len())?.copy_from_slice(bytes);
7731            }
7732            'R' => {
7733                let (len, op) = ops.last().copied()?;
7734                if op != 'S' || len as usize != bytes.len() || bytes.len() > out.len() {
7735                    return None;
7736                }
7737                let start = out.len() - bytes.len();
7738                out.get_mut(start..)?.copy_from_slice(bytes);
7739            }
7740            _ => return None,
7741        }
7742    }
7743    Some(())
7744}
7745
7746enum CsOp {
7747    Match(usize),
7748    Sub(char),
7749    Ins(String),
7750    Del(String),
7751    Skip(usize),
7752}
7753
7754struct CsOpStream<'a> {
7755    raw: &'a str,
7756    pos: usize,
7757    pending: Option<CsOp>,
7758}
7759
7760impl<'a> CsOpStream<'a> {
7761    fn new(raw: &'a str) -> Self {
7762        Self {
7763            raw,
7764            pos: 0,
7765            pending: None,
7766        }
7767    }
7768
7769    fn push_front(&mut self, op: CsOp) {
7770        self.pending = Some(op);
7771    }
7772
7773    fn next_op(&mut self) -> Option<CsOp> {
7774        if self.pending.is_some() {
7775            return self.pending.take();
7776        }
7777        let op = self.raw.as_bytes().get(self.pos).copied()? as char;
7778        self.pos += 1;
7779        match op {
7780            ':' => Some(CsOp::Match(self.take_number()?)),
7781            '*' => {
7782                let _ref_base = self.take_base()?;
7783                let query_base = self.take_base()?;
7784                Some(CsOp::Sub(query_base))
7785            }
7786            '+' => Some(CsOp::Ins(self.take_bases())),
7787            '-' => Some(CsOp::Del(self.take_bases())),
7788            '~' | '<' => {
7789                self.pos = (self.pos + 2).min(self.raw.len());
7790                let len = self.take_number()?;
7791                self.pos = (self.pos + 2).min(self.raw.len());
7792                Some(CsOp::Skip(len))
7793            }
7794            _ => None,
7795        }
7796    }
7797
7798    fn take_number(&mut self) -> Option<usize> {
7799        let start = self.pos;
7800        while self
7801            .raw
7802            .as_bytes()
7803            .get(self.pos)
7804            .is_some_and(u8::is_ascii_digit)
7805        {
7806            self.pos += 1;
7807        }
7808        (self.pos > start).then(|| self.raw[start..self.pos].parse().ok())?
7809    }
7810
7811    fn take_base(&mut self) -> Option<char> {
7812        let ch = self.raw.as_bytes().get(self.pos).copied()? as char;
7813        self.pos += 1;
7814        Some(ch)
7815    }
7816
7817    fn take_bases(&mut self) -> String {
7818        let start = self.pos;
7819        while self.raw.as_bytes().get(self.pos).is_some_and(|base| {
7820            matches!(base.to_ascii_lowercase(), b'a' | b'c' | b'g' | b't' | b'n')
7821        }) {
7822            self.pos += 1;
7823        }
7824        self.raw[start..self.pos].to_ascii_uppercase()
7825    }
7826}
7827
7828/// Parses one alignment into read-order segment blocks.
7829fn parse_alignment_blocks(record: &AsAlignment, read_len: i32) -> Option<Vec<SegmentBlock>> {
7830    if record.cigar == "*" || record.cigar.is_empty() || record.chr == "*" {
7831        return None;
7832    }
7833    let from_clip_like_alignment =
7834        record.from_local_clip || cigar_has_terminal_mapper_hard_clip(&record.cigar)?;
7835    let mut ref_pos = record.pos;
7836    let mut read_pos = 1;
7837    let mut blocks = Vec::new();
7838    let mut current: Option<SegmentBlock> = None;
7839    let mut count = 0i32;
7840    let mut has_count = false;
7841    for mut op in record.cigar.chars() {
7842        if op.is_ascii_digit() {
7843            count = count
7844                .checked_mul(10)?
7845                .checked_add(op.to_digit(10)? as i32)?;
7846            has_count = true;
7847            continue;
7848        }
7849        if !has_count {
7850            return None;
7851        }
7852        if op == 'H' {
7853            op = 'S';
7854        }
7855        match op {
7856            'M' | '=' | 'X' => {
7857                let block = current.get_or_insert(SegmentBlock {
7858                    read_start: read_pos,
7859                    read_end: read_pos + count - 1,
7860                    ref_start: ref_pos,
7861                    ref_end: ref_pos + count - 1,
7862                    from_local_clip: from_clip_like_alignment,
7863                    cigar_ops: Vec::new(),
7864                });
7865                block.read_end = read_pos + count - 1;
7866                block.ref_end = ref_pos + count - 1;
7867                block.cigar_ops.push(SegmentCigarOp { len: count, op });
7868                read_pos += count;
7869                ref_pos += count;
7870            }
7871            'I' => {
7872                if let Some(block) = current.as_mut() {
7873                    block.read_end += count;
7874                    block.cigar_ops.push(SegmentCigarOp { len: count, op });
7875                }
7876                read_pos += count;
7877            }
7878            'D' => {
7879                if let Some(block) = current.as_mut() {
7880                    block.ref_end += count;
7881                    block.cigar_ops.push(SegmentCigarOp { len: count, op });
7882                }
7883                ref_pos += count;
7884            }
7885            'N' => {
7886                if let Some(block) = current.take() {
7887                    blocks.push(block);
7888                }
7889                ref_pos += count;
7890            }
7891            'S' | 'H' => {
7892                if let Some(block) = current.take() {
7893                    blocks.push(block);
7894                }
7895                read_pos += count;
7896            }
7897            'P' => {}
7898            _ => return None,
7899        }
7900        count = 0;
7901        has_count = false;
7902    }
7903    if has_count {
7904        return None;
7905    }
7906    if let Some(block) = current.take() {
7907        blocks.push(block);
7908    }
7909    if blocks.is_empty() {
7910        return None;
7911    }
7912    if record.flag & 0x10 != 0 {
7913        for block in &mut blocks {
7914            let flipped_start = read_len - block.read_end + 1;
7915            let flipped_end = read_len - block.read_start + 1;
7916            block.read_start = flipped_start;
7917            block.read_end = flipped_end;
7918        }
7919        blocks.reverse();
7920    }
7921    Some(blocks)
7922}
7923
7924/// Returns whether a mapper CIGAR contains a terminal hard clip.
7925///
7926/// CIRI-AS-local pseudo-alignments already carry `from_local_clip`; native
7927/// BWA-MEM supplementary split alignments instead usually arrive as terminal
7928/// `H` CIGARs with only the aligned query slice stored in SEQ. Treating those
7929/// anchors as clip-like lets splice correction search the wider microhomology
7930/// window while ordinary soft-clipped primary alignments keep the conservative
7931/// window unless a local pseudo-alignment explicitly validated the clip.
7932fn cigar_has_terminal_mapper_hard_clip(cigar: &str) -> Option<bool> {
7933    let ops = parse_cigar_ops_basic(cigar)?;
7934    Some(
7935        ops.first().is_some_and(|(_, op)| *op == 'H')
7936            || ops.last().is_some_and(|(_, op)| *op == 'H'),
7937    )
7938}
7939
7940/// Builds the final output chain and segment CIGAR from selected alignments.
7941///
7942/// The final `<prefix>.segments` fields are emitted in read-chain order, not
7943/// genomic order. For BSJ reads this is the only representation that preserves
7944/// circular topology: a plus-strand junction is `circ-end-side -> B ->
7945/// circ-start-side`, while a minus-strand junction is the reverse genomic
7946/// direction after the reverse-strand alignment has been normalized into read
7947/// order.
7948fn materialize_chain(
7949    records: &[ParsedAlignment],
7950    read_len: i32,
7951    circ: Option<&CircRecord>,
7952    token_strand: char,
7953    junction_hints: &[(i32, i32)],
7954    correction: Option<&SegmentCorrectionContext<'_>>,
7955    reverse_chain_order: bool,
7956) -> Option<MateChain> {
7957    if records.is_empty() {
7958        return None;
7959    }
7960    let chrom = records.first()?.chrom.clone();
7961    let mut order_strand = records.first()?.strand;
7962    let mut chain_blocks = Vec::new();
7963    let mut used_secondary = 0usize;
7964    let mut used_supplementary = 0usize;
7965    let mut query_coverage = 0i32;
7966    for record in records {
7967        used_secondary += usize::from(is_secondary(record.flag));
7968        used_supplementary += usize::from(is_supplementary(record.flag));
7969        for block in &record.blocks {
7970            query_coverage += block.read_end - block.read_start + 1;
7971            chain_blocks.push(ChainBlockPayload {
7972                block: block.clone(),
7973                strand: record.strand,
7974                source_seq: record.seq.clone(),
7975            });
7976        }
7977    }
7978    chain_blocks.sort_by_key(|payload| {
7979        (
7980            payload.block.read_start,
7981            payload.block.read_end,
7982            payload.block.ref_start,
7983        )
7984    });
7985    if reverse_chain_order {
7986        chain_blocks.reverse();
7987        order_strand = opposite_strand(order_strand);
7988    }
7989    let mut chain_payloads = chain_blocks;
7990    split_confirmed_bsj_overrun_blocks(
7991        &mut chain_payloads,
7992        circ,
7993        junction_hints,
7994        reverse_chain_order,
7995    );
7996    let mut blocks = payload_blocks(&chain_payloads);
7997    if let Some(circ) = circ {
7998        if confirmed_bsj_snap_hint_matches_circ(junction_hints, circ) {
7999            apply_confirmed_circ_outer_boundary_corrections(&mut blocks, circ);
8000        }
8001    }
8002    apply_circ_boundary_corrections(&mut blocks, circ);
8003    sync_payload_blocks_after_boundary_correction(&mut chain_payloads, &blocks, read_len);
8004    blocks = payload_blocks(&chain_payloads);
8005    let mut is_bsj = false;
8006    let mut is_circular = false;
8007    for pair in blocks.windows(2) {
8008        let prev_block = &pair[0];
8009        let next_block = &pair[1];
8010        let wrapped = wraps_in_read_order(prev_block, next_block, order_strand);
8011        if let Some(circ) = circ {
8012            if is_bsj_transition(prev_block, next_block, circ, order_strand) {
8013                is_bsj = true;
8014                is_circular = true;
8015            } else if wrapped {
8016                is_circular = true;
8017            }
8018        } else if wrapped {
8019            is_circular = true;
8020        }
8021    }
8022
8023    let mut output_payloads = chain_payloads.clone();
8024    let mut output_blocks = payload_blocks(&output_payloads);
8025    let boundary_gap_idxs = if is_bsj {
8026        circ.and_then(|circ| read_order_bsj_gap_index(&output_blocks, circ, order_strand))
8027            .into_iter()
8028            .collect::<Vec<_>>()
8029    } else if is_circular {
8030        read_order_wrap_gap_indexes(&output_blocks, order_strand)
8031    } else {
8032        Vec::new()
8033    };
8034    apply_confirmed_bsj_boundary_corrections(&mut output_blocks, circ, &boundary_gap_idxs);
8035    sync_payload_blocks_after_boundary_correction(&mut output_payloads, &output_blocks, read_len);
8036    output_blocks = payload_blocks(&output_payloads);
8037    apply_segment_boundary_corrections(
8038        &mut output_blocks,
8039        &chrom,
8040        &boundary_gap_idxs,
8041        junction_hints,
8042        correction,
8043        token_strand,
8044        &output_payloads,
8045        read_len,
8046    );
8047    sync_payload_blocks_after_boundary_correction(&mut output_payloads, &output_blocks, read_len);
8048    output_blocks = payload_blocks(&output_payloads);
8049    replace_incomplete_payload_sequences_for_output(&mut output_payloads, read_len);
8050    let query_seqs = payload_query_seqs(&output_payloads, read_len);
8051    let reference_seq = correction.and_then(|ctx| ctx.reference.get(&chrom).map(String::as_str));
8052    let (tokens, _token_spans, cigar, cs) = materialize_read_chain_output(
8053        &output_blocks,
8054        &query_seqs,
8055        reference_seq,
8056        token_strand,
8057        &boundary_gap_idxs,
8058        read_len,
8059        reverse_chain_order,
8060    );
8061
8062    Some(MateChain {
8063        chrom,
8064        token_strand,
8065        blocks: output_blocks,
8066        tokens,
8067        cigar,
8068        cs,
8069        is_bsj,
8070        is_circular,
8071        used_secondary,
8072        used_supplementary,
8073        query_coverage,
8074    })
8075}
8076
8077/// Returns the current block view for chain payloads.
8078fn payload_blocks(payloads: &[ChainBlockPayload]) -> Vec<SegmentBlock> {
8079    payloads
8080        .iter()
8081        .map(|payload| payload.block.clone())
8082        .collect()
8083}
8084
8085/// Re-slices query sequence after all selected block boundaries are final.
8086///
8087/// Reverse-strand blocks are stored in read-chain coordinates after CIGAR
8088/// parsing, while their source sequence remains SAM-oriented. The coordinate
8089/// conversion here mirrors the earlier block flip and prevents `cs` and BAM
8090/// `SEQ` from mixing a corrected reference block with an older or
8091/// reverse-complemented query interval.
8092fn payload_query_seqs(payloads: &[ChainBlockPayload], read_len: i32) -> Vec<String> {
8093    payloads
8094        .iter()
8095        .map(|payload| payload_query_seq_for_block(payload, &payload.block, read_len))
8096        .collect()
8097}
8098
8099/// Returns the query slice that one payload block would emit after correction.
8100///
8101/// Boundary scoring and final materialization must slice the same oriented
8102/// sequence coordinates. Keeping that conversion in one helper prevents the
8103/// scorer from preferring a junction that cannot later be represented in the
8104/// `.segments` cs field or `.segments.bam` SEQ field.
8105fn payload_query_seq_for_block(
8106    payload: &ChainBlockPayload,
8107    block: &SegmentBlock,
8108    read_len: i32,
8109) -> String {
8110    let (read_start, read_end) = if payload.strand == '-' {
8111        (
8112            read_len - block.read_end + 1,
8113            read_len - block.read_start + 1,
8114        )
8115    } else {
8116        (block.read_start, block.read_end)
8117    };
8118    query_subseq(&payload.source_seq, read_start, read_end)
8119}
8120
8121/// Synchronizes query coordinates with reference-side boundary corrections.
8122///
8123/// Boundary correction functions currently operate on reference coordinates
8124/// only. For the `.segments` cs field and `.segments.bam` SEQ field, the query
8125/// interval must move by the same edge deltas. Reverse-strand alignments map
8126/// reference start/end to the opposite query edges after block normalization, so
8127/// they use the inverted edge deltas. If the corrected interval cannot be
8128/// represented within the available read sequence, the block keeps its original
8129/// coordinates rather than emitting a confidently wrong sequence.
8130fn sync_payload_blocks_after_boundary_correction(
8131    payloads: &mut [ChainBlockPayload],
8132    corrected_blocks: &[SegmentBlock],
8133    read_len: i32,
8134) {
8135    if payloads.len() != corrected_blocks.len() {
8136        return;
8137    }
8138    for (payload, corrected) in payloads.iter_mut().zip(corrected_blocks.iter()) {
8139        if let Some(synced) =
8140            synced_payload_block_after_boundary_correction(payload, corrected, read_len)
8141        {
8142            payload.block = synced;
8143        }
8144    }
8145}
8146
8147/// Projects a reference-side boundary correction back onto read coordinates.
8148///
8149/// The projection mirrors `sync_payload_blocks_after_boundary_correction` and
8150/// is also used by junction scoring. Candidates that cannot be represented by
8151/// the available query sequence are rejected before evidence tie-breakers can
8152/// promote them.
8153fn synced_payload_block_after_boundary_correction(
8154    payload: &ChainBlockPayload,
8155    corrected: &SegmentBlock,
8156    read_len: i32,
8157) -> Option<SegmentBlock> {
8158    let original = payload.block.clone();
8159    let mut synced = corrected.clone();
8160    let start_delta = synced.ref_start - original.ref_start;
8161    let end_delta = synced.ref_end - original.ref_end;
8162    if start_delta != 0 || end_delta != 0 {
8163        synced.cigar_ops.clear();
8164    }
8165    if payload.strand == '-' {
8166        synced.read_start = original.read_start - end_delta;
8167        synced.read_end = original.read_end - start_delta;
8168    } else {
8169        synced.read_start = original.read_start + start_delta;
8170        synced.read_end = original.read_end + end_delta;
8171    }
8172    let available_read_len = if payload.source_seq.is_empty() {
8173        read_len
8174    } else {
8175        payload.source_seq.len() as i32
8176    };
8177    (synced.read_start >= 1
8178        && synced.read_end >= synced.read_start
8179        && synced.read_end <= available_read_len)
8180        .then_some(synced)
8181}
8182
8183/// Builds read-chain segment tokens and the matching CIRI-specific CIGAR.
8184///
8185/// `N` and `B` both encode the genomic interval skipped between adjacent
8186/// read-chain blocks. `B` is deliberately tied to read-order circular wraps:
8187/// confirmed BSJ reads mark the annotated circ boundary, while `type=backward`
8188/// rows mark every selected wrap even when the boundary is not a confirmed BSJ.
8189/// Re-sorting these blocks by coordinate would turn `C|B|A` circRNA evidence
8190/// into a linear-looking `A|B|C` chain.
8191fn materialize_read_chain_output(
8192    blocks: &[SegmentBlock],
8193    query_seqs: &[String],
8194    reference_seq: Option<&str>,
8195    token_strand: char,
8196    boundary_gap_idxs: &[usize],
8197    read_len: i32,
8198    reverse_chain_order: bool,
8199) -> (Vec<String>, Vec<(i32, i32)>, String, String) {
8200    let mut token_spans = Vec::with_capacity(blocks.len());
8201    let mut tokens = Vec::with_capacity(blocks.len() + boundary_gap_idxs.len());
8202    let mut cigar = String::new();
8203    let mut cs = String::new();
8204    let mut cs_available = reference_seq.is_some() && query_seqs.len() == blocks.len();
8205    if let Some(first) = blocks.first() {
8206        let leading_clip = if reverse_chain_order {
8207            read_len - first.read_end
8208        } else {
8209            first.read_start - 1
8210        };
8211        if leading_clip > 0 {
8212            let _ = write!(&mut cigar, "{}S", leading_clip);
8213        }
8214    }
8215    for (idx, block) in blocks.iter().enumerate() {
8216        if idx > 0 {
8217            let prev = &blocks[idx - 1];
8218            let gap = interval_gap(prev, block);
8219            let op = if boundary_gap_idxs.contains(&idx) {
8220                'B'
8221            } else {
8222                'N'
8223            };
8224            let _ = write!(&mut cigar, "{}{}", gap, op);
8225            if let Some(chr_seq) = reference_seq {
8226                cs.push_str(&segment_gap_cs(prev, block, chr_seq, op == 'B'));
8227            }
8228            if op == 'B' {
8229                tokens.push("<bsj>".to_string());
8230            }
8231        }
8232        let block_cigar = segment_block_cigar(block);
8233        cigar.push_str(&block_cigar);
8234        if let Some(chr_seq) = reference_seq {
8235            let block_cs = segment_block_cs(block, query_seqs.get(idx), chr_seq);
8236            if block_cs == "*" {
8237                cs_available = false;
8238            }
8239            cs.push_str(&block_cs);
8240        }
8241        tokens.push(format!(
8242            "{}-{}:{}",
8243            block.ref_start, block.ref_end, token_strand
8244        ));
8245        token_spans.push((block.ref_start, block.ref_end));
8246    }
8247    if let Some(last) = blocks.last() {
8248        let trailing_clip = if reverse_chain_order {
8249            last.read_start - 1
8250        } else {
8251            read_len - last.read_end
8252        };
8253        if trailing_clip > 0 {
8254            let _ = write!(&mut cigar, "{}S", trailing_clip);
8255        }
8256    }
8257    let cs = if cs_available && !cs.is_empty() {
8258        cs
8259    } else {
8260        "*".to_string()
8261    };
8262    (tokens, token_spans, cigar, cs)
8263}
8264
8265/// Returns the CIGAR operations for one output segment block.
8266///
8267/// Boundary-corrected blocks intentionally drop their source operation list,
8268/// because the old indel offsets no longer describe the corrected reference
8269/// slice. Unchanged mapper blocks keep their internal `I/D` placement so the
8270/// public `.segments` CIGAR can round-trip with the allele-aware `cs` field.
8271fn segment_block_cigar(block: &SegmentBlock) -> String {
8272    let ops = valid_segment_cigar_ops(block);
8273    if ops.is_empty() {
8274        let len = block.ref_end - block.ref_start + 1;
8275        return format!("{}M", len.max(0));
8276    }
8277    let mut cigar = String::new();
8278    for op in ops {
8279        let _ = write!(&mut cigar, "{}{}", op.len, op.op);
8280    }
8281    cigar
8282}
8283
8284/// Returns source CIGAR ops only when they still match the block spans.
8285fn valid_segment_cigar_ops(block: &SegmentBlock) -> &[SegmentCigarOp] {
8286    if block.cigar_ops.is_empty() {
8287        return &[];
8288    }
8289    let query_len: i32 = block
8290        .cigar_ops
8291        .iter()
8292        .filter(|op| matches!(op.op, 'M' | '=' | 'X' | 'I'))
8293        .map(|op| op.len)
8294        .sum();
8295    let ref_len: i32 = block
8296        .cigar_ops
8297        .iter()
8298        .filter(|op| matches!(op.op, 'M' | '=' | 'X' | 'D'))
8299        .map(|op| op.len)
8300        .sum();
8301    if query_len == block.read_end - block.read_start + 1
8302        && ref_len == block.ref_end - block.ref_start + 1
8303    {
8304        &block.cigar_ops
8305    } else {
8306        &[]
8307    }
8308}
8309
8310/// Encodes one aligned block as short-form cs against the reference.
8311///
8312/// Unchanged mapper blocks use their retained internal `M/I/D` operations, so
8313/// insertions and deletions remain at their original query/reference offsets.
8314/// Boundary-corrected blocks fall back to one contiguous `M` comparison; this
8315/// is less expressive, but avoids claiming an indel position after annotation
8316/// or circ-boundary snapping has moved the block edges.
8317fn segment_block_cs(block: &SegmentBlock, query: Option<&String>, reference_seq: &str) -> String {
8318    let Some(query) = query else {
8319        return "*".to_string();
8320    };
8321    if query.is_empty() || block.ref_start <= 0 || block.ref_end < block.ref_start {
8322        return "*".to_string();
8323    }
8324    let start = (block.ref_start - 1) as usize;
8325    let end = block.ref_end as usize;
8326    let Some(reference) = reference_seq.get(start..end) else {
8327        return "*".to_string();
8328    };
8329    let mut out = String::new();
8330    let ops = valid_segment_cigar_ops(block);
8331    if !ops.is_empty() {
8332        let mut ref_offset = 0usize;
8333        let mut query_offset = 0usize;
8334        let mut matches = 0usize;
8335        for op in ops {
8336            let len = op.len as usize;
8337            match op.op {
8338                'M' | '=' | 'X' => {
8339                    let ref_slice = reference.get(ref_offset..ref_offset + len);
8340                    let query_slice = query.get(query_offset..query_offset + len);
8341                    let (Some(ref_slice), Some(query_slice)) = (ref_slice, query_slice) else {
8342                        return "*".to_string();
8343                    };
8344                    append_match_or_sub_cs(&mut out, &mut matches, ref_slice, query_slice);
8345                    ref_offset += len;
8346                    query_offset += len;
8347                }
8348                'I' => {
8349                    flush_cs_match(&mut out, &mut matches);
8350                    let Some(inserted) = query.get(query_offset..query_offset + len) else {
8351                        return "*".to_string();
8352                    };
8353                    out.push('+');
8354                    out.push_str(&inserted.to_ascii_lowercase());
8355                    query_offset += len;
8356                }
8357                'D' => {
8358                    flush_cs_match(&mut out, &mut matches);
8359                    let Some(deleted) = reference.get(ref_offset..ref_offset + len) else {
8360                        return "*".to_string();
8361                    };
8362                    out.push('-');
8363                    out.push_str(&deleted.to_ascii_lowercase());
8364                    ref_offset += len;
8365                }
8366                _ => return "*".to_string(),
8367            }
8368        }
8369        flush_cs_match(&mut out, &mut matches);
8370        if ref_offset != reference.len() || query_offset != query.len() {
8371            return "*".to_string();
8372        }
8373    } else {
8374        let mut matches = 0usize;
8375        append_match_or_sub_cs(&mut out, &mut matches, reference, query);
8376        flush_cs_match(&mut out, &mut matches);
8377    }
8378    if out.is_empty() {
8379        format!(":{}", query.len().min(reference.len()))
8380    } else {
8381        out
8382    }
8383}
8384
8385/// Appends `:`/`*` cs operations for a same-span reference/query comparison.
8386fn append_match_or_sub_cs(out: &mut String, matches: &mut usize, reference: &str, query: &str) {
8387    for (ref_base, query_base) in reference.bytes().zip(query.bytes()) {
8388        if ref_base.eq_ignore_ascii_case(&query_base) {
8389            *matches += 1;
8390            continue;
8391        }
8392        flush_cs_match(out, matches);
8393        out.push('*');
8394        out.push(ref_base.to_ascii_lowercase() as char);
8395        out.push(query_base.to_ascii_lowercase() as char);
8396    }
8397    if query.len() > reference.len() {
8398        flush_cs_match(out, matches);
8399        out.push('+');
8400        out.push_str(&query[reference.len()..].to_ascii_lowercase());
8401    } else if reference.len() > query.len() {
8402        flush_cs_match(out, matches);
8403        out.push('-');
8404        out.push_str(&reference[query.len()..].to_ascii_lowercase());
8405    }
8406}
8407
8408/// Encodes an internal splice or CIRI back-splice jump in cs syntax.
8409fn segment_gap_cs(
8410    left: &SegmentBlock,
8411    right: &SegmentBlock,
8412    reference_seq: &str,
8413    is_bsj: bool,
8414) -> String {
8415    let op = if is_bsj { '<' } else { '~' };
8416    let gap = interval_gap(left, right).max(0);
8417    let (low_end, high_start) = if left.ref_end < right.ref_start {
8418        (left.ref_end, right.ref_start)
8419    } else if right.ref_end < left.ref_start {
8420        (right.ref_end, left.ref_start)
8421    } else {
8422        (
8423            left.ref_end.min(right.ref_end),
8424            left.ref_start.max(right.ref_start),
8425        )
8426    };
8427    let donor = reference_dinucleotide_lower(reference_seq, low_end + 1)
8428        .unwrap_or_else(|| "nn".to_string());
8429    let acceptor = reference_dinucleotide_lower(reference_seq, high_start - 2)
8430        .unwrap_or_else(|| "nn".to_string());
8431    format!("{op}{donor}{gap}{acceptor}")
8432}
8433
8434/// Appends a pending short-form cs match run.
8435fn flush_cs_match(out: &mut String, matches: &mut usize) {
8436    if *matches > 0 {
8437        let _ = write!(out, ":{}", *matches);
8438        *matches = 0;
8439    }
8440}
8441
8442/// Returns a lowercase 1-based two-base reference slice for cs splice signals.
8443fn reference_dinucleotide_lower(chr_seq: &str, start: i32) -> Option<String> {
8444    reference_dinucleotide(chr_seq, start).map(|seq| seq.to_ascii_lowercase())
8445}
8446
8447/// Returns the read-chain gap index where the selected blocks cross BSJ.
8448fn read_order_bsj_gap_index(
8449    read_blocks: &[SegmentBlock],
8450    circ: &CircRecord,
8451    order_strand: char,
8452) -> Option<usize> {
8453    read_blocks.windows(2).enumerate().find_map(|(idx, pair)| {
8454        is_bsj_transition(&pair[0], &pair[1], circ, order_strand).then_some(idx + 1)
8455    })
8456}
8457
8458/// Returns read-chain gap indexes that wrap against strand-specific linear order.
8459///
8460/// These indexes are used for non-BSJ backward rows. They are not treated as
8461/// strong BSJ evidence, but the `.segments` CIGAR still needs `B` operators at
8462/// the actual read-order wrap positions so downstream graph code can distinguish
8463/// backward topology from ordinary splice `N` gaps.
8464fn read_order_wrap_gap_indexes(read_blocks: &[SegmentBlock], order_strand: char) -> Vec<usize> {
8465    read_blocks
8466        .windows(2)
8467        .enumerate()
8468        .filter_map(|(idx, pair)| {
8469            wraps_in_read_order(&pair[0], &pair[1], order_strand).then_some(idx + 1)
8470        })
8471        .collect()
8472}
8473
8474/// Returns the skipped genomic distance between two read-chain blocks.
8475fn interval_gap(left: &SegmentBlock, right: &SegmentBlock) -> i32 {
8476    if left.ref_end < right.ref_start {
8477        right.ref_start - left.ref_end - 1
8478    } else if right.ref_end < left.ref_start {
8479        left.ref_start - right.ref_end - 1
8480    } else {
8481        0
8482    }
8483}
8484
8485/// Snaps read-order blocks onto confirmed circ outer boundaries.
8486///
8487/// This runs before topology detection because BSJ transitions are judged in
8488/// read order. Internal splice correction is intentionally deferred until the
8489/// final read-chain blocks are selected, then each adjacent non-BSJ pair is
8490/// interpreted in genomic low/high order only for choosing splice boundaries.
8491fn apply_circ_boundary_corrections(blocks: &mut [SegmentBlock], circ: Option<&CircRecord>) {
8492    if let Some(circ) = circ {
8493        for block in blocks.iter_mut() {
8494            if (block.ref_start - circ.start).abs() <= 2 {
8495                block.ref_start = circ.start;
8496            }
8497            if (block.ref_end - circ.end).abs() <= 2 {
8498                block.ref_end = circ.end;
8499            }
8500        }
8501    }
8502}
8503
8504/// Snaps all near-circ outer edges when this read has the confirmed BSJ hint.
8505///
8506/// This is broader than `apply_confirmed_bsj_boundary_corrections` because the
8507/// opposite mate of a BSJ read can carry only ordinary `N` gaps while still
8508/// ending a terminal exon a few bases past the confirmed circ boundary. The
8509/// requirement is still strict: without a read-specific `(circ.end,circ.start)`
8510/// hint, ordinary internal chains keep the conservative two-base snap only.
8511fn apply_confirmed_circ_outer_boundary_corrections(blocks: &mut [SegmentBlock], circ: &CircRecord) {
8512    for block in blocks {
8513        force_block_to_confirmed_bsj_boundary(block, circ);
8514    }
8515}
8516
8517/// Splits a mapper-extended single block at a confirmed BSJ boundary.
8518///
8519/// Some BSJ mates are reported by Scan1/Scan2 as one soft-clipped alignment
8520/// that extends a few bases past `circ.end` instead of emitting a supplementary
8521/// low-side block. When the read-specific junction hint already matches this
8522/// circRNA, those overrun bases belong after a BSJ operator at `circ.start`.
8523/// Splitting here keeps the later BAM writer from representing them as intronic
8524/// mismatches while leaving ordinary non-BSJ blocks untouched.
8525fn split_confirmed_bsj_overrun_blocks(
8526    payloads: &mut Vec<ChainBlockPayload>,
8527    circ: Option<&CircRecord>,
8528    junction_hints: &[(i32, i32)],
8529    reverse_chain_order: bool,
8530) {
8531    let Some(circ) = circ else {
8532        return;
8533    };
8534    if !confirmed_bsj_hint_matches_circ(junction_hints, circ) {
8535        return;
8536    }
8537    let mut split_payloads = Vec::with_capacity(payloads.len() + 1);
8538    for payload in payloads.drain(..) {
8539        let block = payload.block.clone();
8540        let overrun = block.ref_end - circ.end;
8541        let high_ref_len = circ.end - block.ref_start + 1;
8542        if block.ref_start <= circ.end
8543            && overrun > 0
8544            && overrun <= CONFIRMED_BSJ_BOUNDARY_CORRECTION_WINDOW
8545            && high_ref_len > 0
8546        {
8547            let block_query_len = block.read_end - block.read_start + 1;
8548            let low_query_len = block_query_len - high_ref_len;
8549            if low_query_len > 0 {
8550                let low_ref_end = circ.start + low_query_len - 1;
8551                if low_ref_end <= circ.end {
8552                    let mut high = payload.clone();
8553                    high.block.ref_end = circ.end;
8554                    if reverse_chain_order {
8555                        high.block.read_start = block.read_start + low_query_len;
8556                        high.block.read_end = block.read_end;
8557                    } else {
8558                        high.block.read_start = block.read_start;
8559                        high.block.read_end = block.read_start + high_ref_len - 1;
8560                    }
8561                    high.block.cigar_ops.clear();
8562
8563                    let mut low = payload;
8564                    low.block.ref_start = circ.start;
8565                    low.block.ref_end = low_ref_end;
8566                    if reverse_chain_order {
8567                        low.block.read_start = block.read_start;
8568                        low.block.read_end = block.read_start + low_query_len - 1;
8569                    } else {
8570                        low.block.read_start = block.read_start + high_ref_len;
8571                        low.block.read_end = block.read_end;
8572                    }
8573                    low.block.cigar_ops.clear();
8574                    split_payloads.push(high);
8575                    split_payloads.push(low);
8576                    continue;
8577                }
8578            }
8579        }
8580        split_payloads.push(payload);
8581    }
8582    *payloads = split_payloads;
8583}
8584
8585/// Returns whether this read has a Scan1/Scan2 hint for the current circ BSJ.
8586///
8587/// The small tolerance mirrors existing circ-boundary snapping and accounts for
8588/// repeat-adjusted candidate rows. It is intentionally much tighter than the
8589/// overrun window so arbitrary nearby split signals cannot create new BSJ
8590/// topology in segments.
8591fn confirmed_bsj_hint_matches_circ(junction_hints: &[(i32, i32)], circ: &CircRecord) -> bool {
8592    junction_hints
8593        .iter()
8594        .any(|&(site2, site1)| (site2 - circ.end).abs() <= 2 && (site1 - circ.start).abs() <= 2)
8595}
8596
8597/// Returns whether the read-chain should snap near confirmed circ boundaries.
8598///
8599/// Positive hints are real Scan1/Scan2 BSJ sites and may also trigger single
8600/// block splitting. Negative hints are an internal row-level marker used only
8601/// for the non-BSJ mate of a confirmed BSJ row: they permit outer-boundary snap
8602/// without creating a new `<bsj>` operator.
8603fn confirmed_bsj_snap_hint_matches_circ(junction_hints: &[(i32, i32)], circ: &CircRecord) -> bool {
8604    junction_hints.iter().any(|&(site2, site1)| {
8605        ((site2 - circ.end).abs() <= 2 && (site1 - circ.start).abs() <= 2)
8606            || ((site2 + circ.end).abs() <= 2 && (site1 + circ.start).abs() <= 2)
8607    })
8608}
8609
8610/// Forces read-chain BSJ gap edges onto the confirmed circRNA boundaries.
8611///
8612/// Scan1/Scan2 have already selected the concrete BSJ site before the segments
8613/// sidecar is materialized. Once a read-order gap has been recognized as that
8614/// BSJ, the circ boundary is therefore stronger than annotation, motif, support,
8615/// or per-read sequence tie-breakers used for ordinary internal splice gaps.
8616fn apply_confirmed_bsj_boundary_corrections(
8617    blocks: &mut [SegmentBlock],
8618    circ: Option<&CircRecord>,
8619    boundary_gap_idxs: &[usize],
8620) {
8621    let Some(circ) = circ else {
8622        return;
8623    };
8624    for &gap_idx in boundary_gap_idxs {
8625        if gap_idx == 0 || gap_idx >= blocks.len() {
8626            continue;
8627        }
8628        force_block_to_confirmed_bsj_boundary(&mut blocks[gap_idx - 1], circ);
8629        force_block_to_confirmed_bsj_boundary(&mut blocks[gap_idx], circ);
8630    }
8631}
8632
8633/// Snaps one BSJ-adjacent block edge to the confirmed circ start or end.
8634///
8635/// Only the outer circ edges are eligible: the low-side block begins at
8636/// `circ.start`, and the high-side block ends at `circ.end`. The wider window
8637/// covers BWA-MEM's local extension through short microhomology without letting
8638/// unrelated internal block edges move across the circRNA.
8639fn force_block_to_confirmed_bsj_boundary(block: &mut SegmentBlock, circ: &CircRecord) {
8640    if (block.ref_start - circ.start).abs() <= CONFIRMED_BSJ_BOUNDARY_CORRECTION_WINDOW {
8641        block.ref_start = circ.start;
8642    }
8643    if (block.ref_end - circ.end).abs() <= CONFIRMED_BSJ_BOUNDARY_CORRECTION_WINDOW {
8644        block.ref_end = circ.end;
8645    }
8646}
8647
8648/// Applies corrected internal splice boundaries to read-chain output blocks.
8649///
8650/// Candidate splice sites still come from annotation, read-specific hints,
8651/// preliminary support, and splice motifs, but the final choice is scored
8652/// against the read sequence before those evidence tie-breakers are applied.
8653/// Only the `<prefix>.segments` representation is changed; CIRI3 Summary
8654/// evidence has already been finalized before this function runs.
8655fn apply_segment_boundary_corrections(
8656    blocks: &mut [SegmentBlock],
8657    chrom: &str,
8658    boundary_gap_idxs: &[usize],
8659    junction_hints: &[(i32, i32)],
8660    correction: Option<&SegmentCorrectionContext<'_>>,
8661    token_strand: char,
8662    payloads: &[ChainBlockPayload],
8663    read_len: i32,
8664) {
8665    if blocks.len() < 2 {
8666        return;
8667    }
8668    for idx in 0..blocks.len() - 1 {
8669        if boundary_gap_idxs.contains(&(idx + 1)) {
8670            continue;
8671        }
8672        let (left_idx, right_idx) = if blocks[idx].ref_start <= blocks[idx + 1].ref_start {
8673            (idx, idx + 1)
8674        } else {
8675            (idx + 1, idx)
8676        };
8677        let prev_end = blocks[left_idx].ref_end;
8678        let next_start = blocks[right_idx].ref_start;
8679        if prev_end >= next_start {
8680            continue;
8681        }
8682        let annotation_window = correction_window_for_pair(&blocks[left_idx], &blocks[right_idx]);
8683        let chosen = if let Some(ctx) = correction {
8684            choose_supported_splice_boundary(
8685                ctx,
8686                chrom,
8687                prev_end,
8688                next_start,
8689                &blocks[left_idx],
8690                &blocks[right_idx],
8691                payloads.get(left_idx),
8692                payloads.get(right_idx),
8693                junction_hints,
8694                token_strand,
8695                annotation_window,
8696                read_len,
8697            )
8698        } else {
8699            choose_hinted_splice_boundary(
8700                prev_end,
8701                next_start,
8702                &blocks[left_idx],
8703                &blocks[right_idx],
8704                junction_hints,
8705                annotation_window,
8706            )
8707        };
8708        if let Some((site2, site1)) = chosen {
8709            blocks[left_idx].ref_end = site2;
8710            blocks[right_idx].ref_start = site1;
8711        }
8712    }
8713}
8714
8715/// Chooses the best nearby internal splice boundary using all available signals.
8716///
8717/// Transcript-level splice pairs are treated as fixed exon-junction evidence
8718/// when they fall inside the local correction window. Remaining annotation,
8719/// motifs, Scan1/Scan2 hints, and preliminary support still define plausible
8720/// candidates whose ordering is sequence-aware, so non-transcript edges cannot
8721/// rescue several newly introduced intronic mismatches just because they carry
8722/// weaker population-level evidence.
8723fn choose_supported_splice_boundary(
8724    ctx: &SegmentCorrectionContext<'_>,
8725    chrom: &str,
8726    prev_end: i32,
8727    next_start: i32,
8728    left: &SegmentBlock,
8729    right: &SegmentBlock,
8730    left_payload: Option<&ChainBlockPayload>,
8731    right_payload: Option<&ChainBlockPayload>,
8732    junction_hints: &[(i32, i32)],
8733    token_strand: char,
8734    window: i32,
8735    read_len: i32,
8736) -> Option<(i32, i32)> {
8737    let chr_seq = ctx.reference.get(chrom);
8738    let mut best: Option<((i32, i32, i32, i32, i32, i32, i32, i32, i32, i32), i32, i32)> = None;
8739    for end in left.ref_end - window..=left.ref_end + window {
8740        if end < left.ref_start || end >= right.ref_start {
8741            continue;
8742        }
8743        for start in right.ref_start - window..=right.ref_start + window {
8744            if start > right.ref_end || end >= start {
8745                continue;
8746            }
8747            let hint_score = junction_hints
8748                .iter()
8749                .filter(|&&(site2, site1)| site2 == end && site1 == start)
8750                .count() as i32;
8751            let (transcript_score, annotation_score) =
8752                annotation_splice_pair_score(ctx.annotation, chrom, end, start, token_strand)
8753                    .unwrap_or((0, 0));
8754            let support_score = ctx
8755                .junction_support
8756                .and_then(|support| support.get(chrom))
8757                .and_then(|support| support.get(&(end, start, token_strand)).copied())
8758                .unwrap_or(0)
8759                .min(i32::MAX as usize) as i32;
8760            let motif_score = if (end - left.ref_end).abs() <= INTERNAL_SPLICE_CORRECTION_WINDOW
8761                && (start - right.ref_start).abs() <= INTERNAL_SPLICE_CORRECTION_WINDOW
8762            {
8763                chr_seq
8764                    .and_then(|seq| splice_motif_score(seq, end, start, token_strand))
8765                    .unwrap_or(0)
8766            } else {
8767                0
8768            };
8769            if hint_score == 0
8770                && transcript_score == 0
8771                && annotation_score == 0
8772                && support_score == 0
8773                && motif_score == 0
8774            {
8775                continue;
8776            }
8777            let sequence_score = boundary_sequence_score(
8778                chr_seq.map(String::as_str),
8779                left,
8780                right,
8781                left_payload,
8782                right_payload,
8783                end,
8784                start,
8785                read_len,
8786            )
8787            .unwrap_or(0);
8788            let sequence_adjusted_score = sequence_score
8789                + i32::from(hint_score > 0) * JUNCTION_SEQUENCE_EVIDENCE_TIE_BONUS
8790                + i32::from(transcript_score > 0) * JUNCTION_SEQUENCE_EVIDENCE_TIE_BONUS
8791                + i32::from(annotation_score > 0);
8792            let movement = (end - prev_end).abs() + (start - next_start).abs();
8793            let key = (
8794                transcript_score,
8795                sequence_adjusted_score,
8796                sequence_score,
8797                hint_score,
8798                annotation_score,
8799                motif_score,
8800                support_score,
8801                -movement,
8802                -(end.abs_diff(prev_end) as i32),
8803                -(start.abs_diff(next_start) as i32),
8804            );
8805            if best.as_ref().is_none_or(|(current, _, _)| key > *current) {
8806                best = Some((key, end, start));
8807            }
8808        }
8809    }
8810    best.map(|(_, end, start)| (end, start))
8811}
8812
8813/// Scores how well one candidate junction explains the retained query bases.
8814///
8815/// The score is intentionally local to this read instead of population-level:
8816/// if a canonical or annotation-supported site requires extra mismatching
8817/// intronic bases, it should lose to a nearby non-canonical site that matches
8818/// the actual read sequence. Missing sequence leaves the candidate neutral so
8819/// older sidecars can still be interpreted through the evidence tie-breakers.
8820fn boundary_sequence_score(
8821    reference_seq: Option<&str>,
8822    left: &SegmentBlock,
8823    right: &SegmentBlock,
8824    left_payload: Option<&ChainBlockPayload>,
8825    right_payload: Option<&ChainBlockPayload>,
8826    end: i32,
8827    start: i32,
8828    read_len: i32,
8829) -> Option<i32> {
8830    let reference_seq = reference_seq?;
8831    let left_payload = left_payload?;
8832    let right_payload = right_payload?;
8833    let mut corrected_left = left.clone();
8834    corrected_left.ref_end = end;
8835    let mut corrected_right = right.clone();
8836    corrected_right.ref_start = start;
8837    let synced_left =
8838        synced_payload_block_after_boundary_correction(left_payload, &corrected_left, read_len)?;
8839    let synced_right =
8840        synced_payload_block_after_boundary_correction(right_payload, &corrected_right, read_len)?;
8841    Some(
8842        block_sequence_score(left_payload, &synced_left, reference_seq, read_len)?
8843            + block_sequence_score(right_payload, &synced_right, reference_seq, read_len)?,
8844    )
8845}
8846
8847/// Scores one corrected block against the reference sequence it claims.
8848///
8849/// Matches receive a small reward while mismatches are penalized more heavily;
8850/// that asymmetry keeps a short accidental motif match from compensating for
8851/// several newly introduced intronic mismatches.
8852fn block_sequence_score(
8853    payload: &ChainBlockPayload,
8854    block: &SegmentBlock,
8855    reference_seq: &str,
8856    read_len: i32,
8857) -> Option<i32> {
8858    if block.ref_start <= 0 || block.ref_end < block.ref_start {
8859        return None;
8860    }
8861    let query = payload_query_seq_for_block(payload, block, read_len);
8862    if query.is_empty() {
8863        return None;
8864    }
8865    let start = (block.ref_start - 1) as usize;
8866    let end = block.ref_end as usize;
8867    let reference = reference_seq.get(start..end)?;
8868    let mut score = 0i32;
8869    for (ref_base, query_base) in reference.bytes().zip(query.bytes()) {
8870        if ref_base == b'N' || query_base == b'N' {
8871            score -= 1;
8872        } else if ref_base.eq_ignore_ascii_case(&query_base) {
8873            score += 2;
8874        } else {
8875            score -= 6;
8876        }
8877    }
8878    let length_delta = reference.len().abs_diff(query.len()) as i32;
8879    Some(score - length_delta * 8)
8880}
8881
8882/// Chooses a nearby annotated exon end/start pair for one internal junction.
8883#[allow(dead_code)]
8884fn choose_annotation_splice_boundary(
8885    annotation: Option<&Annotation>,
8886    chrom: &str,
8887    left: &SegmentBlock,
8888    right: &SegmentBlock,
8889    token_strand: char,
8890    window: i32,
8891) -> Option<(i32, i32)> {
8892    let mut best: Option<(i32, i32, i32, i32)> = None;
8893    for end in left.ref_end - window..=left.ref_end + window {
8894        if end < left.ref_start || end >= right.ref_start {
8895            continue;
8896        }
8897        for start in right.ref_start - window..=right.ref_start + window {
8898            if start > right.ref_end || end >= start {
8899                continue;
8900            }
8901            let Some((transcript_score, annotation_score)) =
8902                annotation_splice_pair_score(annotation, chrom, end, start, token_strand)
8903            else {
8904                continue;
8905            };
8906            let movement = (end - left.ref_end).abs() + (start - right.ref_start).abs();
8907            let score = transcript_score * 1000 + annotation_score;
8908            let key = (
8909                score,
8910                -movement,
8911                -(end.abs_diff(left.ref_end) as i32),
8912                -(start.abs_diff(right.ref_start) as i32),
8913            );
8914            if best.is_none_or(|current| key > current) {
8915                best = Some((key.0, key.1, end, start));
8916            }
8917        }
8918    }
8919    best.map(|(_, _, end, start)| (end, start))
8920}
8921
8922/// Scores one annotation-supported internal splice boundary candidate.
8923fn annotation_splice_pair_score(
8924    annotation: Option<&Annotation>,
8925    chrom: &str,
8926    end: i32,
8927    start: i32,
8928    token_strand: char,
8929) -> Option<(i32, i32)> {
8930    let annotation = annotation?;
8931    let transcript_score =
8932        transcript_splice_pair_score(annotation, chrom, end, start, token_strand);
8933    let boundary_score = boundary_splice_pair_score(annotation, chrom, end, start, token_strand);
8934    match (transcript_score, boundary_score) {
8935        (0, None) => None,
8936        (score, Some(boundary)) => Some((score, boundary)),
8937        (score, None) => Some((score, 0)),
8938    }
8939}
8940
8941/// Scores whether one intron is supported by adjacent exons from the same transcript.
8942fn transcript_splice_pair_score(
8943    annotation: &Annotation,
8944    chrom: &str,
8945    end: i32,
8946    start: i32,
8947    token_strand: char,
8948) -> i32 {
8949    let has_pair = |strand: char| {
8950        transcript_splice_pair_score_for_strand(annotation, chrom, end, start, strand)
8951    };
8952    match token_strand {
8953        '+' | '-' => i32::from(has_pair(token_strand)),
8954        '?' => i32::from(has_pair('+') || has_pair('-')),
8955        _ => 0,
8956    }
8957}
8958
8959/// Scores one transcript splice-pair strand without allocating keys when possible.
8960fn transcript_splice_pair_score_for_strand(
8961    annotation: &Annotation,
8962    chrom: &str,
8963    end: i32,
8964    start: i32,
8965    strand: char,
8966) -> bool {
8967    if let Some(by_strand) = annotation.transcript_splice_index.get(chrom) {
8968        return by_strand
8969            .get(&strand)
8970            .is_some_and(|sites| sites.contains(&(end, start)));
8971    }
8972    annotation
8973        .transcript_splice_map
8974        .contains(&format!("{}\t{}\t{}\t{}", chrom, end, start, strand))
8975}
8976
8977/// Scores boundary-level exon end/start support without requiring transcript identity.
8978fn boundary_splice_pair_score(
8979    annotation: &Annotation,
8980    chrom: &str,
8981    end: i32,
8982    start: i32,
8983    token_strand: char,
8984) -> Option<i32> {
8985    if annotation.chr_exon_end_index.contains_key(chrom)
8986        || annotation.chr_exon_start_index.contains_key(chrom)
8987    {
8988        return typed_boundary_splice_pair_score(annotation, chrom, end, start, token_strand);
8989    }
8990    let end_key = format!("{}\t{}", chrom, end);
8991    let start_key = format!("{}\t{}", chrom, start);
8992    let end_hit = annotation.chr_exon_end_map.get(&end_key)?;
8993    let start_hit = annotation.chr_exon_start_map.get(&start_key)?;
8994    let (end_gene, end_strand) = split_gene_strand(end_hit);
8995    let (start_gene, start_strand) = split_gene_strand(start_hit);
8996    let same_gene = end_gene == start_gene;
8997    if token_strand != '?' && (end_strand != token_strand || start_strand != token_strand) {
8998        return None;
8999    }
9000    Some(if same_gene { 100 } else { 80 })
9001}
9002
9003/// Scores exon end/start support from numeric annotation indexes.
9004fn typed_boundary_splice_pair_score(
9005    annotation: &Annotation,
9006    chrom: &str,
9007    end: i32,
9008    start: i32,
9009    token_strand: char,
9010) -> Option<i32> {
9011    let end_hit = annotation.chr_exon_end_index.get(chrom)?.get(&end)?;
9012    let start_hit = annotation.chr_exon_start_index.get(chrom)?.get(&start)?;
9013    if token_strand != '?' && (end_hit.1 != token_strand || start_hit.1 != token_strand) {
9014        return None;
9015    }
9016    Some(if end_hit.0 == start_hit.0 { 100 } else { 80 })
9017}
9018
9019/// Splits the legacy `"gene\tstrand"` annotation payload without allocating.
9020fn split_gene_strand(value: &str) -> (&str, char) {
9021    let (gene, strand) = value.split_once('\t').unwrap_or((value, ""));
9022    (gene, strand.chars().next().unwrap_or('?'))
9023}
9024
9025/// Returns the annotation/hint correction window for one internal splice pair.
9026///
9027/// Normal mapper blocks keep the conservative CIRI-AS-sized window. Partial
9028/// local clip blocks and very short retained blocks can sit farther from the
9029/// true annotated splice boundary, so annotation and validated read-specific
9030/// hints may use a wider window. De novo motif correction intentionally remains
9031/// conservative to avoid overfitting microhomology around BSJ-adjacent gaps.
9032fn correction_window_for_pair(left: &SegmentBlock, right: &SegmentBlock) -> i32 {
9033    let has_partial_clip_like_block = [left, right].iter().any(|block| {
9034        block.from_local_clip || block.ref_end - block.ref_start + 1 <= MIN_EXON_LENGTH
9035    });
9036    if has_partial_clip_like_block {
9037        PARTIAL_LOCAL_SPLICE_CORRECTION_WINDOW
9038    } else {
9039        INTERNAL_SPLICE_CORRECTION_WINDOW
9040    }
9041}
9042
9043/// Reuses read-specific splice hints produced by CIRI-AS candidate validation.
9044fn choose_hinted_splice_boundary(
9045    prev_end: i32,
9046    next_start: i32,
9047    left: &SegmentBlock,
9048    right: &SegmentBlock,
9049    junction_hints: &[(i32, i32)],
9050    window: i32,
9051) -> Option<(i32, i32)> {
9052    junction_hints
9053        .iter()
9054        .copied()
9055        .filter(|&(site2, site1)| {
9056            site2 >= left.ref_start
9057                && site2 < site1
9058                && site1 <= right.ref_end
9059                && (prev_end - site2).abs() <= window
9060                && (next_start - site1).abs() <= window
9061        })
9062        .min_by_key(|&(site2, site1)| (prev_end - site2).abs() + (next_start - site1).abs())
9063}
9064
9065/// Scores the intronic dinucleotides implied by one genomic-order junction.
9066fn splice_motif_score(chr_seq: &str, end: i32, start: i32, token_strand: char) -> Option<i32> {
9067    if start - end < 3 {
9068        return None;
9069    }
9070    let low = reference_dinucleotide(chr_seq, end + 1)?;
9071    let high = reference_dinucleotide(chr_seq, start - 2)?;
9072    match token_strand {
9073        '+' => match (low.as_str(), high.as_str()) {
9074            ("GT", "AG") => Some(100),
9075            ("GC", "AG") | ("AT", "AC") => Some(60),
9076            _ => None,
9077        },
9078        '-' => match (low.as_str(), high.as_str()) {
9079            ("CT", "AC") => Some(100),
9080            ("CT", "GC") | ("GT", "AT") => Some(60),
9081            _ => None,
9082        },
9083        _ => match (low.as_str(), high.as_str()) {
9084            ("GT", "AG") | ("CT", "AC") => Some(80),
9085            ("GC", "AG") | ("AT", "AC") | ("CT", "GC") | ("GT", "AT") => Some(50),
9086            _ => None,
9087        },
9088    }
9089}
9090
9091/// Returns a 1-based two-base genomic sequence.
9092fn reference_dinucleotide(chr_seq: &str, start: i32) -> Option<String> {
9093    if start <= 0 {
9094        return None;
9095    }
9096    let start_idx = (start - 1) as usize;
9097    let end_idx = start_idx + 2;
9098    if end_idx > chr_seq.len() {
9099        return None;
9100    }
9101    Some(chr_seq[start_idx..end_idx].to_ascii_uppercase())
9102}
9103
9104/// Returns the query-interval overlap between two parsed alignments.
9105fn query_overlap(left: &ParsedAlignment, right: &ParsedAlignment) -> i32 {
9106    let left_start = left
9107        .blocks
9108        .first()
9109        .map(|block| block.read_start)
9110        .unwrap_or(0);
9111    let left_end = left.blocks.last().map(|block| block.read_end).unwrap_or(0);
9112    let right_start = right
9113        .blocks
9114        .first()
9115        .map(|block| block.read_start)
9116        .unwrap_or(0);
9117    let right_end = right.blocks.last().map(|block| block.read_end).unwrap_or(0);
9118    (left_end.min(right_end) - left_start.max(right_start) + 1).max(0)
9119}
9120
9121/// Ranking key for one parsed alignment when two candidates overlap the same read slice.
9122fn parsed_alignment_rank(record: &ParsedAlignment) -> (i32, i32, i32, i32) {
9123    (
9124        i32::from(!is_secondary(record.flag)),
9125        i32::from(!is_supplementary(record.flag)),
9126        record.mapq,
9127        record
9128            .blocks
9129            .iter()
9130            .map(|block| block.read_end - block.read_start + 1)
9131            .sum(),
9132    )
9133}
9134
9135/// Generic chain ranking used before type-specific filtering.
9136fn chain_generic_rank(
9137    chain: &MateChain,
9138    type_name: &str,
9139    circ: Option<&CircRecord>,
9140) -> (i32, i32, i32, i32, i32) {
9141    let topology_rank = match type_name {
9142        "outward" => i32::from(!chain.is_circular),
9143        _ => i32::from(chain.is_circular),
9144    };
9145    (
9146        i32::from(circ.is_none_or(|circ| chain.chrom == circ.chr)),
9147        topology_rank,
9148        i32::from(chain.is_bsj),
9149        -(chain.used_secondary as i32),
9150        chain.query_coverage,
9151    )
9152}
9153
9154/// Final chain ranking for one requested output type.
9155fn chain_rank(
9156    chain: &MateChain,
9157    type_name: &str,
9158    circ: Option<&CircRecord>,
9159) -> (i32, i32, i32, i32, i32, i32) {
9160    let valid_type = match type_name {
9161        "bsj" => i32::from(chain.is_bsj),
9162        "backward" => i32::from(chain.is_circular && !chain.is_bsj),
9163        "outward" => i32::from(!chain.is_circular),
9164        _ => 0,
9165    };
9166    let topology_rank = match type_name {
9167        "outward" => i32::from(!chain.is_circular),
9168        _ => i32::from(chain.is_circular),
9169    };
9170    (
9171        valid_type,
9172        i32::from(circ.is_none_or(|circ| chain.chrom == circ.chr)),
9173        topology_rank,
9174        -(chain.used_secondary as i32),
9175        -(chain.used_supplementary as i32),
9176        chain.query_coverage,
9177    )
9178}
9179
9180/// Returns whether two consecutive blocks wrap against the strand-specific linear order.
9181fn wraps_in_read_order(prev: &SegmentBlock, next: &SegmentBlock, strand: char) -> bool {
9182    match strand {
9183        '+' => next.ref_start < prev.ref_start,
9184        '-' => next.ref_start > prev.ref_start,
9185        _ => false,
9186    }
9187}
9188
9189/// Returns the opposite genomic traversal direction for mate2 chain reversal.
9190fn opposite_strand(strand: char) -> char {
9191    match strand {
9192        '+' => '-',
9193        '-' => '+',
9194        _ => strand,
9195    }
9196}
9197
9198/// Detects the output-chain BSJ transition inside a known circRNA span.
9199///
9200/// The circRNA strand determines token strand, but the adjacent block order is
9201/// determined by the normalized alignment/read chain. Reverse-strand alignments
9202/// therefore use the opposite genomic wrap direction after query-coordinate
9203/// normalization.
9204fn is_bsj_transition(
9205    prev: &SegmentBlock,
9206    next: &SegmentBlock,
9207    circ: &CircRecord,
9208    order_strand: char,
9209) -> bool {
9210    let prev_near_start = (prev.ref_start - circ.start).abs() <= 6;
9211    let prev_near_end = (prev.ref_end - circ.end).abs() <= 6;
9212    let next_near_start = (next.ref_start - circ.start).abs() <= 6;
9213    let next_near_end = (next.ref_end - circ.end).abs() <= 6;
9214    ((prev_near_start && next_near_end) || (prev_near_end && next_near_start))
9215        && wraps_in_read_order(prev, next, order_strand)
9216}
9217
9218/// Applies final `.bsj` mate-level evidence to selected mate chains.
9219///
9220/// The chain builder can miss a BSJ flag when the mapper reports a single
9221/// soft-clipped alignment and the CIRI validator recognizes the clipped mate as
9222/// BSJ evidence. The final `.bsj` row is a post-Summary annotation, so using it
9223/// here improves mate-level segment labels without changing which reads Summary
9224/// counted as CIRI3-compatible junction reads.
9225fn apply_mate_bsj_evidence(
9226    chains: &mut [Option<MateChain>; 2],
9227    mate_bsj_evidence: &[MateBsjEvidence],
9228    circ: &CircRecord,
9229) {
9230    for evidence in mate_bsj_evidence
9231        .iter()
9232        .filter(|evidence| evidence_matches_circ(evidence, circ))
9233    {
9234        if let Some(chain) = chains
9235            .get_mut(evidence.mate_bucket)
9236            .and_then(Option::as_mut)
9237        {
9238            chain.is_bsj = true;
9239            chain.is_circular = true;
9240            if chain.token_strand == '?' {
9241                chain.token_strand = evidence
9242                    .strand
9243                    .chars()
9244                    .next()
9245                    .filter(|strand| matches!(strand, '+' | '-'))
9246                    .unwrap_or('?');
9247            }
9248        }
9249    }
9250}
9251
9252/// Repairs the non-BSJ mate of a confirmed BSJ row with circ-compatible XA hits.
9253///
9254/// This is intentionally narrower than BSJ detection. Scan1/Scan2 have already
9255/// decided that the read supports `circ`; XA is used only to choose the mate
9256/// segment representation for `.segments`. BWA-MEM can prefer one long primary
9257/// alignment in a paralogous region even when an `XA:Z` clipped alternative
9258/// lands inside the confirmed circRNA span. In that situation the circ-context
9259/// XA is a better read-level segment, but it must not create a new BSJ call.
9260fn repair_bsj_non_bsj_mates_by_xa(
9261    chains: &mut [Option<MateChain>; 2],
9262    records: &[AsAlignment],
9263    circ: &CircRecord,
9264    read_len: i32,
9265    token_strand: char,
9266    junction_hints: &[(i32, i32)],
9267    correction: Option<&SegmentCorrectionContext<'_>>,
9268) {
9269    for mate_idx in 0..chains.len() {
9270        let Some(current) = chains[mate_idx].as_ref() else {
9271            continue;
9272        };
9273        if current.is_bsj {
9274            continue;
9275        }
9276        let current_rank = bsj_mate_context_rank(current, circ);
9277        let mut best: Option<((i32, i32, i32, i32, i32, i32), MateChain)> = None;
9278        for (record_idx, record) in records.iter().enumerate() {
9279            if mate_bucket(record.flag) != mate_idx || record.xa_alternatives.is_empty() {
9280                continue;
9281            }
9282            for alternative in record
9283                .xa_alternatives
9284                .iter()
9285                .filter(|alternative| {
9286                    bsj_mate_xa_is_circ_compatible(record, alternative, circ, read_len)
9287                })
9288                .take(BSJ_MATE_XA_MAX_ALTERNATIVES_PER_RECORD)
9289            {
9290                let mut candidate_records = records.to_vec();
9291                candidate_records[record_idx] = alignment_from_xa(record, alternative);
9292                let candidate_chains = build_pair_chains(
9293                    &candidate_records,
9294                    read_len,
9295                    Some(circ),
9296                    "bsj",
9297                    token_strand,
9298                    junction_hints,
9299                    correction,
9300                );
9301                let Some(candidate) = candidate_chains[mate_idx].clone() else {
9302                    continue;
9303                };
9304                if chain_circ_overlap_bases(&candidate, circ) == 0 {
9305                    continue;
9306                }
9307                let rank = bsj_mate_context_rank(&candidate, circ);
9308                if rank <= current_rank {
9309                    continue;
9310                }
9311                if best.as_ref().is_none_or(|(best_rank, _)| rank > *best_rank) {
9312                    best = Some((rank, candidate));
9313                }
9314            }
9315        }
9316        if let Some((_, repaired)) = best {
9317            chains[mate_idx] = Some(repaired);
9318        }
9319    }
9320}
9321
9322/// Repairs a confirmed BSJ row's mate with already retained circ-local blocks.
9323///
9324/// Some BWA-MEM records land a long mate primary in a paralogous locus and keep
9325/// only short circ-compatible evidence as a local-clip pseudo row or
9326/// supplementary block. For a Summary-confirmed BSJ read, a non-BSJ mate with
9327/// zero overlap to the confirmed circRNA is a worse representation than a
9328/// shorter retained block inside the same circRNA span. This repair is narrower
9329/// than the general chain builder: it only fires for non-BSJ mates that
9330/// currently have no circ overlap, and it never creates or rescues a BSJ call.
9331fn repair_bsj_non_bsj_mates_by_circ_records(
9332    chains: &mut [Option<MateChain>; 2],
9333    records: &[AsAlignment],
9334    circ: &CircRecord,
9335    read_len: i32,
9336    token_strand: char,
9337    junction_hints: &[(i32, i32)],
9338    correction: Option<&SegmentCorrectionContext<'_>>,
9339) {
9340    for mate_idx in 0..chains.len() {
9341        let Some(current) = chains[mate_idx].as_ref() else {
9342            continue;
9343        };
9344        if current.is_bsj || chain_outside_circ_bases(current, circ) == 0 {
9345            continue;
9346        }
9347        let target_records: Vec<AsAlignment> = records
9348            .iter()
9349            .filter(|record| {
9350                mate_bucket(record.flag) == mate_idx
9351                    && alignment_record_circ_overlap_bases(record, circ, read_len)
9352                        >= MIN_JUNCTION_SUPPORT_SEGMENT_LENGTH
9353            })
9354            .cloned()
9355            .collect();
9356        if target_records.is_empty() {
9357            continue;
9358        }
9359        let mut candidate_records: Vec<AsAlignment> = records
9360            .iter()
9361            .filter(|record| mate_bucket(record.flag) != mate_idx)
9362            .cloned()
9363            .collect();
9364        candidate_records.extend(target_records);
9365        let candidate_chains = build_pair_chains(
9366            &candidate_records,
9367            read_len,
9368            Some(circ),
9369            "bsj",
9370            token_strand,
9371            junction_hints,
9372            correction,
9373        );
9374        let Some(candidate) = candidate_chains[mate_idx].clone() else {
9375            continue;
9376        };
9377        if chain_circ_overlap_bases(&candidate, circ) == 0 {
9378            continue;
9379        }
9380        if bsj_mate_context_rank(&candidate, circ) > bsj_mate_context_rank(current, circ) {
9381            chains[mate_idx] = Some(candidate);
9382        }
9383    }
9384}
9385
9386/// Re-materializes unresolved BSJ-evidence mates with the confirmed circ site.
9387///
9388/// XA and circ-context repair run before this function because they can replace
9389/// a misleading long primary alignment with a better split alternative. This
9390/// fallback is narrower: if a mate still lacks BSJ topology but final `.bsj`
9391/// evidence says this mate defines the circRNA, the confirmed `(end, start)`
9392/// site is injected as a read-specific hint so short mapper overruns can be
9393/// split into `high-side <bsj> low-side` output blocks.
9394fn repair_bsj_mates_by_confirmed_site(
9395    chains: &mut [Option<MateChain>; 2],
9396    records: &[AsAlignment],
9397    circ: &CircRecord,
9398    mate_bsj_evidence: &[MateBsjEvidence],
9399    read_len: i32,
9400    token_strand: char,
9401    junction_hints: &[(i32, i32)],
9402    correction: Option<&SegmentCorrectionContext<'_>>,
9403) {
9404    let mut confirmed_hints = junction_hints.to_vec();
9405    if !confirmed_hints
9406        .iter()
9407        .any(|&(site2, site1)| site2 == circ.end && site1 == circ.start)
9408    {
9409        confirmed_hints.push((circ.end, circ.start));
9410    }
9411    for evidence in mate_bsj_evidence
9412        .iter()
9413        .filter(|evidence| evidence_matches_circ(evidence, circ))
9414    {
9415        let mate_idx = evidence.mate_bucket;
9416        if chains
9417            .get(mate_idx)
9418            .and_then(Option::as_ref)
9419            .is_some_and(|chain| chain.is_bsj)
9420        {
9421            continue;
9422        }
9423        let candidate_chains = build_pair_chains(
9424            records,
9425            read_len,
9426            Some(circ),
9427            "bsj",
9428            token_strand,
9429            &confirmed_hints,
9430            correction,
9431        );
9432        let Some(candidate) = candidate_chains[mate_idx].clone() else {
9433            continue;
9434        };
9435        if candidate.is_bsj && chain_circ_overlap_bases(&candidate, circ) > 0 {
9436            chains[mate_idx] = Some(candidate);
9437        }
9438    }
9439}
9440
9441/// Returns whether one XA alternative is eligible for BSJ mate repair.
9442fn bsj_mate_xa_is_circ_compatible(
9443    record: &AsAlignment,
9444    alternative: &XaAlternative,
9445    circ: &CircRecord,
9446    read_len: i32,
9447) -> bool {
9448    if alternative.chr != circ.chr || alternative.strand != strand_char(record.flag) {
9449        return false;
9450    }
9451    let alt_record = alignment_from_xa(record, alternative);
9452    let Some((start, end)) = alignment_ref_span(&alt_record, read_len) else {
9453        return false;
9454    };
9455    interval_overlap_bases(start, end, circ.start, circ.end) >= MIN_JUNCTION_SUPPORT_SEGMENT_LENGTH
9456}
9457
9458/// Returns how many reference bases from one raw alignment overlap a circ span.
9459fn alignment_record_circ_overlap_bases(
9460    record: &AsAlignment,
9461    circ: &CircRecord,
9462    read_len: i32,
9463) -> i32 {
9464    if record.chr != circ.chr {
9465        return 0;
9466    }
9467    let Some((start, end)) = alignment_ref_span(record, read_len) else {
9468        return 0;
9469    };
9470    interval_overlap_bases(start, end, circ.start, circ.end)
9471}
9472
9473/// Ranks one non-BSJ mate chain by compatibility with the confirmed circRNA.
9474fn bsj_mate_context_rank(chain: &MateChain, circ: &CircRecord) -> (i32, i32, i32, i32, i32, i32) {
9475    let same_chr = i32::from(chain.chrom == circ.chr);
9476    let overlap = chain_circ_overlap_bases(chain, circ);
9477    let outside = chain_outside_circ_bases(chain, circ);
9478    let distance = chain_distance_to_circ(chain, circ);
9479    (
9480        same_chr,
9481        -outside,
9482        overlap,
9483        -distance,
9484        chain.query_coverage,
9485        -(chain.used_secondary as i32 + chain.used_supplementary as i32),
9486    )
9487}
9488
9489/// Returns the selected bases that overlap a circRNA's outer span.
9490fn chain_circ_overlap_bases(chain: &MateChain, circ: &CircRecord) -> i32 {
9491    if chain.chrom != circ.chr {
9492        return 0;
9493    }
9494    chain
9495        .blocks
9496        .iter()
9497        .map(|block| interval_overlap_bases(block.ref_start, block.ref_end, circ.start, circ.end))
9498        .sum()
9499}
9500
9501/// Returns selected aligned bases outside the confirmed circRNA span.
9502fn chain_outside_circ_bases(chain: &MateChain, circ: &CircRecord) -> i32 {
9503    chain
9504        .blocks
9505        .iter()
9506        .map(|block| {
9507            let len = block.ref_end - block.ref_start + 1;
9508            if chain.chrom != circ.chr {
9509                len
9510            } else {
9511                len - interval_overlap_bases(block.ref_start, block.ref_end, circ.start, circ.end)
9512            }
9513        })
9514        .sum()
9515}
9516
9517/// Returns the minimum genomic distance from a chain to a circRNA span.
9518fn chain_distance_to_circ(chain: &MateChain, circ: &CircRecord) -> i32 {
9519    if chain.chrom != circ.chr {
9520        return i32::MAX / 4;
9521    }
9522    chain
9523        .blocks
9524        .iter()
9525        .map(|block| interval_distance(block.ref_start, block.ref_end, circ.start, circ.end))
9526        .min()
9527        .unwrap_or(i32::MAX / 4)
9528}
9529
9530/// Returns the inclusive overlap length between two genomic intervals.
9531fn interval_overlap_bases(left_start: i32, left_end: i32, right_start: i32, right_end: i32) -> i32 {
9532    let start = left_start.max(right_start);
9533    let end = left_end.min(right_end);
9534    (end - start + 1).max(0)
9535}
9536
9537/// Returns zero for overlapping intervals, otherwise their separating distance.
9538fn interval_distance(left_start: i32, left_end: i32, right_start: i32, right_end: i32) -> i32 {
9539    if left_end < right_start {
9540        right_start - left_end
9541    } else if right_end < left_start {
9542        left_start - right_end
9543    } else {
9544        0
9545    }
9546}
9547
9548/// Returns whether a mate-level BSJ row belongs to the confirmed circRNA row.
9549fn evidence_matches_circ(evidence: &MateBsjEvidence, circ: &CircRecord) -> bool {
9550    evidence.chr == circ.chr
9551        && evidence.start == circ.start
9552        && evidence.end == circ.end
9553        && (evidence.strand == circ.strand || evidence.strand == "NA" || circ.strand == "NA")
9554}
9555
9556/// Converts one optional chain to output segment text, CIGAR, cs, and BSJ flag.
9557fn chain_text(chain: Option<&MateChain>) -> (String, String, String, usize) {
9558    chain.map_or_else(
9559        || ("NA".to_string(), "NA".to_string(), "NA".to_string(), 0),
9560        |chain| {
9561            (
9562                chain.tokens.join("|"),
9563                chain.cigar.clone(),
9564                chain.cs.clone(),
9565                usize::from(chain.is_bsj),
9566            )
9567        },
9568    )
9569}
9570
9571/// Precomputes one RNA-strand hint per read ID from validated splice candidates.
9572///
9573/// Re-scanning `state.candidates` for every backward read turns segments writing
9574/// into an accidental O(reads * candidates) pass on large fixtures. The strand
9575/// hints are cheap to summarize once after motif validation, so we cache them
9576/// into a read-keyed map before building the output rows.
9577fn build_read_strand_hints(state: &ScanState) -> HashMap<String, Option<char>> {
9578    let mut raw_hints: HashMap<&str, i32> = HashMap::new();
9579    let mut conflicted: HashSet<&str> = HashSet::new();
9580    for candidate in &state.candidates {
9581        if candidate.strand_hint == 0 {
9582            continue;
9583        }
9584        let read_id = candidate.read_id.as_str();
9585        match raw_hints.get(read_id).copied() {
9586            Some(existing) if existing != candidate.strand_hint => {
9587                conflicted.insert(read_id);
9588            }
9589            Some(_) => {}
9590            None => {
9591                raw_hints.insert(read_id, candidate.strand_hint);
9592            }
9593        }
9594    }
9595
9596    let mut out = HashMap::with_capacity(raw_hints.len());
9597    for (read_id, hint) in raw_hints {
9598        let strand = if conflicted.contains(read_id) {
9599            None
9600        } else {
9601            Some(match hint {
9602                -1 => '+',
9603                1 => '-',
9604                _ => '?',
9605            })
9606        };
9607        out.insert(read_id.to_string(), strand);
9608    }
9609    out
9610}
9611
9612/// Summarizes a streamed read group's validated candidate strand hint.
9613fn read_strand_hint_from_candidates(candidates: &[PositiveCandidate]) -> Option<char> {
9614    let mut hint = 0;
9615    for candidate in candidates {
9616        if candidate.strand_hint == 0 {
9617            continue;
9618        }
9619        if hint != 0 && hint != candidate.strand_hint {
9620            return None;
9621        }
9622        hint = candidate.strand_hint;
9623    }
9624    match hint {
9625        -1 => Some('+'),
9626        1 => Some('-'),
9627        _ => None,
9628    }
9629}
9630
9631/// Precomputes corrected splice-boundary hints for each read.
9632///
9633/// Each candidate contributes one `(site2, site1)` pair, matching the downstream
9634/// convention that segment `end -> site2` and following segment `start -> site1`
9635/// represent the validated splice boundary after motif/annotation adjustment.
9636fn build_read_junction_hints(state: &ScanState) -> HashMap<String, Vec<(i32, i32)>> {
9637    let mut out: HashMap<String, Vec<(i32, i32)>> = HashMap::new();
9638    for candidate in &state.candidates {
9639        out.entry(candidate.read_id.clone())
9640            .or_default()
9641            .push((candidate.site2, candidate.site1));
9642    }
9643    out
9644}
9645
9646/// Returns local `(site2, site1)` hints for one streamed retained read group.
9647fn read_junction_hints_from_candidates(candidates: &[PositiveCandidate]) -> Vec<(i32, i32)> {
9648    candidates
9649        .iter()
9650        .map(|candidate| (candidate.site2, candidate.site1))
9651        .collect()
9652}
9653
9654/// Writes the simplified `<prefix>.segments` protocol.
9655fn write_segments(path: &str, records: &[SegmentRecord]) -> Result<()> {
9656    let mut writer = BufWriter::new(File::create(path)?);
9657    writeln!(
9658        writer,
9659        "read_id\ttype\tcirc_id\tchrom\tstart\tend\tstrand\tis_circular\tis_r1_bsj\tis_r2_bsj\tr1_align_strand\tr2_align_strand\tr1_cigar\tr1_cs\tr1_segments\tr2_cigar\tr2_cs\tr2_segments"
9660    )?;
9661    for record in records {
9662        writeln!(
9663            writer,
9664            "{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}\t{}",
9665            record.read_id,
9666            record.type_name,
9667            record.circ_id,
9668            record.chrom,
9669            record.start,
9670            record.end,
9671            record.strand,
9672            record.is_circular,
9673            record.is_r1_bsj,
9674            record.is_r2_bsj,
9675            record.r1_align_strand,
9676            record.r2_align_strand,
9677            record.r1_cigar,
9678            record.r1_cs,
9679            record.r1_segments,
9680            record.r2_cigar,
9681            record.r2_cs,
9682            record.r2_segments
9683        )?;
9684    }
9685    Ok(())
9686}
9687
9688/// Builds and writes one major full-length isoform for every confirmed circRNA.
9689///
9690/// This is the Rust integration of the validated phase-seed-union prototype:
9691/// BSJ/backward reads define high-confidence phasing edges, outward reads can
9692/// fill compatible gaps with low weight, and ambiguous non-BSJ rows are ignored
9693/// unless their segment span is uniquely contained by one final circRNA.
9694fn build_major_isoforms_from_segments_file(
9695    circ_records: &[CircRecord],
9696    segments_path: &str,
9697    out_prefix: &str,
9698    reference: &HashMap<String, String>,
9699    annotation: Option<&Annotation>,
9700) -> Result<IsoformRunSummary> {
9701    let circ_by_id: HashMap<&str, usize> = circ_records
9702        .iter()
9703        .enumerate()
9704        .map(|(idx, circ)| (circ.id.as_str(), idx))
9705        .collect();
9706    let circ_index_by_chr = build_major_circ_index(circ_records);
9707    let mut edge_support_by_circ: HashMap<usize, HashMap<MajorEdge, MajorEdgeSupport>> =
9708        HashMap::new();
9709    let mut link_support_by_circ: HashMap<usize, HashMap<MajorJunctionLink, MajorLinkSupport>> =
9710        HashMap::new();
9711    let link_exclusion_by_circ: HashMap<usize, HashMap<MajorJunctionLink, MajorLinkSupport>> =
9712        HashMap::new();
9713    let mut span_support_by_circ: HashMap<usize, Vec<MajorAlignedSpan>> = HashMap::new();
9714
9715    let file =
9716        File::open(segments_path).with_context(|| format!("open segments {}", segments_path))?;
9717    let mut lines = BufReader::new(file).lines();
9718    let header = lines
9719        .next()
9720        .transpose()?
9721        .ok_or_else(|| anyhow!("empty segments file: {}", segments_path))?;
9722    let columns = major_segments_columns(&header, segments_path)?;
9723    for line_result in lines {
9724        let line = line_result?;
9725        if line.trim().is_empty() {
9726            continue;
9727        }
9728        let cols: Vec<&str> = line.split('\t').collect();
9729        let Some(row) = major_segment_row(&cols, columns) else {
9730            continue;
9731        };
9732        let assignments = assign_major_circs_from_segments(
9733            row.type_name,
9734            row.circ_id,
9735            row.chrom,
9736            row.start,
9737            row.end,
9738            row.r1_segments,
9739            row.r2_segments,
9740            &circ_by_id,
9741            &circ_index_by_chr,
9742            circ_records,
9743        );
9744        if assignments.is_empty() {
9745            continue;
9746        }
9747        for assignment in assignments {
9748            let circ_index = assignment.circ_index;
9749            let circ = &circ_records[circ_index];
9750            let r1_is_chimeric = major_segment_has_reused_overlap(row.r1_segments, circ);
9751            let r2_is_chimeric = major_segment_has_reused_overlap(row.r2_segments, circ);
9752            let r1_junctions = if r1_is_chimeric {
9753                Vec::new()
9754            } else {
9755                major_segment_junction_chain(row.r1_segments, circ)
9756            };
9757            let r2_junctions = if r2_is_chimeric {
9758                Vec::new()
9759            } else {
9760                major_segment_junction_chain(row.r2_segments, circ)
9761            };
9762            let record_spans: HashSet<(i32, i32)> = (!r1_is_chimeric)
9763                .then(|| major_contiguous_segment_spans(row.r1_segments, circ))
9764                .into_iter()
9765                .flatten()
9766                .chain(
9767                    (!r2_is_chimeric)
9768                        .then(|| major_contiguous_segment_spans(row.r2_segments, circ))
9769                        .into_iter()
9770                        .flatten(),
9771                )
9772                .collect();
9773            if !record_spans.is_empty() {
9774                let circ_spans = span_support_by_circ.entry(circ_index).or_default();
9775                for (start, end) in record_spans {
9776                    let mut span = MajorAlignedSpan {
9777                        start,
9778                        end,
9779                        bsj: 0.0,
9780                        backward: 0.0,
9781                        outward: 0.0,
9782                    };
9783                    match row.type_name {
9784                        "bsj" => span.bsj = assignment.weight,
9785                        "backward" => span.backward = assignment.weight,
9786                        "outward" => span.outward = assignment.weight,
9787                        _ => {}
9788                    }
9789                    circ_spans.push(span);
9790                }
9791            }
9792            let record_edges: HashSet<MajorEdge> = r1_junctions
9793                .iter()
9794                .chain(r2_junctions.iter())
9795                .filter_map(|junction| match junction {
9796                    MajorJunction::Edge(edge) => Some(*edge),
9797                    MajorJunction::Bsj => None,
9798                })
9799                .collect();
9800            if !record_edges.is_empty() {
9801                let circ_support = edge_support_by_circ.entry(circ_index).or_default();
9802                for edge in record_edges {
9803                    let support = circ_support.entry(edge).or_default();
9804                    match row.type_name {
9805                        "bsj" => support.bsj += assignment.weight,
9806                        "backward" => support.backward += assignment.weight,
9807                        "outward" => support.outward += assignment.weight,
9808                        _ => {}
9809                    }
9810                }
9811            }
9812            let record_links: HashSet<MajorJunctionLink> =
9813                major_adjacent_junction_links(&r1_junctions)
9814                    .into_iter()
9815                    .chain(major_adjacent_junction_links(&r2_junctions))
9816                    .collect();
9817            if !record_links.is_empty() {
9818                let circ_links = link_support_by_circ.entry(circ_index).or_default();
9819                for &link in &record_links {
9820                    let support = circ_links.entry(link).or_default();
9821                    match row.type_name {
9822                        "bsj" => support.bsj += assignment.weight,
9823                        "backward" => support.backward += assignment.weight,
9824                        "outward" => support.outward += assignment.weight,
9825                        _ => {}
9826                    }
9827                }
9828            }
9829        }
9830    }
9831
9832    let sample_id = major_sample_id(out_prefix);
9833    let mut isoforms = Vec::with_capacity(circ_records.len());
9834    for (idx, circ) in circ_records.iter().enumerate() {
9835        let empty = HashMap::new();
9836        let empty_links = HashMap::new();
9837        let empty_exclusions = HashMap::new();
9838        let empty_spans = Vec::new();
9839        let support = edge_support_by_circ.get(&idx).unwrap_or(&empty);
9840        let link_support = link_support_by_circ.get(&idx).unwrap_or(&empty_links);
9841        let link_exclusion = link_exclusion_by_circ
9842            .get(&idx)
9843            .unwrap_or(&empty_exclusions);
9844        let span_support = span_support_by_circ.get(&idx).unwrap_or(&empty_spans);
9845        isoforms.push(select_major_isoform(
9846            circ,
9847            support,
9848            link_support,
9849            link_exclusion,
9850            span_support,
9851            &sample_id,
9852            annotation,
9853        ));
9854    }
9855    isoforms.sort_by(|a, b| {
9856        a.chr
9857            .cmp(&b.chr)
9858            .then_with(|| a.start.cmp(&b.start))
9859            .then_with(|| a.end.cmp(&b.end))
9860            .then_with(|| a.circ_id.cmp(&b.circ_id))
9861            .then_with(|| a.isoform_rank.cmp(&b.isoform_rank))
9862    });
9863
9864    let total_isoforms = isoforms.len();
9865    let circ_rnas = isoforms
9866        .iter()
9867        .map(|record| record.circ_id.as_str())
9868        .collect::<HashSet<_>>()
9869        .len();
9870    write_major_isoform_gtf(&format!("{}.isoforms.gtf", out_prefix), &isoforms)?;
9871    let fasta_summary =
9872        write_major_isoform_fasta(&format!("{}.isoforms.fa", out_prefix), &isoforms, reference)?;
9873    Ok(IsoformRunSummary {
9874        total_isoforms,
9875        circ_rnas,
9876        fasta_isoforms: fasta_summary.isoforms,
9877        fasta_circ_rnas: fasta_summary.circ_rnas,
9878    })
9879}
9880
9881/// Loads the matrix fields and circRNA records needed by cohort assembly.
9882fn load_major_circ_sample_values(
9883    path: &str,
9884) -> Result<(Vec<CircRecord>, HashMap<String, MajorCircSampleValue>)> {
9885    let file = File::open(path).with_context(|| format!("open circ table {}", path))?;
9886    let mut lines = BufReader::new(file).lines();
9887    let header = lines
9888        .next()
9889        .transpose()?
9890        .ok_or_else(|| anyhow!("empty circ table: {}", path))?;
9891    let headers: Vec<&str> = header.split('\t').collect();
9892    let idx = |name: &str| -> Result<usize> {
9893        headers
9894            .iter()
9895            .position(|h| *h == name)
9896            .ok_or_else(|| anyhow!("missing `{}` column in {}", name, path))
9897    };
9898    let id_idx = idx("circRNA_ID")?;
9899    let chr_idx = idx("chr")?;
9900    let start_idx = idx("circRNA_start")?;
9901    let end_idx = idx("circRNA_end")?;
9902    let junc_count_idx = idx("#junction_reads")?;
9903    let ratio_idx = idx("junction_reads_ratio")?;
9904    let gene_idx = idx("gene_id")?;
9905    let strand_idx = idx("strand")?;
9906
9907    let mut records = Vec::new();
9908    let mut values = HashMap::new();
9909    for line in lines {
9910        let line = line?;
9911        if line.trim().is_empty() {
9912            continue;
9913        }
9914        let cols: Vec<&str> = line.split('\t').collect();
9915        let get = |i: usize| -> Result<&str> {
9916            cols.get(i)
9917                .copied()
9918                .ok_or_else(|| anyhow!("malformed circ row in {}: {}", path, line))
9919        };
9920        let circ = CircRecord {
9921            id: get(id_idx)?.to_string(),
9922            chr: get(chr_idx)?.to_string(),
9923            start: get(start_idx)?.parse::<i32>()?,
9924            end: get(end_idx)?.parse::<i32>()?,
9925            junction_read_count: get(junc_count_idx)?.to_string(),
9926            gene_id: get(gene_idx)?.to_string(),
9927            strand: get(strand_idx)?.to_string(),
9928        };
9929        let bsj_reads = get(junc_count_idx)?.parse::<f64>().unwrap_or(0.0);
9930        let junction_ratio = get(ratio_idx)?.parse::<f64>().unwrap_or(0.0);
9931        values.insert(
9932            circ.id.clone(),
9933            MajorCircSampleValue {
9934                circ: circ.clone(),
9935                bsj_reads,
9936                junction_ratio,
9937            },
9938        );
9939        records.push(circ);
9940    }
9941    Ok((records, values))
9942}
9943
9944/// Collects the support maps used by the major-isoform graph builder.
9945fn collect_major_isoform_support(
9946    circ_records: &[CircRecord],
9947    segments_path: &str,
9948) -> Result<MajorIsoformSupportBundle> {
9949    let circ_by_id: HashMap<&str, usize> = circ_records
9950        .iter()
9951        .enumerate()
9952        .map(|(idx, circ)| (circ.id.as_str(), idx))
9953        .collect();
9954    let circ_index_by_chr = build_major_circ_index(circ_records);
9955    let mut support_bundle = MajorIsoformSupportBundle::default();
9956
9957    let file =
9958        File::open(segments_path).with_context(|| format!("open segments {}", segments_path))?;
9959    let mut lines = BufReader::new(file).lines();
9960    let header = lines
9961        .next()
9962        .transpose()?
9963        .ok_or_else(|| anyhow!("empty segments file: {}", segments_path))?;
9964    let columns = major_segments_columns(&header, segments_path)?;
9965    for line_result in lines {
9966        let line = line_result?;
9967        if line.trim().is_empty() {
9968            continue;
9969        }
9970        let cols: Vec<&str> = line.split('\t').collect();
9971        let Some(row) = major_segment_row(&cols, columns) else {
9972            continue;
9973        };
9974        let assignments = assign_major_circs_from_segments(
9975            row.type_name,
9976            row.circ_id,
9977            row.chrom,
9978            row.start,
9979            row.end,
9980            row.r1_segments,
9981            row.r2_segments,
9982            &circ_by_id,
9983            &circ_index_by_chr,
9984            circ_records,
9985        );
9986        for assignment in assignments {
9987            let circ_index = assignment.circ_index;
9988            let circ = &circ_records[circ_index];
9989            let r1_is_chimeric = major_segment_has_reused_overlap(row.r1_segments, circ);
9990            let r2_is_chimeric = major_segment_has_reused_overlap(row.r2_segments, circ);
9991            let r1_junctions = if r1_is_chimeric {
9992                Vec::new()
9993            } else {
9994                major_segment_junction_chain(row.r1_segments, circ)
9995            };
9996            let r2_junctions = if r2_is_chimeric {
9997                Vec::new()
9998            } else {
9999                major_segment_junction_chain(row.r2_segments, circ)
10000            };
10001            let record_spans: HashSet<(i32, i32)> = (!r1_is_chimeric)
10002                .then(|| major_contiguous_segment_spans(row.r1_segments, circ))
10003                .into_iter()
10004                .flatten()
10005                .chain(
10006                    (!r2_is_chimeric)
10007                        .then(|| major_contiguous_segment_spans(row.r2_segments, circ))
10008                        .into_iter()
10009                        .flatten(),
10010                )
10011                .collect();
10012            let mut record_span_vec: Vec<(i32, i32)> = record_spans.iter().copied().collect();
10013            record_span_vec.sort_unstable();
10014            for (start, end) in record_span_vec.iter().copied() {
10015                let mut span = MajorAlignedSpan {
10016                    start,
10017                    end,
10018                    bsj: 0.0,
10019                    backward: 0.0,
10020                    outward: 0.0,
10021                };
10022                match row.type_name {
10023                    "bsj" => span.bsj = assignment.weight,
10024                    "backward" => span.backward = assignment.weight,
10025                    "outward" => span.outward = assignment.weight,
10026                    _ => {}
10027                }
10028                support_bundle
10029                    .span_support_by_circ
10030                    .entry(circ_index)
10031                    .or_default()
10032                    .push(span);
10033            }
10034            let record_edges: HashSet<MajorEdge> = r1_junctions
10035                .iter()
10036                .chain(r2_junctions.iter())
10037                .filter_map(|junction| match junction {
10038                    MajorJunction::Edge(edge) => Some(*edge),
10039                    MajorJunction::Bsj => None,
10040                })
10041                .collect();
10042            if row.type_name == "bsj" && assignment.weight > 0.0 {
10043                support_bundle
10044                    .bsj_reads_by_circ
10045                    .entry(circ_index)
10046                    .or_default()
10047                    .push(MajorBsjReadObservation {
10048                        read_id: row.read_id.to_string(),
10049                        edges: record_edges.clone(),
10050                        spans: record_span_vec.clone(),
10051                    });
10052            }
10053            for edge in record_edges {
10054                let support = support_bundle
10055                    .edge_support_by_circ
10056                    .entry(circ_index)
10057                    .or_default()
10058                    .entry(edge)
10059                    .or_default();
10060                match row.type_name {
10061                    "bsj" => support.bsj += assignment.weight,
10062                    "backward" => support.backward += assignment.weight,
10063                    "outward" => support.outward += assignment.weight,
10064                    _ => {}
10065                }
10066            }
10067            let record_links: HashSet<MajorJunctionLink> =
10068                major_adjacent_junction_links(&r1_junctions)
10069                    .into_iter()
10070                    .chain(major_adjacent_junction_links(&r2_junctions))
10071                    .collect();
10072            for link in record_links {
10073                let support = support_bundle
10074                    .link_support_by_circ
10075                    .entry(circ_index)
10076                    .or_default()
10077                    .entry(link)
10078                    .or_default();
10079                match row.type_name {
10080                    "bsj" => support.bsj += assignment.weight,
10081                    "backward" => support.backward += assignment.weight,
10082                    "outward" => support.outward += assignment.weight,
10083                    _ => {}
10084                }
10085            }
10086        }
10087    }
10088    Ok(support_bundle)
10089}
10090
10091/// Borrowed view of one `<prefix>.segments` row used by the isoform stage.
10092struct MajorSegmentRow<'a> {
10093    read_id: &'a str,
10094    type_name: &'a str,
10095    circ_id: &'a str,
10096    chrom: &'a str,
10097    start: &'a str,
10098    end: &'a str,
10099    r1_segments: &'a str,
10100    r2_segments: &'a str,
10101}
10102
10103/// Resolves required `<prefix>.segments` columns by header name.
10104fn major_segments_columns(header: &str, path: &str) -> Result<MajorSegmentsColumns> {
10105    let headers: Vec<&str> = header.split('\t').collect();
10106    let idx = |name: &str| -> Result<usize> {
10107        headers
10108            .iter()
10109            .position(|field| *field == name)
10110            .ok_or_else(|| anyhow!("missing `{}` column in {}", name, path))
10111    };
10112    Ok(MajorSegmentsColumns {
10113        read_id: idx("read_id")?,
10114        type_name: idx("type")?,
10115        circ_id: idx("circ_id")?,
10116        chrom: idx("chrom")?,
10117        start: idx("start")?,
10118        end: idx("end")?,
10119        r1_segments: idx("r1_segments")?,
10120        r2_segments: idx("r2_segments")?,
10121    })
10122}
10123
10124/// Projects a parsed column slice into the fields needed for graph building.
10125fn major_segment_row<'a>(
10126    cols: &'a [&'a str],
10127    indexes: MajorSegmentsColumns,
10128) -> Option<MajorSegmentRow<'a>> {
10129    Some(MajorSegmentRow {
10130        read_id: *cols.get(indexes.read_id)?,
10131        type_name: *cols.get(indexes.type_name)?,
10132        circ_id: *cols.get(indexes.circ_id)?,
10133        chrom: *cols.get(indexes.chrom)?,
10134        start: *cols.get(indexes.start)?,
10135        end: *cols.get(indexes.end)?,
10136        r1_segments: *cols.get(indexes.r1_segments)?,
10137        r2_segments: *cols.get(indexes.r2_segments)?,
10138    })
10139}
10140
10141/// Builds a start-sorted exact circRNA span index for non-BSJ row assignment.
10142fn build_major_circ_index(
10143    circ_records: &[CircRecord],
10144) -> HashMap<String, Vec<MajorCircIndexEntry>> {
10145    let mut by_chr: HashMap<String, Vec<MajorCircIndexEntry>> = HashMap::new();
10146    for (idx, circ) in circ_records.iter().enumerate() {
10147        by_chr
10148            .entry(circ.chr.clone())
10149            .or_default()
10150            .push(MajorCircIndexEntry {
10151                start: circ.start,
10152                end: circ.end,
10153                circ_index: idx,
10154                max_end_through: circ.end,
10155            });
10156    }
10157    for entries in by_chr.values_mut() {
10158        entries.sort_by_key(|entry| (entry.start, entry.end, entry.circ_index));
10159        let mut max_end = i32::MIN;
10160        for entry in entries {
10161            max_end = max_end.max(entry.end);
10162            entry.max_end_through = max_end;
10163        }
10164    }
10165    by_chr
10166}
10167
10168/// Assigns a segment row to final circRNAs for graph reconstruction.
10169///
10170/// BSJ rows use their Summary circRNA ID directly. Backward/outward rows do not
10171/// have a trustworthy BSJ assignment, so every containing confirmed circRNA is
10172/// scored by how close the read's observed segment endpoints are to that
10173/// circRNA's BSJ boundaries. The normalized weights sum to at most one, with
10174/// the remaining probability intentionally left unassigned when the read looks
10175/// internal to an enclosing locus.
10176fn assign_major_circs_from_segments(
10177    type_name: &str,
10178    circ_id: &str,
10179    chrom: &str,
10180    start_text: &str,
10181    end_text: &str,
10182    r1_segments: &str,
10183    r2_segments: &str,
10184    circ_by_id: &HashMap<&str, usize>,
10185    circ_index_by_chr: &HashMap<String, Vec<MajorCircIndexEntry>>,
10186    circ_records: &[CircRecord],
10187) -> Vec<MajorCircAssignment> {
10188    if type_name == "bsj" {
10189        return circ_by_id
10190            .get(circ_id)
10191            .copied()
10192            .map(|circ_index| {
10193                vec![MajorCircAssignment {
10194                    circ_index,
10195                    weight: 1.0,
10196                }]
10197            })
10198            .unwrap_or_default();
10199    }
10200    let Ok(start) = start_text.parse::<i32>() else {
10201        return Vec::new();
10202    };
10203    let Ok(end) = end_text.parse::<i32>() else {
10204        return Vec::new();
10205    };
10206    let Some(entries) = circ_index_by_chr.get(chrom) else {
10207        return Vec::new();
10208    };
10209    let endpoints = major_segment_endpoints_for_assignment(r1_segments, r2_segments);
10210    if endpoints.is_empty() {
10211        return Vec::new();
10212    }
10213    let scale = major_non_bsj_assignment_distance_scale(&endpoints);
10214    let right = entries.partition_point(|entry| entry.start <= start);
10215    let mut raw = Vec::new();
10216    for entry in entries[..right]
10217        .iter()
10218        .rev()
10219        .take_while(|entry| entry.max_end_through >= end)
10220        .filter(|entry| entry.end >= end)
10221    {
10222        let circ = &circ_records[entry.circ_index];
10223        if circ.chr == chrom && circ.start <= start && circ.end >= end {
10224            let weight = major_non_bsj_assignment_weight(circ, &endpoints, scale);
10225            if weight >= MAJOR_MIN_NON_BSJ_ASSIGNMENT_WEIGHT {
10226                raw.push((entry.circ_index, weight));
10227            }
10228        }
10229    }
10230    let total: f64 = raw.iter().map(|(_, weight)| *weight).sum();
10231    if total <= 0.0 {
10232        return Vec::new();
10233    }
10234    let normalizer = total.max(1.0);
10235    raw.into_iter()
10236        .filter_map(|(circ_index, weight)| {
10237            let weight = weight / normalizer;
10238            (weight >= MAJOR_MIN_NON_BSJ_ASSIGNMENT_WEIGHT)
10239                .then_some(MajorCircAssignment { circ_index, weight })
10240        })
10241        .collect()
10242}
10243
10244/// Collects read-level segment endpoints used to score non-BSJ circ assignment.
10245///
10246/// Only parsed `start-end:strand` tokens are considered; `<bsj>` markers remain
10247/// topology labels and should not create artificial coordinates. Both block
10248/// boundaries are retained because a circular fragment can expose either side
10249/// of the BSJ depending on mate orientation and clipping.
10250fn major_segment_endpoints_for_assignment(r1_segments: &str, r2_segments: &str) -> Vec<(i32, i32)> {
10251    r1_segments
10252        .split('|')
10253        .chain(r2_segments.split('|'))
10254        .filter_map(parse_segment_token)
10255        .map(|(start, end, _)| (start, end))
10256        .collect()
10257}
10258
10259/// Estimates the fragment-scale distance window for non-BSJ assignment.
10260///
10261/// The scale follows aligned read bases rather than genomic span so introns and
10262/// internal circular wraps cannot make a far-away read look boundary-compatible
10263/// with a large outer circRNA.
10264fn major_non_bsj_assignment_distance_scale(endpoints: &[(i32, i32)]) -> f64 {
10265    let aligned_bases: i32 = endpoints
10266        .iter()
10267        .map(|(start, end)| end - start + 1)
10268        .filter(|len| *len > 0)
10269        .sum();
10270    (aligned_bases as f64 * 2.0).clamp(
10271        MAJOR_NON_BSJ_MIN_DISTANCE_SCALE,
10272        MAJOR_NON_BSJ_MAX_DISTANCE_SCALE,
10273    )
10274}
10275
10276/// Scores how likely one non-BSJ row belongs to a candidate circRNA.
10277///
10278/// A true boundary-compatible backward/outward read should place observed
10279/// segment ends close to both BSJ sides within the read-pair fragment scale.
10280/// Internal reads from a large enclosing locus receive an exponentially small
10281/// probability and are mostly left unassigned.
10282fn major_non_bsj_assignment_weight(circ: &CircRecord, endpoints: &[(i32, i32)], scale: f64) -> f64 {
10283    let left_dist = major_min_endpoint_distance(circ.start, endpoints);
10284    let right_dist = major_min_endpoint_distance(circ.end, endpoints);
10285    let combined_dist = left_dist.saturating_add(right_dist) as f64;
10286    (-combined_dist / scale).exp()
10287}
10288
10289/// Returns the nearest distance from a coordinate to any observed segment end.
10290fn major_min_endpoint_distance(position: i32, endpoints: &[(i32, i32)]) -> i32 {
10291    endpoints
10292        .iter()
10293        .flat_map(|(start, end)| [*start, *end])
10294        .map(|edge| (edge - position).abs())
10295        .min()
10296        .unwrap_or(i32::MAX)
10297}
10298
10299/// Returns BSJ and internal splice junctions from one mate segment string.
10300///
10301/// Edges crossing an explicit `<bsj>` marker are represented by a `Bsj` token
10302/// instead of an internal edge. Keeping that boundary token in read order lets
10303/// mature classification require support for BSJ-to-internal neighboring
10304/// junctions, which is the only way to phase two-exon circRNA blocks from the
10305/// segment graph itself.
10306fn major_segment_junction_chain(segments: &str, circ: &CircRecord) -> Vec<MajorJunction> {
10307    let mut out = Vec::new();
10308    let mut previous: Option<(i32, i32, char)> = None;
10309    let mut pending_bsj = false;
10310    for token in segments.split('|') {
10311        if token == "<bsj>" {
10312            if previous.is_some() {
10313                out.push(MajorJunction::Bsj);
10314            }
10315            pending_bsj = previous.is_some();
10316            continue;
10317        }
10318        let Some((start, end, strand)) = parse_segment_token(token) else {
10319            previous = None;
10320            pending_bsj = false;
10321            continue;
10322        };
10323        if end - start + 1 < MIN_JUNCTION_SUPPORT_SEGMENT_LENGTH {
10324            continue;
10325        }
10326        if let Some((prev_start, prev_end, prev_strand)) = previous {
10327            if !pending_bsj && prev_strand == strand {
10328                let (donor_end, acceptor_start) = if prev_start <= start {
10329                    (prev_end, start)
10330                } else {
10331                    (end, prev_start)
10332                };
10333                if donor_end < acceptor_start
10334                    && donor_end >= circ.start
10335                    && acceptor_start <= circ.end
10336                {
10337                    out.push(MajorJunction::Edge(MajorEdge {
10338                        donor_end,
10339                        acceptor_start,
10340                        strand,
10341                    }));
10342                }
10343            }
10344        }
10345        previous = Some((start, end, strand));
10346        pending_bsj = false;
10347    }
10348    out
10349}
10350
10351/// Returns continuous aligned blocks from one mate segment string.
10352///
10353/// These spans are not positive splice evidence. They are used later as
10354/// junction-exclusive evidence when an annotation-only candidate junction falls
10355/// entirely inside one uninterrupted alignment block.
10356fn major_contiguous_segment_spans(segments: &str, circ: &CircRecord) -> Vec<(i32, i32)> {
10357    let mut out = Vec::new();
10358    for token in segments.split('|') {
10359        if token == "<bsj>" {
10360            continue;
10361        }
10362        let Some((start, end, _)) = parse_segment_token(token) else {
10363            continue;
10364        };
10365        if end - start + 1 < MIN_JUNCTION_SUPPORT_SEGMENT_LENGTH {
10366            continue;
10367        }
10368        let clipped_start = start.max(circ.start);
10369        let clipped_end = end.min(circ.end);
10370        if clipped_start <= clipped_end {
10371            out.push((clipped_start, clipped_end));
10372        }
10373    }
10374    out
10375}
10376
10377/// Detects cyclic reuse of aligned bases within one mate segment chain.
10378///
10379/// A mate that crosses multiple circular/splice junctions can contain positive
10380/// neighboring-junction tokens while also reusing the same genomic bases after a
10381/// later junction. In isoform reconstruction this is treated as a chimeric
10382/// reverse-transcription artifact and the affected mate is filtered before it
10383/// can add spans, splice edges, or phasing links. The check is intentionally
10384/// limited to overlaps between different junction-separated groups so adjacent
10385/// blocks from the same uninterrupted mapping are not penalized.
10386fn major_segment_has_reused_overlap(segments: &str, circ: &CircRecord) -> bool {
10387    let mut groups: Vec<(i32, i32, usize)> = Vec::new();
10388    let mut previous: Option<(i32, i32, char)> = None;
10389    let mut pending_bsj = false;
10390    let mut group_id = 0usize;
10391    for token in segments.split('|') {
10392        if token == "<bsj>" {
10393            if previous.is_some() {
10394                group_id += 1;
10395            }
10396            pending_bsj = previous.is_some();
10397            continue;
10398        }
10399        let Some((start, end, strand)) = parse_segment_token(token) else {
10400            previous = None;
10401            pending_bsj = false;
10402            continue;
10403        };
10404        if end - start + 1 < MIN_JUNCTION_SUPPORT_SEGMENT_LENGTH {
10405            continue;
10406        }
10407        if let Some((prev_start, prev_end, prev_strand)) = previous {
10408            if !pending_bsj && prev_strand == strand {
10409                let (donor_end, acceptor_start) = if prev_start <= start {
10410                    (prev_end, start)
10411                } else {
10412                    (end, prev_start)
10413                };
10414                if donor_end < acceptor_start
10415                    && donor_end >= circ.start
10416                    && acceptor_start <= circ.end
10417                {
10418                    group_id += 1;
10419                }
10420            }
10421        }
10422        let clipped_start = start.max(circ.start);
10423        let clipped_end = end.min(circ.end);
10424        if clipped_start <= clipped_end {
10425            for &(prior_start, prior_end, prior_group) in &groups {
10426                if prior_group != group_id {
10427                    let overlap = prior_end.min(clipped_end) - prior_start.max(clipped_start) + 1;
10428                    if overlap >= MIN_JUNCTION_SUPPORT_SEGMENT_LENGTH {
10429                        return true;
10430                    }
10431                }
10432            }
10433            groups.push((clipped_start, clipped_end, group_id));
10434        }
10435        previous = Some((start, end, strand));
10436        pending_bsj = false;
10437    }
10438    false
10439}
10440
10441/// Builds unique neighboring-junction link keys from one read-chain list.
10442fn major_adjacent_junction_links(junctions: &[MajorJunction]) -> Vec<MajorJunctionLink> {
10443    junctions
10444        .windows(2)
10445        .map(|pair| MajorJunctionLink::new(pair[0], pair[1]))
10446        .collect()
10447}
10448
10449/// Returns whether every neighboring junction pair in the selected path is phased.
10450///
10451/// The selected path is circular, so the implicit BSJ is adjacent to the first
10452/// and last internal splice edges. A single-exon circRNA has no internal
10453/// junction, so this function leaves it to block-level evidence checks; any
10454/// multi-exon path must have high-confidence BSJ/backward read-chain support
10455/// for all BSJ/internal boundary links and all internal neighboring-junction
10456/// links.
10457fn major_chain_has_adjacent_link_support(
10458    edges: &[MajorEdge],
10459    link_support: &HashMap<MajorJunctionLink, MajorLinkSupport>,
10460    link_exclusion: &HashMap<MajorJunctionLink, MajorLinkSupport>,
10461) -> bool {
10462    if edges.is_empty() {
10463        return true;
10464    }
10465    let mut junctions = Vec::with_capacity(edges.len() + 2);
10466    junctions.push(MajorJunction::Bsj);
10467    junctions.extend(edges.iter().copied().map(MajorJunction::Edge));
10468    junctions.push(MajorJunction::Bsj);
10469    junctions.windows(2).all(|pair| {
10470        major_link_is_mature_supported(
10471            MajorJunctionLink::new(pair[0], pair[1]),
10472            link_support,
10473            link_exclusion,
10474        )
10475    })
10476}
10477
10478/// Returns whether one neighboring-junction link is supported and not excluded.
10479///
10480/// Positive phasing and junction-exclusive evidence are treated symmetrically
10481/// for mature classification: one high-confidence BSJ/backward read can support
10482/// a link, but one high-confidence BSJ/backward read showing contradictory
10483/// cyclic reuse prevents that link from certifying a mature path.
10484fn major_link_is_mature_supported(
10485    link: MajorJunctionLink,
10486    link_support: &HashMap<MajorJunctionLink, MajorLinkSupport>,
10487    link_exclusion: &HashMap<MajorJunctionLink, MajorLinkSupport>,
10488) -> bool {
10489    let supported = link_support
10490        .get(&link)
10491        .is_some_and(|support| support.bsj + support.backward >= MAJOR_MIN_MATURE_LINK_SUPPORT);
10492    if !supported {
10493        return false;
10494    }
10495    !link_exclusion
10496        .get(&link)
10497        .is_some_and(|support| support.bsj + support.backward >= MAJOR_MIN_MATURE_LINK_SUPPORT)
10498}
10499
10500/// Selects the single major isoform path for one circRNA.
10501///
10502/// The selected structure is the validated phase-seed-union path. Even when
10503/// the graph contains phase-supported alternatives, the default output keeps
10504/// one isoform per circRNA until multi-sample major switching or a dedicated
10505/// usage model justifies exposing additional candidates.
10506fn select_major_isoform(
10507    circ: &CircRecord,
10508    support: &HashMap<MajorEdge, MajorEdgeSupport>,
10509    link_support: &HashMap<MajorJunctionLink, MajorLinkSupport>,
10510    link_exclusion: &HashMap<MajorJunctionLink, MajorLinkSupport>,
10511    span_support: &[MajorAlignedSpan],
10512    sample_id: &str,
10513    annotation: Option<&Annotation>,
10514) -> MajorIsoformRecord {
10515    let phase_edges = major_phase_chain(circ, support);
10516    let seed_edges = major_seed_chain(support);
10517    let union_edges = major_union_chain(seed_edges, phase_edges);
10518    let mut record = major_isoform_from_edges(
10519        circ,
10520        &union_edges,
10521        support,
10522        link_support,
10523        link_exclusion,
10524        span_support,
10525        sample_id,
10526        annotation,
10527    );
10528    major_set_isoform_identity(&mut record, 1);
10529    record
10530}
10531
10532/// Builds one isoform record from an already selected internal-edge chain.
10533fn major_isoform_from_edges(
10534    circ: &CircRecord,
10535    union_edges: &[MajorEdge],
10536    support: &HashMap<MajorEdge, MajorEdgeSupport>,
10537    link_support: &HashMap<MajorJunctionLink, MajorLinkSupport>,
10538    link_exclusion: &HashMap<MajorJunctionLink, MajorLinkSupport>,
10539    span_support: &[MajorAlignedSpan],
10540    sample_id: &str,
10541    annotation: Option<&Annotation>,
10542) -> MajorIsoformRecord {
10543    let strand = major_isoform_strand(circ, union_edges);
10544    let annotation_exons =
10545        major_projection_annotation_exons_for_circ(circ, annotation, span_support);
10546    let chain_is_phased =
10547        major_chain_has_adjacent_link_support(union_edges, link_support, link_exclusion);
10548    let exon_build = major_exons_from_edges(
10549        circ.start,
10550        circ.end,
10551        strand,
10552        union_edges,
10553        &annotation_exons,
10554        support,
10555        link_support,
10556        link_exclusion,
10557        span_support,
10558        chain_is_phased,
10559    );
10560    let isoform_origin = exon_build.origin().to_string();
10561    let mut estimate_reason = exon_build.estimate_reason();
10562    let exons = exon_build.exons;
10563    let isoform_len = exons.iter().map(|(start, end)| end - start + 1).sum();
10564    let path_score: f64 = union_edges
10565        .iter()
10566        .filter_map(|edge| support.get(edge))
10567        .map(major_edge_weight)
10568        .sum();
10569    let cov = union_edges
10570        .iter()
10571        .filter_map(|edge| support.get(edge))
10572        .map(major_edge_weight)
10573        .fold(None, |best: Option<f64>, value| {
10574            Some(best.map_or(value, |current| current.min(value)))
10575        })
10576        .unwrap_or_else(|| circ.junction_read_count.parse::<usize>().unwrap_or(0) as f64);
10577    let segment_coverage_pct = major_segment_coverage_pct(&exons, span_support);
10578    if isoform_origin == "estimate"
10579        && segment_coverage_pct < MAJOR_MIN_FASTA_SEGMENT_COVERAGE_PCT
10580        && major_has_unannotated_long_exon(&exons, &annotation_exons)
10581    {
10582        major_append_estimate_reason(
10583            &mut estimate_reason,
10584            "low_segment_coverage_unannotated_long_exon",
10585        );
10586    }
10587    MajorIsoformRecord {
10588        circ_id: circ.id.clone(),
10589        isoform_id: format!("{}.iso1", circ.id),
10590        sample_id: sample_id.to_string(),
10591        chr: circ.chr.clone(),
10592        start: circ.start,
10593        end: circ.end,
10594        strand,
10595        source_gene_id: circ.gene_id.clone(),
10596        exons,
10597        cov,
10598        segment_coverage_pct,
10599        path_score,
10600        bsj_reads: circ.junction_read_count.parse::<usize>().unwrap_or(0),
10601        isoform_len,
10602        isoform_rank: 0,
10603        isoform_class: "candidate".to_string(),
10604        isoform_origin,
10605        estimate_reason,
10606    }
10607}
10608
10609/// Assigns stable user-facing rank, class, and ID after candidate sorting.
10610fn major_set_isoform_identity(record: &mut MajorIsoformRecord, rank: usize) {
10611    record.isoform_rank = rank;
10612    record.isoform_class = "major".to_string();
10613    record.isoform_id = format!("{}.iso{}", record.circ_id, rank);
10614}
10615
10616/// Builds the BSJ-phased chain and fills gaps with backward/outward evidence.
10617fn major_phase_chain(
10618    circ: &CircRecord,
10619    support: &HashMap<MajorEdge, MajorEdgeSupport>,
10620) -> Vec<MajorEdge> {
10621    let skeleton = choose_major_edge_chain(
10622        &support
10623            .iter()
10624            .filter_map(|(&edge, counts)| (counts.bsj > 0.0).then_some((edge, counts.bsj)))
10625            .collect::<Vec<_>>(),
10626    );
10627    let mut phase = skeleton.clone();
10628    for (start, end) in major_completion_intervals(circ.start, circ.end, &skeleton) {
10629        let completion = choose_major_edge_chain(
10630            &support
10631                .iter()
10632                .filter_map(|(&edge, counts)| {
10633                    if edge.donor_end >= start && edge.acceptor_start <= end {
10634                        let weight = counts.backward as f64 + counts.outward as f64 * 0.05;
10635                        (weight > 0.0).then_some((edge, weight))
10636                    } else {
10637                        None
10638                    }
10639                })
10640                .collect::<Vec<_>>(),
10641        );
10642        phase.extend(completion);
10643    }
10644    major_sorted_unique_chain(phase)
10645}
10646
10647/// Returns circRNA intervals between BSJ-phased skeleton edges.
10648fn major_completion_intervals(
10649    circ_start: i32,
10650    circ_end: i32,
10651    skeleton: &[MajorEdge],
10652) -> Vec<(i32, i32)> {
10653    let mut intervals = Vec::with_capacity(skeleton.len() + 1);
10654    let mut cursor = circ_start;
10655    for edge in skeleton {
10656        if cursor <= edge.donor_end {
10657            intervals.push((cursor, edge.donor_end));
10658        }
10659        cursor = edge.acceptor_start;
10660    }
10661    if cursor <= circ_end {
10662        intervals.push((cursor, circ_end));
10663    }
10664    intervals
10665}
10666
10667/// Builds a high-confidence seed chain from strongest compatible edges.
10668fn major_seed_chain(support: &HashMap<MajorEdge, MajorEdgeSupport>) -> Vec<MajorEdge> {
10669    let mut ranked: Vec<(MajorEdge, f64)> = support
10670        .iter()
10671        .filter_map(|(&edge, counts)| {
10672            let seed = counts.bsj as f64 * 3.0 + counts.backward as f64 * 2.0;
10673            (seed > 0.0).then_some((edge, seed))
10674        })
10675        .collect();
10676    ranked.sort_by(|(edge_a, score_a), (edge_b, score_b)| {
10677        score_b
10678            .partial_cmp(score_a)
10679            .unwrap_or(Ordering::Equal)
10680            .then_with(|| edge_a.cmp(edge_b))
10681    });
10682    let Some((_, max_score)) = ranked.first().copied() else {
10683        return Vec::new();
10684    };
10685    let min_score = max_score * 0.05;
10686    let mut chain = Vec::new();
10687    for (edge, score) in ranked {
10688        if score < min_score {
10689            continue;
10690        }
10691        if chain
10692            .iter()
10693            .all(|selected| major_edges_compatible(*selected, edge))
10694        {
10695            chain.push(edge);
10696        }
10697    }
10698    major_sorted_unique_chain(chain)
10699}
10700
10701/// Merges the seed and phase paths, keeping the seed path as the primary path.
10702fn major_union_chain(mut seed: Vec<MajorEdge>, phase: Vec<MajorEdge>) -> Vec<MajorEdge> {
10703    for edge in phase {
10704        if seed
10705            .iter()
10706            .all(|selected| major_edges_compatible(*selected, edge))
10707        {
10708            seed.push(edge);
10709        }
10710    }
10711    major_sorted_unique_chain(seed)
10712}
10713
10714/// Chooses the highest-weight compatible edge chain by dynamic programming.
10715fn choose_major_edge_chain(weighted_edges: &[(MajorEdge, f64)]) -> Vec<MajorEdge> {
10716    if weighted_edges.is_empty() {
10717        return Vec::new();
10718    }
10719    let mut edges = weighted_edges.to_vec();
10720    edges.sort_by(|(edge_a, weight_a), (edge_b, weight_b)| {
10721        edge_a
10722            .donor_end
10723            .cmp(&edge_b.donor_end)
10724            .then_with(|| edge_a.acceptor_start.cmp(&edge_b.acceptor_start))
10725            .then_with(|| edge_a.strand.cmp(&edge_b.strand))
10726            .then_with(|| weight_b.partial_cmp(weight_a).unwrap_or(Ordering::Equal))
10727    });
10728    let n = edges.len();
10729    let mut best = vec![0.0; n];
10730    let mut prev = vec![None; n];
10731    for i in 0..n {
10732        best[i] = edges[i].1;
10733        for j in 0..i {
10734            if major_edges_compatible(edges[j].0, edges[i].0) && best[j] + edges[i].1 > best[i] {
10735                best[i] = best[j] + edges[i].1;
10736                prev[i] = Some(j);
10737            }
10738        }
10739    }
10740    let mut best_idx = 0;
10741    for i in 1..n {
10742        if best[i] > best[best_idx] {
10743            best_idx = i;
10744        }
10745    }
10746    let mut chain = Vec::new();
10747    let mut current = Some(best_idx);
10748    while let Some(idx) = current {
10749        chain.push(edges[idx].0);
10750        current = prev[idx];
10751    }
10752    chain.reverse();
10753    major_sorted_unique_chain(chain)
10754}
10755
10756/// Tests whether two internal splice edges can coexist in one transcript path.
10757fn major_edges_compatible(a: MajorEdge, b: MajorEdge) -> bool {
10758    if a == b {
10759        return true;
10760    }
10761    if a.strand != b.strand {
10762        return false;
10763    }
10764    let (left, right) = if a.donor_end <= b.donor_end {
10765        (a, b)
10766    } else {
10767        (b, a)
10768    };
10769    left.acceptor_start <= right.donor_end
10770}
10771
10772/// Sorts a chain in genomic order and removes duplicate edges.
10773fn major_sorted_unique_chain(mut chain: Vec<MajorEdge>) -> Vec<MajorEdge> {
10774    chain.sort_by_key(|edge| (edge.donor_end, edge.acceptor_start, edge.strand));
10775    chain.dedup();
10776    chain
10777}
10778
10779/// Returns annotation exon intervals overlapping a circRNA span.
10780///
10781/// The segment graph defines which introns are skipped, but mature RNA length
10782/// must not count every genomic base between two supported splice edges. When
10783/// annotation is available, each graph block is projected through one
10784/// transcript-consistent exon chain from the Summary-assigned gene. Using the
10785/// gene-level exon union here can combine mutually exclusive transcript exons
10786/// and create unsupported isoform structures, so the union is only a fallback
10787/// for annotations that lack transcript IDs.
10788fn major_annotation_exons_for_circ(
10789    circ: &CircRecord,
10790    annotation: Option<&Annotation>,
10791) -> Vec<(i32, i32)> {
10792    let Some(annotation) = annotation else {
10793        return Vec::new();
10794    };
10795    let mut best_transcript: Option<((i32, i32, i32, i32), Vec<(i32, i32)>)> = None;
10796    for gene in circ.gene_id.split(',') {
10797        let Some(transcripts) = annotation.gene_transcript_exon_map.get(gene) else {
10798            continue;
10799        };
10800        for transcript_exons in transcripts {
10801            let Some(clipped) = major_clip_annotation_chain(transcript_exons, circ.start, circ.end)
10802            else {
10803                continue;
10804            };
10805            let rank = major_annotation_transcript_rank(transcript_exons, &clipped, circ);
10806            if best_transcript
10807                .as_ref()
10808                .is_none_or(|(best_rank, _)| rank > *best_rank)
10809            {
10810                best_transcript = Some((rank, clipped));
10811            }
10812        }
10813    }
10814    if let Some((_rank, exons)) = best_transcript {
10815        return exons;
10816    }
10817    let mut exons: Vec<(i32, i32)> = circ
10818        .gene_id
10819        .split(',')
10820        .filter_map(|gene| annotation.gene_exon_map.get(gene))
10821        .flat_map(|items| items.iter().copied())
10822        .filter_map(|(start, end)| {
10823            let clipped_start = start.max(circ.start);
10824            let clipped_end = end.min(circ.end);
10825            (clipped_start <= clipped_end).then_some((clipped_start, clipped_end))
10826        })
10827        .collect();
10828    exons.sort_unstable();
10829    exons.dedup();
10830    exons
10831}
10832
10833/// Chooses annotation exons used to project unphased major-isoform blocks.
10834///
10835/// Transcript-consistent annotation remains the default because it avoids
10836/// impossible exon combinations. When high-confidence BSJ/backward spans show
10837/// that one transcript chain fails to cover observed mate-level anchors, the
10838/// projection can switch to a gene-level hybrid chain that better explains the
10839/// anchored blocks. This keeps the result as an estimate while avoiding the
10840/// worse fallback of filling the whole circRNA span as one exon.
10841fn major_projection_annotation_exons_for_circ(
10842    circ: &CircRecord,
10843    annotation: Option<&Annotation>,
10844    span_support: &[MajorAlignedSpan],
10845) -> Vec<(i32, i32)> {
10846    let primary = major_annotation_exons_for_circ(circ, annotation);
10847    let Some(annotation) = annotation else {
10848        return primary;
10849    };
10850    let anchors = major_estimate_anchor_blocks(circ.start, circ.end, span_support);
10851    if anchors.is_empty() {
10852        return primary;
10853    }
10854    let gene_exons = major_gene_annotation_exons_for_circ(circ, annotation);
10855    if gene_exons.is_empty() {
10856        return primary;
10857    }
10858    let hybrid = major_anchor_guided_annotation_exons(circ, &gene_exons, &anchors);
10859    if hybrid.is_empty() {
10860        return primary;
10861    }
10862    let primary_score = major_annotation_anchor_score(&primary, &anchors, circ);
10863    let hybrid_score = major_annotation_anchor_score(&hybrid, &anchors, circ);
10864    if hybrid_score > primary_score {
10865        hybrid
10866    } else {
10867        primary
10868    }
10869}
10870
10871/// Returns the gene-level exon union clipped to one circRNA span.
10872///
10873/// This is intentionally separate from `major_annotation_exons_for_circ`:
10874/// full-length mature calls prefer transcript consistency, while estimate
10875/// projection sometimes needs a retained-intron block from one transcript and a
10876/// terminal exon from another to explain observed read anchors.
10877fn major_gene_annotation_exons_for_circ(
10878    circ: &CircRecord,
10879    annotation: &Annotation,
10880) -> Vec<(i32, i32)> {
10881    let mut exons: Vec<(i32, i32)> = circ
10882        .gene_id
10883        .split(',')
10884        .filter_map(|gene| annotation.gene_exon_map.get(gene))
10885        .flat_map(|items| items.iter().copied())
10886        .filter_map(|(start, end)| {
10887            let clipped_start = start.max(circ.start);
10888            let clipped_end = end.min(circ.end);
10889            (clipped_start <= clipped_end).then_some((clipped_start, clipped_end))
10890        })
10891        .collect();
10892    exons.sort_unstable();
10893    exons.dedup();
10894    exons
10895}
10896
10897/// Merges BSJ/backward continuous spans into estimate anchors.
10898///
10899/// These anchors are not used to certify maturity. They only tell annotation
10900/// projection which parts of a long unphased block have direct read support, so
10901/// the fallback can choose retained-intron or terminal exons that actually cover
10902/// the observed alignments.
10903fn major_estimate_anchor_blocks(
10904    circ_start: i32,
10905    circ_end: i32,
10906    span_support: &[MajorAlignedSpan],
10907) -> Vec<(i32, i32)> {
10908    let mut spans: Vec<(i32, i32)> = span_support
10909        .iter()
10910        .filter(|span| span.bsj + span.backward >= MAJOR_MIN_MATURE_LINK_SUPPORT)
10911        .filter_map(|span| {
10912            let start = span.start.max(circ_start);
10913            let end = span.end.min(circ_end);
10914            (start <= end).then_some((start, end))
10915        })
10916        .collect();
10917    spans.sort_unstable();
10918    let mut anchors: Vec<(i32, i32)> = Vec::new();
10919    for (start, end) in spans {
10920        if let Some(last) = anchors.last_mut() {
10921            if start <= last.1 + MAJOR_ESTIMATE_ANCHOR_MERGE_GAP + 1 {
10922                last.1 = last.1.max(end);
10923                continue;
10924            }
10925        }
10926        anchors.push((start, end));
10927    }
10928    anchors
10929}
10930
10931/// Selects the best gene-level annotation exon for every read-supported anchor.
10932///
10933/// The rank strongly prefers exons that contain the whole anchor, then the
10934/// largest overlap, then terminal-boundary agreement and compact exon length.
10935/// This favors retained-intron exons when mates extend past a canonical exon
10936/// boundary, while still choosing the shortest common terminal exon for a BSJ
10937/// side anchor.
10938fn major_anchor_guided_annotation_exons(
10939    circ: &CircRecord,
10940    gene_exons: &[(i32, i32)],
10941    anchors: &[(i32, i32)],
10942) -> Vec<(i32, i32)> {
10943    let mut selected = Vec::new();
10944    for &(anchor_start, anchor_end) in anchors {
10945        let best = gene_exons
10946            .iter()
10947            .copied()
10948            .filter(|&(exon_start, exon_end)| exon_start <= anchor_end && exon_end >= anchor_start)
10949            .max_by_key(|&(exon_start, exon_end)| {
10950                let overlap_start = exon_start.max(anchor_start);
10951                let overlap_end = exon_end.min(anchor_end);
10952                let overlap = overlap_end - overlap_start + 1;
10953                let contains = (exon_start <= anchor_start && exon_end >= anchor_end) as i32;
10954                let terminal_match =
10955                    (exon_start == circ.start) as i32 + (exon_end == circ.end) as i32;
10956                let exon_len = exon_end - exon_start + 1;
10957                let anchor_delta =
10958                    (exon_start - anchor_start).abs() + (exon_end - anchor_end).abs();
10959                (contains, overlap, terminal_match, -exon_len, -anchor_delta)
10960            });
10961        if let Some(exon) = best {
10962            selected.push(exon);
10963        }
10964    }
10965    major_normalize_annotation_blocks(selected)
10966}
10967
10968/// Scores how well an annotation projection explains read-supported anchors.
10969fn major_annotation_anchor_score(
10970    exons: &[(i32, i32)],
10971    anchors: &[(i32, i32)],
10972    circ: &CircRecord,
10973) -> (i32, i32, i32, i32, i32) {
10974    let mut contained = 0;
10975    let mut covered_bases = 0;
10976    for &(anchor_start, anchor_end) in anchors {
10977        if exons
10978            .iter()
10979            .any(|&(exon_start, exon_end)| exon_start <= anchor_start && exon_end >= anchor_end)
10980        {
10981            contained += 1;
10982        }
10983        covered_bases += major_interval_union_overlap(exons, anchor_start, anchor_end);
10984    }
10985    let terminal_matches = exons.iter().any(|&(start, _end)| start == circ.start) as i32
10986        + exons.iter().any(|&(_start, end)| end == circ.end) as i32;
10987    let total_len: i32 = exons.iter().map(|(start, end)| end - start + 1).sum();
10988    (
10989        contained,
10990        covered_bases,
10991        terminal_matches,
10992        -(exons.len() as i32),
10993        -total_len,
10994    )
10995}
10996
10997/// Returns covered bases between an exon set and one anchor interval.
10998fn major_interval_union_overlap(exons: &[(i32, i32)], anchor_start: i32, anchor_end: i32) -> i32 {
10999    let mut pieces: Vec<(i32, i32)> = exons
11000        .iter()
11001        .filter_map(|&(start, end)| {
11002            let clipped_start = start.max(anchor_start);
11003            let clipped_end = end.min(anchor_end);
11004            (clipped_start <= clipped_end).then_some((clipped_start, clipped_end))
11005        })
11006        .collect();
11007    pieces.sort_unstable();
11008    let mut total = 0;
11009    let mut current: Option<(i32, i32)> = None;
11010    for (start, end) in pieces {
11011        if let Some((current_start, current_end)) = current {
11012            if start <= current_end + 1 {
11013                current = Some((current_start, current_end.max(end)));
11014            } else {
11015                total += current_end - current_start + 1;
11016                current = Some((start, end));
11017            }
11018        } else {
11019            current = Some((start, end));
11020        }
11021    }
11022    if let Some((start, end)) = current {
11023        total += end - start + 1;
11024    }
11025    total
11026}
11027
11028/// Sorts, deduplicates and removes overlaps from hybrid annotation blocks.
11029///
11030/// Hybrid estimate projection may pick exons from different transcripts. The
11031/// GTF sidecar must still emit a non-overlapping exon chain, so overlapping
11032/// selected blocks are conservatively merged instead of emitted as conflicting
11033/// features.
11034fn major_normalize_annotation_blocks(mut exons: Vec<(i32, i32)>) -> Vec<(i32, i32)> {
11035    exons.sort_unstable();
11036    exons.dedup();
11037    let mut normalized: Vec<(i32, i32)> = Vec::with_capacity(exons.len());
11038    for (start, end) in exons {
11039        if let Some(last) = normalized.last_mut() {
11040            if start <= last.1 {
11041                last.1 = last.1.max(end);
11042                continue;
11043            }
11044        }
11045        normalized.push((start, end));
11046    }
11047    normalized
11048}
11049
11050/// Clips one transcript exon chain to a circRNA span.
11051fn major_clip_annotation_chain(
11052    exons: &[(i32, i32)],
11053    circ_start: i32,
11054    circ_end: i32,
11055) -> Option<Vec<(i32, i32)>> {
11056    let mut clipped = Vec::new();
11057    for &(start, end) in exons {
11058        let clipped_start = start.max(circ_start);
11059        let clipped_end = end.min(circ_end);
11060        if clipped_start <= clipped_end {
11061            clipped.push((clipped_start, clipped_end));
11062        }
11063    }
11064    if clipped.is_empty() {
11065        None
11066    } else {
11067        clipped.sort_unstable();
11068        clipped.dedup();
11069        Some(clipped)
11070    }
11071}
11072
11073/// Ranks transcript candidates for annotation fallback projection.
11074///
11075/// Exact terminal exon-boundary agreement with the circRNA span is preferred.
11076/// This avoids selecting a transcript merely because a long terminal exon
11077/// overlaps the circ boundary after clipping, which would erase the intended
11078/// transcript-specific chain.
11079fn major_annotation_transcript_rank(
11080    transcript_exons: &[(i32, i32)],
11081    clipped: &[(i32, i32)],
11082    circ: &CircRecord,
11083) -> (i32, i32, i32, i32) {
11084    let boundary_matches = transcript_exons
11085        .iter()
11086        .any(|(start, _end)| *start == circ.start) as i32
11087        + transcript_exons
11088            .iter()
11089            .any(|(_start, end)| *end == circ.end) as i32;
11090    let terminal_delta = transcript_exons
11091        .iter()
11092        .map(|(start, _end)| (*start - circ.start).abs())
11093        .min()
11094        .unwrap_or(i32::MAX / 4)
11095        + transcript_exons
11096            .iter()
11097            .map(|(_start, end)| (*end - circ.end).abs())
11098            .min()
11099            .unwrap_or(i32::MAX / 4);
11100    let clipped_len = clipped.iter().map(|(start, end)| end - start + 1).sum();
11101    (
11102        boundary_matches,
11103        -terminal_delta,
11104        clipped_len,
11105        -(clipped.len() as i32),
11106    )
11107}
11108
11109/// Converts an internal edge chain into 1-based inclusive exon intervals.
11110fn major_exons_from_edges(
11111    circ_start: i32,
11112    circ_end: i32,
11113    strand: char,
11114    edges: &[MajorEdge],
11115    annotation_exons: &[(i32, i32)],
11116    support: &HashMap<MajorEdge, MajorEdgeSupport>,
11117    link_support: &HashMap<MajorJunctionLink, MajorLinkSupport>,
11118    link_exclusion: &HashMap<MajorJunctionLink, MajorLinkSupport>,
11119    span_support: &[MajorAlignedSpan],
11120    chain_is_phased: bool,
11121) -> MajorExonBuild {
11122    let mut exons = Vec::with_capacity(edges.len() + 1);
11123    let mut projected_long_block = false;
11124    let mut unresolved_long_block = false;
11125    let mut inferred_internal_block = false;
11126    let mut unphased_single_exon_block = false;
11127    let mut cursor = circ_start;
11128    let block_phasing = major_selected_block_phasing(edges, link_support, link_exclusion);
11129    let is_single_exon_path = edges.is_empty();
11130    for (idx, edge) in edges.iter().enumerate() {
11131        if cursor <= edge.donor_end {
11132            let status = push_major_exon_block(
11133                &mut exons,
11134                cursor,
11135                edge.donor_end,
11136                strand,
11137                block_phasing.get(idx).copied().unwrap_or(false),
11138                false,
11139                annotation_exons,
11140                support,
11141                span_support,
11142            );
11143            projected_long_block |= status.projected;
11144            unresolved_long_block |= status.unresolved;
11145            inferred_internal_block |= status.inferred;
11146            unphased_single_exon_block |= status.unphased_single_exon;
11147        }
11148        cursor = edge.acceptor_start;
11149    }
11150    if cursor <= circ_end {
11151        let status = push_major_exon_block(
11152            &mut exons,
11153            cursor,
11154            circ_end,
11155            strand,
11156            block_phasing.last().copied().unwrap_or(false),
11157            is_single_exon_path,
11158            annotation_exons,
11159            support,
11160            span_support,
11161        );
11162        projected_long_block |= status.projected;
11163        unresolved_long_block |= status.unresolved;
11164        inferred_internal_block |= status.inferred;
11165        unphased_single_exon_block |= status.unphased_single_exon;
11166    }
11167    if exons.is_empty() && circ_start <= circ_end {
11168        exons.push((circ_start, circ_end));
11169    }
11170    exons.sort_unstable();
11171    exons.dedup();
11172    MajorExonBuild {
11173        exons,
11174        projected_long_block,
11175        unresolved_long_block,
11176        unphased_junction_chain: !chain_is_phased,
11177        inferred_internal_block,
11178        unphased_single_exon_block,
11179    }
11180}
11181
11182/// Returns per-block phasing status for the selected circular junction path.
11183fn major_selected_block_phasing(
11184    edges: &[MajorEdge],
11185    link_support: &HashMap<MajorJunctionLink, MajorLinkSupport>,
11186    link_exclusion: &HashMap<MajorJunctionLink, MajorLinkSupport>,
11187) -> Vec<bool> {
11188    if edges.is_empty() {
11189        return vec![false];
11190    }
11191    let mut junctions = Vec::with_capacity(edges.len() + 2);
11192    junctions.push(MajorJunction::Bsj);
11193    junctions.extend(edges.iter().copied().map(MajorJunction::Edge));
11194    junctions.push(MajorJunction::Bsj);
11195    junctions
11196        .windows(2)
11197        .map(|pair| {
11198            major_link_is_mature_supported(
11199                MajorJunctionLink::new(pair[0], pair[1]),
11200                link_support,
11201                link_exclusion,
11202            )
11203        })
11204        .collect()
11205}
11206
11207/// Appends one transcript block after resolving unphased internal structure.
11208fn push_major_exon_block(
11209    out: &mut Vec<(i32, i32)>,
11210    block_start: i32,
11211    block_end: i32,
11212    strand: char,
11213    block_is_phased: bool,
11214    is_single_exon_path: bool,
11215    annotation_exons: &[(i32, i32)],
11216    support: &HashMap<MajorEdge, MajorEdgeSupport>,
11217    span_support: &[MajorAlignedSpan],
11218) -> MajorBlockBuildStatus {
11219    let block_len = block_end - block_start + 1;
11220    let annotation_edges =
11221        major_annotation_edges_for_block(block_start, block_end, strand, annotation_exons);
11222    let candidate_edges = major_infer_block_edges(
11223        block_start,
11224        block_end,
11225        strand,
11226        annotation_exons,
11227        support,
11228        span_support,
11229    );
11230    if is_single_exon_path && annotation_edges.is_empty() && candidate_edges.is_empty() {
11231        out.push((block_start, block_end));
11232        if major_single_exon_block_has_mature_support(block_start, block_end, span_support) {
11233            return MajorBlockBuildStatus::default();
11234        }
11235        return MajorBlockBuildStatus {
11236            projected: false,
11237            unresolved: false,
11238            inferred: false,
11239            unphased_single_exon: true,
11240        };
11241    }
11242    if block_is_phased
11243        && block_len <= MAJOR_MAX_UNSPLICED_EXON_LEN
11244        && candidate_edges.is_empty()
11245        && annotation_edges.is_empty()
11246    {
11247        out.push((block_start, block_end));
11248        return MajorBlockBuildStatus::default();
11249    }
11250    if !candidate_edges.is_empty() {
11251        return push_major_blocks_from_inferred_edges(
11252            out,
11253            block_start,
11254            block_end,
11255            &candidate_edges,
11256            annotation_exons,
11257        );
11258    }
11259    if !annotation_edges.is_empty() {
11260        out.push((block_start, block_end));
11261        return MajorBlockBuildStatus {
11262            projected: false,
11263            unresolved: block_len > MAJOR_MAX_UNSPLICED_EXON_LEN,
11264            inferred: false,
11265            unphased_single_exon: false,
11266        };
11267    }
11268    if annotation_exons.is_empty() {
11269        out.push((block_start, block_end));
11270        return MajorBlockBuildStatus {
11271            projected: false,
11272            unresolved: block_len > MAJOR_MAX_UNSPLICED_EXON_LEN,
11273            inferred: false,
11274            unphased_single_exon: false,
11275        };
11276    }
11277    let before = out.len();
11278    for &(exon_start, exon_end) in annotation_exons {
11279        if exon_end < block_start {
11280            continue;
11281        }
11282        if exon_start > block_end {
11283            break;
11284        }
11285        let start = exon_start.max(block_start);
11286        let end = exon_end.min(block_end);
11287        if start <= end {
11288            out.push((start, end));
11289        }
11290    }
11291    if out.len() == before {
11292        out.push((block_start, block_end));
11293        MajorBlockBuildStatus {
11294            projected: false,
11295            unresolved: true,
11296            inferred: false,
11297            unphased_single_exon: false,
11298        }
11299    } else {
11300        MajorBlockBuildStatus {
11301            projected: true,
11302            unresolved: false,
11303            inferred: !block_is_phased,
11304            unphased_single_exon: false,
11305        }
11306    }
11307}
11308
11309/// Infers splice edges inside one unphased graph block.
11310///
11311/// Read-supported BSJ/backward/outward junctions are positive candidates.
11312/// Annotation-only junctions are added only when no high-confidence continuous
11313/// BSJ/backward alignment spans across the candidate intron, so aligned
11314/// junction-exclusive evidence can prevent over-splitting.
11315fn major_infer_block_edges(
11316    block_start: i32,
11317    block_end: i32,
11318    strand: char,
11319    annotation_exons: &[(i32, i32)],
11320    support: &HashMap<MajorEdge, MajorEdgeSupport>,
11321    span_support: &[MajorAlignedSpan],
11322) -> Vec<MajorEdge> {
11323    let mut weighted = Vec::new();
11324    let mut seen = HashSet::new();
11325    for (&edge, counts) in support {
11326        if edge.donor_end >= block_start
11327            && edge.acceptor_start <= block_end
11328            && edge.donor_end < edge.acceptor_start
11329        {
11330            let weight = major_edge_weight(counts);
11331            if weight > 0.0 && seen.insert(edge) {
11332                weighted.push((edge, weight));
11333            }
11334        }
11335    }
11336    for edge in major_annotation_edges_for_block(block_start, block_end, strand, annotation_exons) {
11337        if seen.contains(&edge) {
11338            continue;
11339        }
11340        if !major_edge_has_high_conf_exclusion(edge, span_support) {
11341            seen.insert(edge);
11342            weighted.push((edge, 0.01));
11343        }
11344    }
11345    choose_major_edge_chain(&weighted)
11346}
11347
11348/// Returns annotation-implied splice edges inside a graph block.
11349fn major_annotation_edges_for_block(
11350    block_start: i32,
11351    block_end: i32,
11352    strand: char,
11353    annotation_exons: &[(i32, i32)],
11354) -> Vec<MajorEdge> {
11355    let mut clipped: Vec<(i32, i32)> = annotation_exons
11356        .iter()
11357        .filter_map(|&(start, end)| {
11358            let clipped_start = start.max(block_start);
11359            let clipped_end = end.min(block_end);
11360            (clipped_start <= clipped_end).then_some((clipped_start, clipped_end))
11361        })
11362        .collect();
11363    clipped.sort_unstable();
11364    clipped.dedup();
11365    clipped
11366        .windows(2)
11367        .filter_map(|pair| {
11368            let donor_end = pair[0].1;
11369            let acceptor_start = pair[1].0;
11370            (donor_end < acceptor_start).then_some(MajorEdge {
11371                donor_end,
11372                acceptor_start,
11373                strand,
11374            })
11375        })
11376        .collect()
11377}
11378
11379/// Tests whether a single-exon circRNA block has enough evidence to be mature.
11380///
11381/// With no internal junctions, the normal neighboring-junction phasing test has
11382/// nothing to check. Annotation can define a plausible single-exon structure,
11383/// but it cannot by itself prove that read evidence excludes hidden internal
11384/// splicing. A single-exon call is therefore mature only when high-confidence
11385/// BSJ/backward continuous spans tile the complete block without gaps.
11386fn major_single_exon_block_has_mature_support(
11387    block_start: i32,
11388    block_end: i32,
11389    span_support: &[MajorAlignedSpan],
11390) -> bool {
11391    major_high_conf_spans_cover_block(block_start, block_end, span_support)
11392}
11393
11394/// Returns whether BSJ/backward continuous spans tile the whole block.
11395fn major_high_conf_spans_cover_block(
11396    block_start: i32,
11397    block_end: i32,
11398    span_support: &[MajorAlignedSpan],
11399) -> bool {
11400    let mut spans: Vec<(i32, i32)> = span_support
11401        .iter()
11402        .filter(|span| span.bsj + span.backward >= MAJOR_MIN_MATURE_LINK_SUPPORT)
11403        .filter_map(|span| {
11404            let start = span.start.max(block_start);
11405            let end = span.end.min(block_end);
11406            (start <= end).then_some((start, end))
11407        })
11408        .collect();
11409    spans.sort_unstable();
11410    let mut cursor = block_start;
11411    for (start, end) in spans {
11412        if start > cursor {
11413            return false;
11414        }
11415        if end >= block_end {
11416            return true;
11417        }
11418        cursor = cursor.max(end + 1);
11419    }
11420    false
11421}
11422
11423/// Returns the weighted percentage of selected exon-chain bases covered by segments.
11424///
11425/// This is a structural audit metric, not the expression-like `cov` score. BSJ
11426/// spans contribute full coverage, while backward/outward spans contribute their
11427/// circRNA assignment probability. Per-base support is capped at one so several
11428/// ambiguous reads cannot inflate coverage beyond the selected exon length.
11429fn major_segment_coverage_pct(exons: &[(i32, i32)], span_support: &[MajorAlignedSpan]) -> f64 {
11430    let isoform_len: i32 = exons.iter().map(|(start, end)| end - start + 1).sum();
11431    if isoform_len <= 0 {
11432        return 0.0;
11433    }
11434    let mut covered = 0.0;
11435    for &(exon_start, exon_end) in exons {
11436        let mut events: Vec<(i32, f64)> = span_support
11437            .iter()
11438            .filter_map(|span| {
11439                let start = span.start.max(exon_start);
11440                let end = span.end.min(exon_end);
11441                let weight = span.bsj + span.backward + span.outward;
11442                (start <= end && weight > 0.0).then_some([(start, weight), (end + 1, -weight)])
11443            })
11444            .flatten()
11445            .collect();
11446        events.sort_by(|a, b| a.0.cmp(&b.0));
11447        let mut active: f64 = 0.0;
11448        let mut cursor = exon_start;
11449        for (position, delta) in events {
11450            if position > cursor && active > 0.0 {
11451                let end = (position - 1).min(exon_end);
11452                if end >= cursor {
11453                    covered += (end - cursor + 1) as f64 * active.min(1.0);
11454                }
11455            }
11456            active += delta;
11457            if position > cursor {
11458                cursor = position;
11459            }
11460            if cursor > exon_end {
11461                break;
11462            }
11463        }
11464    }
11465    covered * 100.0 / isoform_len as f64
11466}
11467
11468/// Appends one estimate reason while preserving stable comma-separated output.
11469fn major_append_estimate_reason(reasons: &mut String, reason: &str) {
11470    if reasons == "none" || reasons.is_empty() {
11471        *reasons = reason.to_string();
11472    } else if !reasons.split(',').any(|existing| existing == reason) {
11473        reasons.push(',');
11474        reasons.push_str(reason);
11475    }
11476}
11477
11478/// Returns whether a selected isoform contains a long exon absent from GTF.
11479///
11480/// The check requires complete containment by one annotation exon. Partial
11481/// overlaps are not enough to trust the entire projected sequence because the
11482/// uncovered portion may still be an unresolved internal structure.
11483fn major_has_unannotated_long_exon(exons: &[(i32, i32)], annotation_exons: &[(i32, i32)]) -> bool {
11484    exons.iter().any(|&(start, end)| {
11485        end - start + 1 > MAJOR_MAX_UNSPLICED_EXON_LEN
11486            && !annotation_exons
11487                .iter()
11488                .any(|&(anno_start, anno_end)| anno_start <= start && anno_end >= end)
11489    })
11490}
11491
11492/// Tests whether high-confidence continuous alignment excludes a splice edge.
11493fn major_edge_has_high_conf_exclusion(edge: MajorEdge, span_support: &[MajorAlignedSpan]) -> bool {
11494    let left_anchor_start = edge.donor_end - MAJOR_EXCLUSIVE_JUNCTION_MIN_ANCHOR + 1;
11495    let right_anchor_end = edge.acceptor_start + MAJOR_EXCLUSIVE_JUNCTION_MIN_ANCHOR - 1;
11496    span_support.iter().any(|span| {
11497        span.start <= left_anchor_start
11498            && span.end >= right_anchor_end
11499            && span.bsj + span.backward >= MAJOR_MIN_MATURE_LINK_SUPPORT
11500    })
11501}
11502
11503/// Converts inferred internal edges into exon blocks.
11504fn push_major_blocks_from_inferred_edges(
11505    out: &mut Vec<(i32, i32)>,
11506    block_start: i32,
11507    block_end: i32,
11508    edges: &[MajorEdge],
11509    annotation_exons: &[(i32, i32)],
11510) -> MajorBlockBuildStatus {
11511    let before = out.len();
11512    let mut projected = false;
11513    let mut unresolved = false;
11514    let mut cursor = block_start;
11515    for edge in edges {
11516        if cursor <= edge.donor_end {
11517            let status =
11518                push_major_resolved_subblock(out, cursor, edge.donor_end, annotation_exons);
11519            projected |= status.projected;
11520            unresolved |= status.unresolved;
11521        }
11522        cursor = edge.acceptor_start;
11523    }
11524    if cursor <= block_end {
11525        let status = push_major_resolved_subblock(out, cursor, block_end, annotation_exons);
11526        projected |= status.projected;
11527        unresolved |= status.unresolved;
11528    }
11529    MajorBlockBuildStatus {
11530        projected: projected
11531            || edges.iter().any(|edge| {
11532                major_annotation_edges_for_block(
11533                    block_start,
11534                    block_end,
11535                    edge.strand,
11536                    annotation_exons,
11537                )
11538                .contains(edge)
11539            }),
11540        unresolved: unresolved || out.len() == before,
11541        inferred: true,
11542        unphased_single_exon: false,
11543    }
11544}
11545
11546/// Appends an inferred subblock, projecting only if it is still implausibly long.
11547fn push_major_resolved_subblock(
11548    out: &mut Vec<(i32, i32)>,
11549    block_start: i32,
11550    block_end: i32,
11551    annotation_exons: &[(i32, i32)],
11552) -> MajorBlockBuildStatus {
11553    let block_len = block_end - block_start + 1;
11554    if block_len <= MAJOR_MAX_UNSPLICED_EXON_LEN {
11555        out.push((block_start, block_end));
11556        return MajorBlockBuildStatus::default();
11557    }
11558    if annotation_exons.is_empty() {
11559        out.push((block_start, block_end));
11560        return MajorBlockBuildStatus {
11561            projected: false,
11562            unresolved: true,
11563            inferred: false,
11564            unphased_single_exon: false,
11565        };
11566    }
11567    let before = out.len();
11568    for &(exon_start, exon_end) in annotation_exons {
11569        if exon_end < block_start {
11570            continue;
11571        }
11572        if exon_start > block_end {
11573            break;
11574        }
11575        let start = exon_start.max(block_start);
11576        let end = exon_end.min(block_end);
11577        if start <= end {
11578            out.push((start, end));
11579        }
11580    }
11581    MajorBlockBuildStatus {
11582        projected: out.len() > before,
11583        unresolved: out.len() == before,
11584        inferred: false,
11585        unphased_single_exon: false,
11586    }
11587}
11588
11589/// Chooses the user-facing strand for a major isoform.
11590fn major_isoform_strand(circ: &CircRecord, edges: &[MajorEdge]) -> char {
11591    circ.strand
11592        .chars()
11593        .next()
11594        .filter(|strand| matches!(strand, '+' | '-'))
11595        .or_else(|| edges.first().map(|edge| edge.strand))
11596        .unwrap_or('.')
11597}
11598
11599/// Weight used by the selected path score and coverage estimate.
11600fn major_edge_weight(support: &MajorEdgeSupport) -> f64 {
11601    support.bsj + support.backward + support.outward * 0.05
11602}
11603
11604/// Returns a stable sample label from the output prefix.
11605fn major_sample_id(out_prefix: &str) -> String {
11606    let sample = std::path::Path::new(out_prefix)
11607        .file_name()
11608        .and_then(|name| name.to_str())
11609        .filter(|name| !name.is_empty())
11610        .unwrap_or(out_prefix);
11611    sample.strip_suffix(".ciri").unwrap_or(sample).to_string()
11612}
11613
11614/// Returns a structure key for merging sample-major isoforms across a cohort.
11615fn major_isoform_structure_key(record: &MajorIsoformRecord) -> MajorIsoformStructureKey {
11616    MajorIsoformStructureKey {
11617        chr: record.chr.clone(),
11618        start: record.start,
11619        end: record.end,
11620        strand: record.strand,
11621        exons: record.exons.clone(),
11622    }
11623}
11624
11625/// Returns internal splice edges implied by an exon chain.
11626fn major_edges_from_isoform(record: &MajorIsoformRecord) -> Vec<MajorEdge> {
11627    record
11628        .exons
11629        .windows(2)
11630        .filter_map(|pair| {
11631            let donor_end = pair[0].1;
11632            let acceptor_start = pair[1].0;
11633            (donor_end < acceptor_start).then_some(MajorEdge {
11634                donor_end,
11635                acceptor_start,
11636                strand: record.strand,
11637            })
11638        })
11639        .collect()
11640}
11641
11642/// Tests whether one read-local contiguous span excludes a candidate splice edge.
11643///
11644/// The same anchor rule is used by mature classification: a span must cover at
11645/// least `MIN_JUNCTION_SUPPORT_SEGMENT_LENGTH` bases on both sides of a
11646/// candidate intron before it can be treated as evidence that the intron was not
11647/// spliced in this molecule.
11648fn major_read_span_excludes_edge(span: (i32, i32), edge: MajorEdge) -> bool {
11649    let left_anchor_start = edge.donor_end - MAJOR_EXCLUSIVE_JUNCTION_MIN_ANCHOR + 1;
11650    let right_anchor_end = edge.acceptor_start + MAJOR_EXCLUSIVE_JUNCTION_MIN_ANCHOR - 1;
11651    span.0 <= left_anchor_start && span.1 >= right_anchor_end
11652}
11653
11654/// Scores compatibility between one BSJ read and one cohort candidate isoform.
11655///
11656/// A read can only support a candidate when all splice edges it directly
11657/// observes are present in the candidate and none of its continuous blocks
11658/// confidently spans over a candidate edge. Compatible candidates receive equal
11659/// probability for that read; this avoids over-interpreting short BSJ reads that
11660/// cannot phase the whole isoform.
11661fn major_bsj_read_candidate_score(
11662    observation: &MajorBsjReadObservation,
11663    candidate_edges: &HashSet<MajorEdge>,
11664) -> f64 {
11665    if !observation
11666        .edges
11667        .iter()
11668        .all(|edge| candidate_edges.contains(edge))
11669    {
11670        return 0.0;
11671    }
11672    if candidate_edges.iter().any(|edge| {
11673        observation
11674            .spans
11675            .iter()
11676            .any(|&span| major_read_span_excludes_edge(span, *edge))
11677    }) {
11678        return 0.0;
11679    }
11680    1.0
11681}
11682
11683/// Assigns one sample's BSJ reads probabilistically to fixed cohort candidates.
11684///
11685/// This is deliberately separate from the graph-building support maps. Backward
11686/// and outward reads can help discover candidate structures during co-assembly,
11687/// but usage and switching estimates only use exact BSJ molecules because those
11688/// rows have a trustworthy circRNA identity.
11689fn cohort_bsj_assignment_supports(
11690    records: &[MajorIsoformRecord],
11691    sample: &MajorSampleAssembly,
11692    circ_id: &str,
11693) -> Vec<f64> {
11694    let Some(circ_index) = sample.circ_index_by_id.get(circ_id).copied() else {
11695        return vec![0.0; records.len()];
11696    };
11697    let observations = sample
11698        .support
11699        .bsj_reads_by_circ
11700        .get(&circ_index)
11701        .map(Vec::as_slice)
11702        .unwrap_or(&[]);
11703    let candidate_edges: Vec<HashSet<MajorEdge>> = records
11704        .iter()
11705        .map(|record| major_edges_from_isoform(record).into_iter().collect())
11706        .collect();
11707    let mut supports = vec![0.0; records.len()];
11708    for observation in observations {
11709        if observation.read_id.is_empty() {
11710            continue;
11711        }
11712        let scores: Vec<f64> = candidate_edges
11713            .iter()
11714            .map(|edges| major_bsj_read_candidate_score(observation, edges))
11715            .collect();
11716        let denominator: f64 = scores.iter().sum();
11717        if denominator <= 0.0 {
11718            continue;
11719        }
11720        for (support, score) in supports.iter_mut().zip(scores) {
11721            *support += score / denominator;
11722        }
11723    }
11724    supports
11725}
11726
11727/// Sums BSJ read-assignment support across samples for cohort candidate ranking.
11728fn cohort_candidate_assignment_support_by_key(
11729    records: &[MajorIsoformRecord],
11730    samples: &[MajorSampleAssembly],
11731    circ_id: &str,
11732) -> HashMap<MajorIsoformStructureKey, f64> {
11733    let mut totals = vec![0.0; records.len()];
11734    for sample in samples {
11735        for (total, value) in totals
11736            .iter_mut()
11737            .zip(cohort_bsj_assignment_supports(records, sample, circ_id))
11738        {
11739            *total += value;
11740        }
11741    }
11742    records
11743        .iter()
11744        .zip(totals)
11745        .map(|(record, total)| (major_isoform_structure_key(record), total))
11746        .collect()
11747}
11748
11749/// Returns whether selected high-confidence candidates have a real usage shift.
11750///
11751/// Cohort switching should not be triggered by small sample-to-sample changes
11752/// around a 50/50 split. The candidate set is already restricted to
11753/// sample-major structures with enough BSJ support and switching-grade structure;
11754/// this final gate requires at least one candidate usage to change by 50
11755/// percentage points across eligible samples.
11756fn cohort_usage_shift_passes(
11757    records: &[MajorIsoformRecord],
11758    samples: &[MajorSampleAssembly],
11759    circ_id: &str,
11760) -> bool {
11761    let mut sample_major_keys = HashSet::new();
11762    let mut sample_supports = Vec::new();
11763    for sample in samples {
11764        let bsj_reads = sample
11765            .circ_values
11766            .get(circ_id)
11767            .map(|value| value.bsj_reads)
11768            .unwrap_or(0.0);
11769        if bsj_reads < COHORT_SWITCHING_MIN_BSJ_READS {
11770            continue;
11771        }
11772        let supports = cohort_bsj_assignment_supports(records, sample, circ_id);
11773        let denominator: f64 = supports.iter().sum();
11774        if denominator > 0.0 {
11775            sample_supports.push(supports);
11776        }
11777    }
11778    records.iter().any(|record| {
11779        let Some(record_index) = records.iter().position(|candidate| {
11780            major_isoform_structure_key(candidate) == major_isoform_structure_key(record)
11781        }) else {
11782            return false;
11783        };
11784        let mut min_usage = f64::INFINITY;
11785        let mut max_usage = f64::NEG_INFINITY;
11786        for supports in &sample_supports {
11787            let denominator: f64 = supports.iter().sum();
11788            let usage = supports[record_index] / denominator;
11789            min_usage = min_usage.min(usage);
11790            max_usage = max_usage.max(usage);
11791        }
11792        sample_supports.len() >= 2 && max_usage - min_usage >= COHORT_SWITCHING_MIN_USAGE_DELTA
11793    }) && samples
11794        .iter()
11795        .filter_map(|sample| cohort_sample_major_usage_key(records, sample, circ_id))
11796        .any(|key| {
11797            sample_major_keys.insert(key);
11798            sample_major_keys.len() > 1
11799        })
11800}
11801
11802/// Returns the unique highest-usage cohort candidate for one eligible sample.
11803fn cohort_sample_major_usage_key(
11804    records: &[MajorIsoformRecord],
11805    sample: &MajorSampleAssembly,
11806    circ_id: &str,
11807) -> Option<MajorIsoformStructureKey> {
11808    let bsj_reads = sample
11809        .circ_values
11810        .get(circ_id)
11811        .map(|value| value.bsj_reads)
11812        .unwrap_or(0.0);
11813    if bsj_reads < COHORT_SWITCHING_MIN_BSJ_READS {
11814        return None;
11815    }
11816    let supports = cohort_bsj_assignment_supports(records, sample, circ_id);
11817    let denominator: f64 = supports.iter().sum();
11818    if denominator <= 0.0 {
11819        return None;
11820    }
11821    let mut ranked: Vec<(f64, MajorIsoformStructureKey)> = records
11822        .iter()
11823        .zip(supports)
11824        .map(|(record, support)| (support / denominator, major_isoform_structure_key(record)))
11825        .collect();
11826    ranked.sort_by(|(usage_a, key_a), (usage_b, key_b)| {
11827        usage_b
11828            .partial_cmp(usage_a)
11829            .unwrap_or(Ordering::Equal)
11830            .then_with(|| key_a.cmp(key_b))
11831    });
11832    let (top_usage, top_key) = ranked.first()?.clone();
11833    if ranked
11834        .get(1)
11835        .is_some_and(|(next_usage, _)| (top_usage - *next_usage).abs() < 1e-9)
11836    {
11837        return None;
11838    }
11839    Some(top_key)
11840}
11841
11842/// Returns whether a cohort candidate is reliable enough to call switching.
11843///
11844/// FASTA output only means a sequence is useful to inspect. Switching requires a
11845/// stricter structure interpretation: mature phased chains are accepted, and
11846/// estimates are accepted only when their uncertainty is limited to
11847/// annotation-guided projection, unphased junction chaining, or an ambiguous
11848/// long block with enough assignment support. Low-coverage single-exon calls
11849/// and unconfident long exons remain audit-only because they do not provide a
11850/// stable enough structure for sample-to-sample major isoform switching.
11851fn major_isoform_is_switching_eligible(record: &MajorIsoformRecord) -> bool {
11852    if record.isoform_origin == "mature" {
11853        return true;
11854    }
11855    if record.isoform_origin != "estimate" {
11856        return false;
11857    }
11858    let reasons: HashSet<&str> = record.estimate_reason.split(',').collect();
11859    if reasons.contains("unphased_single_exon_block")
11860        || reasons.contains("low_segment_coverage_unannotated_long_exon")
11861    {
11862        return false;
11863    }
11864    reasons.contains("gtf_long_block_projection")
11865        || reasons.contains("inferred_internal_block")
11866        || reasons.contains("unphased_junction_chain")
11867}
11868
11869/// Builds a coordinate-sorted union of circRNAs represented in any sample.
11870fn cohort_circ_records(samples: &[MajorSampleAssembly]) -> Vec<CircRecord> {
11871    let mut by_id: HashMap<String, CircRecord> = HashMap::new();
11872    let mut read_counts: HashMap<String, f64> = HashMap::new();
11873    for sample in samples {
11874        for value in sample.circ_values.values() {
11875            *read_counts.entry(value.circ.id.clone()).or_default() += value.bsj_reads;
11876            by_id
11877                .entry(value.circ.id.clone())
11878                .or_insert_with(|| value.circ.clone());
11879        }
11880    }
11881    let mut rows: Vec<_> = by_id.into_values().collect();
11882    for row in &mut rows {
11883        if let Some(reads) = read_counts.get(&row.id) {
11884            row.junction_read_count = format!("{:.0}", reads);
11885        }
11886    }
11887    rows.sort_by(|a, b| {
11888        cohort_chrom_sort_key(&a.chr)
11889            .cmp(&cohort_chrom_sort_key(&b.chr))
11890            .then_with(|| a.start.cmp(&b.start))
11891            .then_with(|| a.end.cmp(&b.end))
11892            .then_with(|| a.strand.cmp(&b.strand))
11893            .then_with(|| a.id.cmp(&b.id))
11894    });
11895    rows
11896}
11897
11898/// Converts co-assembled circRNA records into matrix row metadata.
11899fn cohort_circ_infos_from_records(records: &[CircRecord]) -> Vec<CohortCircInfo> {
11900    records
11901        .iter()
11902        .map(|record| CohortCircInfo {
11903            id: record.id.clone(),
11904        })
11905        .collect()
11906}
11907
11908/// Merges all per-sample structural support into one cohort co-assembly bundle.
11909fn cohort_support_bundle(
11910    circ_records: &[CircRecord],
11911    samples: &[MajorSampleAssembly],
11912) -> MajorIsoformSupportBundle {
11913    let cohort_index_by_id: HashMap<&str, usize> = circ_records
11914        .iter()
11915        .enumerate()
11916        .map(|(idx, circ)| (circ.id.as_str(), idx))
11917        .collect();
11918    let mut merged = MajorIsoformSupportBundle::default();
11919    for sample in samples {
11920        for (circ_id, &sample_idx) in &sample.circ_index_by_id {
11921            let Some(cohort_idx) = cohort_index_by_id.get(circ_id.as_str()).copied() else {
11922                continue;
11923            };
11924            merge_major_support_for_circ(&mut merged, cohort_idx, &sample.support, sample_idx);
11925        }
11926    }
11927    merged
11928}
11929
11930/// Adds one sample-local circRNA support map into the cohort support map.
11931fn merge_major_support_for_circ(
11932    out: &mut MajorIsoformSupportBundle,
11933    out_idx: usize,
11934    input: &MajorIsoformSupportBundle,
11935    input_idx: usize,
11936) {
11937    if let Some(edges) = input.edge_support_by_circ.get(&input_idx) {
11938        let out_edges = out.edge_support_by_circ.entry(out_idx).or_default();
11939        for (&edge, support) in edges {
11940            let target = out_edges.entry(edge).or_default();
11941            target.bsj += support.bsj;
11942            target.backward += support.backward;
11943            target.outward += support.outward;
11944        }
11945    }
11946    if let Some(links) = input.link_support_by_circ.get(&input_idx) {
11947        let out_links = out.link_support_by_circ.entry(out_idx).or_default();
11948        for (&link, support) in links {
11949            let target = out_links.entry(link).or_default();
11950            target.bsj += support.bsj;
11951            target.backward += support.backward;
11952            target.outward += support.outward;
11953        }
11954    }
11955    if let Some(links) = input.link_exclusion_by_circ.get(&input_idx) {
11956        let out_links = out.link_exclusion_by_circ.entry(out_idx).or_default();
11957        for (&link, support) in links {
11958            let target = out_links.entry(link).or_default();
11959            target.bsj += support.bsj;
11960            target.backward += support.backward;
11961            target.outward += support.outward;
11962        }
11963    }
11964    if let Some(spans) = input.span_support_by_circ.get(&input_idx) {
11965        out.span_support_by_circ
11966            .entry(out_idx)
11967            .or_default()
11968            .extend(spans.iter().copied());
11969    }
11970}
11971
11972/// Builds high-confidence candidate isoforms from the cohort co-assembly graph.
11973fn select_cohort_isoform_candidates(
11974    circ_index: usize,
11975    circ: &CircRecord,
11976    support_bundle: &MajorIsoformSupportBundle,
11977    sample_id: &str,
11978    annotation: Option<&Annotation>,
11979) -> Vec<MajorIsoformRecord> {
11980    let empty = HashMap::new();
11981    let empty_links = HashMap::new();
11982    let empty_exclusions = HashMap::new();
11983    let empty_spans = Vec::new();
11984    let support = support_bundle
11985        .edge_support_by_circ
11986        .get(&circ_index)
11987        .unwrap_or(&empty);
11988    let link_support = support_bundle
11989        .link_support_by_circ
11990        .get(&circ_index)
11991        .unwrap_or(&empty_links);
11992    let link_exclusion = support_bundle
11993        .link_exclusion_by_circ
11994        .get(&circ_index)
11995        .unwrap_or(&empty_exclusions);
11996    let span_support = support_bundle
11997        .span_support_by_circ
11998        .get(&circ_index)
11999        .unwrap_or(&empty_spans);
12000    let mut by_key: HashMap<MajorIsoformStructureKey, MajorIsoformRecord> = HashMap::new();
12001    for chain in cohort_candidate_edge_chains(circ, support) {
12002        let mut record = major_isoform_from_edges(
12003            circ,
12004            &chain,
12005            support,
12006            link_support,
12007            link_exclusion,
12008            span_support,
12009            sample_id,
12010            annotation,
12011        );
12012        major_set_isoform_identity(&mut record, 1);
12013        if major_isoform_is_switching_eligible(&record) {
12014            by_key
12015                .entry(major_isoform_structure_key(&record))
12016                .or_insert(record);
12017        }
12018    }
12019    if by_key.is_empty() {
12020        let mut fallback = select_major_isoform(
12021            circ,
12022            support,
12023            link_support,
12024            link_exclusion,
12025            span_support,
12026            sample_id,
12027            annotation,
12028        );
12029        major_set_isoform_identity(&mut fallback, 1);
12030        by_key.insert(major_isoform_structure_key(&fallback), fallback);
12031    }
12032    by_key.into_values().collect()
12033}
12034
12035/// Enumerates bounded candidate edge chains from the merged cohort graph.
12036fn cohort_candidate_edge_chains(
12037    circ: &CircRecord,
12038    support: &HashMap<MajorEdge, MajorEdgeSupport>,
12039) -> Vec<Vec<MajorEdge>> {
12040    let primary = major_union_chain(major_seed_chain(support), major_phase_chain(circ, support));
12041    let mut chains = vec![primary];
12042    let mut weighted: Vec<(MajorEdge, f64)> = support
12043        .iter()
12044        .filter_map(|(&edge, counts)| {
12045            let weight = major_edge_weight(counts);
12046            (weight >= COHORT_MIN_ALT_EDGE_SUPPORT).then_some((edge, weight))
12047        })
12048        .collect();
12049    weighted.sort_by(|(edge_a, score_a), (edge_b, score_b)| {
12050        score_b
12051            .partial_cmp(score_a)
12052            .unwrap_or(Ordering::Equal)
12053            .then_with(|| edge_a.cmp(edge_b))
12054    });
12055    for (edge, _score) in weighted.into_iter().take(COHORT_MAX_ALT_EDGE_PROBES) {
12056        chains.push(cohort_chain_forced_edge(edge, support));
12057    }
12058    let mut seen = HashSet::new();
12059    chains
12060        .into_iter()
12061        .map(major_sorted_unique_chain)
12062        .filter(|chain| seen.insert(chain.clone()))
12063        .collect()
12064}
12065
12066/// Builds a compatible high-support chain around one required alternative edge.
12067fn cohort_chain_forced_edge(
12068    focal: MajorEdge,
12069    support: &HashMap<MajorEdge, MajorEdgeSupport>,
12070) -> Vec<MajorEdge> {
12071    let mut chain = vec![focal];
12072    let mut weighted: Vec<(MajorEdge, f64)> = support
12073        .iter()
12074        .filter_map(|(&edge, counts)| {
12075            let weight = major_edge_weight(counts);
12076            (edge != focal && weight > 0.0).then_some((edge, weight))
12077        })
12078        .collect();
12079    weighted.sort_by(|(edge_a, score_a), (edge_b, score_b)| {
12080        score_b
12081            .partial_cmp(score_a)
12082            .unwrap_or(Ordering::Equal)
12083            .then_with(|| edge_a.cmp(edge_b))
12084    });
12085    for (edge, _score) in weighted {
12086        if chain
12087            .iter()
12088            .all(|selected| major_edges_compatible(*selected, edge))
12089        {
12090            chain.push(edge);
12091        }
12092    }
12093    major_sorted_unique_chain(chain)
12094}
12095
12096/// Human-friendly chromosome sort key used by cohort matrix rows.
12097fn cohort_chrom_sort_key(chrom: &str) -> (u8, u32, String) {
12098    let core = chrom.strip_prefix("chr").unwrap_or(chrom);
12099    if let Ok(rank) = core.parse::<u32>() {
12100        return (0, rank, String::new());
12101    }
12102    match core {
12103        "X" => (0, 23, String::new()),
12104        "Y" => (0, 24, String::new()),
12105        "M" | "MT" => (0, 25, String::new()),
12106        _ => (1, 0, chrom.to_string()),
12107    }
12108}
12109
12110/// Writes a circRNA-by-sample BSJ read-count matrix.
12111fn write_cohort_bsj_matrix(
12112    path: &str,
12113    sample_ids: &[String],
12114    circ_infos: &[CohortCircInfo],
12115    samples: &[MajorSampleAssembly],
12116) -> Result<()> {
12117    let mut writer = BufWriter::new(File::create(path)?);
12118    write!(writer, "circRNA_id")?;
12119    for sample_id in sample_ids {
12120        write!(writer, "\t{}", sample_id)?;
12121    }
12122    writeln!(writer)?;
12123    for circ in circ_infos {
12124        write!(writer, "{}", circ.id)?;
12125        for sample in samples {
12126            let value = sample
12127                .circ_values
12128                .get(&circ.id)
12129                .map(|value| value.bsj_reads)
12130                .unwrap_or(0.0);
12131            write!(writer, "\t{:.0}", value)?;
12132        }
12133        writeln!(writer)?;
12134    }
12135    writer.flush()?;
12136    Ok(())
12137}
12138
12139/// Writes a circRNA-by-sample junction-read-ratio matrix.
12140fn write_cohort_ratio_matrix(
12141    path: &str,
12142    sample_ids: &[String],
12143    circ_infos: &[CohortCircInfo],
12144    samples: &[MajorSampleAssembly],
12145) -> Result<()> {
12146    let mut writer = BufWriter::new(File::create(path)?);
12147    write!(writer, "circRNA_id")?;
12148    for sample_id in sample_ids {
12149        write!(writer, "\t{}", sample_id)?;
12150    }
12151    writeln!(writer)?;
12152    for circ in circ_infos {
12153        write!(writer, "{}", circ.id)?;
12154        for sample in samples {
12155            let value = sample
12156                .circ_values
12157                .get(&circ.id)
12158                .map(|value| value.junction_ratio)
12159                .unwrap_or(0.0);
12160            write!(writer, "\t{:.6}", value)?;
12161        }
12162        writeln!(writer)?;
12163    }
12164    writer.flush()?;
12165    Ok(())
12166}
12167
12168/// Writes isoform usage only for circRNAs with major isoform switching.
12169fn write_cohort_usage_matrix(
12170    path: &str,
12171    sample_ids: &[String],
12172    switching_rows: &[(String, Vec<MajorIsoformRecord>)],
12173    samples: &[MajorSampleAssembly],
12174) -> Result<()> {
12175    let mut writer = BufWriter::new(File::create(path)?);
12176    write!(writer, "isoform_id")?;
12177    for sample_id in sample_ids {
12178        write!(writer, "\t{}", sample_id)?;
12179    }
12180    writeln!(writer)?;
12181    for (circ_id, records) in switching_rows {
12182        let sample_supports: Vec<Vec<f64>> = samples
12183            .iter()
12184            .map(|sample| cohort_bsj_assignment_supports(records, sample, circ_id))
12185            .collect();
12186        for record in records {
12187            write!(writer, "{}", record.isoform_id)?;
12188            let record_index = records
12189                .iter()
12190                .position(|candidate| {
12191                    major_isoform_structure_key(candidate) == major_isoform_structure_key(record)
12192                })
12193                .unwrap_or(0);
12194            for supports in &sample_supports {
12195                let numerator = supports.get(record_index).copied().unwrap_or(0.0);
12196                let denominator: f64 = supports.iter().sum();
12197                let usage = if denominator > 0.0 {
12198                    numerator / denominator
12199                } else {
12200                    0.0
12201                };
12202                write!(writer, "\t{:.6}", usage)?;
12203            }
12204            writeln!(writer)?;
12205        }
12206    }
12207    writer.flush()?;
12208    Ok(())
12209}
12210
12211/// Writes major isoforms as a GTF sidecar with structure audit metadata.
12212///
12213/// The numeric support values live in ordered attributes rather than the GTF
12214/// score column because the public schema distinguishes `weakness`, `score`,
12215/// and `weight`; using `.` for column six avoids a second ambiguous score.
12216fn write_major_isoform_gtf(path: &str, records: &[MajorIsoformRecord]) -> Result<()> {
12217    let mut writer = BufWriter::new(File::create(path)?);
12218    for record in records {
12219        let attrs = major_gtf_attributes(record);
12220        writeln!(
12221            writer,
12222            "{}\tCIRI\tcircRNA\t{}\t{}\t.\t{}\t.\t{}",
12223            record.chr, record.start, record.end, record.strand, attrs
12224        )?;
12225        writeln!(
12226            writer,
12227            "{}\tCIRI\ttranscript\t{}\t{}\t.\t{}\t.\t{}",
12228            record.chr, record.start, record.end, record.strand, attrs
12229        )?;
12230        for (idx, (start, end)) in record.exons.iter().enumerate() {
12231            writeln!(
12232                writer,
12233                "{}\tCIRI\texon\t{}\t{}\t.\t{}\t.\t{} exon_number \"{}\";",
12234                record.chr,
12235                start,
12236                end,
12237                record.strand,
12238                attrs,
12239                idx + 1
12240            )?;
12241        }
12242    }
12243    writer.flush()?;
12244    Ok(())
12245}
12246
12247/// Formats stable GTF attributes shared by circRNA/transcript/exon rows.
12248///
12249/// Attribute order is intentionally fixed across feature types so downstream
12250/// parsers can compare rows mechanically. Exon rows append only `exon_number`
12251/// after this shared block.
12252fn major_gtf_attributes(record: &MajorIsoformRecord) -> String {
12253    format!(
12254        "gene_id \"{}\"; transcript_id \"{}\"; source_gene_id \"{}\"; type \"{}\"; evidence \"{}\"; weakness \"{:.3}\"; score \"{:.3}\"; bsj_reads \"{}\"; weight \"{:.3}\"; exon_count \"{}\"; isoform_len \"{}\";",
12255        gtf_escape(&record.circ_id),
12256        gtf_escape(&record.isoform_id),
12257        gtf_escape(&record.source_gene_id),
12258        gtf_escape(&record.isoform_origin),
12259        gtf_escape(&major_evidence_text(record)),
12260        record.cov,
12261        record.segment_coverage_pct,
12262        record.bsj_reads,
12263        record.path_score,
12264        record.exons.len(),
12265        record.isoform_len
12266    )
12267}
12268
12269/// Summarizes mature/estimate construction evidence for GTF audit attributes.
12270///
12271/// Internal estimate reasons are implementation-oriented and can combine
12272/// several low-level block states. The public `evidence` value keeps a stable,
12273/// compact vocabulary that explains the biological limitation without exposing
12274/// builder-specific method names such as `phase_seed_union`.
12275fn major_evidence_text(record: &MajorIsoformRecord) -> String {
12276    if record.isoform_origin == "mature" {
12277        return "phased_junction".to_string();
12278    }
12279    let reasons: HashSet<&str> = record.estimate_reason.split(',').collect();
12280    let mut evidence = Vec::new();
12281    if reasons.contains("gtf_long_block_projection") || reasons.contains("inferred_internal_block")
12282    {
12283        evidence.push("annotation_guided");
12284    }
12285    if reasons.contains("unphased_junction_chain") {
12286        evidence.push("unphased_junction");
12287    }
12288    if reasons.contains("unphased_single_exon_block") {
12289        evidence.push("low_coverage_exon");
12290    }
12291    if reasons.contains("unresolved_long_block") {
12292        evidence.push("ambiguous_exon");
12293    }
12294    if reasons.contains("low_segment_coverage_unannotated_long_exon") {
12295        evidence.push("unconfident_long_exon");
12296    }
12297    if evidence.is_empty() {
12298        evidence.push("estimate");
12299    }
12300    evidence.join(",")
12301}
12302
12303/// Formats the exon chain for compact FASTA header display.
12304fn major_cirexon_text(record: &MajorIsoformRecord) -> String {
12305    record
12306        .exons
12307        .iter()
12308        .map(|(start, end)| format!("{}-{}:{}", start, end, record.strand))
12309        .collect::<Vec<_>>()
12310        .join(",")
12311}
12312
12313/// Escapes double quotes in GTF attribute values.
12314fn gtf_escape(value: &str) -> String {
12315    value.replace('"', "\\\"")
12316}
12317
12318/// Counts FASTA records emitted from selected major isoforms.
12319#[derive(Debug, Clone, Copy, Default)]
12320struct MajorIsoformFastaSummary {
12321    isoforms: usize,
12322    circ_rnas: usize,
12323}
12324
12325/// Writes reference-derived FASTA sequences for selected major isoforms.
12326fn write_major_isoform_fasta(
12327    path: &str,
12328    records: &[MajorIsoformRecord],
12329    reference: &HashMap<String, String>,
12330) -> Result<MajorIsoformFastaSummary> {
12331    let mut writer = BufWriter::new(File::create(path)?);
12332    let mut summary = MajorIsoformFastaSummary::default();
12333    let mut circ_ids = HashSet::new();
12334    for record in records {
12335        if !major_isoform_should_emit_fasta(record) {
12336            continue;
12337        }
12338        let seq = major_isoform_sequence(record, reference)?;
12339        let cirexon = major_cirexon_text(record);
12340        let evidence = major_evidence_text(record);
12341        writeln!(
12342            writer,
12343            ">{} circRNA_id={} sample_id={} type={} evidence={} len={} cirexon={}",
12344            record.isoform_id,
12345            record.circ_id,
12346            record.sample_id,
12347            record.isoform_origin,
12348            evidence,
12349            record.isoform_len,
12350            cirexon
12351        )?;
12352        for chunk in seq.as_bytes().chunks(80) {
12353            writer.write_all(chunk)?;
12354            writer.write_all(b"\n")?;
12355        }
12356        summary.isoforms += 1;
12357        circ_ids.insert(record.circ_id.as_str());
12358    }
12359    writer.flush()?;
12360    summary.circ_rnas = circ_ids.len();
12361    Ok(summary)
12362}
12363
12364/// Returns whether a major isoform is reliable enough for sequence FASTA output.
12365///
12366/// The GTF remains the complete audit table, including unresolved estimates.
12367/// FASTA is stricter because unresolved placeholders become misleading sequence
12368/// records. Mature isoforms are always emitted; estimates need enough direct
12369/// segment coverage, and unphased candidates are admitted only when weighted
12370/// segment coverage is high enough to make the sequence-ready subset strict.
12371fn major_isoform_should_emit_fasta(record: &MajorIsoformRecord) -> bool {
12372    if record.isoform_origin == "mature" {
12373        return true;
12374    }
12375    if record.isoform_origin != "estimate" {
12376        return false;
12377    }
12378    if record
12379        .estimate_reason
12380        .contains("low_segment_coverage_unannotated_long_exon")
12381    {
12382        return false;
12383    }
12384    if record.segment_coverage_pct >= MAJOR_MIN_CANDIDATE_SEGMENT_COVERAGE_PCT {
12385        return true;
12386    }
12387    if record.estimate_reason.contains("unresolved_long_block") {
12388        return false;
12389    }
12390    if record.estimate_reason.contains("unphased_junction_chain") {
12391        return major_isoform_is_trusted_unphased_candidate(record);
12392    }
12393    if record
12394        .estimate_reason
12395        .contains("unphased_single_exon_block")
12396    {
12397        return false;
12398    }
12399    if record.segment_coverage_pct < MAJOR_MIN_TRUSTED_ESTIMATE_SEGMENT_COVERAGE_PCT {
12400        return false;
12401    }
12402    true
12403}
12404
12405/// Returns whether an unphased estimate is reliable enough for FASTA.
12406///
12407/// The candidate still carries an internal unphased reason for filtering, but
12408/// the public FASTA header stays compact; only high-coverage unphased estimates
12409/// enter the sequence-ready subset.
12410fn major_isoform_is_trusted_unphased_candidate(record: &MajorIsoformRecord) -> bool {
12411    !record.estimate_reason.contains("unresolved_long_block")
12412        && !record
12413            .estimate_reason
12414            .contains("unphased_single_exon_block")
12415        && !record
12416            .estimate_reason
12417            .contains("low_segment_coverage_unannotated_long_exon")
12418        && record.segment_coverage_pct >= MAJOR_MIN_CANDIDATE_SEGMENT_COVERAGE_PCT
12419}
12420
12421/// Extracts an isoform sequence in transcript orientation.
12422fn major_isoform_sequence(
12423    record: &MajorIsoformRecord,
12424    reference: &HashMap<String, String>,
12425) -> Result<String> {
12426    let chr_seq = reference
12427        .get(&record.chr)
12428        .ok_or_else(|| anyhow!("missing reference sequence for {}", record.chr))?;
12429    let mut seq = String::with_capacity(record.isoform_len.max(0) as usize);
12430    for &(start, end) in &record.exons {
12431        if start < 1 || end < start || end as usize > chr_seq.len() {
12432            bail!(
12433                "invalid isoform exon coordinate {}:{}-{} for {}",
12434                record.chr,
12435                start,
12436                end,
12437                record.isoform_id
12438            );
12439        }
12440        seq.push_str(&chr_seq[(start - 1) as usize..end as usize]);
12441    }
12442    if record.strand == '-' {
12443        Ok(reverse_complement(&seq))
12444    } else {
12445        Ok(seq)
12446    }
12447}
12448
12449/// Returns the 0-based mate bucket used by `<prefix>.segments`.
12450fn mate_bucket(flag: i32) -> usize {
12451    if flag & 0x40 != 0 {
12452        0
12453    } else {
12454        1
12455    }
12456}
12457
12458/// Stable sidecar-order key for raw mapper alignments.
12459///
12460/// The final chain builder still performs biological/topology ranking; this key
12461/// only makes de-duplicated sidecar input deterministic across Scan1/Scan2 shard
12462/// merge order without preferring secondary alignments ahead of primary ones.
12463fn as_alignment_order_key(record: &AsAlignment) -> (usize, usize, i32) {
12464    (
12465        usize::from(is_secondary(record.flag)),
12466        usize::from(is_supplementary(record.flag)),
12467        -record.mapq,
12468    )
12469}
12470
12471/// Returns whether one SAM flag represents a secondary alignment.
12472fn is_secondary(flag: i32) -> bool {
12473    flag & 0x100 != 0
12474}
12475
12476/// Returns whether one SAM flag represents a supplementary alignment.
12477fn is_supplementary(flag: i32) -> bool {
12478    flag & 0x800 != 0
12479}
12480
12481/// Returns the alignment strand as a user-facing `+` / `-`.
12482fn strand_char(flag: i32) -> char {
12483    if flag & 0x10 != 0 {
12484        '-'
12485    } else {
12486        '+'
12487    }
12488}
12489
12490#[cfg(test)]
12491mod tests {
12492    use super::*;
12493
12494    fn test_scan_state<'a>(reference: &'a HashMap<String, String>, read_len: i32) -> ScanState<'a> {
12495        ScanState {
12496            circ_by_id: HashMap::new(),
12497            junction_read_to_circ: HashMap::new(),
12498            mate_bsj_evidence: HashMap::new(),
12499            reference,
12500            annotation: None,
12501            circ_spans_by_chr: HashMap::new(),
12502            clusters_by_chr: HashMap::new(),
12503            read_len,
12504            min_mapq: 10,
12505            candidates: Vec::new(),
12506            outward_read_ids: HashSet::new(),
12507            prebuilt_segment_records: Vec::new(),
12508            segment_groups: HashMap::new(),
12509        }
12510    }
12511
12512    #[test]
12513    fn annotation_breaks_multi_motif_offset_ties() {
12514        let mut annotation = Annotation::new();
12515        annotation
12516            .chr_exon_start_map
12517            .insert("chr1\t149094535".to_string(), "GENE1\t-".to_string());
12518        annotation
12519            .chr_exon_end_map
12520            .insert("chr1\t16901187".to_string(), "GENE1\t-".to_string());
12521        let candidate = PositiveCandidate {
12522            read_id: "simulate:4875198".to_string(),
12523            index: 0,
12524            chr: "chr1".to_string(),
12525            site1: 149094532,
12526            site2: 16901184,
12527            adjust1: -2,
12528            adjust2: -2,
12529            strand_hint: 0,
12530            cigars: [None, None, None],
12531        };
12532
12533        let (hint, index) = index_compare("GTACTCAC", "ATCTCCCT", &candidate, Some(&annotation));
12534
12535        assert_eq!(hint, 1);
12536        assert_eq!(index, 6);
12537    }
12538
12539    #[test]
12540    fn motif_tie_falls_back_to_lowest_offset_without_annotation() {
12541        let candidate = PositiveCandidate {
12542            read_id: "read".to_string(),
12543            index: 0,
12544            chr: "chr1".to_string(),
12545            site1: 149094532,
12546            site2: 16901184,
12547            adjust1: -2,
12548            adjust2: -2,
12549            strand_hint: 0,
12550            cigars: [None, None, None],
12551        };
12552
12553        let (hint, index) = index_compare("GTACTCAC", "ATCTCCCT", &candidate, None);
12554
12555        assert_eq!(hint, 1);
12556        assert_eq!(index, 2);
12557    }
12558
12559    #[test]
12560    fn annotation_can_choose_positive_strand_over_negative_branch() {
12561        let mut annotation = Annotation::new();
12562        annotation
12563            .chr_exon_start_map
12564            .insert("chr1\t101".to_string(), "GENE1\t+".to_string());
12565        annotation
12566            .chr_exon_end_map
12567            .insert("chr1\t201".to_string(), "GENE1\t+".to_string());
12568        let candidate = PositiveCandidate {
12569            read_id: "read".to_string(),
12570            index: 0,
12571            chr: "chr1".to_string(),
12572            site1: 100,
12573            site2: 200,
12574            adjust1: 0,
12575            adjust2: 0,
12576            strand_hint: 0,
12577            cigars: [None, None, None],
12578        };
12579
12580        let (hint, index) = index_compare("ACAG", "CTGT", &candidate, Some(&annotation));
12581
12582        assert_eq!(hint, -1);
12583        assert_eq!(index, 2);
12584    }
12585
12586    #[test]
12587    fn reverse_alignment_blocks_are_flipped_into_read_order() {
12588        let record = AsAlignment {
12589            flag: 0x10,
12590            chr: "chr1".to_string(),
12591            pos: 100,
12592            mapq: 60,
12593            cigar: "10S90M".to_string(),
12594            seq: "A".repeat(100),
12595            cs: "*".to_string(),
12596            from_local_clip: false,
12597            xa_alternatives: Vec::new(),
12598        };
12599
12600        let blocks = parse_alignment_blocks(&record, 100).unwrap();
12601
12602        assert_eq!(blocks.len(), 1);
12603        assert_eq!(blocks[0].read_start, 1);
12604        assert_eq!(blocks[0].read_end, 90);
12605        assert_eq!(blocks[0].ref_start, 100);
12606        assert_eq!(blocks[0].ref_end, 189);
12607    }
12608
12609    #[test]
12610    fn backward_segments_keep_non_bsj_circular_order() {
12611        let records = vec![
12612            AsAlignment {
12613                flag: 0x40,
12614                chr: "chr1".to_string(),
12615                pos: 200,
12616                mapq: 60,
12617                cigar: "50M50S".to_string(),
12618                seq: "A".repeat(100),
12619                cs: "*".to_string(),
12620                from_local_clip: false,
12621                xa_alternatives: Vec::new(),
12622            },
12623            AsAlignment {
12624                flag: 0x40 | 0x800,
12625                chr: "chr1".to_string(),
12626                pos: 100,
12627                mapq: 60,
12628                cigar: "50S50M".to_string(),
12629                seq: "A".repeat(100),
12630                cs: "*".to_string(),
12631                from_local_clip: false,
12632                xa_alternatives: Vec::new(),
12633            },
12634        ];
12635        let record =
12636            build_backward_segment_record("read1", &records, None, &[], None, 100).unwrap();
12637
12638        assert_eq!(record.type_name, "backward");
12639        assert_eq!(record.circ_id, "NA");
12640        assert_eq!(record.chrom, "chr1");
12641        assert_eq!(record.start, "100");
12642        assert_eq!(record.end, "249");
12643        assert_eq!(record.strand, "NA");
12644        assert_eq!(record.is_circular, 1);
12645        assert_eq!(record.r1_align_strand, "+");
12646        assert_eq!(record.r2_align_strand, "NA");
12647        assert_eq!(record.r1_segments, "200-249:?|<bsj>|100-149:?");
12648        assert_eq!(record.r1_cigar, "50M50B50M");
12649        assert_eq!(record.is_r1_bsj, 0);
12650    }
12651
12652    #[test]
12653    fn outward_pair_groups_emit_linear_pair_support_without_b_marker() {
12654        let records = vec![
12655            AsAlignment {
12656                flag: 0x40 | 0x10,
12657                chr: "chr1".to_string(),
12658                pos: 100,
12659                mapq: 60,
12660                cigar: "1S150M21S".to_string(),
12661                seq: "A".repeat(100),
12662                cs: "*".to_string(),
12663                from_local_clip: false,
12664                xa_alternatives: Vec::new(),
12665            },
12666            AsAlignment {
12667                flag: 0x80,
12668                chr: "chr1".to_string(),
12669                pos: 200,
12670                mapq: 60,
12671                cigar: "1S50M100N50M21S".to_string(),
12672                seq: "A".repeat(100),
12673                cs: "*".to_string(),
12674                from_local_clip: false,
12675                xa_alternatives: Vec::new(),
12676            },
12677        ];
12678        let reference = HashMap::new();
12679        let state = ScanState {
12680            circ_by_id: HashMap::new(),
12681            junction_read_to_circ: HashMap::new(),
12682            mate_bsj_evidence: HashMap::new(),
12683            reference: &reference,
12684            annotation: None,
12685            circ_spans_by_chr: {
12686                let mut map = HashMap::new();
12687                map.insert(
12688                    "chr1".to_string(),
12689                    vec![CircSpan {
12690                        start: 90,
12691                        end: 410,
12692                        max_end_through: 410,
12693                    }],
12694                );
12695                map
12696            },
12697            clusters_by_chr: HashMap::new(),
12698            read_len: 100,
12699            min_mapq: 10,
12700            candidates: Vec::new(),
12701            outward_read_ids: HashSet::new(),
12702            prebuilt_segment_records: Vec::new(),
12703            segment_groups: HashMap::new(),
12704        };
12705
12706        assert!(is_outward_pair_group(&records, &state));
12707        let record = build_outward_segment_record("read1", &records, None, 100, 10).unwrap();
12708
12709        assert_eq!(record.type_name, "outward");
12710        assert_eq!(record.circ_id, "NA");
12711        assert_eq!(record.chrom, "chr1");
12712        assert_eq!(record.start, "100");
12713        assert_eq!(record.end, "399");
12714        assert_eq!(record.is_circular, 1);
12715        assert_eq!(record.is_r1_bsj, 0);
12716        assert_eq!(record.is_r2_bsj, 0);
12717        assert_eq!(record.r1_align_strand, "-");
12718        assert_eq!(record.r2_align_strand, "+");
12719        assert_eq!(record.r1_cigar, "150M1S");
12720        assert_eq!(record.r1_segments, "100-249:?");
12721        assert_eq!(record.r2_cigar, "50M100N50M1S");
12722        assert_eq!(record.r2_segments, "350-399:?|200-249:?");
12723        assert!(!record.r1_segments.contains("<bsj>"));
12724        assert!(!record.r2_segments.contains("<bsj>"));
12725        assert!(!record.r1_cigar.contains('B'));
12726        assert!(!record.r2_cigar.contains('B'));
12727    }
12728
12729    #[test]
12730    fn outward_segments_infer_strand_from_internal_annotation() {
12731        let records = vec![
12732            AsAlignment {
12733                flag: 0x40 | 0x10,
12734                chr: "chr1".to_string(),
12735                pos: 100,
12736                mapq: 60,
12737                cigar: "1S150M21S".to_string(),
12738                seq: "A".repeat(100),
12739                cs: "*".to_string(),
12740                from_local_clip: false,
12741                xa_alternatives: Vec::new(),
12742            },
12743            AsAlignment {
12744                flag: 0x80,
12745                chr: "chr1".to_string(),
12746                pos: 200,
12747                mapq: 60,
12748                cigar: "1S50M100N50M21S".to_string(),
12749                seq: "A".repeat(100),
12750                cs: "*".to_string(),
12751                from_local_clip: false,
12752                xa_alternatives: Vec::new(),
12753            },
12754        ];
12755        let reference = HashMap::new();
12756        let mut annotation = Annotation::new();
12757        annotation
12758            .chr_exon_end_index
12759            .entry("chr1".to_string())
12760            .or_default()
12761            .insert(249, ("GENE1".to_string(), '+'));
12762        annotation
12763            .chr_exon_start_index
12764            .entry("chr1".to_string())
12765            .or_default()
12766            .insert(350, ("GENE1".to_string(), '+'));
12767        annotation
12768            .transcript_splice_index
12769            .entry("chr1".to_string())
12770            .or_default()
12771            .entry('+')
12772            .or_default()
12773            .insert((249, 350));
12774        let correction = SegmentCorrectionContext {
12775            reference: &reference,
12776            annotation: Some(&annotation),
12777            junction_support: None,
12778        };
12779
12780        let record = build_outward_segment_record("read1", &records, Some(&correction), 100, 10)
12781            .expect("annotation-supported outward row");
12782
12783        assert_eq!(record.type_name, "outward");
12784        assert_eq!(record.strand, "+");
12785        assert_eq!(record.r1_align_strand, "-");
12786        assert_eq!(record.r2_align_strand, "+");
12787        assert_eq!(record.r1_segments, "100-249:+");
12788        assert_eq!(record.r2_segments, "350-399:+|200-249:+");
12789        assert!(!record.r2_segments.contains("<bsj>"));
12790        assert!(!record.r2_cigar.contains('B'));
12791    }
12792
12793    #[test]
12794    fn outward_pair_groups_reject_contained_primary_overlap() {
12795        let records = vec![
12796            AsAlignment {
12797                flag: 0x40 | 0x10,
12798                chr: "chr1".to_string(),
12799                pos: 100,
12800                mapq: 60,
12801                cigar: "150M".to_string(),
12802                seq: "A".repeat(100),
12803                cs: "*".to_string(),
12804                from_local_clip: false,
12805                xa_alternatives: Vec::new(),
12806            },
12807            AsAlignment {
12808                flag: 0x80,
12809                chr: "chr1".to_string(),
12810                pos: 100,
12811                mapq: 60,
12812                cigar: "100M".to_string(),
12813                seq: "A".repeat(100),
12814                cs: "*".to_string(),
12815                from_local_clip: false,
12816                xa_alternatives: Vec::new(),
12817            },
12818        ];
12819        let reference = HashMap::new();
12820        let state = ScanState {
12821            circ_by_id: HashMap::new(),
12822            junction_read_to_circ: HashMap::new(),
12823            mate_bsj_evidence: HashMap::new(),
12824            reference: &reference,
12825            annotation: None,
12826            circ_spans_by_chr: {
12827                let mut map = HashMap::new();
12828                map.insert(
12829                    "chr1".to_string(),
12830                    vec![CircSpan {
12831                        start: 90,
12832                        end: 260,
12833                        max_end_through: 260,
12834                    }],
12835                );
12836                map
12837            },
12838            clusters_by_chr: HashMap::new(),
12839            read_len: 100,
12840            min_mapq: 10,
12841            candidates: Vec::new(),
12842            outward_read_ids: HashSet::new(),
12843            prebuilt_segment_records: Vec::new(),
12844            segment_groups: HashMap::new(),
12845        };
12846
12847        assert!(!is_outward_pair_group(&records, &state));
12848    }
12849
12850    #[test]
12851    fn outward_pair_groups_reject_linear_alternative_pair() {
12852        let records = vec![
12853            AsAlignment {
12854                flag: 0x40 | 0x10,
12855                chr: "chr1".to_string(),
12856                pos: 100,
12857                mapq: 60,
12858                cigar: "1S100M21S".to_string(),
12859                seq: "A".repeat(122),
12860                cs: "*".to_string(),
12861                from_local_clip: false,
12862                xa_alternatives: Vec::new(),
12863            },
12864            AsAlignment {
12865                flag: 0x80,
12866                chr: "chr1".to_string(),
12867                pos: 250,
12868                mapq: 60,
12869                cigar: "1S100M21S".to_string(),
12870                seq: "A".repeat(122),
12871                cs: "*".to_string(),
12872                from_local_clip: false,
12873                xa_alternatives: Vec::new(),
12874            },
12875            AsAlignment {
12876                flag: 0x40 | 0x100,
12877                chr: "chr1".to_string(),
12878                pos: 1000,
12879                mapq: 60,
12880                cigar: "100M".to_string(),
12881                seq: "A".repeat(100),
12882                cs: "*".to_string(),
12883                from_local_clip: false,
12884                xa_alternatives: Vec::new(),
12885            },
12886            AsAlignment {
12887                flag: 0x80 | 0x10 | 0x100,
12888                chr: "chr1".to_string(),
12889                pos: 1120,
12890                mapq: 60,
12891                cigar: "100M".to_string(),
12892                seq: "A".repeat(100),
12893                cs: "*".to_string(),
12894                from_local_clip: false,
12895                xa_alternatives: Vec::new(),
12896            },
12897        ];
12898        let reference = HashMap::new();
12899        let state = test_scan_state(&reference, 122);
12900
12901        assert!(!is_outward_pair_group(&records, &state));
12902    }
12903
12904    #[test]
12905    fn outward_pair_groups_use_main_mapq_threshold() {
12906        let records = vec![
12907            AsAlignment {
12908                flag: 0x40 | 0x10,
12909                chr: "chr1".to_string(),
12910                pos: 100,
12911                mapq: 9,
12912                cigar: "1S100M21S".to_string(),
12913                seq: "A".repeat(122),
12914                cs: "*".to_string(),
12915                from_local_clip: false,
12916                xa_alternatives: Vec::new(),
12917            },
12918            AsAlignment {
12919                flag: 0x80,
12920                chr: "chr1".to_string(),
12921                pos: 250,
12922                mapq: 60,
12923                cigar: "1S100M21S".to_string(),
12924                seq: "A".repeat(122),
12925                cs: "*".to_string(),
12926                from_local_clip: false,
12927                xa_alternatives: Vec::new(),
12928            },
12929        ];
12930        let reference = HashMap::new();
12931        let state = test_scan_state(&reference, 122);
12932
12933        assert!(!is_outward_pair_group(&records, &state));
12934    }
12935
12936    #[test]
12937    fn outward_geometry_accepts_unclipped_gap_without_length_cap() {
12938        let reverse = AsAlignment {
12939            flag: 0x40 | 0x10,
12940            chr: "chr1".to_string(),
12941            pos: 100,
12942            mapq: 60,
12943            cigar: "51M".to_string(),
12944            seq: "A".repeat(51),
12945            cs: "*".to_string(),
12946            from_local_clip: false,
12947            xa_alternatives: Vec::new(),
12948        };
12949        let forward = AsAlignment {
12950            flag: 0x80,
12951            chr: "chr1".to_string(),
12952            pos: 1000,
12953            mapq: 60,
12954            cigar: "101M".to_string(),
12955            seq: "A".repeat(101),
12956            cs: "*".to_string(),
12957            from_local_clip: false,
12958            xa_alternatives: Vec::new(),
12959        };
12960
12961        assert!(has_3p_outward_pair_geometry(
12962            &reverse,
12963            (100, 150),
12964            &forward,
12965            (1000, 1100)
12966        ));
12967    }
12968
12969    #[test]
12970    fn outward_geometry_rejects_short_unclipped_outward_length() {
12971        let reverse = AsAlignment {
12972            flag: 0x40 | 0x10,
12973            chr: "chr1".to_string(),
12974            pos: 100,
12975            mapq: 60,
12976            cigar: "51M".to_string(),
12977            seq: "A".repeat(51),
12978            cs: "*".to_string(),
12979            from_local_clip: false,
12980            xa_alternatives: Vec::new(),
12981        };
12982        let forward = AsAlignment {
12983            flag: 0x80,
12984            chr: "chr1".to_string(),
12985            pos: 115,
12986            mapq: 60,
12987            cigar: "51M".to_string(),
12988            seq: "A".repeat(51),
12989            cs: "*".to_string(),
12990            from_local_clip: false,
12991            xa_alternatives: Vec::new(),
12992        };
12993
12994        assert!(!has_3p_outward_pair_geometry(
12995            &reverse,
12996            (100, 150),
12997            &forward,
12998            (115, 165)
12999        ));
13000    }
13001
13002    #[test]
13003    fn outward_geometry_rejects_identical_unclipped_span() {
13004        let reverse = AsAlignment {
13005            flag: 0x40 | 0x10,
13006            chr: "chr1".to_string(),
13007            pos: 100,
13008            mapq: 60,
13009            cigar: "21M".to_string(),
13010            seq: "A".repeat(151),
13011            cs: "*".to_string(),
13012            from_local_clip: false,
13013            xa_alternatives: Vec::new(),
13014        };
13015        let forward = AsAlignment {
13016            flag: 0x80,
13017            chr: "chr1".to_string(),
13018            pos: 100,
13019            mapq: 60,
13020            cigar: "21M".to_string(),
13021            seq: "A".repeat(151),
13022            cs: "*".to_string(),
13023            from_local_clip: false,
13024            xa_alternatives: Vec::new(),
13025        };
13026
13027        assert!(!has_3p_outward_pair_geometry(
13028            &reverse,
13029            (100, 120),
13030            &forward,
13031            (100, 120)
13032        ));
13033    }
13034
13035    #[test]
13036    fn outward_geometry_rejects_mate_5p_clip_overlap_tail() {
13037        let reverse = AsAlignment {
13038            flag: 0x40 | 0x10,
13039            chr: "chr1".to_string(),
13040            pos: 100,
13041            mapq: 60,
13042            cigar: "15S51M30S".to_string(),
13043            seq: "A".repeat(96),
13044            cs: "*".to_string(),
13045            from_local_clip: false,
13046            xa_alternatives: Vec::new(),
13047        };
13048        let forward = AsAlignment {
13049            flag: 0x80,
13050            chr: "chr1".to_string(),
13051            pos: 200,
13052            mapq: 60,
13053            cigar: "15S51M30S".to_string(),
13054            seq: "A".repeat(96),
13055            cs: "*".to_string(),
13056            from_local_clip: false,
13057            xa_alternatives: Vec::new(),
13058        };
13059
13060        assert!(!has_3p_outward_pair_geometry(
13061            &reverse,
13062            (100, 150),
13063            &forward,
13064            (100, 150)
13065        ));
13066    }
13067
13068    #[test]
13069    fn outward_geometry_keeps_identical_span_with_3p_clips() {
13070        let reverse = AsAlignment {
13071            flag: 0x40 | 0x10,
13072            chr: "chr1".to_string(),
13073            pos: 100,
13074            mapq: 60,
13075            cigar: "109S21M21S".to_string(),
13076            seq: "A".repeat(151),
13077            cs: "*".to_string(),
13078            from_local_clip: false,
13079            xa_alternatives: Vec::new(),
13080        };
13081        let forward = AsAlignment {
13082            flag: 0x80,
13083            chr: "chr1".to_string(),
13084            pos: 100,
13085            mapq: 60,
13086            cigar: "1S21M129S".to_string(),
13087            seq: "A".repeat(151),
13088            cs: "*".to_string(),
13089            from_local_clip: false,
13090            xa_alternatives: Vec::new(),
13091        };
13092
13093        assert!(has_3p_outward_pair_geometry(
13094            &reverse,
13095            (100, 120),
13096            &forward,
13097            (100, 120)
13098        ));
13099    }
13100
13101    #[test]
13102    fn major_isoform_sequence_reverse_complements_full_chain() {
13103        let mut reference = HashMap::new();
13104        reference.insert("chrT".to_string(), "ACCGTTTAGGCC".to_string());
13105        let record = MajorIsoformRecord {
13106            circ_id: "chrT:1|12".to_string(),
13107            isoform_id: "chrT:1|12.iso1".to_string(),
13108            sample_id: "sample".to_string(),
13109            chr: "chrT".to_string(),
13110            start: 1,
13111            end: 12,
13112            strand: '-',
13113            source_gene_id: "gene".to_string(),
13114            exons: vec![(1, 4), (9, 12)],
13115            cov: 1.0,
13116            segment_coverage_pct: 100.0,
13117            path_score: 1.0,
13118            bsj_reads: 1,
13119            isoform_len: 8,
13120            isoform_rank: 1,
13121            isoform_class: "major".to_string(),
13122            isoform_origin: "mature".to_string(),
13123            estimate_reason: "none".to_string(),
13124        };
13125
13126        assert_eq!(
13127            major_isoform_sequence(&record, &reference).unwrap(),
13128            "GGCCCGGT"
13129        );
13130    }
13131
13132    #[test]
13133    fn major_isoform_fasta_skips_obvious_unresolved_estimates() {
13134        let mature = MajorIsoformRecord {
13135            circ_id: "chrT:1|12".to_string(),
13136            isoform_id: "chrT:1|12.iso1".to_string(),
13137            sample_id: "sample".to_string(),
13138            chr: "chrT".to_string(),
13139            start: 1,
13140            end: 12,
13141            strand: '+',
13142            source_gene_id: "gene".to_string(),
13143            exons: vec![(1, 12)],
13144            cov: 1.0,
13145            segment_coverage_pct: 100.0,
13146            path_score: 1.0,
13147            bsj_reads: 1,
13148            isoform_len: 12,
13149            isoform_rank: 1,
13150            isoform_class: "major".to_string(),
13151            isoform_origin: "mature".to_string(),
13152            estimate_reason: "none".to_string(),
13153        };
13154        assert!(major_isoform_should_emit_fasta(&mature));
13155
13156        let mut usable_estimate = mature.clone();
13157        usable_estimate.isoform_origin = "estimate".to_string();
13158        usable_estimate.estimate_reason =
13159            "gtf_long_block_projection,inferred_internal_block".to_string();
13160        usable_estimate.exons = vec![(1, 6), (9, 12)];
13161        usable_estimate.isoform_len = 10;
13162        usable_estimate.segment_coverage_pct = 100.0;
13163        assert!(major_isoform_should_emit_fasta(&usable_estimate));
13164
13165        let mut weakly_covered = usable_estimate.clone();
13166        weakly_covered.segment_coverage_pct = 49.999;
13167        assert!(!major_isoform_should_emit_fasta(&weakly_covered));
13168
13169        let mut unphased_chain = usable_estimate.clone();
13170        unphased_chain.estimate_reason =
13171            "gtf_long_block_projection,unphased_junction_chain,inferred_internal_block".to_string();
13172        unphased_chain.segment_coverage_pct = 100.0;
13173        unphased_chain.isoform_len = 10_000;
13174        unphased_chain.bsj_reads = 1;
13175        assert!(major_isoform_should_emit_fasta(&unphased_chain));
13176
13177        let mut weak_unphased_chain = unphased_chain.clone();
13178        weak_unphased_chain.segment_coverage_pct = 89.999;
13179        assert!(!major_isoform_should_emit_fasta(&weak_unphased_chain));
13180
13181        let mut unresolved_unphased_chain = unphased_chain;
13182        unresolved_unphased_chain.estimate_reason =
13183            "unresolved_long_block,unphased_junction_chain".to_string();
13184        assert!(major_isoform_should_emit_fasta(&unresolved_unphased_chain));
13185
13186        let mut unresolved = usable_estimate.clone();
13187        unresolved.estimate_reason = "unresolved_long_block,unphased_junction_chain".to_string();
13188        unresolved.segment_coverage_pct = 89.999;
13189        assert!(!major_isoform_should_emit_fasta(&unresolved));
13190
13191        let mut unphased_single = usable_estimate.clone();
13192        unphased_single.exons = vec![(1, 100)];
13193        unphased_single.isoform_len = 100;
13194        unphased_single.estimate_reason = "unphased_single_exon_block".to_string();
13195        unphased_single.segment_coverage_pct = 89.999;
13196        assert!(!major_isoform_should_emit_fasta(&unphased_single));
13197
13198        let mut covered_unphased_single = unphased_single.clone();
13199        covered_unphased_single.segment_coverage_pct = 98.0;
13200        assert!(major_isoform_should_emit_fasta(&covered_unphased_single));
13201
13202        let mut long_annotation_guided = usable_estimate;
13203        long_annotation_guided.exons = vec![(1, 6000), (7000, 12050)];
13204        long_annotation_guided.isoform_len = 11051;
13205        long_annotation_guided.estimate_reason =
13206            "gtf_long_block_projection,inferred_internal_block".to_string();
13207        assert!(major_isoform_should_emit_fasta(&long_annotation_guided));
13208
13209        let mut weak_unannotated_long = long_annotation_guided;
13210        weak_unannotated_long.estimate_reason =
13211            "low_segment_coverage_unannotated_long_exon".to_string();
13212        assert!(!major_isoform_should_emit_fasta(&weak_unannotated_long));
13213    }
13214
13215    #[test]
13216    fn major_isoforms_are_rebuilt_from_segments_file() {
13217        let tmp_prefix = std::env::temp_dir().join(format!(
13218            "ciri_major_isoform_segments_{}",
13219            std::process::id()
13220        ));
13221        let segments_path = tmp_prefix.with_extension("segments");
13222        let out_prefix = tmp_prefix.to_string_lossy().to_string();
13223        std::fs::write(
13224            &segments_path,
13225            concat!(
13226                "read_id\ttype\tcirc_id\tchrom\tstart\tend\tstrand\tis_circular\tis_r1_bsj\tis_r2_bsj\tr1_align_strand\tr2_align_strand\tr1_cigar\tr1_segments\tr2_cigar\tr2_segments\n",
13227                "read1\tbsj\tchrT:100|300\tchrT\t100\t300\t+\t1\t1\t0\t+\tNA\t21M49N21M\t280-300:+|<bsj>|100-150:+|200-220:+\tNA\tNA\n",
13228                "read2\tbsj\tchrT:100|300\tchrT\t100\t300\t+\t1\t1\t0\t+\tNA\t31M49N101M\t120-150:+|200-300:+|<bsj>|100-110:+\tNA\tNA\n",
13229                "read3\tbsj\tchrT:100|300\tchrT\t100\t300\t+\t1\t1\t0\t+\tNA\t21M49N21M\t280-300:+|<bsj>|100-150:+|200-220:+\tNA\tNA\n",
13230            ),
13231        )
13232        .unwrap();
13233        let circ = CircRecord {
13234            id: "chrT:100|300".to_string(),
13235            chr: "chrT".to_string(),
13236            start: 100,
13237            end: 300,
13238            junction_read_count: "3".to_string(),
13239            gene_id: "geneT".to_string(),
13240            strand: "+".to_string(),
13241        };
13242        let mut reference = HashMap::new();
13243        reference.insert("chrT".to_string(), "ACGT".repeat(100));
13244
13245        let summary = build_major_isoforms_from_segments_file(
13246            &[circ],
13247            segments_path.to_str().unwrap(),
13248            &out_prefix,
13249            &reference,
13250            None,
13251        )
13252        .unwrap();
13253
13254        assert_eq!(summary.total_isoforms, 1);
13255        assert_eq!(summary.circ_rnas, 1);
13256        assert_eq!(summary.fasta_isoforms, 1);
13257        assert_eq!(summary.fasta_circ_rnas, 1);
13258        let gtf = std::fs::read_to_string(format!("{}.isoforms.gtf", out_prefix)).unwrap();
13259        assert!(gtf.contains("\tCIRI\tcircRNA\t"));
13260        let legacy_source = format!("{}-{}", "CIRI", "rs");
13261        assert!(!gtf.contains(&legacy_source));
13262        assert!(gtf.contains("\texon\t100\t150\t"));
13263        assert!(gtf.contains("\texon\t200\t300\t"));
13264        assert!(gtf.contains("type \"mature\";"));
13265        assert!(gtf.contains("evidence \"phased_junction\";"));
13266        assert!(gtf.contains("weakness \"3.000\";"));
13267        assert!(gtf.contains("score \"100.000\";"));
13268        assert!(gtf.contains("weight \"3.000\";"));
13269        for removed_key in [
13270            "circRNA_id \"",
13271            "isoform_id \"",
13272            "sample_id \"",
13273            "isoform_rank \"",
13274            "isoform_class \"",
13275            "estimate_reason \"",
13276            "path_method \"",
13277            "cov \"",
13278            "segment_coverage_pct \"",
13279            "path_score \"",
13280            "structure_hash \"",
13281        ] {
13282            assert!(
13283                !gtf.contains(removed_key),
13284                "{removed_key} should not be in GTF"
13285            );
13286        }
13287        let fasta = std::fs::read_to_string(format!("{}.isoforms.fa", out_prefix)).unwrap();
13288        assert!(fasta.contains(">chrT:100|300.iso1 "));
13289        assert!(fasta.contains("type=mature"));
13290        assert!(fasta.contains("evidence=phased_junction"));
13291        assert!(fasta.contains("len=152"));
13292        assert!(fasta.contains("cirexon=100-150:+,200-300:+"));
13293        assert!(!fasta.contains("structure_hash="));
13294        assert!(!fasta.contains("estimate_reason="));
13295        assert!(!fasta.contains("segment_coverage_pct="));
13296        let _ = std::fs::remove_file(&segments_path);
13297        let _ = std::fs::remove_file(format!("{}.isoforms.gtf", out_prefix));
13298        let _ = std::fs::remove_file(format!("{}.isoforms.fa", out_prefix));
13299    }
13300
13301    #[test]
13302    fn cohort_assembly_reports_switching_major_isoforms() {
13303        let tmp_dir =
13304            std::env::temp_dir().join(format!("ciri_cohort_assemble_{}", std::process::id()));
13305        std::fs::create_dir_all(&tmp_dir).unwrap();
13306        let s1_prefix = tmp_dir.join("sample1").to_string_lossy().to_string();
13307        let s2_prefix = tmp_dir.join("sample2").to_string_lossy().to_string();
13308        let out_prefix = tmp_dir.join("merged").to_string_lossy().to_string();
13309        let out_header = "circRNA_ID\tchr\tcircRNA_start\tcircRNA_end\t#junction_reads\tSM_MS_SMS\t#non_junction_reads\tjunction_reads_ratio\tcircRNA_type\tgene_id\tstrand\tjunction_reads_ID\tScore\n";
13310        std::fs::write(
13311            format!("{}.out", s1_prefix),
13312            format!(
13313                "{}chrT:100|300\tchrT\t100\t300\t5\t5_0_0\t0\t0.80\texon\tgeneT\t+\ts1r1,s1r2,s1r3,s1r4,s1r5\t5\n",
13314                out_header
13315            ),
13316        )
13317        .unwrap();
13318        std::fs::write(
13319            format!("{}.out", s2_prefix),
13320            format!(
13321                "{}chrT:100|300\tchrT\t100\t300\t5\t5_0_0\t0\t0.70\texon\tgeneT\t+\ts2r1,s2r2,s2r3,s2r4,s2r5\t5\n",
13322                out_header
13323            ),
13324        )
13325        .unwrap();
13326        std::fs::write(
13327            format!("{}.segments", s1_prefix),
13328            concat!(
13329                "read_id\ttype\tcirc_id\tchrom\tstart\tend\tstrand\tis_circular\tis_r1_bsj\tis_r2_bsj\tr1_align_strand\tr2_align_strand\tr1_cigar\tr1_segments\tr2_cigar\tr2_segments\n",
13330                "s1r1\tbsj\tchrT:100|300\tchrT\t100\t300\t+\t1\t1\t0\t+\tNA\t21M49N21M\t280-300:+|<bsj>|100-150:+|200-220:+\tNA\tNA\n",
13331                "s1r2\tbsj\tchrT:100|300\tchrT\t100\t300\t+\t1\t1\t0\t+\tNA\t31M49N101M\t120-150:+|200-300:+|<bsj>|100-110:+\tNA\tNA\n",
13332                "s1r3\tbsj\tchrT:100|300\tchrT\t100\t300\t+\t1\t1\t0\t+\tNA\t21M49N21M\t280-300:+|<bsj>|100-150:+|200-220:+\tNA\tNA\n",
13333                "s1r4\tbsj\tchrT:100|300\tchrT\t100\t300\t+\t1\t1\t0\t+\tNA\t31M49N101M\t120-150:+|200-300:+|<bsj>|100-110:+\tNA\tNA\n",
13334                "s1r5\tbsj\tchrT:100|300\tchrT\t100\t300\t+\t1\t1\t0\t+\tNA\t31M49N101M\t120-150:+|200-300:+|<bsj>|100-110:+\tNA\tNA\n",
13335            ),
13336        )
13337        .unwrap();
13338        std::fs::write(
13339            format!("{}.segments", s2_prefix),
13340            concat!(
13341                "read_id\ttype\tcirc_id\tchrom\tstart\tend\tstrand\tis_circular\tis_r1_bsj\tis_r2_bsj\tr1_align_strand\tr2_align_strand\tr1_cigar\tr1_segments\tr2_cigar\tr2_segments\n",
13342                "s2r1\tbsj\tchrT:100|300\tchrT\t100\t300\t+\t1\t1\t0\t+\tNA\t21M69N21M\t280-300:+|<bsj>|100-170:+|240-260:+\tNA\tNA\n",
13343                "s2r2\tbsj\tchrT:100|300\tchrT\t100\t300\t+\t1\t1\t0\t+\tNA\t51M69N101M\t120-170:+|240-300:+|<bsj>|100-110:+\tNA\tNA\n",
13344                "s2r3\tbsj\tchrT:100|300\tchrT\t100\t300\t+\t1\t1\t0\t+\tNA\t21M69N21M\t280-300:+|<bsj>|100-170:+|240-260:+\tNA\tNA\n",
13345                "s2r4\tbsj\tchrT:100|300\tchrT\t100\t300\t+\t1\t1\t0\t+\tNA\t51M69N101M\t120-170:+|240-300:+|<bsj>|100-110:+\tNA\tNA\n",
13346                "s2r5\tbsj\tchrT:100|300\tchrT\t100\t300\t+\t1\t1\t0\t+\tNA\t51M69N101M\t120-170:+|240-300:+|<bsj>|100-110:+\tNA\tNA\n",
13347            ),
13348        )
13349        .unwrap();
13350        let mut reference = HashMap::new();
13351        reference.insert("chrT".to_string(), "ACGT".repeat(100));
13352
13353        let summary = run_ciri_assemble(CohortAssembleConfig {
13354            samples: vec![
13355                CohortSampleInput {
13356                    sample_id: "sample1".to_string(),
13357                    prefix: s1_prefix.clone(),
13358                },
13359                CohortSampleInput {
13360                    sample_id: "sample2".to_string(),
13361                    prefix: s2_prefix.clone(),
13362                },
13363            ],
13364            out_prefix: &out_prefix,
13365            reference: &reference,
13366            annotation: None,
13367        })
13368        .unwrap();
13369
13370        assert_eq!(summary.samples, 2);
13371        assert_eq!(summary.circ_rnas, 1);
13372        assert_eq!(summary.switching_circ_rnas, 1);
13373        assert_eq!(summary.isoforms, 2);
13374        let bsj_matrix = std::fs::read_to_string(format!("{}.bsj.tsv", out_prefix)).unwrap();
13375        assert!(bsj_matrix.contains("circRNA_id\tsample1\tsample2\n"));
13376        assert!(bsj_matrix.contains("chrT:100|300\t5\t5\n"));
13377        let ratio_matrix = std::fs::read_to_string(format!("{}.ratio.tsv", out_prefix)).unwrap();
13378        assert!(ratio_matrix.contains("chrT:100|300\t0.800000\t0.700000\n"));
13379        let usage_matrix = std::fs::read_to_string(format!("{}.usage.tsv", out_prefix)).unwrap();
13380        assert!(usage_matrix.contains("isoform_id\tsample1\tsample2\n"));
13381        assert!(usage_matrix.contains("chrT:100|300.iso1"));
13382        assert!(usage_matrix.contains("chrT:100|300.iso2"));
13383        let gtf = std::fs::read_to_string(format!("{}.isoforms.gtf", out_prefix)).unwrap();
13384        assert!(gtf.contains("transcript_id \"chrT:100|300.iso1\";"));
13385        assert!(gtf.contains("transcript_id \"chrT:100|300.iso2\";"));
13386
13387        let _ = std::fs::remove_file(format!("{}.out", s1_prefix));
13388        let _ = std::fs::remove_file(format!("{}.segments", s1_prefix));
13389        let _ = std::fs::remove_file(format!("{}.out", s2_prefix));
13390        let _ = std::fs::remove_file(format!("{}.segments", s2_prefix));
13391        let _ = std::fs::remove_file(format!("{}.bsj.tsv", out_prefix));
13392        let _ = std::fs::remove_file(format!("{}.ratio.tsv", out_prefix));
13393        let _ = std::fs::remove_file(format!("{}.usage.tsv", out_prefix));
13394        let _ = std::fs::remove_file(format!("{}.isoforms.gtf", out_prefix));
13395        let _ = std::fs::remove_file(format!("{}.isoforms.fa", out_prefix));
13396        let _ = std::fs::remove_dir(&tmp_dir);
13397    }
13398
13399    #[test]
13400    fn cohort_switching_requires_large_usage_shift() {
13401        let circ = CircRecord {
13402            id: "chrT:100|300".to_string(),
13403            chr: "chrT".to_string(),
13404            start: 100,
13405            end: 300,
13406            junction_read_count: "11".to_string(),
13407            gene_id: "geneT".to_string(),
13408            strand: "+".to_string(),
13409        };
13410        let iso_a = test_major_isoform("chrT:100|300", &[(100, 150), (200, 300)]);
13411        let iso_b = test_major_isoform("chrT:100|300", &[(100, 170), (240, 300)]);
13412        let weak_shift_samples = vec![
13413            test_major_sample("sample1", &circ, iso_a.clone(), 6.0, 5.0),
13414            test_major_sample("sample2", &circ, iso_b.clone(), 5.0, 6.0),
13415        ];
13416        let records = vec![iso_a.clone(), iso_b.clone()];
13417        assert!(!cohort_usage_shift_passes(
13418            &records,
13419            &weak_shift_samples,
13420            "chrT:100|300"
13421        ));
13422
13423        let strong_shift_samples = vec![
13424            test_major_sample("sample1", &circ, iso_a.clone(), 9.0, 3.0),
13425            test_major_sample("sample2", &circ, iso_b.clone(), 3.0, 9.0),
13426        ];
13427        assert!(cohort_usage_shift_passes(
13428            &records,
13429            &strong_shift_samples,
13430            "chrT:100|300"
13431        ));
13432
13433        let tied_major_samples = vec![
13434            test_major_sample("sample1", &circ, iso_a.clone(), 10.0, 0.0),
13435            test_major_sample("sample2", &circ, iso_b.clone(), 5.0, 5.0),
13436        ];
13437        assert!(!cohort_usage_shift_passes(
13438            &records,
13439            &tied_major_samples,
13440            "chrT:100|300"
13441        ));
13442
13443        assert!(major_isoform_is_switching_eligible(&iso_a));
13444        let mut annotation_guided = iso_a.clone();
13445        annotation_guided.isoform_origin = "estimate".to_string();
13446        annotation_guided.estimate_reason =
13447            "gtf_long_block_projection,unphased_junction_chain".to_string();
13448        assert!(major_isoform_is_switching_eligible(&annotation_guided));
13449
13450        let mut ambiguous = annotation_guided.clone();
13451        ambiguous.estimate_reason =
13452            "gtf_long_block_projection,unphased_junction_chain,unresolved_long_block".to_string();
13453        assert!(major_isoform_is_switching_eligible(&ambiguous));
13454
13455        let mut low_coverage_exon = annotation_guided.clone();
13456        low_coverage_exon.estimate_reason = "unphased_single_exon_block".to_string();
13457        assert!(!major_isoform_is_switching_eligible(&low_coverage_exon));
13458
13459        let mut unconfident_long = annotation_guided.clone();
13460        unconfident_long.estimate_reason =
13461            "unphased_junction_chain,low_segment_coverage_unannotated_long_exon".to_string();
13462        assert!(!major_isoform_is_switching_eligible(&unconfident_long));
13463    }
13464
13465    fn test_major_isoform(circ_id: &str, exons: &[(i32, i32)]) -> MajorIsoformRecord {
13466        MajorIsoformRecord {
13467            circ_id: circ_id.to_string(),
13468            isoform_id: format!("{}.iso1", circ_id),
13469            sample_id: "sample".to_string(),
13470            chr: "chrT".to_string(),
13471            start: 100,
13472            end: 300,
13473            strand: '+',
13474            source_gene_id: "geneT".to_string(),
13475            exons: exons.to_vec(),
13476            cov: 10.0,
13477            segment_coverage_pct: 100.0,
13478            path_score: 10.0,
13479            bsj_reads: 10,
13480            isoform_len: exons.iter().map(|(start, end)| end - start + 1).sum(),
13481            isoform_rank: 1,
13482            isoform_class: "major".to_string(),
13483            isoform_origin: "mature".to_string(),
13484            estimate_reason: "none".to_string(),
13485        }
13486    }
13487
13488    fn test_major_sample(
13489        sample_id: &str,
13490        circ: &CircRecord,
13491        _major: MajorIsoformRecord,
13492        support_a: f64,
13493        support_b: f64,
13494    ) -> MajorSampleAssembly {
13495        let edge_a = MajorEdge {
13496            donor_end: 150,
13497            acceptor_start: 200,
13498            strand: '+',
13499        };
13500        let edge_b = MajorEdge {
13501            donor_end: 170,
13502            acceptor_start: 240,
13503            strand: '+',
13504        };
13505        let mut edge_support = HashMap::new();
13506        edge_support.insert(
13507            edge_a,
13508            MajorEdgeSupport {
13509                bsj: support_a,
13510                backward: 0.0,
13511                outward: 0.0,
13512            },
13513        );
13514        edge_support.insert(
13515            edge_b,
13516            MajorEdgeSupport {
13517                bsj: support_b,
13518                backward: 0.0,
13519                outward: 0.0,
13520            },
13521        );
13522        let mut bsj_observations = Vec::new();
13523        for idx in 0..support_a.round().max(0.0) as usize {
13524            bsj_observations.push(MajorBsjReadObservation {
13525                read_id: format!("{}_a_{}", sample_id, idx),
13526                edges: HashSet::from([edge_a]),
13527                spans: Vec::new(),
13528            });
13529        }
13530        for idx in 0..support_b.round().max(0.0) as usize {
13531            bsj_observations.push(MajorBsjReadObservation {
13532                read_id: format!("{}_b_{}", sample_id, idx),
13533                edges: HashSet::from([edge_b]),
13534                spans: Vec::new(),
13535            });
13536        }
13537        MajorSampleAssembly {
13538            sample_id: sample_id.to_string(),
13539            circ_index_by_id: HashMap::from([(circ.id.clone(), 0)]),
13540            circ_values: HashMap::from([(
13541                circ.id.clone(),
13542                MajorCircSampleValue {
13543                    circ: circ.clone(),
13544                    bsj_reads: support_a + support_b,
13545                    junction_ratio: 1.0,
13546                },
13547            )]),
13548            support: MajorIsoformSupportBundle {
13549                edge_support_by_circ: HashMap::from([(0, edge_support)]),
13550                bsj_reads_by_circ: HashMap::from([(0, bsj_observations)]),
13551                ..Default::default()
13552            },
13553        }
13554    }
13555
13556    #[test]
13557    fn major_segment_coverage_pct_reports_selected_exon_union() {
13558        let exons = vec![(100, 199), (300, 399)];
13559        let spans = vec![
13560            MajorAlignedSpan {
13561                start: 120,
13562                end: 160,
13563                bsj: 1.0,
13564                backward: 0.0,
13565                outward: 0.0,
13566            },
13567            MajorAlignedSpan {
13568                start: 150,
13569                end: 199,
13570                bsj: 0.0,
13571                backward: 0.0,
13572                outward: 1.0,
13573            },
13574            MajorAlignedSpan {
13575                start: 350,
13576                end: 399,
13577                bsj: 0.0,
13578                backward: 1.0,
13579                outward: 0.0,
13580            },
13581        ];
13582
13583        assert!((major_segment_coverage_pct(&exons, &spans) - 65.0).abs() < 1e-6);
13584    }
13585
13586    #[test]
13587    fn major_segment_coverage_pct_respects_fractional_assignment() {
13588        let exons = vec![(100, 199)];
13589        let spans = vec![MajorAlignedSpan {
13590            start: 100,
13591            end: 199,
13592            bsj: 0.0,
13593            backward: 0.25,
13594            outward: 0.0,
13595        }];
13596
13597        assert!((major_segment_coverage_pct(&exons, &spans) - 25.0).abs() < 1e-6);
13598    }
13599
13600    #[test]
13601    fn major_non_bsj_assignment_leaves_internal_reads_mostly_unassigned() {
13602        let circ_records = vec![CircRecord {
13603            id: "chrT:100|1000".to_string(),
13604            chr: "chrT".to_string(),
13605            start: 100,
13606            end: 1000,
13607            junction_read_count: "1".to_string(),
13608            gene_id: "geneT".to_string(),
13609            strand: "+".to_string(),
13610        }];
13611        let circ_by_id: HashMap<&str, usize> = circ_records
13612            .iter()
13613            .enumerate()
13614            .map(|(idx, circ)| (circ.id.as_str(), idx))
13615            .collect();
13616        let circ_index_by_chr = build_major_circ_index(&circ_records);
13617
13618        let boundary = assign_major_circs_from_segments(
13619            "backward",
13620            "NA",
13621            "chrT",
13622            "100",
13623            "1000",
13624            "100-150:+|950-1000:+",
13625            "NA",
13626            &circ_by_id,
13627            &circ_index_by_chr,
13628            &circ_records,
13629        );
13630        assert_eq!(boundary.len(), 1);
13631        assert!((boundary[0].weight - 1.0).abs() < 1e-6);
13632
13633        let internal = assign_major_circs_from_segments(
13634            "backward",
13635            "NA",
13636            "chrT",
13637            "450",
13638            "550",
13639            "450-500:+|520-550:+",
13640            "NA",
13641            &circ_by_id,
13642            &circ_index_by_chr,
13643            &circ_records,
13644        );
13645        assert_eq!(internal.len(), 1);
13646        assert!(internal[0].weight < 0.1);
13647    }
13648
13649    #[test]
13650    fn major_unannotated_long_exon_filter_requires_complete_annotation_support() {
13651        let exons = vec![(100, 2500), (3000, 3100)];
13652        assert!(major_has_unannotated_long_exon(
13653            &exons,
13654            &[(150, 2500), (3000, 3100)]
13655        ));
13656        assert!(!major_has_unannotated_long_exon(
13657            &exons,
13658            &[(100, 2500), (3000, 3100)]
13659        ));
13660
13661        let mut reason = "gtf_long_block_projection".to_string();
13662        major_append_estimate_reason(&mut reason, "low_segment_coverage_unannotated_long_exon");
13663        assert_eq!(
13664            reason,
13665            "gtf_long_block_projection,low_segment_coverage_unannotated_long_exon"
13666        );
13667    }
13668
13669    #[test]
13670    fn major_isoform_blocks_project_to_annotation_exons() {
13671        let circ = CircRecord {
13672            id: "chrT:100|20500".to_string(),
13673            chr: "chrT".to_string(),
13674            start: 100,
13675            end: 20500,
13676            junction_read_count: "3".to_string(),
13677            gene_id: "geneT".to_string(),
13678            strand: "+".to_string(),
13679        };
13680        let mut annotation = Annotation::new();
13681        annotation.gene_exon_map.insert(
13682            "geneT".to_string(),
13683            vec![(80, 150), (220, 260), (20450, 20520)],
13684        );
13685
13686        let annotation_exons = major_annotation_exons_for_circ(&circ, Some(&annotation));
13687        assert_eq!(
13688            annotation_exons,
13689            vec![(100, 150), (220, 260), (20450, 20500)]
13690        );
13691        assert_eq!(
13692            major_exons_from_edges(
13693                circ.start,
13694                circ.end,
13695                '+',
13696                &[],
13697                &annotation_exons,
13698                &HashMap::new(),
13699                &HashMap::new(),
13700                &HashMap::new(),
13701                &[],
13702                true,
13703            )
13704            .exons,
13705            vec![(100, 150), (220, 260), (20450, 20500)]
13706        );
13707        let build = major_exons_from_edges(
13708            circ.start,
13709            circ.end,
13710            '+',
13711            &[],
13712            &annotation_exons,
13713            &HashMap::new(),
13714            &HashMap::new(),
13715            &HashMap::new(),
13716            &[],
13717            true,
13718        );
13719        assert_eq!(build.origin(), "estimate");
13720        assert_eq!(
13721            build.estimate_reason(),
13722            "gtf_long_block_projection,inferred_internal_block"
13723        );
13724    }
13725
13726    #[test]
13727    fn major_isoform_prefers_transcript_consistent_annotation_chain() {
13728        let circ = CircRecord {
13729            id: "chrT:120|420".to_string(),
13730            chr: "chrT".to_string(),
13731            start: 120,
13732            end: 420,
13733            junction_read_count: "3".to_string(),
13734            gene_id: "geneT".to_string(),
13735            strand: "+".to_string(),
13736        };
13737        let mut annotation = Annotation::new();
13738        annotation.gene_exon_map.insert(
13739            "geneT".to_string(),
13740            vec![(100, 180), (120, 150), (220, 250), (300, 500), (390, 420)],
13741        );
13742        annotation.gene_transcript_exon_map.insert(
13743            "geneT".to_string(),
13744            vec![
13745                vec![(100, 180), (300, 500)],
13746                vec![(120, 150), (220, 250), (390, 420)],
13747            ],
13748        );
13749
13750        assert_eq!(
13751            major_annotation_exons_for_circ(&circ, Some(&annotation)),
13752            vec![(120, 150), (220, 250), (390, 420)]
13753        );
13754    }
13755
13756    #[test]
13757    fn major_isoform_uses_read_anchored_hybrid_annotation_estimate() {
13758        let circ = CircRecord {
13759            id: "chrT:100|1534".to_string(),
13760            chr: "chrT".to_string(),
13761            start: 100,
13762            end: 1534,
13763            junction_read_count: "2".to_string(),
13764            gene_id: "geneT".to_string(),
13765            strand: "+".to_string(),
13766        };
13767        let mut annotation = Annotation::new();
13768        annotation.gene_exon_map.insert(
13769            "geneT".to_string(),
13770            vec![(100, 267), (100, 1397), (1406, 1534), (1477, 1534)],
13771        );
13772        annotation.gene_transcript_exon_map.insert(
13773            "geneT".to_string(),
13774            vec![vec![(100, 267), (1406, 1534)], vec![(100, 1397)]],
13775        );
13776        let spans = vec![
13777            MajorAlignedSpan {
13778                start: 100,
13779                end: 199,
13780                bsj: 1.0,
13781                backward: 0.0,
13782                outward: 0.0,
13783            },
13784            MajorAlignedSpan {
13785                start: 248,
13786                end: 415,
13787                bsj: 1.0,
13788                backward: 0.0,
13789                outward: 0.0,
13790            },
13791            MajorAlignedSpan {
13792                start: 1465,
13793                end: 1534,
13794                bsj: 1.0,
13795                backward: 0.0,
13796                outward: 0.0,
13797            },
13798        ];
13799
13800        let annotation_exons =
13801            major_projection_annotation_exons_for_circ(&circ, Some(&annotation), &spans);
13802        assert_eq!(annotation_exons, vec![(100, 1397), (1406, 1534)]);
13803
13804        let build = major_exons_from_edges(
13805            circ.start,
13806            circ.end,
13807            '+',
13808            &[],
13809            &annotation_exons,
13810            &HashMap::new(),
13811            &HashMap::new(),
13812            &HashMap::new(),
13813            &spans,
13814            true,
13815        );
13816        assert_eq!(build.exons, vec![(100, 1397), (1406, 1534)]);
13817        assert_eq!(build.origin(), "estimate");
13818        assert_eq!(
13819            build.estimate_reason(),
13820            "gtf_long_block_projection,inferred_internal_block"
13821        );
13822    }
13823
13824    #[test]
13825    fn major_isoform_infers_unphased_short_blocks_from_annotation() {
13826        let annotation_exons = vec![(100, 150), (220, 260), (450, 500)];
13827        let build = major_exons_from_edges(
13828            100,
13829            500,
13830            '+',
13831            &[],
13832            &annotation_exons,
13833            &HashMap::new(),
13834            &HashMap::new(),
13835            &HashMap::new(),
13836            &[],
13837            true,
13838        );
13839        assert_eq!(build.exons, vec![(100, 150), (220, 260), (450, 500)]);
13840        assert_eq!(build.origin(), "estimate");
13841        assert_eq!(
13842            build.estimate_reason(),
13843            "gtf_long_block_projection,inferred_internal_block"
13844        );
13845    }
13846
13847    #[test]
13848    fn major_isoform_uses_exclusion_to_avoid_annotation_over_split() {
13849        let annotation_exons = vec![(100, 150), (220, 260), (450, 500)];
13850        let spans = vec![MajorAlignedSpan {
13851            start: 100,
13852            end: 500,
13853            bsj: 1.0,
13854            backward: 0.0,
13855            outward: 0.0,
13856        }];
13857        let build = major_exons_from_edges(
13858            100,
13859            500,
13860            '+',
13861            &[],
13862            &annotation_exons,
13863            &HashMap::new(),
13864            &HashMap::new(),
13865            &HashMap::new(),
13866            &spans,
13867            true,
13868        );
13869        assert_eq!(build.exons, vec![(100, 500)]);
13870        assert_eq!(build.origin(), "mature");
13871        assert_eq!(build.estimate_reason(), "none");
13872    }
13873
13874    #[test]
13875    fn major_isoform_requires_anchored_exclusion_across_annotation_edge() {
13876        let edge = MajorEdge {
13877            donor_end: 150,
13878            acceptor_start: 220,
13879            strand: '+',
13880        };
13881        let short_overrun = vec![MajorAlignedSpan {
13882            start: 147,
13883            end: 223,
13884            bsj: 1.0,
13885            backward: 0.0,
13886            outward: 0.0,
13887        }];
13888        assert!(!major_edge_has_high_conf_exclusion(edge, &short_overrun));
13889
13890        let anchored_span = vec![MajorAlignedSpan {
13891            start: 141,
13892            end: 229,
13893            bsj: 1.0,
13894            backward: 0.0,
13895            outward: 0.0,
13896        }];
13897        assert!(major_edge_has_high_conf_exclusion(edge, &anchored_span));
13898    }
13899
13900    #[test]
13901    fn major_isoform_marks_uncovered_single_exon_as_estimate() {
13902        let spans = vec![
13903            MajorAlignedSpan {
13904                start: 100,
13905                end: 160,
13906                bsj: 1.0,
13907                backward: 0.0,
13908                outward: 0.0,
13909            },
13910            MajorAlignedSpan {
13911                start: 340,
13912                end: 500,
13913                bsj: 1.0,
13914                backward: 0.0,
13915                outward: 0.0,
13916            },
13917        ];
13918        let build = major_exons_from_edges(
13919            100,
13920            500,
13921            '+',
13922            &[],
13923            &[],
13924            &HashMap::new(),
13925            &HashMap::new(),
13926            &HashMap::new(),
13927            &spans,
13928            true,
13929        );
13930        assert_eq!(build.exons, vec![(100, 500)]);
13931        assert_eq!(build.origin(), "estimate");
13932        assert_eq!(build.estimate_reason(), "unphased_single_exon_block");
13933    }
13934
13935    #[test]
13936    fn major_isoform_keeps_covered_single_exon_mature() {
13937        let spans = vec![
13938            MajorAlignedSpan {
13939                start: 100,
13940                end: 260,
13941                bsj: 1.0,
13942                backward: 0.0,
13943                outward: 0.0,
13944            },
13945            MajorAlignedSpan {
13946                start: 261,
13947                end: 500,
13948                bsj: 0.0,
13949                backward: 1.0,
13950                outward: 0.0,
13951            },
13952        ];
13953        let build = major_exons_from_edges(
13954            100,
13955            500,
13956            '+',
13957            &[],
13958            &[],
13959            &HashMap::new(),
13960            &HashMap::new(),
13961            &HashMap::new(),
13962            &spans,
13963            true,
13964        );
13965        assert_eq!(build.exons, vec![(100, 500)]);
13966        assert_eq!(build.origin(), "mature");
13967        assert_eq!(build.estimate_reason(), "none");
13968    }
13969
13970    #[test]
13971    fn major_isoform_marks_annotation_only_single_exon_as_estimate() {
13972        let build = major_exons_from_edges(
13973            100,
13974            500,
13975            '+',
13976            &[],
13977            &[(100, 500)],
13978            &HashMap::new(),
13979            &HashMap::new(),
13980            &HashMap::new(),
13981            &[],
13982            true,
13983        );
13984        assert_eq!(build.exons, vec![(100, 500)]);
13985        assert_eq!(build.origin(), "estimate");
13986        assert_eq!(build.estimate_reason(), "unphased_single_exon_block");
13987    }
13988
13989    #[test]
13990    fn major_isoform_rejects_clipped_annotation_single_exon_as_mature() {
13991        let build = major_exons_from_edges(
13992            100,
13993            400,
13994            '+',
13995            &[],
13996            &[(100, 400)],
13997            &HashMap::new(),
13998            &HashMap::new(),
13999            &HashMap::new(),
14000            &[],
14001            true,
14002        );
14003        assert_eq!(build.exons, vec![(100, 400)]);
14004        assert_eq!(build.origin(), "estimate");
14005        assert_eq!(build.estimate_reason(), "unphased_single_exon_block");
14006    }
14007
14008    #[test]
14009    fn major_isoform_uses_read_supported_junction_inside_unphased_block() {
14010        let edge = MajorEdge {
14011            donor_end: 150,
14012            acceptor_start: 450,
14013            strand: '+',
14014        };
14015        let mut support = HashMap::new();
14016        support.insert(
14017            edge,
14018            MajorEdgeSupport {
14019                bsj: 1.0,
14020                backward: 0.0,
14021                outward: 0.0,
14022            },
14023        );
14024        let build = major_exons_from_edges(
14025            100,
14026            500,
14027            '+',
14028            &[],
14029            &[],
14030            &support,
14031            &HashMap::new(),
14032            &HashMap::new(),
14033            &[],
14034            false,
14035        );
14036        assert_eq!(build.exons, vec![(100, 150), (450, 500)]);
14037        assert_eq!(build.origin(), "estimate");
14038        assert_eq!(
14039            build.estimate_reason(),
14040            "unphased_junction_chain,inferred_internal_block"
14041        );
14042    }
14043
14044    #[test]
14045    fn major_isoform_marks_unphased_junction_chain_as_estimate() {
14046        let edges = vec![
14047            MajorEdge {
14048                donor_end: 150,
14049                acceptor_start: 220,
14050                strand: '+',
14051            },
14052            MajorEdge {
14053                donor_end: 260,
14054                acceptor_start: 450,
14055                strand: '+',
14056            },
14057        ];
14058        let build = major_exons_from_edges(
14059            100,
14060            500,
14061            '+',
14062            &edges,
14063            &[],
14064            &HashMap::new(),
14065            &HashMap::new(),
14066            &HashMap::new(),
14067            &[],
14068            false,
14069        );
14070        assert_eq!(build.exons, vec![(100, 150), (220, 260), (450, 500)]);
14071        assert_eq!(build.origin(), "estimate");
14072        assert_eq!(build.estimate_reason(), "unphased_junction_chain");
14073    }
14074
14075    #[test]
14076    fn major_isoform_requires_bsj_boundary_phasing_for_single_internal_edge() {
14077        let edge = MajorEdge {
14078            donor_end: 150,
14079            acceptor_start: 450,
14080            strand: '+',
14081        };
14082        let no_boundary_support = HashMap::new();
14083        let no_boundary_exclusion = HashMap::new();
14084        assert!(!major_chain_has_adjacent_link_support(
14085            &[edge],
14086            &no_boundary_support,
14087            &no_boundary_exclusion
14088        ));
14089
14090        let mut boundary_support = HashMap::new();
14091        boundary_support.insert(
14092            MajorJunctionLink::new(MajorJunction::Bsj, MajorJunction::Edge(edge)),
14093            MajorLinkSupport {
14094                bsj: 0.0,
14095                backward: 0.0,
14096                outward: 1.0,
14097            },
14098        );
14099        assert!(!major_chain_has_adjacent_link_support(
14100            &[edge],
14101            &boundary_support,
14102            &no_boundary_exclusion
14103        ));
14104
14105        boundary_support.insert(
14106            MajorJunctionLink::new(MajorJunction::Bsj, MajorJunction::Edge(edge)),
14107            MajorLinkSupport {
14108                bsj: 1.0,
14109                backward: 0.0,
14110                outward: 0.0,
14111            },
14112        );
14113        assert!(!major_chain_has_adjacent_link_support(
14114            &[edge],
14115            &boundary_support,
14116            &no_boundary_exclusion
14117        ));
14118        boundary_support.insert(
14119            MajorJunctionLink::new(MajorJunction::Edge(edge), MajorJunction::Bsj),
14120            MajorLinkSupport {
14121                bsj: 0.0,
14122                backward: 1.0,
14123                outward: 0.0,
14124            },
14125        );
14126        assert!(major_chain_has_adjacent_link_support(
14127            &[edge],
14128            &boundary_support,
14129            &no_boundary_exclusion
14130        ));
14131        let mut boundary_exclusion = HashMap::new();
14132        boundary_exclusion.insert(
14133            MajorJunctionLink::new(MajorJunction::Bsj, MajorJunction::Edge(edge)),
14134            MajorLinkSupport {
14135                bsj: 1.0,
14136                backward: 0.0,
14137                outward: 0.0,
14138            },
14139        );
14140        assert!(!major_chain_has_adjacent_link_support(
14141            &[edge],
14142            &boundary_support,
14143            &boundary_exclusion
14144        ));
14145    }
14146
14147    #[test]
14148    fn major_segment_junction_chain_keeps_bsj_boundary_tokens() {
14149        let circ = CircRecord {
14150            id: "chrT:100|500".to_string(),
14151            chr: "chrT".to_string(),
14152            start: 100,
14153            end: 500,
14154            junction_read_count: "1".to_string(),
14155            gene_id: "geneT".to_string(),
14156            strand: "+".to_string(),
14157        };
14158        let junctions = major_segment_junction_chain("480-500:+|<bsj>|100-150:+|450-470:+", &circ);
14159        assert_eq!(
14160            junctions,
14161            vec![
14162                MajorJunction::Bsj,
14163                MajorJunction::Edge(MajorEdge {
14164                    donor_end: 150,
14165                    acceptor_start: 450,
14166                    strand: '+',
14167                })
14168            ]
14169        );
14170        assert_eq!(
14171            major_adjacent_junction_links(&junctions),
14172            vec![MajorJunctionLink::new(
14173                MajorJunction::Bsj,
14174                MajorJunction::Edge(MajorEdge {
14175                    donor_end: 150,
14176                    acceptor_start: 450,
14177                    strand: '+',
14178                })
14179            )]
14180        );
14181    }
14182
14183    #[test]
14184    fn major_segment_reused_overlap_excludes_mature_phasing() {
14185        let circ = CircRecord {
14186            id: "chr18:29234877|29255341".to_string(),
14187            chr: "chr18".to_string(),
14188            start: 29234877,
14189            end: 29255341,
14190            junction_read_count: "1".to_string(),
14191            gene_id: "NA".to_string(),
14192            strand: "-".to_string(),
14193        };
14194        let reused = "29255287-29255344:-|<bsj>|29236698-29236725:-|29255273-29255337:-";
14195        assert!(major_segment_has_reused_overlap(reused, &circ));
14196
14197        let edge = MajorEdge {
14198            donor_end: 29236725,
14199            acceptor_start: 29255273,
14200            strand: '-',
14201        };
14202        let mut support = HashMap::new();
14203        support.insert(
14204            MajorJunctionLink::new(MajorJunction::Bsj, MajorJunction::Edge(edge)),
14205            MajorLinkSupport {
14206                bsj: 1.0,
14207                backward: 0.0,
14208                outward: 0.0,
14209            },
14210        );
14211        support.insert(
14212            MajorJunctionLink::new(MajorJunction::Edge(edge), MajorJunction::Bsj),
14213            MajorLinkSupport {
14214                bsj: 1.0,
14215                backward: 0.0,
14216                outward: 0.0,
14217            },
14218        );
14219        let mut exclusion = HashMap::new();
14220        exclusion.insert(
14221            MajorJunctionLink::new(MajorJunction::Bsj, MajorJunction::Edge(edge)),
14222            MajorLinkSupport {
14223                bsj: 1.0,
14224                backward: 0.0,
14225                outward: 0.0,
14226            },
14227        );
14228        assert!(!major_chain_has_adjacent_link_support(
14229            &[edge],
14230            &support,
14231            &exclusion
14232        ));
14233    }
14234
14235    #[test]
14236    fn major_isoform_filters_chimeric_mate_before_graph_support() {
14237        let tmp_prefix = std::env::temp_dir().join(format!(
14238            "ciri_major_isoform_chimeric_{}",
14239            std::process::id()
14240        ));
14241        let segments_path = tmp_prefix.with_extension("segments");
14242        let out_prefix = tmp_prefix.to_string_lossy().to_string();
14243        std::fs::write(
14244            &segments_path,
14245            concat!(
14246                "read_id\ttype\tcirc_id\tchrom\tstart\tend\tstrand\tis_circular\tis_r1_bsj\tis_r2_bsj\tr1_align_strand\tr2_align_strand\tr1_cigar\tr1_segments\tr2_cigar\tr2_segments\n",
14247                "read1\tbsj\tchrT:100|300\tchrT\t100\t300\t-\t1\t1\t1\t+\t-\t58M80B21M69N51M\t246-303:-|<bsj>|150-170:-|240-290:-\t21M69N61M109B31M\t150-170:-|240-300:-|<bsj>|100-130:-\n",
14248            ),
14249        )
14250        .unwrap();
14251        let circ = CircRecord {
14252            id: "chrT:100|300".to_string(),
14253            chr: "chrT".to_string(),
14254            start: 100,
14255            end: 300,
14256            junction_read_count: "1".to_string(),
14257            gene_id: "NA".to_string(),
14258            strand: "-".to_string(),
14259        };
14260        let mut reference = HashMap::new();
14261        reference.insert("chrT".to_string(), "ACGT".repeat(100));
14262
14263        let summary = build_major_isoforms_from_segments_file(
14264            &[circ],
14265            segments_path.to_str().unwrap(),
14266            &out_prefix,
14267            &reference,
14268            None,
14269        )
14270        .unwrap();
14271
14272        assert_eq!(summary.total_isoforms, 1);
14273        assert_eq!(summary.circ_rnas, 1);
14274        let gtf = std::fs::read_to_string(format!("{}.isoforms.gtf", out_prefix)).unwrap();
14275        assert!(gtf.contains("\texon\t100\t170\t"));
14276        assert!(gtf.contains("\texon\t240\t300\t"));
14277        assert!(gtf.contains("type \"estimate\";"));
14278        assert!(gtf.contains("evidence \"unphased_junction\";"));
14279        let _ = std::fs::remove_file(&segments_path);
14280        let _ = std::fs::remove_file(format!("{}.isoforms.gtf", out_prefix));
14281        let _ = std::fs::remove_file(format!("{}.isoforms.fa", out_prefix));
14282    }
14283
14284    #[test]
14285    fn non_bsj_local_clip_alignment_uses_cluster_window_beyond_circ_span() {
14286        let clip = "ACGTACGTACGTACGTACGT";
14287        let mut chr_seq = "T".repeat(500);
14288        chr_seq.replace_range(299..319, clip);
14289        let mut reference = HashMap::new();
14290        reference.insert("chr1".to_string(), chr_seq);
14291        let records = vec![AsAlignment {
14292            flag: 0x40,
14293            chr: "chr1".to_string(),
14294            pos: 100,
14295            mapq: 60,
14296            cigar: "130M20S".to_string(),
14297            seq: format!("{}{}", "G".repeat(130), clip),
14298            cs: "*".to_string(),
14299            from_local_clip: false,
14300            xa_alternatives: Vec::new(),
14301        }];
14302        let state = ScanState {
14303            circ_by_id: HashMap::new(),
14304            junction_read_to_circ: HashMap::new(),
14305            mate_bsj_evidence: HashMap::new(),
14306            reference: &reference,
14307            annotation: None,
14308            circ_spans_by_chr: {
14309                let mut map = HashMap::new();
14310                map.insert(
14311                    "chr1".to_string(),
14312                    vec![CircSpan {
14313                        start: 90,
14314                        end: 220,
14315                        max_end_through: 220,
14316                    }],
14317                );
14318                map
14319            },
14320            clusters_by_chr: {
14321                let mut map = HashMap::new();
14322                map.insert(
14323                    "chr1".to_string(),
14324                    vec![CircCluster {
14325                        chr: "chr1".to_string(),
14326                        start: 80,
14327                        end: 350,
14328                    }],
14329                );
14330                map
14331            },
14332            read_len: 150,
14333            min_mapq: 10,
14334            candidates: Vec::new(),
14335            outward_read_ids: HashSet::new(),
14336            prebuilt_segment_records: Vec::new(),
14337            segment_groups: HashMap::new(),
14338        };
14339
14340        let enriched = add_non_bsj_local_clip_alignments(&records, &state);
14341
14342        assert!(enriched.iter().any(|record| {
14343            record.from_local_clip
14344                && record.pos == 300
14345                && record.cigar == "130S20M"
14346                && record.cs == ":20"
14347        }));
14348    }
14349
14350    #[test]
14351    fn backward_segments_reject_weak_supplementary_when_xa_makes_linear_chain() {
14352        let records = vec![
14353            AsAlignment {
14354                flag: 0x40 | 0x10,
14355                chr: "chr1".to_string(),
14356                pos: 92091,
14357                mapq: 0,
14358                cigar: "27S123M".to_string(),
14359                seq: "A".repeat(150),
14360                cs: "*".to_string(),
14361                from_local_clip: false,
14362                xa_alternatives: Vec::new(),
14363            },
14364            AsAlignment {
14365                flag: 0x40 | 0x10 | 0x800,
14366                chr: "chr1".to_string(),
14367                pos: 521712,
14368                mapq: 0,
14369                cigar: "29M121H".to_string(),
14370                seq: "A".repeat(29),
14371                cs: "*".to_string(),
14372                from_local_clip: false,
14373                xa_alternatives: vec![XaAlternative {
14374                    chr: "chr1".to_string(),
14375                    strand: '-',
14376                    pos: 91603,
14377                    cigar: "29M121S".to_string(),
14378                    edit_distance: 0,
14379                }],
14380            },
14381        ];
14382
14383        let record =
14384            build_backward_segment_record("sim:433108", &records, Some('-'), &[], None, 150);
14385
14386        assert!(record.is_none());
14387    }
14388
14389    #[test]
14390    fn backward_segments_reject_long_anchor_when_xa_makes_linear_chain() {
14391        let records = vec![
14392            AsAlignment {
14393                flag: 0x40,
14394                chr: "chr1".to_string(),
14395                pos: 2000,
14396                mapq: 0,
14397                cigar: "50M50S".to_string(),
14398                seq: "A".repeat(100),
14399                cs: "*".to_string(),
14400                from_local_clip: false,
14401                xa_alternatives: Vec::new(),
14402            },
14403            AsAlignment {
14404                flag: 0x40 | 0x800,
14405                chr: "chr1".to_string(),
14406                pos: 100,
14407                mapq: 0,
14408                cigar: "50S50M".to_string(),
14409                seq: "A".repeat(100),
14410                cs: "*".to_string(),
14411                from_local_clip: false,
14412                xa_alternatives: vec![XaAlternative {
14413                    chr: "chr1".to_string(),
14414                    strand: '+',
14415                    pos: 2050,
14416                    cigar: "50S50M".to_string(),
14417                    edit_distance: 0,
14418                }],
14419            },
14420        ];
14421
14422        let record =
14423            build_backward_segment_record("long_anchor", &records, Some('+'), &[], None, 100);
14424
14425        assert!(record.is_none());
14426    }
14427
14428    #[test]
14429    fn backward_segments_reject_linear_xa_before_circular_repair() {
14430        let records = vec![
14431            AsAlignment {
14432                flag: 83,
14433                chr: "chr1".to_string(),
14434                pos: 92_091,
14435                mapq: 0,
14436                cigar: "42S108M".to_string(),
14437                seq: "A".repeat(150),
14438                cs: "*".to_string(),
14439                from_local_clip: false,
14440                xa_alternatives: Vec::new(),
14441            },
14442            AsAlignment {
14443                flag: 2131,
14444                chr: "chr1".to_string(),
14445                pos: 521_697,
14446                mapq: 0,
14447                cigar: "44M106H".to_string(),
14448                seq: "A".repeat(44),
14449                cs: "*".to_string(),
14450                from_local_clip: false,
14451                xa_alternatives: vec![
14452                    XaAlternative {
14453                        chr: "chr1".to_string(),
14454                        strand: '-',
14455                        pos: 237_914,
14456                        cigar: "44M106S".to_string(),
14457                        edit_distance: 0,
14458                    },
14459                    XaAlternative {
14460                        chr: "chr1".to_string(),
14461                        strand: '-',
14462                        pos: 91_588,
14463                        cigar: "44M106S".to_string(),
14464                        edit_distance: 0,
14465                    },
14466                ],
14467            },
14468        ];
14469
14470        let record =
14471            build_backward_segment_record("sim:432652", &records, Some('-'), &[], None, 150);
14472
14473        assert!(record.is_none());
14474    }
14475
14476    #[test]
14477    fn backward_segments_joint_xa_replacement_can_reject_linear_pair() {
14478        let records = vec![
14479            AsAlignment {
14480                flag: 81,
14481                chr: "chr1".to_string(),
14482                pos: 120_775,
14483                mapq: 0,
14484                cigar: "60S90M".to_string(),
14485                seq: "A".repeat(150),
14486                cs: "*".to_string(),
14487                from_local_clip: false,
14488                xa_alternatives: Vec::new(),
14489            },
14490            AsAlignment {
14491                flag: 2129,
14492                chr: "chr1".to_string(),
14493                pos: 238_508,
14494                mapq: 0,
14495                cigar: "62M88H".to_string(),
14496                seq: "A".repeat(62),
14497                cs: "*".to_string(),
14498                from_local_clip: false,
14499                xa_alternatives: vec![XaAlternative {
14500                    chr: "chr1".to_string(),
14501                    strand: '-',
14502                    pos: 92_181,
14503                    cigar: "62M88S".to_string(),
14504                    edit_distance: 0,
14505                }],
14506            },
14507            AsAlignment {
14508                flag: 161,
14509                chr: "chr1".to_string(),
14510                pos: 91_547,
14511                mapq: 55,
14512                cigar: "85M65S".to_string(),
14513                seq: "A".repeat(150),
14514                cs: "*".to_string(),
14515                from_local_clip: false,
14516                xa_alternatives: Vec::new(),
14517            },
14518            AsAlignment {
14519                flag: 2209,
14520                chr: "chr1".to_string(),
14521                pos: 238_418,
14522                mapq: 0,
14523                cigar: "83H67M".to_string(),
14524                seq: "A".repeat(67),
14525                cs: "*".to_string(),
14526                from_local_clip: false,
14527                xa_alternatives: vec![XaAlternative {
14528                    chr: "chr1".to_string(),
14529                    strand: '+',
14530                    pos: 92_091,
14531                    cigar: "83S67M".to_string(),
14532                    edit_distance: 0,
14533                }],
14534            },
14535        ];
14536
14537        let record =
14538            build_backward_segment_record("sim:432594", &records, Some('-'), &[], None, 150);
14539
14540        assert!(record.is_none());
14541    }
14542
14543    #[test]
14544    fn backward_segments_uses_primary_mapq0_xa_in_joint_ranking() {
14545        let records = vec![
14546            AsAlignment {
14547                flag: 81,
14548                chr: "chr1".to_string(),
14549                pos: 92_091,
14550                mapq: 0,
14551                cigar: "44S106M".to_string(),
14552                seq: "A".repeat(150),
14553                cs: "*".to_string(),
14554                from_local_clip: false,
14555                xa_alternatives: vec![XaAlternative {
14556                    chr: "chr1".to_string(),
14557                    strand: '-',
14558                    pos: 238_418,
14559                    cigar: "44S106M".to_string(),
14560                    edit_distance: 0,
14561                }],
14562            },
14563            AsAlignment {
14564                flag: 2129,
14565                chr: "chr1".to_string(),
14566                pos: 521_695,
14567                mapq: 0,
14568                cigar: "46M104H".to_string(),
14569                seq: "A".repeat(46),
14570                cs: "*".to_string(),
14571                from_local_clip: false,
14572                xa_alternatives: vec![XaAlternative {
14573                    chr: "chr1".to_string(),
14574                    strand: '-',
14575                    pos: 237_912,
14576                    cigar: "46M104S".to_string(),
14577                    edit_distance: 0,
14578                }],
14579            },
14580            AsAlignment {
14581                flag: 161,
14582                chr: "chr1".to_string(),
14583                pos: 237_835,
14584                mapq: 23,
14585                cigar: "123M27S".to_string(),
14586                seq: "A".repeat(150),
14587                cs: "*".to_string(),
14588                from_local_clip: false,
14589                xa_alternatives: Vec::new(),
14590            },
14591            AsAlignment {
14592                flag: 2209,
14593                chr: "chr1".to_string(),
14594                pos: 238_418,
14595                mapq: 0,
14596                cigar: "121H29M".to_string(),
14597                seq: "A".repeat(29),
14598                cs: "*".to_string(),
14599                from_local_clip: false,
14600                xa_alternatives: vec![XaAlternative {
14601                    chr: "chr1".to_string(),
14602                    strand: '+',
14603                    pos: 92_091,
14604                    cigar: "121S29M".to_string(),
14605                    edit_distance: 0,
14606                }],
14607            },
14608        ];
14609
14610        let record =
14611            build_backward_segment_record("sim:4599148", &records, Some('-'), &[], None, 150);
14612
14613        assert!(record.is_none());
14614    }
14615
14616    #[test]
14617    fn backward_segments_accepts_nm1_xa_when_span_is_better() {
14618        let records = vec![
14619            AsAlignment {
14620                flag: 0x40,
14621                chr: "chr1".to_string(),
14622                pos: 2_000,
14623                mapq: 0,
14624                cigar: "50M50S".to_string(),
14625                seq: "A".repeat(100),
14626                cs: "*".to_string(),
14627                from_local_clip: false,
14628                xa_alternatives: Vec::new(),
14629            },
14630            AsAlignment {
14631                flag: 0x40 | 0x800,
14632                chr: "chr1".to_string(),
14633                pos: 100,
14634                mapq: 0,
14635                cigar: "50S50M".to_string(),
14636                seq: "A".repeat(100),
14637                cs: "*".to_string(),
14638                from_local_clip: false,
14639                xa_alternatives: vec![XaAlternative {
14640                    chr: "chr1".to_string(),
14641                    strand: '+',
14642                    pos: 2_050,
14643                    cigar: "50S50M".to_string(),
14644                    edit_distance: 1,
14645                }],
14646            },
14647        ];
14648
14649        let record =
14650            build_backward_segment_record("nm1_linear_xa", &records, Some('+'), &[], None, 100);
14651
14652        assert!(record.is_none());
14653    }
14654
14655    #[test]
14656    fn backward_segments_choose_best_spanning_xa_regardless_of_topology() {
14657        let records = vec![
14658            AsAlignment {
14659                flag: 0x40,
14660                chr: "chr1".to_string(),
14661                pos: 5_000,
14662                mapq: 60,
14663                cigar: "50M50S".to_string(),
14664                seq: "A".repeat(100),
14665                cs: "*".to_string(),
14666                from_local_clip: false,
14667                xa_alternatives: Vec::new(),
14668            },
14669            AsAlignment {
14670                flag: 0x40 | 0x800,
14671                chr: "chr1".to_string(),
14672                pos: 100,
14673                mapq: 0,
14674                cigar: "50S50M".to_string(),
14675                seq: "A".repeat(100),
14676                cs: "*".to_string(),
14677                from_local_clip: false,
14678                xa_alternatives: vec![
14679                    XaAlternative {
14680                        chr: "chr1".to_string(),
14681                        strand: '+',
14682                        pos: 4_990,
14683                        cigar: "50S50M".to_string(),
14684                        edit_distance: 0,
14685                    },
14686                    XaAlternative {
14687                        chr: "chr1".to_string(),
14688                        strand: '+',
14689                        pos: 9_000,
14690                        cigar: "50S50M".to_string(),
14691                        edit_distance: 0,
14692                    },
14693                ],
14694            },
14695        ];
14696
14697        let record =
14698            build_backward_segment_record("best_circular_xa", &records, Some('+'), &[], None, 100)
14699                .unwrap();
14700
14701        assert_eq!(record.start, "4990");
14702        assert_eq!(record.end, "5049");
14703        assert_eq!(record.r1_segments, "5000-5049:+|<bsj>|4990-5039:+");
14704        assert_eq!(record.r1_cigar, "50M0B50M");
14705    }
14706
14707    #[test]
14708    fn backward_segments_reject_span_above_ciri3_max_span() {
14709        let records = vec![
14710            AsAlignment {
14711                flag: 0x40,
14712                chr: "chr1".to_string(),
14713                pos: 300_000,
14714                mapq: 60,
14715                cigar: "50M50S".to_string(),
14716                seq: "A".repeat(100),
14717                cs: "*".to_string(),
14718                from_local_clip: false,
14719                xa_alternatives: Vec::new(),
14720            },
14721            AsAlignment {
14722                flag: 0x40 | 0x800,
14723                chr: "chr1".to_string(),
14724                pos: 100,
14725                mapq: 60,
14726                cigar: "50S50M".to_string(),
14727                seq: "A".repeat(100),
14728                cs: "*".to_string(),
14729                from_local_clip: false,
14730                xa_alternatives: Vec::new(),
14731            },
14732        ];
14733
14734        let record =
14735            build_backward_segment_record("wide_span", &records, Some('+'), &[], None, 100);
14736
14737        assert!(record.is_none());
14738    }
14739
14740    #[test]
14741    fn backward_segments_repair_remote_supplementary_with_local_xa() {
14742        let records = vec![
14743            AsAlignment {
14744                flag: 99,
14745                chr: "chr1".to_string(),
14746                pos: 203_745_131,
14747                mapq: 60,
14748                cigar: "115M35S".to_string(),
14749                seq: "A".repeat(150),
14750                cs: "*".to_string(),
14751                from_local_clip: false,
14752                xa_alternatives: Vec::new(),
14753            },
14754            AsAlignment {
14755                flag: 2147,
14756                chr: "chr1".to_string(),
14757                pos: 203_741_196,
14758                mapq: 60,
14759                cigar: "115H35M".to_string(),
14760                seq: "A".repeat(35),
14761                cs: "*".to_string(),
14762                from_local_clip: false,
14763                xa_alternatives: Vec::new(),
14764            },
14765            AsAlignment {
14766                flag: 147,
14767                chr: "chr1".to_string(),
14768                pos: 203_741_196,
14769                mapq: 60,
14770                cigar: "22S80M48S".to_string(),
14771                seq: "A".repeat(150),
14772                cs: "*".to_string(),
14773                from_local_clip: false,
14774                xa_alternatives: Vec::new(),
14775            },
14776            AsAlignment {
14777                flag: 2195,
14778                chr: "chr1".to_string(),
14779                pos: 203_743_002,
14780                mapq: 60,
14781                cigar: "101H49M".to_string(),
14782                seq: "A".repeat(49),
14783                cs: "*".to_string(),
14784                from_local_clip: false,
14785                xa_alternatives: Vec::new(),
14786            },
14787            AsAlignment {
14788                flag: 2195,
14789                chr: "chr1".to_string(),
14790                pos: 90_982,
14791                mapq: 0,
14792                cigar: "23M127H".to_string(),
14793                seq: "A".repeat(23),
14794                cs: "*".to_string(),
14795                from_local_clip: false,
14796                xa_alternatives: vec![XaAlternative {
14797                    chr: "chr1".to_string(),
14798                    strand: '-',
14799                    pos: 203_745_224,
14800                    cigar: "22M128S".to_string(),
14801                    edit_distance: 0,
14802                }],
14803            },
14804        ];
14805
14806        let record =
14807            build_backward_segment_record("sim:3966179", &records, Some('+'), &[], None, 150)
14808                .unwrap();
14809
14810        assert_eq!(record.start, "203741196");
14811        assert_eq!(record.end, "203745245");
14812        assert_eq!(
14813            record.r2_segments,
14814            "203745224-203745245:+|<bsj>|203741196-203741275:+|203743002-203743050:+"
14815        );
14816        assert_eq!(record.r2_cigar, "22M3948B80M1726N49M");
14817    }
14818
14819    #[test]
14820    fn backward_segments_filter_mixed_chromosome_chains() {
14821        let records = vec![
14822            AsAlignment {
14823                flag: 0x40,
14824                chr: "chr1".to_string(),
14825                pos: 200,
14826                mapq: 60,
14827                cigar: "50M50S".to_string(),
14828                seq: "A".repeat(100),
14829                cs: "*".to_string(),
14830                from_local_clip: false,
14831                xa_alternatives: Vec::new(),
14832            },
14833            AsAlignment {
14834                flag: 0x40 | 0x800,
14835                chr: "chr1".to_string(),
14836                pos: 100,
14837                mapq: 60,
14838                cigar: "50S50M".to_string(),
14839                seq: "A".repeat(100),
14840                cs: "*".to_string(),
14841                from_local_clip: false,
14842                xa_alternatives: Vec::new(),
14843            },
14844            AsAlignment {
14845                flag: 0x80,
14846                chr: "chr2".to_string(),
14847                pos: 500,
14848                mapq: 60,
14849                cigar: "100M".to_string(),
14850                seq: "A".repeat(100),
14851                cs: "*".to_string(),
14852                from_local_clip: false,
14853                xa_alternatives: Vec::new(),
14854            },
14855        ];
14856
14857        let record = build_backward_segment_record("read1", &records, None, &[], None, 100);
14858
14859        assert!(record.is_none());
14860    }
14861
14862    #[test]
14863    fn segment_records_sort_by_genomic_position() {
14864        let mut records = vec![
14865            SegmentRecord {
14866                read_id: "read3".to_string(),
14867                type_name: "backward",
14868                circ_id: "NA".to_string(),
14869                chrom: "chr2".to_string(),
14870                start: "20".to_string(),
14871                end: "40".to_string(),
14872                strand: "NA".to_string(),
14873                is_circular: 1,
14874                is_r1_bsj: 0,
14875                is_r2_bsj: 0,
14876                r1_align_strand: "NA".to_string(),
14877                r2_align_strand: "NA".to_string(),
14878                r1_cigar: "NA".to_string(),
14879                r1_cs: "NA".to_string(),
14880                r1_segments: "NA".to_string(),
14881                r2_cigar: "NA".to_string(),
14882                r2_cs: "NA".to_string(),
14883                r2_segments: "NA".to_string(),
14884            },
14885            SegmentRecord {
14886                read_id: "read2".to_string(),
14887                type_name: "backward",
14888                circ_id: "NA".to_string(),
14889                chrom: "chr1".to_string(),
14890                start: "100".to_string(),
14891                end: "250".to_string(),
14892                strand: "NA".to_string(),
14893                is_circular: 1,
14894                is_r1_bsj: 0,
14895                is_r2_bsj: 0,
14896                r1_align_strand: "NA".to_string(),
14897                r2_align_strand: "NA".to_string(),
14898                r1_cigar: "NA".to_string(),
14899                r1_cs: "NA".to_string(),
14900                r1_segments: "NA".to_string(),
14901                r2_cigar: "NA".to_string(),
14902                r2_cs: "NA".to_string(),
14903                r2_segments: "NA".to_string(),
14904            },
14905            SegmentRecord {
14906                read_id: "read1".to_string(),
14907                type_name: "bsj",
14908                circ_id: "chr1:100|200".to_string(),
14909                chrom: "chr1".to_string(),
14910                start: "100".to_string(),
14911                end: "200".to_string(),
14912                strand: "+".to_string(),
14913                is_circular: 1,
14914                is_r1_bsj: 1,
14915                is_r2_bsj: 0,
14916                r1_align_strand: "NA".to_string(),
14917                r2_align_strand: "NA".to_string(),
14918                r1_cigar: "NA".to_string(),
14919                r1_cs: "NA".to_string(),
14920                r1_segments: "NA".to_string(),
14921                r2_cigar: "NA".to_string(),
14922                r2_cs: "NA".to_string(),
14923                r2_segments: "NA".to_string(),
14924            },
14925        ];
14926
14927        sort_segment_records(&mut records);
14928
14929        assert_eq!(
14930            records
14931                .iter()
14932                .map(|record| record.read_id.as_str())
14933                .collect::<Vec<_>>(),
14934            vec!["read1", "read2", "read3"]
14935        );
14936    }
14937
14938    #[test]
14939    fn bsj_segments_insert_boundary_marker() {
14940        let circ = CircRecord {
14941            id: "chr1:100|199".to_string(),
14942            chr: "chr1".to_string(),
14943            start: 100,
14944            end: 199,
14945            junction_read_count: "1".to_string(),
14946            gene_id: "GENE1".to_string(),
14947            strand: "+".to_string(),
14948        };
14949        let records = vec![
14950            AsAlignment {
14951                flag: 0x40,
14952                chr: "chr1".to_string(),
14953                pos: 150,
14954                mapq: 60,
14955                cigar: "50M50S".to_string(),
14956                seq: "A".repeat(100),
14957                cs: "*".to_string(),
14958                from_local_clip: false,
14959                xa_alternatives: Vec::new(),
14960            },
14961            AsAlignment {
14962                flag: 0x40 | 0x800,
14963                chr: "chr1".to_string(),
14964                pos: 100,
14965                mapq: 60,
14966                cigar: "50S50M".to_string(),
14967                seq: "A".repeat(100),
14968                cs: "*".to_string(),
14969                from_local_clip: false,
14970                xa_alternatives: Vec::new(),
14971            },
14972        ];
14973
14974        let record =
14975            build_bsj_segment_record("read1", &records, &circ, &[], &[], None, 100).unwrap();
14976
14977        assert_eq!(record.type_name, "bsj");
14978        assert_eq!(record.circ_id, circ.id);
14979        assert_eq!(record.r1_segments, "150-199:+|<bsj>|100-149:+");
14980        assert_eq!(record.r1_cigar, "50M0B50M");
14981        assert_eq!(record.is_r1_bsj, 1);
14982    }
14983
14984    #[test]
14985    fn bsj_marker_follows_read_order_wrap_in_three_block_chain() {
14986        let circ = CircRecord {
14987            id: "chr1:100|349".to_string(),
14988            chr: "chr1".to_string(),
14989            start: 100,
14990            end: 349,
14991            junction_read_count: "1".to_string(),
14992            gene_id: "GENE1".to_string(),
14993            strand: "+".to_string(),
14994        };
14995        let parsed = vec![ParsedAlignment {
14996            flag: 0,
14997            chrom: "chr1".to_string(),
14998            strand: '+',
14999            mapq: 60,
15000            seq: "A".repeat(1000),
15001            blocks: vec![
15002                SegmentBlock {
15003                    read_start: 1,
15004                    read_end: 10,
15005                    ref_start: 200,
15006                    ref_end: 209,
15007                    from_local_clip: false,
15008                    cigar_ops: Vec::new(),
15009                },
15010                SegmentBlock {
15011                    read_start: 11,
15012                    read_end: 60,
15013                    ref_start: 300,
15014                    ref_end: 349,
15015                    from_local_clip: false,
15016                    cigar_ops: Vec::new(),
15017                },
15018                SegmentBlock {
15019                    read_start: 61,
15020                    read_end: 110,
15021                    ref_start: 100,
15022                    ref_end: 149,
15023                    from_local_clip: false,
15024                    cigar_ops: Vec::new(),
15025                },
15026            ],
15027        }];
15028
15029        let chain = materialize_chain(&parsed, 110, Some(&circ), '+', &[], None, false).unwrap();
15030
15031        assert_eq!(
15032            chain.tokens,
15033            vec![
15034                "200-209:+".to_string(),
15035                "300-349:+".to_string(),
15036                "<bsj>".to_string(),
15037                "100-149:+".to_string(),
15038            ]
15039        );
15040        assert_eq!(chain.cigar, "10M90N50M150B50M");
15041        assert!(chain.is_bsj);
15042    }
15043
15044    #[test]
15045    fn materialize_chain_emits_short_cs_with_custom_bsj_op() {
15046        let mut chr = vec![b'N'; 220];
15047        chr[99..109].copy_from_slice(b"ACGTACGTAA");
15048        chr[109..111].copy_from_slice(b"GT");
15049        chr[197..199].copy_from_slice(b"AG");
15050        chr[199..209].copy_from_slice(b"CCCCCCCCCC");
15051        let mut reference = HashMap::new();
15052        reference.insert("chr1".to_string(), String::from_utf8(chr).unwrap());
15053        let correction = SegmentCorrectionContext {
15054            reference: &reference,
15055            annotation: None,
15056            junction_support: None,
15057        };
15058        let circ = CircRecord {
15059            id: "chr1:100|209".to_string(),
15060            chr: "chr1".to_string(),
15061            start: 100,
15062            end: 209,
15063            junction_read_count: "1".to_string(),
15064            gene_id: "GENE1".to_string(),
15065            strand: "+".to_string(),
15066        };
15067        let parsed = vec![ParsedAlignment {
15068            flag: 0,
15069            chrom: "chr1".to_string(),
15070            strand: '+',
15071            mapq: 60,
15072            seq: "CCCCCCCCCCACGTTCGTAA".to_string(),
15073            blocks: vec![
15074                SegmentBlock {
15075                    read_start: 1,
15076                    read_end: 10,
15077                    ref_start: 200,
15078                    ref_end: 209,
15079                    from_local_clip: false,
15080                    cigar_ops: Vec::new(),
15081                },
15082                SegmentBlock {
15083                    read_start: 11,
15084                    read_end: 20,
15085                    ref_start: 100,
15086                    ref_end: 109,
15087                    from_local_clip: false,
15088                    cigar_ops: Vec::new(),
15089                },
15090            ],
15091        }];
15092
15093        let chain = materialize_chain(&parsed, 20, Some(&circ), '+', &[], Some(&correction), false)
15094            .unwrap();
15095
15096        assert_eq!(chain.cigar, "10M90B10M");
15097        assert_eq!(chain.cs, ":10<gt90ag:4*at:5");
15098    }
15099
15100    #[test]
15101    fn materialize_chain_preserves_internal_indel_position_in_cs() {
15102        let mut chr = vec![b'N'; 120];
15103        chr[99..109].copy_from_slice(b"AAAAACCCCC");
15104        let mut reference = HashMap::new();
15105        reference.insert("chr1".to_string(), String::from_utf8(chr).unwrap());
15106        let correction = SegmentCorrectionContext {
15107            reference: &reference,
15108            annotation: None,
15109            junction_support: None,
15110        };
15111        let parsed = vec![ParsedAlignment {
15112            flag: 0,
15113            chrom: "chr1".to_string(),
15114            strand: '+',
15115            mapq: 60,
15116            seq: "AAAAAGCCCCC".to_string(),
15117            blocks: vec![SegmentBlock {
15118                read_start: 1,
15119                read_end: 11,
15120                ref_start: 100,
15121                ref_end: 109,
15122                from_local_clip: false,
15123                cigar_ops: vec![
15124                    SegmentCigarOp { len: 5, op: 'M' },
15125                    SegmentCigarOp { len: 1, op: 'I' },
15126                    SegmentCigarOp { len: 5, op: 'M' },
15127                ],
15128            }],
15129        }];
15130
15131        let chain =
15132            materialize_chain(&parsed, 11, None, '+', &[], Some(&correction), false).unwrap();
15133
15134        assert_eq!(chain.cigar, "5M1I5M");
15135        assert_eq!(chain.cs, ":5+g:5");
15136    }
15137
15138    #[test]
15139    fn materialize_chain_reslices_query_after_internal_boundary_correction() {
15140        let mut annotation = Annotation::new();
15141        annotation
15142            .chr_exon_end_map
15143            .insert("chr1\t103".to_string(), "GENE1\t+".to_string());
15144        annotation
15145            .chr_exon_start_map
15146            .insert("chr1\t201".to_string(), "GENE1\t+".to_string());
15147        annotation
15148            .transcript_splice_map
15149            .insert("chr1\t103\t201\t+".to_string());
15150        let mut chr = vec![b'N'; 230];
15151        chr[99..103].copy_from_slice(b"ACGT");
15152        chr[103..105].copy_from_slice(b"GT");
15153        chr[198..200].copy_from_slice(b"AG");
15154        chr[200..204].copy_from_slice(b"GGAA");
15155        let mut reference = HashMap::new();
15156        reference.insert("chr1".to_string(), String::from_utf8(chr).unwrap());
15157        let correction = SegmentCorrectionContext {
15158            reference: &reference,
15159            annotation: Some(&annotation),
15160            junction_support: None,
15161        };
15162        let parsed = vec![ParsedAlignment {
15163            flag: 0,
15164            chrom: "chr1".to_string(),
15165            strand: '+',
15166            mapq: 60,
15167            seq: "ACGTCCGGAA".to_string(),
15168            blocks: vec![
15169                SegmentBlock {
15170                    read_start: 1,
15171                    read_end: 5,
15172                    ref_start: 100,
15173                    ref_end: 104,
15174                    from_local_clip: false,
15175                    cigar_ops: Vec::new(),
15176                },
15177                SegmentBlock {
15178                    read_start: 6,
15179                    read_end: 10,
15180                    ref_start: 200,
15181                    ref_end: 204,
15182                    from_local_clip: false,
15183                    cigar_ops: Vec::new(),
15184                },
15185            ],
15186        }];
15187
15188        let chain =
15189            materialize_chain(&parsed, 10, None, '+', &[], Some(&correction), false).unwrap();
15190
15191        assert_eq!(
15192            chain.tokens,
15193            vec!["100-103:+".to_string(), "201-204:+".to_string()]
15194        );
15195        assert_eq!(chain.cigar, "4M97N4M");
15196        assert_eq!(chain.cs, ":4~gt97ag:4");
15197    }
15198
15199    #[test]
15200    fn materialize_chain_reslices_query_after_circ_boundary_correction() {
15201        let mut chr = vec![b'N'; 220];
15202        chr[99..143].copy_from_slice("ACGT".repeat(11).as_bytes());
15203        chr[143..145].copy_from_slice(b"GT");
15204        chr[187..189].copy_from_slice(b"AG");
15205        chr[189..199].copy_from_slice(b"CCCCCCCCCC");
15206        let mut reference = HashMap::new();
15207        reference.insert("chr1".to_string(), String::from_utf8(chr).unwrap());
15208        let correction = SegmentCorrectionContext {
15209            reference: &reference,
15210            annotation: None,
15211            junction_support: None,
15212        };
15213        let circ = CircRecord {
15214            id: "chr1:100|199".to_string(),
15215            chr: "chr1".to_string(),
15216            start: 100,
15217            end: 199,
15218            junction_read_count: "1".to_string(),
15219            gene_id: "GENE1".to_string(),
15220            strand: "+".to_string(),
15221        };
15222        let right_query = "ACGT".repeat(11);
15223        let parsed = vec![ParsedAlignment {
15224            flag: 0,
15225            chrom: "chr1".to_string(),
15226            strand: '+',
15227            mapq: 60,
15228            seq: format!("CCCCCCCCCCT{right_query}"),
15229            blocks: vec![
15230                SegmentBlock {
15231                    read_start: 1,
15232                    read_end: 10,
15233                    ref_start: 190,
15234                    ref_end: 199,
15235                    from_local_clip: false,
15236                    cigar_ops: Vec::new(),
15237                },
15238                SegmentBlock {
15239                    read_start: 11,
15240                    read_end: 55,
15241                    ref_start: 99,
15242                    ref_end: 143,
15243                    from_local_clip: false,
15244                    cigar_ops: Vec::new(),
15245                },
15246            ],
15247        }];
15248
15249        let chain = materialize_chain(&parsed, 55, Some(&circ), '+', &[], Some(&correction), false)
15250            .unwrap();
15251
15252        assert_eq!(
15253            chain.tokens,
15254            vec![
15255                "190-199:+".to_string(),
15256                "<bsj>".to_string(),
15257                "100-143:+".to_string()
15258            ]
15259        );
15260        assert_eq!(chain.cigar, "10M46B44M");
15261        assert_eq!(chain.cs, ":10<gt46ag:44");
15262    }
15263
15264    #[test]
15265    fn materialize_chain_reslices_reverse_query_from_opposite_edge() {
15266        let mut chr = vec![b'N'; 120];
15267        chr[99..103].copy_from_slice(b"ACGT");
15268        let mut reference = HashMap::new();
15269        reference.insert("chr1".to_string(), String::from_utf8(chr).unwrap());
15270        let correction = SegmentCorrectionContext {
15271            reference: &reference,
15272            annotation: None,
15273            junction_support: None,
15274        };
15275        let circ = CircRecord {
15276            id: "chr1:100|103".to_string(),
15277            chr: "chr1".to_string(),
15278            start: 100,
15279            end: 103,
15280            junction_read_count: "1".to_string(),
15281            gene_id: "GENE1".to_string(),
15282            strand: "-".to_string(),
15283        };
15284        let parsed = vec![ParsedAlignment {
15285            flag: 0x10,
15286            chrom: "chr1".to_string(),
15287            strand: '-',
15288            mapq: 60,
15289            seq: "TACGT".to_string(),
15290            blocks: vec![SegmentBlock {
15291                read_start: 1,
15292                read_end: 5,
15293                ref_start: 99,
15294                ref_end: 103,
15295                from_local_clip: false,
15296                cigar_ops: Vec::new(),
15297            }],
15298        }];
15299
15300        let chain =
15301            materialize_chain(&parsed, 5, Some(&circ), '-', &[], Some(&correction), false).unwrap();
15302
15303        assert_eq!(chain.tokens, vec!["100-103:-".to_string()]);
15304        assert_eq!(chain.cigar, "4M1S");
15305        assert_eq!(chain.cs, ":4");
15306    }
15307
15308    #[test]
15309    fn short_cs_reconstruction_restores_clips_and_hard_clip_offsets() {
15310        let reference = "NNNNNNNNNACGATTTT".to_string();
15311
15312        let soft =
15313            sequence_from_short_cs("2S4M", ":4", 10, 6, Some(&reference), Some("L:TT")).unwrap();
15314        assert_eq!(soft, "TTACGA");
15315
15316        let hard = sequence_from_short_cs("3H4M", ":4", 10, 7, Some(&reference), None).unwrap();
15317        assert_eq!(hard, "NNNACGA");
15318    }
15319
15320    #[test]
15321    fn materialize_chain_reverse_order_cs_uses_output_query_orientation() {
15322        let mut reference = HashMap::new();
15323        reference.insert("chr1".to_string(), "NNNNNNNNNACGA".to_string());
15324        let correction = SegmentCorrectionContext {
15325            reference: &reference,
15326            annotation: None,
15327            junction_support: None,
15328        };
15329        let parsed = vec![ParsedAlignment {
15330            flag: 0x10,
15331            chrom: "chr1".to_string(),
15332            strand: '-',
15333            mapq: 60,
15334            seq: "NNACGA".to_string(),
15335            blocks: vec![SegmentBlock {
15336                read_start: 1,
15337                read_end: 4,
15338                ref_start: 10,
15339                ref_end: 13,
15340                from_local_clip: false,
15341                cigar_ops: Vec::new(),
15342            }],
15343        }];
15344
15345        let chain = materialize_chain(&parsed, 6, None, '+', &[], Some(&correction), true).unwrap();
15346
15347        assert_eq!(chain.cigar, "2S4M");
15348        assert_eq!(chain.cs, ":4");
15349    }
15350
15351    #[test]
15352    fn materialize_chain_reverse_alignment_cs_uses_sam_sequence_orientation() {
15353        let mut reference = HashMap::new();
15354        reference.insert("chr1".to_string(), "NNNNNNNNNACGA".to_string());
15355        let correction = SegmentCorrectionContext {
15356            reference: &reference,
15357            annotation: None,
15358            junction_support: None,
15359        };
15360        let parsed = vec![ParsedAlignment {
15361            flag: 0x10,
15362            chrom: "chr1".to_string(),
15363            strand: '-',
15364            mapq: 60,
15365            seq: "NNACGA".to_string(),
15366            blocks: vec![SegmentBlock {
15367                read_start: 1,
15368                read_end: 4,
15369                ref_start: 10,
15370                ref_end: 13,
15371                from_local_clip: false,
15372                cigar_ops: Vec::new(),
15373            }],
15374        }];
15375
15376        let chain =
15377            materialize_chain(&parsed, 6, None, '+', &[], Some(&correction), false).unwrap();
15378
15379        assert_eq!(chain.cigar, "4M2S");
15380        assert_eq!(chain.cs, ":4");
15381    }
15382
15383    #[test]
15384    fn bsj_marker_uses_start_side_block_for_reverse_wrap_order() {
15385        let circ = CircRecord {
15386            id: "chr1:100|349".to_string(),
15387            chr: "chr1".to_string(),
15388            start: 100,
15389            end: 349,
15390            junction_read_count: "1".to_string(),
15391            gene_id: "GENE1".to_string(),
15392            strand: "-".to_string(),
15393        };
15394        let parsed = vec![ParsedAlignment {
15395            flag: 0x10,
15396            chrom: "chr1".to_string(),
15397            strand: '-',
15398            mapq: 60,
15399            seq: "A".repeat(1000),
15400            blocks: vec![
15401                SegmentBlock {
15402                    read_start: 1,
15403                    read_end: 50,
15404                    ref_start: 100,
15405                    ref_end: 149,
15406                    from_local_clip: false,
15407                    cigar_ops: Vec::new(),
15408                },
15409                SegmentBlock {
15410                    read_start: 51,
15411                    read_end: 110,
15412                    ref_start: 300,
15413                    ref_end: 349,
15414                    from_local_clip: false,
15415                    cigar_ops: Vec::new(),
15416                },
15417                SegmentBlock {
15418                    read_start: 111,
15419                    read_end: 120,
15420                    ref_start: 200,
15421                    ref_end: 209,
15422                    from_local_clip: false,
15423                    cigar_ops: Vec::new(),
15424                },
15425            ],
15426        }];
15427
15428        let chain = materialize_chain(&parsed, 120, Some(&circ), '-', &[], None, false).unwrap();
15429
15430        assert_eq!(
15431            chain.tokens,
15432            vec![
15433                "100-149:-".to_string(),
15434                "<bsj>".to_string(),
15435                "300-349:-".to_string(),
15436                "200-209:-".to_string(),
15437            ]
15438        );
15439        assert_eq!(chain.cigar, "50M150B50M90N10M");
15440        assert!(chain.is_bsj);
15441    }
15442
15443    #[test]
15444    fn mate_bsj_evidence_marks_soft_clipped_mate() {
15445        let circ = CircRecord {
15446            id: "chr1:100|199".to_string(),
15447            chr: "chr1".to_string(),
15448            start: 100,
15449            end: 199,
15450            junction_read_count: "1".to_string(),
15451            gene_id: "GENE1".to_string(),
15452            strand: "+".to_string(),
15453        };
15454        let records = vec![AsAlignment {
15455            flag: 0x80,
15456            chr: "chr1".to_string(),
15457            pos: 100,
15458            mapq: 60,
15459            cigar: "10S90M".to_string(),
15460            seq: "A".repeat(100),
15461            cs: "*".to_string(),
15462            from_local_clip: false,
15463            xa_alternatives: Vec::new(),
15464        }];
15465        let evidence = vec![MateBsjEvidence {
15466            mate_bucket: 1,
15467            chr: "chr1".to_string(),
15468            start: 100,
15469            end: 199,
15470            strand: "+".to_string(),
15471            priority: 0,
15472            source_stage: "scan2".to_string(),
15473        }];
15474
15475        let record =
15476            build_bsj_segment_record("read1", &records, &circ, &evidence, &[], None, 100).unwrap();
15477
15478        assert_eq!(record.is_r1_bsj, 0);
15479        assert_eq!(record.r2_segments, "100-189:+");
15480        assert_eq!(record.r2_cigar, "90M10S");
15481        assert_eq!(record.is_r2_bsj, 1);
15482    }
15483
15484    #[test]
15485    fn bsj_segments_repair_non_bsj_mate_with_circ_context_xa() {
15486        let circ = CircRecord {
15487            id: "chr1:1000|2000".to_string(),
15488            chr: "chr1".to_string(),
15489            start: 1000,
15490            end: 2000,
15491            junction_read_count: "1".to_string(),
15492            gene_id: "GENE1".to_string(),
15493            strand: "+".to_string(),
15494        };
15495        let records = vec![
15496            AsAlignment {
15497                flag: 0x40,
15498                chr: "chr1".to_string(),
15499                pos: 100000,
15500                mapq: 60,
15501                cigar: "33M1I116M".to_string(),
15502                seq: "A".repeat(150),
15503                cs: "*".to_string(),
15504                from_local_clip: false,
15505                xa_alternatives: vec![XaAlternative {
15506                    chr: "chr1".to_string(),
15507                    strand: '+',
15508                    pos: 1500,
15509                    cigar: "45S105M".to_string(),
15510                    edit_distance: 0,
15511                }],
15512            },
15513            AsAlignment {
15514                flag: 0x80,
15515                chr: "chr1".to_string(),
15516                pos: 1900,
15517                mapq: 60,
15518                cigar: "80M70S".to_string(),
15519                seq: "A".repeat(150),
15520                cs: "*".to_string(),
15521                from_local_clip: false,
15522                xa_alternatives: Vec::new(),
15523            },
15524        ];
15525        let evidence = vec![MateBsjEvidence {
15526            mate_bucket: 1,
15527            chr: "chr1".to_string(),
15528            start: 1000,
15529            end: 2000,
15530            strand: "+".to_string(),
15531            priority: 0,
15532            source_stage: "scan2".to_string(),
15533        }];
15534
15535        let record =
15536            build_bsj_segment_record("read1", &records, &circ, &evidence, &[], None, 150).unwrap();
15537
15538        assert_eq!(record.r1_segments, "1500-1604:+");
15539        assert_eq!(record.r1_cigar, "45S105M");
15540        assert_eq!(record.is_r1_bsj, 0);
15541        assert_eq!(record.is_r2_bsj, 1);
15542    }
15543
15544    #[test]
15545    fn bsj_segments_repair_mate_before_bsj_evidence_label() {
15546        let circ = CircRecord {
15547            id: "chr1:93298946|93299217".to_string(),
15548            chr: "chr1".to_string(),
15549            start: 93298946,
15550            end: 93299217,
15551            junction_read_count: "1".to_string(),
15552            gene_id: "GENE1".to_string(),
15553            strand: "+".to_string(),
15554        };
15555        let records = vec![
15556            AsAlignment {
15557                flag: 0x80 | 0x10,
15558                chr: "chr1".to_string(),
15559                pos: 91489476,
15560                mapq: 60,
15561                cigar: "32S118M".to_string(),
15562                seq: "A".repeat(150),
15563                cs: "*".to_string(),
15564                from_local_clip: false,
15565                xa_alternatives: vec![XaAlternative {
15566                    chr: "chr1".to_string(),
15567                    strand: '-',
15568                    pos: 93298944,
15569                    cigar: "31S72M47S".to_string(),
15570                    edit_distance: 0,
15571                }],
15572            },
15573            AsAlignment {
15574                flag: 0x80 | 0x10 | 0x800,
15575                chr: "chr1".to_string(),
15576                pos: 93299185,
15577                mapq: 60,
15578                cigar: "34M116H".to_string(),
15579                seq: "A".repeat(34),
15580                cs: "*".to_string(),
15581                from_local_clip: false,
15582                xa_alternatives: Vec::new(),
15583            },
15584        ];
15585        let evidence = vec![MateBsjEvidence {
15586            mate_bucket: 1,
15587            chr: "chr1".to_string(),
15588            start: circ.start,
15589            end: circ.end,
15590            strand: "+".to_string(),
15591            priority: 2,
15592            source_stage: "scan2".to_string(),
15593        }];
15594
15595        let record =
15596            build_bsj_segment_record("read1", &records, &circ, &evidence, &[], None, 150).unwrap();
15597
15598        assert_eq!(
15599            record.r2_segments,
15600            "93299185-93299217:+|<bsj>|93298946-93299015:+"
15601        );
15602        assert_eq!(record.is_r2_bsj, 1);
15603        assert!(!record.r2_segments.contains("91489476"));
15604    }
15605
15606    #[test]
15607    fn materialize_chain_preserves_terminal_soft_clips_in_output_cigar() {
15608        let parsed = vec![ParsedAlignment {
15609            flag: 0,
15610            chrom: "chr1".to_string(),
15611            strand: '+',
15612            mapq: 60,
15613            seq: "A".repeat(1000),
15614            blocks: vec![SegmentBlock {
15615                read_start: 11,
15616                read_end: 90,
15617                ref_start: 100,
15618                ref_end: 179,
15619                from_local_clip: false,
15620                cigar_ops: Vec::new(),
15621            }],
15622        }];
15623
15624        let chain = materialize_chain(&parsed, 100, None, '+', &[], None, false).unwrap();
15625
15626        assert_eq!(chain.tokens, vec!["100-179:+".to_string()]);
15627        assert_eq!(chain.cigar, "10S80M10S");
15628    }
15629
15630    #[test]
15631    fn bsj_transition_follows_output_chain_wrap() {
15632        let circ = CircRecord {
15633            id: "chr1:100|199".to_string(),
15634            chr: "chr1".to_string(),
15635            start: 100,
15636            end: 199,
15637            junction_read_count: "1".to_string(),
15638            gene_id: "GENE1".to_string(),
15639            strand: "+".to_string(),
15640        };
15641
15642        let low_then_high = (
15643            SegmentBlock {
15644                read_start: 1,
15645                read_end: 90,
15646                ref_start: 100,
15647                ref_end: 149,
15648                from_local_clip: false,
15649                cigar_ops: Vec::new(),
15650            },
15651            SegmentBlock {
15652                read_start: 91,
15653                read_end: 150,
15654                ref_start: 150,
15655                ref_end: 199,
15656                from_local_clip: false,
15657                cigar_ops: Vec::new(),
15658            },
15659        );
15660        let high_then_low = (low_then_high.1.clone(), low_then_high.0.clone());
15661
15662        assert!(is_bsj_transition(
15663            &low_then_high.0,
15664            &low_then_high.1,
15665            &circ,
15666            '-'
15667        ));
15668        assert!(!is_bsj_transition(
15669            &high_then_low.0,
15670            &high_then_low.1,
15671            &circ,
15672            '-'
15673        ));
15674        assert!(!is_bsj_transition(
15675            &low_then_high.0,
15676            &low_then_high.1,
15677            &circ,
15678            '+'
15679        ));
15680        assert!(is_bsj_transition(
15681            &high_then_low.0,
15682            &high_then_low.1,
15683            &circ,
15684            '+'
15685        ));
15686    }
15687
15688    #[test]
15689    fn materialize_chain_keeps_distant_blocks_separate() {
15690        let parsed = vec![
15691            ParsedAlignment {
15692                flag: 0x10,
15693                chrom: "chr1".to_string(),
15694                strand: '-',
15695                mapq: 60,
15696                seq: "A".repeat(1000),
15697                blocks: vec![SegmentBlock {
15698                    read_start: 1,
15699                    read_end: 29,
15700                    ref_start: 93811203,
15701                    ref_end: 93811231,
15702                    from_local_clip: false,
15703                    cigar_ops: Vec::new(),
15704                }],
15705            },
15706            ParsedAlignment {
15707                flag: 0x10,
15708                chrom: "chr1".to_string(),
15709                strand: '-',
15710                mapq: 60,
15711                seq: "A".repeat(1000),
15712                blocks: vec![SegmentBlock {
15713                    read_start: 29,
15714                    read_end: 83,
15715                    ref_start: 93806014,
15716                    ref_end: 93806068,
15717                    from_local_clip: false,
15718                    cigar_ops: Vec::new(),
15719                }],
15720            },
15721            ParsedAlignment {
15722                flag: 0x10,
15723                chrom: "chr1".to_string(),
15724                strand: '-',
15725                mapq: 60,
15726                seq: "A".repeat(1000),
15727                blocks: vec![SegmentBlock {
15728                    read_start: 83,
15729                    read_end: 150,
15730                    ref_start: 93811273,
15731                    ref_end: 93811340,
15732                    from_local_clip: false,
15733                    cigar_ops: Vec::new(),
15734                }],
15735            },
15736        ];
15737
15738        let chain = materialize_chain(&parsed, 150, None, '-', &[], None, false).unwrap();
15739
15740        assert_eq!(
15741            chain.tokens,
15742            vec![
15743                "93811203-93811231:-".to_string(),
15744                "93806014-93806068:-".to_string(),
15745                "<bsj>".to_string(),
15746                "93811273-93811340:-".to_string(),
15747            ]
15748        );
15749        assert_eq!(chain.cigar, "29M5134N55M5204B68M");
15750    }
15751
15752    #[test]
15753    fn materialize_chain_corrects_internal_splice_boundaries_with_annotation() {
15754        let mut annotation = Annotation::new();
15755        annotation
15756            .chr_exon_end_map
15757            .insert("chr1\t171292331".to_string(), "GENE1\t+".to_string());
15758        annotation
15759            .chr_exon_start_map
15760            .insert("chr1\t171300779".to_string(), "GENE1\t+".to_string());
15761        let reference = HashMap::new();
15762        let correction = SegmentCorrectionContext {
15763            reference: &reference,
15764            annotation: Some(&annotation),
15765            junction_support: None,
15766        };
15767        let parsed = vec![ParsedAlignment {
15768            flag: 0,
15769            chrom: "chr1".to_string(),
15770            strand: '+',
15771            mapq: 60,
15772            seq: "A".repeat(1000),
15773            blocks: vec![
15774                SegmentBlock {
15775                    read_start: 1,
15776                    read_end: 88,
15777                    ref_start: 171292245,
15778                    ref_end: 171292332,
15779                    from_local_clip: false,
15780                    cigar_ops: Vec::new(),
15781                },
15782                SegmentBlock {
15783                    read_start: 89,
15784                    read_end: 146,
15785                    ref_start: 171300777,
15786                    ref_end: 171300834,
15787                    from_local_clip: false,
15788                    cigar_ops: Vec::new(),
15789                },
15790            ],
15791        }];
15792
15793        let chain =
15794            materialize_chain(&parsed, 146, None, '+', &[], Some(&correction), false).unwrap();
15795
15796        assert_eq!(
15797            chain.tokens,
15798            vec![
15799                "171292245-171292331:+".to_string(),
15800                "171300779-171300834:+".to_string(),
15801            ]
15802        );
15803        assert_eq!(chain.cigar, "87M8447N56M");
15804    }
15805
15806    #[test]
15807    fn materialize_chain_prefers_transcript_consistent_boundary() {
15808        let mut annotation = Annotation::new();
15809        annotation
15810            .chr_exon_end_map
15811            .insert("chr1\t196".to_string(), "GENE1\t+".to_string());
15812        annotation
15813            .chr_exon_start_map
15814            .insert("chr1\t300".to_string(), "GENE1\t+".to_string());
15815        annotation
15816            .chr_exon_end_map
15817            .insert("chr1\t199".to_string(), "GENE1\t+".to_string());
15818        annotation
15819            .chr_exon_start_map
15820            .insert("chr1\t304".to_string(), "GENE1\t+".to_string());
15821        annotation
15822            .transcript_splice_map
15823            .insert("chr1\t199\t304\t+".to_string());
15824        let reference = HashMap::new();
15825        let correction = SegmentCorrectionContext {
15826            reference: &reference,
15827            annotation: Some(&annotation),
15828            junction_support: None,
15829        };
15830        let parsed = vec![ParsedAlignment {
15831            flag: 0,
15832            chrom: "chr1".to_string(),
15833            strand: '+',
15834            mapq: 60,
15835            seq: "A".repeat(1000),
15836            blocks: vec![
15837                SegmentBlock {
15838                    read_start: 1,
15839                    read_end: 97,
15840                    ref_start: 100,
15841                    ref_end: 196,
15842                    from_local_clip: false,
15843                    cigar_ops: Vec::new(),
15844                },
15845                SegmentBlock {
15846                    read_start: 98,
15847                    read_end: 148,
15848                    ref_start: 300,
15849                    ref_end: 350,
15850                    from_local_clip: false,
15851                    cigar_ops: Vec::new(),
15852                },
15853            ],
15854        }];
15855
15856        let chain =
15857            materialize_chain(&parsed, 148, None, '+', &[], Some(&correction), false).unwrap();
15858
15859        assert_eq!(
15860            chain.tokens,
15861            vec!["100-199:+".to_string(), "304-350:+".to_string()]
15862        );
15863        assert_eq!(chain.cigar, "100M104N47M");
15864    }
15865
15866    #[test]
15867    fn materialize_chain_prefers_supported_boundary_for_local_clip() {
15868        let reference = HashMap::new();
15869        let mut support: JunctionSupportMap = HashMap::new();
15870        support
15871            .entry("chr1".to_string())
15872            .or_default()
15873            .insert((199, 328, '+'), 12);
15874        let correction = SegmentCorrectionContext {
15875            reference: &reference,
15876            annotation: None,
15877            junction_support: Some(&support),
15878        };
15879        let parsed = vec![ParsedAlignment {
15880            flag: 0,
15881            chrom: "chr1".to_string(),
15882            strand: '+',
15883            mapq: 60,
15884            seq: "A".repeat(1000),
15885            blocks: vec![
15886                SegmentBlock {
15887                    read_start: 1,
15888                    read_end: 100,
15889                    ref_start: 100,
15890                    ref_end: 199,
15891                    from_local_clip: false,
15892                    cigar_ops: Vec::new(),
15893                },
15894                SegmentBlock {
15895                    read_start: 131,
15896                    read_end: 150,
15897                    ref_start: 340,
15898                    ref_end: 359,
15899                    from_local_clip: true,
15900                    cigar_ops: Vec::new(),
15901                },
15902            ],
15903        }];
15904
15905        let chain =
15906            materialize_chain(&parsed, 150, None, '+', &[], Some(&correction), false).unwrap();
15907
15908        assert_eq!(
15909            chain.tokens,
15910            vec!["100-199:+".to_string(), "328-359:+".to_string()]
15911        );
15912        assert_eq!(chain.cigar, "100M128N32M");
15913    }
15914
15915    #[test]
15916    fn build_pair_chains_widens_correction_for_terminal_mapper_hard_clips() {
15917        let mut annotation = Annotation::new();
15918        annotation
15919            .chr_exon_end_map
15920            .insert("chr1\t199".to_string(), "GENE1\t+".to_string());
15921        annotation
15922            .chr_exon_start_map
15923            .insert("chr1\t300".to_string(), "GENE1\t+".to_string());
15924        annotation
15925            .transcript_splice_map
15926            .insert("chr1\t199\t300\t+".to_string());
15927        let reference = HashMap::new();
15928        let correction = SegmentCorrectionContext {
15929            reference: &reference,
15930            annotation: Some(&annotation),
15931            junction_support: None,
15932        };
15933        let records = vec![
15934            AsAlignment {
15935                flag: 0x40,
15936                chr: "chr1".to_string(),
15937                pos: 100,
15938                mapq: 60,
15939                cigar: "101M49S".to_string(),
15940                seq: "A".repeat(150),
15941                cs: "*".to_string(),
15942                from_local_clip: false,
15943                xa_alternatives: Vec::new(),
15944            },
15945            AsAlignment {
15946                flag: 0x40 | 0x800,
15947                chr: "chr1".to_string(),
15948                pos: 295,
15949                mapq: 60,
15950                cigar: "95H55M".to_string(),
15951                seq: "A".repeat(55),
15952                cs: "*".to_string(),
15953                from_local_clip: false,
15954                xa_alternatives: Vec::new(),
15955            },
15956        ];
15957
15958        let chains = build_pair_chains(
15959            &records,
15960            150,
15961            None,
15962            "outward",
15963            '+',
15964            &[(200, 295)],
15965            Some(&correction),
15966        );
15967        let chain = chains[0].as_ref().unwrap();
15968
15969        assert_eq!(
15970            chain.tokens,
15971            vec!["100-199:+".to_string(), "300-349:+".to_string()]
15972        );
15973        assert_eq!(chain.cigar, "100M100N50M");
15974    }
15975
15976    #[test]
15977    fn build_pair_chains_reuses_full_sequence_for_local_clip_extension() {
15978        let mut reference_bases = vec![b'C'; 400];
15979        let donor_seq = b"ATGAAACTGGATGAAGATGTGAAG";
15980        reference_bases[99..123].copy_from_slice(donor_seq);
15981        reference_bases[299..349].fill(b'T');
15982        let mut reference = HashMap::new();
15983        reference.insert(
15984            "chr1".to_string(),
15985            String::from_utf8(reference_bases).unwrap(),
15986        );
15987        let mut annotation = Annotation::new();
15988        annotation
15989            .chr_exon_end_map
15990            .insert("chr1\t123".to_string(), "GENE1\t+".to_string());
15991        annotation
15992            .chr_exon_start_map
15993            .insert("chr1\t300".to_string(), "GENE1\t+".to_string());
15994        annotation
15995            .transcript_splice_map
15996            .insert("chr1\t123\t300\t+".to_string());
15997        let correction = SegmentCorrectionContext {
15998            reference: &reference,
15999            annotation: Some(&annotation),
16000            junction_support: None,
16001        };
16002        let full_seq = format!(
16003            "{}{}{}",
16004            String::from_utf8(donor_seq.to_vec()).unwrap(),
16005            "T".repeat(50),
16006            "A".repeat(76)
16007        );
16008        let records = vec![
16009            AsAlignment {
16010                flag: 0x40,
16011                chr: "chr1".to_string(),
16012                pos: 300,
16013                mapq: 60,
16014                cigar: "24S50M76S".to_string(),
16015                seq: full_seq,
16016                cs: ":50".to_string(),
16017                from_local_clip: false,
16018                xa_alternatives: Vec::new(),
16019            },
16020            AsAlignment {
16021                flag: 0x40 | 0x800,
16022                chr: "chr1".to_string(),
16023                pos: 100,
16024                mapq: 59,
16025                cigar: "22M128S".to_string(),
16026                seq: "*".to_string(),
16027                cs: ":22".to_string(),
16028                from_local_clip: true,
16029                xa_alternatives: Vec::new(),
16030            },
16031        ];
16032
16033        let chains = build_pair_chains(&records, 150, None, "outward", '+', &[], Some(&correction));
16034        let chain = chains[0].as_ref().unwrap();
16035
16036        assert_eq!(
16037            chain.tokens,
16038            vec!["100-123:+".to_string(), "300-349:+".to_string()]
16039        );
16040        assert_eq!(chain.cigar, "24M176N50M76S");
16041        assert_eq!(chain.cs, ":24~cc176cc:50");
16042    }
16043
16044    #[test]
16045    fn materialize_chain_prefers_sequence_match_over_canonical_motif_drift() {
16046        let mut annotation = Annotation::new();
16047        for end in [199, 203] {
16048            annotation
16049                .chr_exon_end_map
16050                .insert(format!("chr1\t{end}"), "GENE1\t+".to_string());
16051            annotation
16052                .transcript_splice_map
16053                .insert(format!("chr1\t{end}\t328\t+"));
16054        }
16055        annotation
16056            .chr_exon_start_map
16057            .insert("chr1\t328".to_string(), "GENE1\t+".to_string());
16058        let mut reference_bases = vec![b'A'; 400];
16059        for pos in 200..=203 {
16060            reference_bases[(pos - 1) as usize] = b'C';
16061        }
16062        reference_bases[203] = b'G';
16063        reference_bases[204] = b'T';
16064        reference_bases[325] = b'A';
16065        reference_bases[326] = b'G';
16066        for pos in 328..=381 {
16067            reference_bases[(pos - 1) as usize] = b'T';
16068        }
16069        let mut reference = HashMap::new();
16070        reference.insert(
16071            "chr1".to_string(),
16072            String::from_utf8(reference_bases).unwrap(),
16073        );
16074        let mut support: JunctionSupportMap = HashMap::new();
16075        support
16076            .entry("chr1".to_string())
16077            .or_default()
16078            .insert((203, 328, '+'), 50);
16079        let correction = SegmentCorrectionContext {
16080            reference: &reference,
16081            annotation: Some(&annotation),
16082            junction_support: Some(&support),
16083        };
16084        let parsed = vec![ParsedAlignment {
16085            flag: 0,
16086            chrom: "chr1".to_string(),
16087            strand: '+',
16088            mapq: 60,
16089            seq: format!("{}{}", "A".repeat(100), "T".repeat(54)),
16090            blocks: vec![
16091                SegmentBlock {
16092                    read_start: 1,
16093                    read_end: 104,
16094                    ref_start: 100,
16095                    ref_end: 203,
16096                    from_local_clip: false,
16097                    cigar_ops: Vec::new(),
16098                },
16099                SegmentBlock {
16100                    read_start: 105,
16101                    read_end: 154,
16102                    ref_start: 332,
16103                    ref_end: 381,
16104                    from_local_clip: false,
16105                    cigar_ops: Vec::new(),
16106                },
16107            ],
16108        }];
16109
16110        let chain =
16111            materialize_chain(&parsed, 154, None, '+', &[], Some(&correction), false).unwrap();
16112
16113        assert_eq!(
16114            chain.tokens,
16115            vec!["100-199:+".to_string(), "328-381:+".to_string()]
16116        );
16117        assert_eq!(chain.cigar, "100M128N54M");
16118    }
16119
16120    #[test]
16121    fn materialize_chain_prioritizes_transcript_junction_over_microhomology_extension() {
16122        let mut annotation = Annotation::new();
16123        annotation
16124            .chr_exon_end_map
16125            .insert("chr1\t199".to_string(), "GENE1\t+".to_string());
16126        annotation
16127            .chr_exon_start_map
16128            .insert("chr1\t328".to_string(), "GENE1\t+".to_string());
16129        annotation
16130            .transcript_splice_map
16131            .insert("chr1\t199\t328\t+".to_string());
16132        let mut reference_bases = vec![b'A'; 400];
16133        for pos in 328..=381 {
16134            reference_bases[(pos - 1) as usize] = b'T';
16135        }
16136        let mut reference = HashMap::new();
16137        reference.insert(
16138            "chr1".to_string(),
16139            String::from_utf8(reference_bases).unwrap(),
16140        );
16141        let mut support: JunctionSupportMap = HashMap::new();
16142        support
16143            .entry("chr1".to_string())
16144            .or_default()
16145            .insert((203, 328, '+'), 50);
16146        let correction = SegmentCorrectionContext {
16147            reference: &reference,
16148            annotation: Some(&annotation),
16149            junction_support: Some(&support),
16150        };
16151        let parsed = vec![ParsedAlignment {
16152            flag: 0,
16153            chrom: "chr1".to_string(),
16154            strand: '+',
16155            mapq: 60,
16156            seq: format!("{}{}", "A".repeat(104), "T".repeat(54)),
16157            blocks: vec![
16158                SegmentBlock {
16159                    read_start: 1,
16160                    read_end: 104,
16161                    ref_start: 100,
16162                    ref_end: 203,
16163                    from_local_clip: false,
16164                    cigar_ops: Vec::new(),
16165                },
16166                SegmentBlock {
16167                    read_start: 105,
16168                    read_end: 158,
16169                    ref_start: 328,
16170                    ref_end: 381,
16171                    from_local_clip: false,
16172                    cigar_ops: Vec::new(),
16173                },
16174            ],
16175        }];
16176
16177        let chain =
16178            materialize_chain(&parsed, 158, None, '+', &[], Some(&correction), false).unwrap();
16179
16180        assert_eq!(
16181            chain.tokens,
16182            vec!["100-199:+".to_string(), "328-381:+".to_string()]
16183        );
16184        assert_eq!(chain.cigar, "100M128N54M");
16185    }
16186
16187    #[test]
16188    fn materialize_chain_treats_one_base_microhomology_as_evidence_tie() {
16189        let mut annotation = Annotation::new();
16190        annotation
16191            .chr_exon_end_map
16192            .insert("chr1\t199".to_string(), "GENE1\t+".to_string());
16193        annotation
16194            .chr_exon_start_map
16195            .insert("chr1\t328".to_string(), "GENE1\t+".to_string());
16196        annotation
16197            .transcript_splice_map
16198            .insert("chr1\t199\t328\t+".to_string());
16199        let mut reference = HashMap::new();
16200        reference.insert("chr1".to_string(), "A".repeat(400));
16201        let mut support: JunctionSupportMap = HashMap::new();
16202        support
16203            .entry("chr1".to_string())
16204            .or_default()
16205            .insert((200, 328, '+'), 50);
16206        let correction = SegmentCorrectionContext {
16207            reference: &reference,
16208            annotation: Some(&annotation),
16209            junction_support: Some(&support),
16210        };
16211        let parsed = vec![ParsedAlignment {
16212            flag: 0,
16213            chrom: "chr1".to_string(),
16214            strand: '+',
16215            mapq: 60,
16216            seq: "A".repeat(151),
16217            blocks: vec![
16218                SegmentBlock {
16219                    read_start: 1,
16220                    read_end: 101,
16221                    ref_start: 100,
16222                    ref_end: 200,
16223                    from_local_clip: false,
16224                    cigar_ops: Vec::new(),
16225                },
16226                SegmentBlock {
16227                    read_start: 102,
16228                    read_end: 151,
16229                    ref_start: 328,
16230                    ref_end: 377,
16231                    from_local_clip: false,
16232                    cigar_ops: Vec::new(),
16233                },
16234            ],
16235        }];
16236
16237        let chain =
16238            materialize_chain(&parsed, 151, None, '+', &[], Some(&correction), false).unwrap();
16239
16240        assert_eq!(
16241            chain.tokens,
16242            vec!["100-199:+".to_string(), "328-377:+".to_string()]
16243        );
16244    }
16245
16246    #[test]
16247    fn supported_alternative_filter_marks_only_nearby_different_junctions() {
16248        let mut support_map: JunctionSupportMap = HashMap::new();
16249        support_map
16250            .entry("chr1".to_string())
16251            .or_default()
16252            .insert((199, 328, '+'), 12);
16253        let support = build_junction_support_index(&support_map);
16254        assert!(segments_have_supported_alternative(
16255            "100-199:+|340-359:+",
16256            support.get("chr1").unwrap()
16257        ));
16258
16259        let mut same_support_map: JunctionSupportMap = HashMap::new();
16260        same_support_map
16261            .entry("chr1".to_string())
16262            .or_default()
16263            .insert((199, 340, '+'), 12);
16264        let same_support = build_junction_support_index(&same_support_map);
16265        assert!(!segments_have_supported_alternative(
16266            "100-199:+|340-359:+",
16267            same_support.get("chr1").unwrap()
16268        ));
16269    }
16270
16271    #[test]
16272    fn materialize_chain_widens_annotation_window_for_local_clip_block() {
16273        let mut annotation = Annotation::new();
16274        annotation
16275            .chr_exon_end_map
16276            .insert("chr1\t199".to_string(), "GENE1\t+".to_string());
16277        annotation
16278            .chr_exon_start_map
16279            .insert("chr1\t328".to_string(), "GENE1\t+".to_string());
16280        let reference = HashMap::new();
16281        let correction = SegmentCorrectionContext {
16282            reference: &reference,
16283            annotation: Some(&annotation),
16284            junction_support: None,
16285        };
16286        let parsed = vec![ParsedAlignment {
16287            flag: 0,
16288            chrom: "chr1".to_string(),
16289            strand: '+',
16290            mapq: 60,
16291            seq: "A".repeat(1000),
16292            blocks: vec![
16293                SegmentBlock {
16294                    read_start: 1,
16295                    read_end: 100,
16296                    ref_start: 100,
16297                    ref_end: 199,
16298                    from_local_clip: false,
16299                    cigar_ops: Vec::new(),
16300                },
16301                SegmentBlock {
16302                    read_start: 131,
16303                    read_end: 150,
16304                    ref_start: 340,
16305                    ref_end: 359,
16306                    from_local_clip: true,
16307                    cigar_ops: Vec::new(),
16308                },
16309            ],
16310        }];
16311
16312        let chain =
16313            materialize_chain(&parsed, 150, None, '+', &[], Some(&correction), false).unwrap();
16314
16315        assert_eq!(
16316            chain.tokens,
16317            vec!["100-199:+".to_string(), "328-359:+".to_string()]
16318        );
16319        assert_eq!(chain.cigar, "100M128N32M");
16320    }
16321
16322    #[test]
16323    fn materialize_chain_skips_bsj_gap_but_corrects_adjacent_internal_junction() {
16324        let circ = CircRecord {
16325            id: "chr1:100|349".to_string(),
16326            chr: "chr1".to_string(),
16327            start: 100,
16328            end: 349,
16329            junction_read_count: "1".to_string(),
16330            gene_id: "GENE1".to_string(),
16331            strand: "+".to_string(),
16332        };
16333        let mut annotation = Annotation::new();
16334        annotation
16335            .chr_exon_end_map
16336            .insert("chr1\t149".to_string(), "GENE1\t+".to_string());
16337        annotation
16338            .chr_exon_start_map
16339            .insert("chr1\t200".to_string(), "GENE1\t+".to_string());
16340        annotation
16341            .chr_exon_end_map
16342            .insert("chr1\t345".to_string(), "GENE1\t+".to_string());
16343        annotation
16344            .chr_exon_start_map
16345            .insert("chr1\t104".to_string(), "GENE1\t+".to_string());
16346        let reference = HashMap::new();
16347        let correction = SegmentCorrectionContext {
16348            reference: &reference,
16349            annotation: Some(&annotation),
16350            junction_support: None,
16351        };
16352        let parsed = vec![ParsedAlignment {
16353            flag: 0,
16354            chrom: "chr1".to_string(),
16355            strand: '+',
16356            mapq: 60,
16357            seq: "A".repeat(1000),
16358            blocks: vec![
16359                SegmentBlock {
16360                    read_start: 1,
16361                    read_end: 50,
16362                    ref_start: 300,
16363                    ref_end: 349,
16364                    from_local_clip: false,
16365                    cigar_ops: Vec::new(),
16366                },
16367                SegmentBlock {
16368                    read_start: 51,
16369                    read_end: 101,
16370                    ref_start: 100,
16371                    ref_end: 150,
16372                    from_local_clip: false,
16373                    cigar_ops: Vec::new(),
16374                },
16375                SegmentBlock {
16376                    read_start: 102,
16377                    read_end: 153,
16378                    ref_start: 198,
16379                    ref_end: 249,
16380                    from_local_clip: false,
16381                    cigar_ops: Vec::new(),
16382                },
16383            ],
16384        }];
16385
16386        let chain = materialize_chain(
16387            &parsed,
16388            153,
16389            Some(&circ),
16390            '+',
16391            &[],
16392            Some(&correction),
16393            false,
16394        )
16395        .unwrap();
16396
16397        assert_eq!(
16398            chain.tokens,
16399            vec![
16400                "300-349:+".to_string(),
16401                "<bsj>".to_string(),
16402                "100-149:+".to_string(),
16403                "200-249:+".to_string(),
16404            ]
16405        );
16406        assert_eq!(chain.cigar, "50M150B50M50N50M");
16407    }
16408
16409    #[test]
16410    fn materialize_chain_forces_confirmed_bsj_boundary_over_annotation() {
16411        let circ = CircRecord {
16412            id: "chr1:100|349".to_string(),
16413            chr: "chr1".to_string(),
16414            start: 100,
16415            end: 349,
16416            junction_read_count: "1".to_string(),
16417            gene_id: "GENE1".to_string(),
16418            strand: "+".to_string(),
16419        };
16420        let mut annotation = Annotation::new();
16421        annotation
16422            .chr_exon_end_map
16423            .insert("chr1\t345".to_string(), "GENE1\t+".to_string());
16424        annotation
16425            .chr_exon_start_map
16426            .insert("chr1\t104".to_string(), "GENE1\t+".to_string());
16427        let reference = HashMap::new();
16428        let correction = SegmentCorrectionContext {
16429            reference: &reference,
16430            annotation: Some(&annotation),
16431            junction_support: None,
16432        };
16433        let parsed = vec![ParsedAlignment {
16434            flag: 0,
16435            chrom: "chr1".to_string(),
16436            strand: '+',
16437            mapq: 60,
16438            seq: "A".repeat(102),
16439            blocks: vec![
16440                SegmentBlock {
16441                    read_start: 1,
16442                    read_end: 56,
16443                    ref_start: 300,
16444                    ref_end: 355,
16445                    from_local_clip: false,
16446                    cigar_ops: Vec::new(),
16447                },
16448                SegmentBlock {
16449                    read_start: 57,
16450                    read_end: 102,
16451                    ref_start: 104,
16452                    ref_end: 149,
16453                    from_local_clip: false,
16454                    cigar_ops: Vec::new(),
16455                },
16456            ],
16457        }];
16458
16459        let chain = materialize_chain(
16460            &parsed,
16461            102,
16462            Some(&circ),
16463            '+',
16464            &[],
16465            Some(&correction),
16466            false,
16467        )
16468        .unwrap();
16469
16470        assert_eq!(
16471            chain.tokens,
16472            vec![
16473                "300-349:+".to_string(),
16474                "<bsj>".to_string(),
16475                "100-149:+".to_string(),
16476            ]
16477        );
16478        assert_eq!(chain.cigar, "50M150B50M");
16479    }
16480
16481    #[test]
16482    fn materialize_chain_splits_confirmed_bsj_single_block_overrun() {
16483        let circ = CircRecord {
16484            id: "chr1:100|199".to_string(),
16485            chr: "chr1".to_string(),
16486            start: 100,
16487            end: 199,
16488            junction_read_count: "1".to_string(),
16489            gene_id: "GENE1".to_string(),
16490            strand: "+".to_string(),
16491        };
16492        let parsed = vec![ParsedAlignment {
16493            flag: 0,
16494            chrom: "chr1".to_string(),
16495            strand: '+',
16496            mapq: 60,
16497            seq: "A".repeat(55),
16498            blocks: vec![SegmentBlock {
16499                read_start: 1,
16500                read_end: 55,
16501                ref_start: 150,
16502                ref_end: 204,
16503                from_local_clip: false,
16504                cigar_ops: Vec::new(),
16505            }],
16506        }];
16507
16508        let chain =
16509            materialize_chain(&parsed, 55, Some(&circ), '+', &[(199, 100)], None, false).unwrap();
16510
16511        assert!(chain.is_bsj);
16512        assert_eq!(
16513            chain.tokens,
16514            vec![
16515                "150-199:+".to_string(),
16516                "<bsj>".to_string(),
16517                "100-104:+".to_string(),
16518            ]
16519        );
16520        assert_eq!(chain.cigar, "50M45B5M");
16521    }
16522
16523    #[test]
16524    fn materialize_chain_splits_reverse_confirmed_bsj_single_block_overrun() {
16525        let circ = CircRecord {
16526            id: "chr1:100|199".to_string(),
16527            chr: "chr1".to_string(),
16528            start: 100,
16529            end: 199,
16530            junction_read_count: "1".to_string(),
16531            gene_id: "GENE1".to_string(),
16532            strand: "+".to_string(),
16533        };
16534        let parsed = vec![ParsedAlignment {
16535            flag: 0x10,
16536            chrom: "chr1".to_string(),
16537            strand: '-',
16538            mapq: 60,
16539            seq: "A".repeat(55),
16540            blocks: vec![SegmentBlock {
16541                read_start: 1,
16542                read_end: 55,
16543                ref_start: 150,
16544                ref_end: 204,
16545                from_local_clip: false,
16546                cigar_ops: Vec::new(),
16547            }],
16548        }];
16549
16550        let chain =
16551            materialize_chain(&parsed, 55, Some(&circ), '+', &[(199, 100)], None, true).unwrap();
16552
16553        assert!(chain.is_bsj);
16554        assert_eq!(
16555            chain.tokens,
16556            vec![
16557                "150-199:+".to_string(),
16558                "<bsj>".to_string(),
16559                "100-104:+".to_string(),
16560            ]
16561        );
16562        assert_eq!(chain.cigar, "50M45B5M");
16563    }
16564
16565    #[test]
16566    fn materialize_chain_snaps_confirmed_bsj_row_terminal_mate_boundary() {
16567        let circ = CircRecord {
16568            id: "chr1:100|199".to_string(),
16569            chr: "chr1".to_string(),
16570            start: 100,
16571            end: 199,
16572            junction_read_count: "1".to_string(),
16573            gene_id: "GENE1".to_string(),
16574            strand: "+".to_string(),
16575        };
16576        let parsed = vec![ParsedAlignment {
16577            flag: 0,
16578            chrom: "chr1".to_string(),
16579            strand: '+',
16580            mapq: 60,
16581            seq: "A".repeat(68),
16582            blocks: vec![
16583                SegmentBlock {
16584                    read_start: 1,
16585                    read_end: 12,
16586                    ref_start: 80,
16587                    ref_end: 91,
16588                    from_local_clip: false,
16589                    cigar_ops: Vec::new(),
16590                },
16591                SegmentBlock {
16592                    read_start: 13,
16593                    read_end: 68,
16594                    ref_start: 150,
16595                    ref_end: 205,
16596                    from_local_clip: false,
16597                    cigar_ops: Vec::new(),
16598                },
16599            ],
16600        }];
16601
16602        let chain =
16603            materialize_chain(&parsed, 68, Some(&circ), '+', &[(-199, -100)], None, false).unwrap();
16604
16605        assert!(!chain.is_bsj);
16606        assert_eq!(
16607            chain.tokens,
16608            vec!["80-91:+".to_string(), "150-199:+".to_string()]
16609        );
16610        assert_eq!(chain.cigar, "12M58N50M6S");
16611    }
16612
16613    #[test]
16614    fn materialize_chain_uses_splice_signal_without_annotation() {
16615        let mut chr = vec![b'N'; 300];
16616        chr[149] = b'G';
16617        chr[150] = b'T';
16618        chr[198] = b'A';
16619        chr[199] = b'G';
16620        let chr = String::from_utf8(chr).unwrap();
16621        let mut reference = HashMap::new();
16622        reference.insert("chr1".to_string(), chr);
16623        let correction = SegmentCorrectionContext {
16624            reference: &reference,
16625            annotation: None,
16626            junction_support: None,
16627        };
16628        let parsed = vec![ParsedAlignment {
16629            flag: 0,
16630            chrom: "chr1".to_string(),
16631            strand: '+',
16632            mapq: 60,
16633            seq: "A".repeat(1000),
16634            blocks: vec![
16635                SegmentBlock {
16636                    read_start: 1,
16637                    read_end: 52,
16638                    ref_start: 100,
16639                    ref_end: 151,
16640                    from_local_clip: false,
16641                    cigar_ops: Vec::new(),
16642                },
16643                SegmentBlock {
16644                    read_start: 53,
16645                    read_end: 105,
16646                    ref_start: 198,
16647                    ref_end: 250,
16648                    from_local_clip: false,
16649                    cigar_ops: Vec::new(),
16650                },
16651            ],
16652        }];
16653
16654        let chain =
16655            materialize_chain(&parsed, 105, None, '+', &[], Some(&correction), false).unwrap();
16656
16657        assert_eq!(
16658            chain.tokens,
16659            vec!["100-149:+".to_string(), "201-250:+".to_string()]
16660        );
16661        assert_eq!(chain.cigar, "50M51N50M");
16662    }
16663}