1use 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;
48const CONFIRMED_BSJ_BOUNDARY_CORRECTION_WINDOW: i32 = 16;
55const JUNCTION_SEQUENCE_EVIDENCE_TIE_BONUS: i32 = 3;
61const MAPQ_THRES: i32 = 5;
62const OUTWARD_MIN_TERMINAL_CLIP: i32 = 19;
68const OUTWARD_MIN_PAIR_OFFSET: i32 = 19;
72const MAPQ_UNI: i32 = 0;
73const MAPQ_BOTH: i32 = 0;
74const MAJOR_MAX_UNSPLICED_EXON_LEN: i32 = MAX_EXON_LENGTH;
81const MAJOR_MIN_MATURE_LINK_SUPPORT: f64 = 1.0;
87const MAJOR_EXCLUSIVE_JUNCTION_MIN_ANCHOR: i32 = MIN_JUNCTION_SUPPORT_SEGMENT_LENGTH;
94const MAJOR_MIN_NON_BSJ_ASSIGNMENT_WEIGHT: f64 = 0.001;
100const MAJOR_NON_BSJ_MIN_DISTANCE_SCALE: f64 = 100.0;
105const MAJOR_NON_BSJ_MAX_DISTANCE_SCALE: f64 = 1000.0;
111const MAJOR_ESTIMATE_ANCHOR_MERGE_GAP: i32 = 300;
119const MAJOR_MIN_FASTA_SEGMENT_COVERAGE_PCT: f64 = 10.0;
126const MAJOR_MIN_TRUSTED_ESTIMATE_SEGMENT_COVERAGE_PCT: f64 = 50.0;
132const MAJOR_MIN_CANDIDATE_SEGMENT_COVERAGE_PCT: f64 = 90.0;
138const COHORT_SWITCHING_MIN_BSJ_READS: f64 = 5.0;
141const COHORT_SWITCHING_MIN_USAGE_DELTA: f64 = 0.50;
144const COHORT_MIN_ALT_EDGE_SUPPORT: f64 = 5.0;
146const COHORT_MAX_ALT_EDGE_PROBES: usize = 64;
148const NON_BSJ_LOCAL_CLIP_ANCHOR_FLANK_MULTIPLIER: i32 = 2;
150const NON_BSJ_LOCAL_CLIP_MAX_ROWS_PER_READ: usize = 16;
152const NON_BSJ_LOCAL_CLIP_MAX_PLACEMENTS_PER_SIDE: usize = 4;
154const XA_REJECT_MIN_ANCHOR_LEN: i32 = MIN_JUNCTION_SUPPORT_SEGMENT_LENGTH;
156const XA_REJECT_MIN_SPAN_REDUCTION: i32 = 1000;
158const XA_SEAMLESS_QUERY_TOLERANCE: i32 = 2;
160const XA_MAX_ALTERNATIVES_PER_RECORD: usize = 4;
162const BSJ_MATE_XA_MAX_ALTERNATIVES_PER_RECORD: usize = 8;
164const BACKWARD_MAX_SPAN: i32 = 200000;
167
168pub struct AsConfig<'a> {
174 pub input_path: &'a str,
176 pub circ_path: &'a str,
178 pub bsj_path: Option<&'a str>,
185 pub segment_evidence_paths: Vec<&'a str>,
192 pub non_bsj_segment_evidence_paths: Vec<&'a str>,
199 pub out_prefix: &'a str,
201 pub keep_temp_files: bool,
207 pub reference: &'a HashMap<String, String>,
209 pub annotation: Option<&'a Annotation>,
216 pub min_mapq: i32,
222 pub progress_log: Option<&'a mut dyn FnMut(&str, &str) -> Result<()>>,
230}
231
232#[derive(Debug, Clone, Copy, Default)]
238pub struct SegmentRunSummary {
239 pub total_segments: usize,
241 pub bsj_segments: usize,
243 pub backward_segments: usize,
245 pub outward_segments: usize,
247}
248
249#[derive(Debug, Clone, Copy, Default)]
256pub struct IsoformRunSummary {
257 pub total_isoforms: usize,
259 pub circ_rnas: usize,
261 pub fasta_isoforms: usize,
263 pub fasta_circ_rnas: usize,
265}
266
267#[derive(Debug, Clone)]
273pub struct CohortSampleInput {
274 pub sample_id: String,
276 pub prefix: String,
278}
279
280pub struct CohortAssembleConfig<'a> {
282 pub samples: Vec<CohortSampleInput>,
284 pub out_prefix: &'a str,
286 pub reference: &'a HashMap<String, String>,
288 pub annotation: Option<&'a Annotation>,
291}
292
293#[derive(Debug, Clone, Copy, Default)]
295pub struct CohortAssembleSummary {
296 pub samples: usize,
298 pub circ_rnas: usize,
300 pub isoforms: usize,
302 pub switching_circ_rnas: usize,
304 pub fasta_isoforms: usize,
306}
307
308#[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#[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#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
394struct SegmentCigarOp {
395 len: i32,
396 op: char,
397}
398
399#[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#[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#[derive(Debug, Clone)]
440struct ChainBlockPayload {
441 block: SegmentBlock,
442 strand: char,
443 source_seq: String,
444}
445
446#[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#[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#[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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, PartialOrd, Ord)]
505enum MajorJunction {
506 Bsj,
507 Edge(MajorEdge),
508}
509
510#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
518struct MajorJunctionLink {
519 left: MajorJunction,
520 right: MajorJunction,
521}
522
523impl MajorJunctionLink {
524 fn new(a: MajorJunction, b: MajorJunction) -> Self {
531 Self { left: a, right: b }
532 }
533}
534
535#[derive(Debug, Clone, Copy, Default)]
541struct MajorEdgeSupport {
542 bsj: f64,
543 backward: f64,
544 outward: f64,
545}
546
547#[derive(Debug, Clone, Copy, Default)]
549struct MajorLinkSupport {
550 bsj: f64,
551 backward: f64,
552 outward: f64,
553}
554
555#[derive(Debug, Clone)]
562struct MajorBsjReadObservation {
563 read_id: String,
564 edges: HashSet<MajorEdge>,
565 spans: Vec<(i32, i32)>,
566}
567
568#[derive(Debug, Clone, Copy)]
575struct MajorAlignedSpan {
576 start: i32,
577 end: i32,
578 bsj: f64,
579 backward: f64,
580 outward: f64,
581}
582
583#[derive(Debug, Clone, Copy)]
590struct MajorCircAssignment {
591 circ_index: usize,
592 weight: f64,
593}
594
595#[derive(Debug, Clone, Copy)]
601struct MajorCircIndexEntry {
602 start: i32,
603 end: i32,
604 circ_index: usize,
605 max_end_through: i32,
606}
607
608#[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#[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#[derive(Debug, Clone)]
647struct MajorCircSampleValue {
648 circ: CircRecord,
649 bsj_reads: f64,
650 junction_ratio: f64,
651}
652
653struct 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#[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#[derive(Debug, Clone)]
673struct CohortCircInfo {
674 id: String,
675}
676
677struct 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 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 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#[derive(Debug, Clone, Copy, Default)]
730struct MajorBlockBuildStatus {
731 projected: bool,
732 unresolved: bool,
733 inferred: bool,
734 unphased_single_exon: bool,
735}
736
737#[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#[derive(Debug, Clone)]
760struct CircCluster {
761 chr: String,
762 start: i32,
763 end: i32,
764}
765
766#[derive(Debug, Clone, Copy)]
772struct CircSpan {
773 start: i32,
774 end: i32,
775 max_end_through: i32,
776}
777
778#[derive(Debug, Clone, PartialEq, Eq, Hash)]
785struct LocalClipWindow {
786 chr: String,
787 start: i32,
788 end: i32,
789}
790
791#[derive(Debug, Clone, Copy)]
797struct Msid {
798 kind: i32,
799 clip1: i32,
800 clip2: i32,
801 ref_len: i32,
802}
803
804#[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
818pub 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
972pub 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
995pub 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
1133fn 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
1150fn segments_profile_enabled() -> bool {
1156 matches!(std::env::var("CIRI_PROFILE_SEGMENTS"), Ok(v) if !v.is_empty() && v != "0")
1157}
1158
1159fn 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
1169fn 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
1177fn 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
1203fn 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
1216struct 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
1237struct 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#[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
1266struct SegmentScanShardPaths {
1268 path: String,
1269}
1270
1271struct RetainedSegmentGroup {
1277 read_id: String,
1278 records: Vec<AsAlignment>,
1279 candidates: Vec<PositiveCandidate>,
1280 segment_records: Vec<SegmentRecord>,
1281}
1282
1283struct SegmentEvidenceRow {
1290 read_id: String,
1291 alignment: AsAlignment,
1292 read_len: i32,
1293}
1294
1295struct SegmentScanShardWriter {
1302 writer: BufWriter<File>,
1303}
1304
1305struct 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
1320fn 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
1386fn 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
1446fn 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
1515fn segment_spill_partition_count() -> usize {
1517 rayon::current_num_threads().saturating_mul(4).clamp(1, 256)
1518}
1519
1520fn 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
1527fn 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
1576fn 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
1603fn 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 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
1693fn 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
1753fn 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
1768fn is_non_bsj_group_stage(stage: &str) -> bool {
1770 stage == "N" || stage == "scan2_non_bsj_group"
1771}
1772
1773fn 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
1821fn 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
1869fn 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
1925fn 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
1942fn 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
2021fn 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
2074fn 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#[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
2110fn 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
2173fn 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 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
2299fn 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
2335fn 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
2577fn 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
2595fn 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
2623fn 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
2659fn 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
2681fn 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
2706fn 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
2723fn 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
2744fn 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
2791fn 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
2807fn 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 ¤t_read_id,
2849 ¤t_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 ¤t_read_id,
2875 ¤t_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(¤t_read_id, ¤t_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
2941fn 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 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
3015fn 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
3024fn 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
3065fn 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
3086fn 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
3098fn 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
3139fn 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
3147fn 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
3178fn 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
3215fn 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
3234fn 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
3245fn 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
3259fn 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
3268fn 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(¤t_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(¤t_id, &group)?;
3327 }
3328 finish_segment_progress(&mut reader, pb, &mut last_progress_pos)?;
3329 Ok(())
3330}
3331
3332fn 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(¤t_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(¤t_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 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
3439fn 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
3450fn 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 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 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 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 fn new(paths: &SegmentScanShardPaths) -> Result<Self> {
3498 Ok(Self {
3499 writer: BufWriter::with_capacity(512 * 1024, File::create(&paths.path)?),
3500 })
3501 }
3502
3503 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 fn flush(&mut self) -> Result<()> {
3583 self.writer.flush()?;
3584 Ok(())
3585 }
3586}
3587
3588fn option_field(value: Option<&str>) -> &str {
3590 value.filter(|value| !value.is_empty()).unwrap_or("*")
3591}
3592
3593fn 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
3610fn 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
3629fn 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
3752fn 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
3822fn parse_optional_field(value: &str) -> Option<String> {
3824 (value != "*").then(|| value.to_string())
3825}
3826
3827fn 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
3883fn 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(¤t_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(¤t_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
3989fn 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
3994fn 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
4008fn 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
4022fn 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
4076fn 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
4090fn 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
4106fn 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
4135fn 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
4154fn 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
4203fn 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
4242fn 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
4264fn 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
4280fn 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
4329fn 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 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
4366fn 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
4385fn local_junction_hints(candidates: &[PositiveCandidate]) -> Vec<(i32, i32)> {
4387 candidates
4388 .iter()
4389 .map(|candidate| (candidate.site2, candidate.site1))
4390 .collect()
4391}
4392
4393fn 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
4408fn 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
4416fn 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
4454fn 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
4570fn 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
4584fn 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
4597fn 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
4625fn 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 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
4719fn 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
4735fn point_in_cluster_range(pos: i32, cluster: &CircCluster) -> bool {
4737 pos >= cluster.start - MIN_INTRON && pos <= cluster.end + MIN_INTRON
4738}
4739
4740fn overlaps_any_circ_cluster(records: &[AsAlignment], state: &ScanState) -> bool {
4742 overlaps_any_circ_cluster_ctx(records, &SegmentScanContext::from_state(state))
4743}
4744
4745fn 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#[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
4788fn 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
4813fn 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
4852fn 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
4874fn 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
4911fn 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
4934fn 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
4950fn 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
4969fn 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
4992fn 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
5000fn 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
5021fn 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
5042fn 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
5064fn 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
5118fn 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
5171fn 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
5252fn 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
5360fn 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
5461fn div_trunc(value: i32, divisor: i32) -> i32 {
5463 value / divisor
5464}
5465
5466fn reverse_bit(flag: i32) -> i32 {
5468 if flag & 0x10 != 0 {
5469 1
5470 } else {
5471 0
5472 }
5473}
5474
5475fn is_reverse_strand(flag: i32) -> bool {
5477 flag & 0x10 != 0
5478}
5479
5480fn 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
5487fn boundary_start_ok(record: &AsAlignment, circ: Option<&CircRecord>) -> bool {
5489 circ.is_none_or(|circ| record.pos >= circ.start - 6)
5490}
5491
5492fn 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
5497fn 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
5627fn invalid_msid(ref_len: i32) -> Msid {
5629 Msid {
5630 kind: 0,
5631 clip1: 0,
5632 clip2: 0,
5633 ref_len,
5634 }
5635}
5636
5637fn 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
5651fn validate_splice_motifs(
5653 candidates: &mut Vec<PositiveCandidate>,
5654 reference: &HashMap<String, String>,
5655 annotation: Option<&Annotation>,
5656) -> Result<()> {
5657 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
5679fn 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
5734fn 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
5744fn 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
5782fn 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
5795fn 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
5814fn 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
5845fn 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
5898fn 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
5972fn 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
6045fn 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
6085fn 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
6095fn 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
6114fn 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
6129fn 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
6188fn 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
6224fn 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
6261fn 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
6291fn 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
6320fn cleanup_segment_shards(shards: &[SegmentScanShardPaths]) {
6322 for shard in shards {
6323 let _ = std::fs::remove_file(&shard.path);
6324 }
6325}
6326
6327fn 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
6359fn 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
6366fn 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
6400fn 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
6424fn 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
6442fn 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
6486fn 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 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
6527fn 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
6535fn 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
6627fn 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
6706fn 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
6793fn 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
6836fn 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
6858enum XaBackwardResolution {
6860 Keep,
6861 Repair([Option<MateChain>; 2]),
6862 Reject,
6863}
6864
6865fn 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
6924struct XaCandidateSet {
6926 record_index: usize,
6927 alternatives: Vec<AsAlignment>,
6928}
6929
6930fn 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
6965fn 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
7066fn 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
7095fn 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
7103fn 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
7122fn 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
7137fn 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
7172fn 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
7187fn 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
7196fn 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
7206fn 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
7227fn 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
7246fn sort_position(value: &str) -> i32 {
7248 value.parse::<i32>().unwrap_or(i32::MAX)
7249}
7250
7251fn 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
7262fn 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
7280fn 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
7296fn 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
7308fn 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
7346fn 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
7392fn 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
7413fn 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
7498fn 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
7520fn sequence_n_count(seq: &str) -> usize {
7526 seq.bytes()
7527 .filter(|base| matches!(base.to_ascii_uppercase(), b'N'))
7528 .count()
7529}
7530
7531fn 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
7570fn 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
7607fn 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
7617fn 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
7710fn 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
7828fn 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
7924fn 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
7940fn 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
8077fn payload_blocks(payloads: &[ChainBlockPayload]) -> Vec<SegmentBlock> {
8079 payloads
8080 .iter()
8081 .map(|payload| payload.block.clone())
8082 .collect()
8083}
8084
8085fn 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
8099fn 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
8121fn 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
8147fn 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
8183fn 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
8265fn 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
8284fn 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
8310fn 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
8385fn 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
8408fn 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
8434fn flush_cs_match(out: &mut String, matches: &mut usize) {
8436 if *matches > 0 {
8437 let _ = write!(out, ":{}", *matches);
8438 *matches = 0;
8439 }
8440}
8441
8442fn reference_dinucleotide_lower(chr_seq: &str, start: i32) -> Option<String> {
8444 reference_dinucleotide(chr_seq, start).map(|seq| seq.to_ascii_lowercase())
8445}
8446
8447fn 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
8458fn 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
8474fn 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
8485fn 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
8504fn 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
8517fn 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
8585fn 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
8597fn 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
8610fn 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
8633fn 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
8648fn 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
8715fn 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
8813fn 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
8847fn 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#[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
8922fn 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
8941fn 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
8959fn 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
8977fn 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
9003fn 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
9019fn 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
9025fn 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
9043fn 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
9065fn 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
9091fn 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
9104fn 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
9121fn 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
9135fn 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
9154fn 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
9180fn 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
9189fn opposite_strand(strand: char) -> char {
9191 match strand {
9192 '+' => '-',
9193 '-' => '+',
9194 _ => strand,
9195 }
9196}
9197
9198fn 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
9218fn 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
9252fn 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
9322fn 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
9386fn 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
9441fn 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
9458fn 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
9473fn 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
9489fn 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
9501fn 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
9517fn 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
9530fn 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
9537fn 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
9548fn 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
9556fn 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
9571fn 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
9612fn 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
9631fn 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
9646fn 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
9654fn 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
9688fn 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
9881fn 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
9944fn 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
10091struct 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
10103fn 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
10124fn 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
10141fn 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
10168fn 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
10244fn 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
10259fn 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
10276fn 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
10289fn 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
10299fn 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
10351fn 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
10377fn 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
10441fn 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
10449fn 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
10478fn 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
10500fn 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
10532fn 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
10609fn 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
10616fn 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
10647fn 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
10667fn 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
10701fn 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
10714fn 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
10756fn 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
10772fn 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
10779fn 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
10833fn 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
10871fn 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
10897fn 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
10931fn 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
10968fn 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
10997fn 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
11028fn 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
11050fn 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
11073fn 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
11109fn 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
11182fn 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
11207fn 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
11309fn 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
11348fn 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
11379fn 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
11394fn 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
11423fn 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
11468fn 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
11478fn 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
11492fn 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
11503fn 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
11546fn 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
11589fn 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
11599fn major_edge_weight(support: &MajorEdgeSupport) -> f64 {
11601 support.bsj + support.backward + support.outward * 0.05
11602}
11603
11604fn 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
11614fn 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
11625fn 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
11642fn 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
11654fn 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
11683fn 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
11727fn 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
11749fn 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
11802fn 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
11842fn 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
11869fn 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
11898fn 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
11908fn 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
11930fn 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
11972fn 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
12035fn 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
12066fn 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
12096fn 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
12110fn 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
12139fn 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
12168fn 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
12211fn 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
12247fn 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
12269fn 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
12303fn 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
12313fn gtf_escape(value: &str) -> String {
12315 value.replace('"', "\\\"")
12316}
12317
12318#[derive(Debug, Clone, Copy, Default)]
12320struct MajorIsoformFastaSummary {
12321 isoforms: usize,
12322 circ_rnas: usize,
12323}
12324
12325fn 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
12364fn 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
12405fn 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
12421fn 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
12449fn mate_bucket(flag: i32) -> usize {
12451 if flag & 0x40 != 0 {
12452 0
12453 } else {
12454 1
12455 }
12456}
12457
12458fn 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
12471fn is_secondary(flag: i32) -> bool {
12473 flag & 0x100 != 0
12474}
12475
12476fn is_supplementary(flag: i32) -> bool {
12478 flag & 0x800 != 0
12479}
12480
12481fn 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}