nx_sequence.nx source
↩ module page · 243 lines · 9921 B
1// nx_sequence.nx -- foundational DNA / RNA sequence algebra primitive.
2//
3// license_tier: INDEPENDENT_REDERIVE
4// genealogy_id: international-research-sources/biology/dna_2bit_encoding_field_standard
5//
6// G0.1 of NISHI_GENOMICS_SUBSTRATE_ROADMAP.md. Encoding +
7// reverse-complement + k-mer enumeration + canonical k-mer. Built
8// bits-up against existing nx_syscalls; no other genomics dependency.
9//
10// 2-bit packed DNA encoding:
11// A = 00, C = 01, G = 10, T = 11
12// Complement = code XOR 0b11 (A<->T, C<->G).
13// 4 bases per byte, packed big-endian within byte:
14// byte bits 7-6 = base position 0 within byte
15// byte bits 5-4 = base position 1
16// byte bits 3-2 = base position 2
17// byte bits 1-0 = base position 3
18// Bytes ordered first-to-last. A trailing partial byte zero-pads
19// the low bits (so "ACG" = 0x18 = 00 01 10 00 in one byte).
20//
21// k-mer encoding (k <= 32):
22// Single i64 holding the 2*k low bits. Base position 0 of the k-mer
23// sits in the highest 2 bits of the 2*k-bit window. This means
24// lexicographic ordering of bases (A < C < G < T) matches arithmetic
25// ordering of the packed i64, so canonical k-mer = min(fwd, rc) is
26// a simple arithmetic min.
27//
28// Why 2-bit:
29// - Minimum storage for an alphabet of 4 (human reference ~750 MB
30// uncompressed FASTA -> ~190 MB packed)
31// - Reverse-complement of a packed code is a single XOR
32// - SIMD-friendly: 16 bases per 32-bit word, 32 bases per i64
33// - k-mer hashing fits naturally
34//
35// N / ambiguity handling:
36// Out of scope for G0.1. See nx_sequence_ambig.nx (G0.5) for the
37// IUPAC sidecar bitset. In G0.1, callers must filter Ns upstream
38// or the encoder returns -1 (refused) on any non-ACGT input.
39//
40// API:
41// dna_encode_base(c) -> i64 ASCII -> 2-bit code or -1
42// dna_decode_base(code) -> i64 2-bit -> ASCII
43// dna_complement_code(code) -> i64 2-bit -> complemented 2-bit
44// dna_pack(ascii, len, out_bases) -> i64 pack ASCII into 2-bit; 0 ok / -1 refused
45// dna_unpack(bases, len, out) -> i64 unpack 2-bit into ASCII; 0
46// dna_get_base(bases, pos) -> i64 read 2-bit code at base pos
47// dna_set_base(bases, pos, code) -> i64 write 2-bit code at base pos
48// dna_revcomp(in, len, out) -> i64 reverse-complement packed seq; 0
49// dna_kmer_at(bases, pos, k) -> i64 extract packed k-mer at pos
50// dna_kmer_revcomp(kmer, k) -> i64 reverse-complement a packed k-mer
51// dna_kmer_canonical(kmer, k) -> i64 min(fwd, revcomp) -- canonical form
52//
53// nx_safety_envelope: (schema: nishi-library/seeds/safety-critical-standards.toml)
54// intended_use: "DNA / RNA sequence algebra -- foundation for
55// alignment, variant calling, k-mer indexing,
56// CRISPR guide design, polygenic scoring"
57// sil_target: SIL2 (data-type primitive; correctness
58// errors here cascade into wrong
59// variant calls + wrong guide designs)
60// asil_target: QM
61// dal_target: DAL C
62// iec_62304_class: B
63// evidence: [no_floating_point, no_table_lookup,
64// bit_equal_reproducible,
65// 2bit_encoding_field_standard,
66// revcomp_round_trip_verified,
67// kmer_canonical_palindrome_verified,
68// constant_time_per_base,
69// license_tier_INDEPENDENT_REDERIVE]
70// hazard_register: [bug-tape-iupac-N-silently-dropped,
71// bug-tape-revcomp-partial-byte-misalign,
72// bug-tape-kmer-k-greater-than-32-overflow,
73// bug-tape-canonical-kmer-not-symmetric]
74// residual_risk: "G0.1 refuses non-ACGT input; callers using
75// long-read data with Ns must pre-filter or
76// wait for nx_sequence_ambig (G0.5). Partial-
77// byte alignment handled per-base, not packed
78// SIMD; G0.4 perf gate may require a packed
79// fast path."
80// verdict: NOT_YET_EVALUATED
81
82import "nx_syscalls.nx"
83import "nx_const.nx"
84
85// All DNA + ASCII + complement constants are sourced from nx_const
86// (NishiLang's `const` requires integer literals, so we use the
87// NX_ constants directly in function bodies rather than locally
88// re-aliasing them). Substrate-wide source of truth lives in
89// nx_const.nx per docs/NISHI_CONSTANT_REGISTRY.md.
90
91// ---- single-base conversions ----
92
93// ASCII base -> 2-bit code. Returns -1 for any non-ACGT input
94// (refused per G0.1 scope; Ns handled in G0.5).
95func dna_encode_base(c: i64) -> i64 {
96 let cc: i64 = c & 0xff
97 if cc == NX_ASCII_DNA_A { return NX_DNA_A }
98 if cc == NX_ASCII_DNA_a { return NX_DNA_A }
99 if cc == NX_ASCII_DNA_C { return NX_DNA_C }
100 if cc == NX_ASCII_DNA_c { return NX_DNA_C }
101 if cc == NX_ASCII_DNA_G { return NX_DNA_G }
102 if cc == NX_ASCII_DNA_g { return NX_DNA_G }
103 if cc == NX_ASCII_DNA_T { return NX_DNA_T }
104 if cc == NX_ASCII_DNA_t { return NX_DNA_T }
105 return -1
106}
107
108// 2-bit code -> uppercase ASCII base. Caller responsibility to pass
109// a valid code 0..3; out-of-range returns 0x4E ('N') as a fail-safe.
110func dna_decode_base(code: i64) -> i64 {
111 let cc: i64 = code & 3
112 if cc == NX_DNA_A { return NX_ASCII_DNA_A }
113 if cc == NX_DNA_C { return NX_ASCII_DNA_C }
114 if cc == NX_DNA_G { return NX_ASCII_DNA_G }
115 if cc == NX_DNA_T { return NX_ASCII_DNA_T }
116 return 0x4E
117}
118
119// Complement of a 2-bit code: A<->T, C<->G.
120func dna_complement_code(code: i64) -> i64 {
121 return (code & 3) ^ NX_DNA_COMPLEMENT_MASK
122}
123
124// ---- per-base packed-array accessors ----
125
126// Read 2-bit code at base position pos from a packed array.
127// byte index = pos / 4; sub-index = pos % 4 (0..3, 0 = high bits).
128// Shift amount = 6 - 2 * subindex.
129func dna_get_base(bases: *u8, pos: i64) -> i64 {
130 let byte_idx: i64 = pos >> 2
131 let sub: i64 = pos & 3
132 let shift: i64 = 6 - (sub << 1)
133 let b: i64 = bases[byte_idx] & 0xff
134 return (b >> shift) & 3
135}
136
137// Write 2-bit code at base position pos into a packed array.
138// Reads current byte, clears the target 2-bit slot, ORs in the new code.
139func dna_set_base(bases: *u8, pos: i64, code: i64) -> i64 {
140 let byte_idx: i64 = pos >> 2
141 let sub: i64 = pos & 3
142 let shift: i64 = 6 - (sub << 1)
143 let mask: i64 = 3 << shift
144 let cur: i64 = bases[byte_idx] & 0xff
145 let cleared: i64 = cur & (mask ^ 0xff)
146 let written: i64 = cleared | ((code & 3) << shift)
147 bases[byte_idx] = written & 0xff
148 return 0
149}
150
151// ---- whole-sequence pack / unpack ----
152
153// Pack ASCII bases into 2-bit form. out_bases must have capacity
154// >= (len + 3) / 4 bytes, pre-zeroed (callers using sys_mmap get
155// zeroed pages). Returns 0 on success, -1 if any base is non-ACGT.
156func dna_pack(ascii: *u8, len: i64, out_bases: *u8) -> i64 {
157 var i: i64 = 0
158 while i < len {
159 let c: i64 = ascii[i] & 0xff
160 let code: i64 = dna_encode_base(c)
161 if code < 0 { return -1 }
162 dna_set_base(out_bases, i, code)
163 i = i + 1
164 }
165 return 0
166}
167
168// Unpack 2-bit bases back to ASCII. out must have capacity >= len.
169// No terminator written; caller wraps in length-prefix or sentinel.
170func dna_unpack(bases: *u8, len: i64, out: *u8) -> i64 {
171 var i: i64 = 0
172 while i < len {
173 let code: i64 = dna_get_base(bases, i)
174 out[i] = dna_decode_base(code) & 0xff
175 i = i + 1
176 }
177 return 0
178}
179
180// ---- reverse-complement ----
181
182// Reverse-complement of a packed sequence. out_bases must have
183// capacity >= (len + 3) / 4 bytes, pre-zeroed. out_bases may NOT
184// alias in_bases. Walks base-by-base for correctness across the
185// partial-byte boundary; a packed SIMD fast path lives in G0.4 perf.
186func dna_revcomp(in_bases: *u8, len: i64, out_bases: *u8) -> i64 {
187 var i: i64 = 0
188 while i < len {
189 let fwd: i64 = dna_get_base(in_bases, i)
190 let rc: i64 = dna_complement_code(fwd)
191 dna_set_base(out_bases, len - 1 - i, rc)
192 i = i + 1
193 }
194 return 0
195}
196
197// ---- k-mer extraction ----
198
199// Extract a k-mer at base position pos as a packed i64. k must be
200// 1..32. Base 0 of the k-mer sits in the highest 2 bits of the
201// 2*k-bit window: kmer = (b0 << (2k-2)) | (b1 << (2k-4)) | ... | bN.
202// Caller responsibility: pos + k <= len.
203func dna_kmer_at(bases: *u8, pos: i64, k: i64) -> i64 {
204 var kmer: i64 = 0
205 var i: i64 = 0
206 var shift: i64 = (k - 1) << 1
207 while i < k {
208 let b: i64 = dna_get_base(bases, pos + i)
209 kmer = kmer | ((b & 3) << shift)
210 shift = shift - 2
211 i = i + 1
212 }
213 return kmer
214}
215
216// Reverse-complement a packed k-mer in-register. Reverses the
217// base order AND complements each 2-bit code. Used by the canonical
218// k-mer function and by minimizer / hash flows.
219func dna_kmer_revcomp(kmer: i64, k: i64) -> i64 {
220 var rc: i64 = 0
221 var i: i64 = 0
222 var fwd_shift: i64 = (k - 1) << 1
223 var rc_shift: i64 = 0
224 while i < k {
225 let fwd_code: i64 = (kmer >> fwd_shift) & 3
226 let comp: i64 = fwd_code ^ NX_DNA_COMPLEMENT_MASK
227 rc = rc | (comp << rc_shift)
228 fwd_shift = fwd_shift - 2
229 rc_shift = rc_shift + 2
230 i = i + 1
231 }
232 return rc
233}
234
235// Canonical k-mer: lexicographic min of the k-mer and its reverse
236// complement. Because our packing puts base 0 in the highest 2 bits,
237// lexicographic order matches arithmetic order, so this is min(a, b).
238// Palindromic k-mers (kmer == revcomp(kmer)) are their own canonical.
239func dna_kmer_canonical(kmer: i64, k: i64) -> i64 {
240 let rc: i64 = dna_kmer_revcomp(kmer, k)
241 if kmer < rc { return kmer }
242 return rc
243}