code wiki / (root) / nx_alignment.nx

nx_alignment.nx source

↩ module page · 174 lines · 7985 B

1// nx_alignment.nx -- AlignmentRecord struct + mapq + revcomp coord remap. 2// 3// license_tier: INDEPENDENT_REDERIVE 4// genealogy_id: international-research-sources/li-2018-minimap2-mapq + sam-spec-v1 5// 6// G1.4b of NISHI_GENOMICS_SUBSTRATE_ROADMAP.md. The output structure 7// that downstream consumers (variant callers, BAM writers, alignment 8// browsers, audit tooling) consume. Closes the "what does an 9// aligner return?" gap by giving every read-vs-reference alignment 10// a uniform queryable record. 11// 12// Field semantics (SAM-spec aligned but Nishi-native): 13// query_id : caller-assigned numeric id for the query/read 14// ref_id : caller-assigned numeric id for the reference sequence 15// q_start : 0-indexed start position in the ORIGINAL query 16// (NOT the rc-query; rc alignments are remapped via 17// nx_remap_revcomp_coords before record construction) 18// q_end : 0-indexed end (exclusive) in original query 19// r_start : 0-indexed start in reference 20// r_end : 0-indexed end (exclusive) in reference 21// strand : NX_STRAND_FWD (+1) or NX_STRAND_REV (-1) from nx_const 22// score : SW alignment score (linear or affine, caller's choice) 23// mapq : mapping quality, 0..NX_MAPQ_MAX clamped 24// n_seeds : number of seeds in the chain that anchored this 25// alignment (passes through from nx_align_chain output) 26// 27// Mapping-quality model (G1.4b reference impl): 28// mapq = clamp(0, 60, floor(60 * (primary - secondary) / max(1, primary))) 29// - primary = score of the best alignment for this query 30// - secondary = score of the best ALTERNATIVE alignment, or 0 if unique 31// - secondary == 0 -> mapq = 60 (uniquely placed) 32// - secondary == primary -> mapq = 0 (cannot distinguish primary from a tie) 33// - secondary == primary/2 -> mapq = 30 (moderate confidence) 34// - secondary > primary -> mapq = 0 (negative clamps to 0) 35// - primary <= 0 -> mapq = 0 (no real alignment) 36// This is the SIMPLEST coherent integer-arithmetic mapq that 37// matches the high-end BWA-MEM 60-cap convention without 38// pretending to model minimap2's full probabilistic formula 39// (which uses chain count + log + sub-best heuristics, all of 40// which require nx_fixed_point primitives -- G3+). 41// 42// Reverse-strand coordinate remapping: 43// When a chain is found against the REVCOMP query, the chain's 44// (q_start, q_end) are in rc-query coordinates. To present a 45// record in original-query coordinates the substrate flips: 46// orig_q_start = q_len - rc_q_end 47// orig_q_end = q_len - rc_q_start 48// (rc-coordinate i corresponds to original-coordinate (q_len-1-i) 49// for a single base; intervals flip and re-bound.) 50// 51// What G1.4b does NOT do (deferred): 52// - CIGAR string output -- requires SW backtrace (G1.0c) and 53// soft-clipping logic; both queued as G1.4b.2 54// - SAM/BAM serialisation -- nx_sam_writer (G6) 55// - Probabilistic mapq from full minimap2 formula (needs log + 56// float-equivalent fixed-point math) -- G1.5 57// - Secondary alignment reporting (multi-hit reads) -- G1.4c 58// - Paired-end fragment-pair grouping -- G1.6 59// 60// API: 61// nx_alignment_new(...10 args bundled as fields[10] array...) -> *AlignmentRecord 62// nx_alignment_compute_mapq(primary_score, secondary_score) -> i64 0..60 63// nx_remap_revcomp_coords(q_len, rc_start, rc_end, 64// out_orig_start, out_orig_end) -> i64 0 65// 66// nx_safety_envelope: (schema: nishi-library/seeds/safety-critical-standards.toml) 67// intended_use: "Output record for read-to-reference 68// alignment; the structure downstream 69// variant-calling / BAM-writing / alignment- 70// audit primitives consume" 71// sil_target: SIL2 72// asil_target: QM 73// dal_target: DAL C 74// iec_62304_class: B 75// evidence: [no_floating_point, deterministic, 76// bit_equal_reproducible, 77// integer_arithmetic_mapq_clamped_0_60, 78// revcomp_coord_remap_involution_KAT, 79// composes_nx_const_strand_constants, 80// license_tier_INDEPENDENT_REDERIVE] 81// hazard_register: [bug-tape-mapq-negative-not-clamped, 82// bug-tape-mapq-divide-by-zero-primary, 83// bug-tape-revcomp-remap-off-by-one, 84// bug-tape-revcomp-remap-not-involutive, 85// bug-tape-record-strand-tag-out-of-domain] 86// residual_risk: "G1.4b uses a simplified mapq; comparison 87// with BWA-MEM mapq scores on identical 88// alignments will diverge. Documented in 89// the formula comment so consumers can pick 90// the heuristic that matches their tooling." 91// verdict: NOT_YET_EVALUATED 92 93import "nx_syscalls.nx" 94import "nx_const.nx" 95 96// Mapping-quality upper bound (BWA-MEM / minimap2 / Bowtie2 / SAM-spec convention). 97// Not in nx_const because "60 is the mapq ceiling" is an aligner-convention 98// choice (Bowtie1 used 255), not a universal mathematical constant. 99const NX_MAPQ_MAX: i64 = 60 100const NX_MAPQ_MIN: i64 = 0 101 102struct AlignmentRecord { 103 query_id: i64, 104 ref_id: i64, 105 q_start: i64, 106 q_end: i64, 107 r_start: i64, 108 r_end: i64, 109 strand: i64, 110 score: i64, 111 mapq: i64, 112 n_seeds: i64, 113} 114 115const NX_ALIGNMENT_RECORD_BYTES: i64 = 80 // 10 i64 fields 116 117// Build a fresh AlignmentRecord from a packed fields[] i64 array. 118// fields layout matches struct field order: 119// [0]=query_id [1]=ref_id [2]=q_start [3]=q_end [4]=r_start 120// [5]=r_end [6]=strand [7]=score [8]=mapq [9]=n_seeds 121// Caller is responsible for valid strand value (NX_STRAND_FWD or 122// NX_STRAND_REV). Returns pointer to the newly mmapped record. 123func nx_alignment_new(fields: *i64) -> *AlignmentRecord { 124 let rec: *AlignmentRecord = sys_mmap(NX_ALIGNMENT_RECORD_BYTES) as *AlignmentRecord 125 rec.query_id = fields[0] 126 rec.ref_id = fields[1] 127 rec.q_start = fields[2] 128 rec.q_end = fields[3] 129 rec.r_start = fields[4] 130 rec.r_end = fields[5] 131 rec.strand = fields[6] 132 rec.score = fields[7] 133 rec.mapq = fields[8] 134 rec.n_seeds = fields[9] 135 return rec 136} 137 138// Integer mapping quality clamped to [0, NX_MAPQ_MAX]. 139// mapq = clamp(0, 60, 60 * (primary - secondary) / max(1, primary)) 140// Primary <= 0 short-circuits to 0 (no real alignment). 141// Secondary >= primary -> 0 (cannot distinguish from a tie / better hit). 142func nx_alignment_compute_mapq(primary_score: i64, secondary_score: i64) -> i64 { 143 if primary_score <= 0 { return NX_MAPQ_MIN } 144 if secondary_score >= primary_score { return NX_MAPQ_MIN } 145 146 let diff: i64 = primary_score - secondary_score 147 let raw: i64 = (NX_MAPQ_MAX * diff) / primary_score 148 149 if raw < NX_MAPQ_MIN { return NX_MAPQ_MIN } 150 if raw > NX_MAPQ_MAX { return NX_MAPQ_MAX } 151 return raw 152} 153 154// Remap an interval from revcomp-query coordinates back to original-query 155// coordinates. rc-coord i corresponds to original-coord (q_len-1-i) for 156// a single base; for the half-open interval [rc_start, rc_end), the 157// remapped half-open interval is [q_len - rc_end, q_len - rc_start). 158// 159// Returns 0 on success; -1 if input range is out of bounds. 160// 161// Involution property: remap(remap(x)) == x. Verified in KAT. 162func nx_remap_revcomp_coords(q_len: i64, 163 rc_start: i64, rc_end: i64, 164 out_orig_start: *i64, 165 out_orig_end: *i64) -> i64 { 166 if q_len <= 0 { return -1 } 167 if rc_start < 0 { return -1 } 168 if rc_end <= rc_start { return -1 } 169 if rc_end > q_len { return -1 } 170 171 out_orig_start[0] = q_len - rc_end 172 out_orig_end[0] = q_len - rc_start 173 return 0 174}