nx_chem_peak_list.nx source
↩ module page · 404 lines · 15504 B
1// nx_chem_peak_list.nx -- C5.0 milestone: text-format peak list parser.
2//
3// A lab analyst's LC-MS output (after peak picking) is typically a
4// list of "m/z [intensity] [retention_time]" entries. This module
5// parses that format into a PeakList struct that composes directly
6// with C4.0 adulterant_db_lookup_mh_plus.
7//
8// Supported format (whitespace-separated, '#' comments):
9//
10// # comment line ignored
11// m/z [intensity] [rt_seconds]
12// 280.1826 10000 742.3
13// 102.1274 8500 45.2
14// 195.0875 15000 234.1
15// 166.1223 5000 89.5
16//
17// Decimals are parsed to Q4 (4 fractional digits) for m/z; rt stored
18// as Q3 milliseconds. Intensity stored as raw nx_int.
19//
20// Honest gaps (deferred):
21// - mzML/mzXML parsing (XML-based standard): too complex without
22// full XML parser in substrate; C5.1.
23// - JCAMP-DX (NMR/IR/spectra format): C5.2.
24// - Centroided vs profile-mode discrimination: caller responsibility.
25// - Adduct/charge state assignment heuristics (a la CAMERA): C5.3.
26
27import "nx_chem.nx"
28import "nx_syscalls.nx"
29import "nx_loop.nx"
30
31struct PeakObservation {
32 mz_q4: nx_int,
33 intensity_q4: nx_int,
34 rt_q3: nx_int,
35}
36const NX_PEAK_OBS_BYTES: nx_int = 24
37
38struct PeakList {
39 peaks: *PeakObservation,
40 n: nx_int,
41 cap: nx_int,
42}
43const NX_PEAK_LIST_BYTES: nx_int = 24
44
45// =================================================================
46// Allocate empty peak list with given capacity.
47// =================================================================
48func nx_chem_peak_list_new(cap: nx_int) -> *PeakList {
49 let pl: *PeakList = (sys_mmap(NX_PEAK_LIST_BYTES as i64)) as *PeakList
50 pl.peaks = (sys_mmap((cap * NX_PEAK_OBS_BYTES) as i64)) as *PeakObservation
51 pl.n = 0
52 pl.cap = cap
53 return pl
54}
55
56// =================================================================
57// Append one peak to the list. Returns 0 on success, -1 if full.
58// =================================================================
59func nx_chem_peak_list_add(pl: *PeakList, mz_q4: nx_int, intensity_q4: nx_int, rt_q3: nx_int) -> nx_int {
60 if pl.n >= pl.cap { return -1 }
61 let p: *PeakObservation = ((pl.peaks as nx_int) + (pl.n * NX_PEAK_OBS_BYTES)) as *PeakObservation
62 p.mz_q4 = mz_q4
63 p.intensity_q4 = intensity_q4
64 p.rt_q3 = rt_q3
65 pl.n = pl.n + 1
66 return 0
67}
68
69// =================================================================
70// Parse a decimal number starting at buf[offset]. Stores Q4 value
71// in *out_value (4 fractional digits, zero-padded if fewer in input).
72// Returns bytes consumed, or 0 if no digit found.
73//
74// Examples (input -> Q4 output):
75// "280" -> 2800000 (no fractional part; padded)
76// "280.18" -> 2801800 (2 fractional digits; padded with 00)
77// "280.1826" -> 2801826 (exact Q4)
78// "280.18263" -> 2801826 (extra digits truncated)
79// =================================================================
80func nx_chem_parse_decimal_q4(buf: *u8, offset: nx_int, len: nx_int, out_value: *nx_int) -> nx_int {
81 var i: nx_int = offset
82 var int_part: nx_int = 0
83 var frac_part: nx_int = 0
84 var frac_digits: nx_int = 0
85 var got_digit: nx_int = 0
86 // Integer part -- bound by buffer length.
87 let lp_int: *NxLoopFrame = nx_loop_begin(len + 1)
88 while nx_loop_step(lp_int) == 1 {
89 if i >= len { nx_loop_break(lp_int) }
90 else {
91 let c: nx_int = buf[i] as nx_int
92 if c < 48 { nx_loop_break(lp_int) }
93 else {
94 if c > 57 { nx_loop_break(lp_int) }
95 else {
96 int_part = (int_part * 10) + (c - 48)
97 got_digit = 1
98 i = i + 1
99 }
100 }
101 }
102 }
103 // Fractional part (optional)
104 if i < len {
105 if buf[i] as nx_int == 46 { // '.'
106 i = i + 1
107 // Fractional part -- bound by buffer length.
108 let lp_frac: *NxLoopFrame = nx_loop_begin(len + 1)
109 while nx_loop_step(lp_frac) == 1 {
110 if i >= len { nx_loop_break(lp_frac) }
111 else {
112 let c2: nx_int = buf[i] as nx_int
113 if c2 < 48 { nx_loop_break(lp_frac) }
114 else {
115 if c2 > 57 { nx_loop_break(lp_frac) }
116 else {
117 if frac_digits < 4 {
118 frac_part = (frac_part * 10) + (c2 - 48)
119 frac_digits = frac_digits + 1
120 got_digit = 1
121 }
122 i = i + 1
123 }
124 }
125 }
126 }
127 // Pad to 4 digits
128 while frac_digits < 4 {
129 frac_part = frac_part * 10
130 frac_digits = frac_digits + 1
131 }
132 }
133 }
134 if frac_digits == 0 {
135 frac_part = 0
136 // Multiply integer to Q4 (10000) for output
137 }
138 *out_value = (int_part * 10000) + frac_part
139 if got_digit == 0 { return 0 }
140 return i - offset
141}
142
143// =================================================================
144// Predicate: is the byte a horizontal whitespace char (space, tab,
145// or comma)? C5.5: comma added so the same parser handles BOTH
146// TSV (whitespace-separated) and CSV (comma-separated) peak files
147// transparently. Real-world LC-MS exports use both formats.
148// =================================================================
149func nx_chem_is_hspace(c: nx_int) -> nx_int {
150 if c == 32 { return 1 } // ' '
151 if c == 9 { return 1 } // '\t'
152 if c == 44 { return 1 } // ',' (CSV separator)
153 return 0
154}
155
156// =================================================================
157// Predicate: is the byte a line-terminator (CR or LF)?
158// =================================================================
159func nx_chem_is_lineterm(c: nx_int) -> nx_int {
160 if c == 10 { return 1 } // '\n'
161 if c == 13 { return 1 } // '\r'
162 return 0
163}
164
165// =================================================================
166// Parse complete peak-list text into a PeakList struct.
167// Format:
168// - One peak per line
169// - Whitespace-separated fields: m/z [intensity] [rt_seconds]
170// - '#' anywhere starts a comment to end-of-line
171// - Empty lines ignored
172// - rt_seconds is converted to Q3 milliseconds (rt_q3 = rt_q4 / 10)
173//
174// Returns PeakList with parsed entries.
175// =================================================================
176func nx_chem_peak_list_parse(buf: *u8, len: nx_int) -> *PeakList {
177 let pl: *PeakList = nx_chem_peak_list_new(4096)
178 var i: nx_int = 0
179 while i < len {
180 // Skip leading whitespace + newlines -- bound by buffer length.
181 let lp_ws: *NxLoopFrame = nx_loop_begin(len + 1)
182 while nx_loop_step(lp_ws) == 1 {
183 if i >= len { nx_loop_break(lp_ws) }
184 else {
185 let c: nx_int = buf[i] as nx_int
186 if c == 32 { i = i + 1 }
187 else { if c == 9 { i = i + 1 }
188 else { if c == 10 { i = i + 1 }
189 else { if c == 13 { i = i + 1 }
190 else { nx_loop_break(lp_ws) } } } }
191 }
192 }
193 if i >= len { return pl }
194 // Comment line: skip to EOL
195 if (buf[i] as nx_int) == 35 { // '#'
196 // Skip comment to EOL -- bound by buffer length.
197 let lp_cmt: *NxLoopFrame = nx_loop_begin(len + 1)
198 while nx_loop_step(lp_cmt) == 1 {
199 if i >= len { nx_loop_break(lp_cmt) }
200 else {
201 if nx_chem_is_lineterm(buf[i] as nx_int) == 1 { nx_loop_break(lp_cmt) }
202 else { i = i + 1 }
203 }
204 }
205 }
206 else {
207 // Parse m/z (mandatory)
208 var mz_q4: nx_int = 0
209 let n1: nx_int = nx_chem_parse_decimal_q4(buf, i, len, &mz_q4)
210 if n1 == 0 {
211 // unparseable -- skip to EOL, bound by buffer length.
212 let lp_skip: *NxLoopFrame = nx_loop_begin(len + 1)
213 while nx_loop_step(lp_skip) == 1 {
214 if i >= len { nx_loop_break(lp_skip) }
215 else {
216 if nx_chem_is_lineterm(buf[i] as nx_int) == 1 { nx_loop_break(lp_skip) }
217 else { i = i + 1 }
218 }
219 }
220 }
221 else {
222 i = i + n1
223 // Skip horizontal whitespace
224 var done_hs1: nx_int = 0
225 while done_hs1 == 0 {
226 if i >= len { done_hs1 = 1 }
227 else {
228 if nx_chem_is_hspace(buf[i] as nx_int) == 1 { i = i + 1 }
229 else { done_hs1 = 1 }
230 }
231 }
232 // Parse intensity (optional)
233 var intensity_q4: nx_int = 0
234 if i < len {
235 if nx_chem_is_lineterm(buf[i] as nx_int) == 0 {
236 if (buf[i] as nx_int) != 35 {
237 let n2: nx_int = nx_chem_parse_decimal_q4(buf, i, len, &intensity_q4)
238 if n2 > 0 { i = i + n2 }
239 }
240 }
241 }
242 // Skip horizontal whitespace
243 var done_hs2: nx_int = 0
244 while done_hs2 == 0 {
245 if i >= len { done_hs2 = 1 }
246 else {
247 if nx_chem_is_hspace(buf[i] as nx_int) == 1 { i = i + 1 }
248 else { done_hs2 = 1 }
249 }
250 }
251 // Parse rt_seconds (optional)
252 var rt_q4: nx_int = 0
253 if i < len {
254 if nx_chem_is_lineterm(buf[i] as nx_int) == 0 {
255 if (buf[i] as nx_int) != 35 {
256 let n3: nx_int = nx_chem_parse_decimal_q4(buf, i, len, &rt_q4)
257 if n3 > 0 { i = i + n3 }
258 }
259 }
260 }
261 let rt_q3: nx_int = rt_q4 / 10
262 let _add: nx_int = nx_chem_peak_list_add(pl, mz_q4, intensity_q4, rt_q3)
263 // Skip rest of line -- bound by buffer length.
264 let lp_eol: *NxLoopFrame = nx_loop_begin(len + 1)
265 while nx_loop_step(lp_eol) == 1 {
266 if i >= len { nx_loop_break(lp_eol) }
267 else {
268 if nx_chem_is_lineterm(buf[i] as nx_int) == 1 { nx_loop_break(lp_eol) }
269 else { i = i + 1 }
270 }
271 }
272 }
273 }
274 }
275 return pl
276}
277
278// =================================================================
279// Read peak list from a text file on disk.
280// Composes sys_read_file (runtime/nx_syscalls.nx) + peak_list_parse.
281//
282// Returns an empty PeakList (n=0) if file missing or empty. The
283// caller should check pl.n == 0 to distinguish "no peaks found" from
284// successful parse-zero-peaks.
285//
286// This is the entry point a real lab analyst uses: write peak text
287// to a known path, run the substrate binary, get a verdict report.
288// =================================================================
289func nx_chem_peak_list_read_file(path: *u8) -> *PeakList {
290 var len_out: i64 = 0
291 let buf: *u8 = sys_read_file(path, &len_out)
292 if (buf as nx_int) == 0 {
293 // file missing -- return empty list
294 return nx_chem_peak_list_new(8)
295 }
296 if len_out <= 0 {
297 return nx_chem_peak_list_new(8)
298 }
299 return nx_chem_peak_list_parse(buf, len_out as nx_int)
300}
301
302// =================================================================
303// Write a buffer to disk. Creates / truncates the file.
304// Returns total bytes written, or -1 on error.
305// =================================================================
306func nx_chem_write_file(path: *u8, buf: *u8, len: nx_int) -> nx_int {
307 let fd: i64 = sys_openat_wr(path, 420) // 0644 = 0o644 = 420 decimal
308 if fd < 0 { return -1 }
309 let n: i64 = sys_write(fd, buf, len as i64)
310 let _c: i64 = sys_close(fd)
311 return n as nx_int
312}
313
314// =================================================================
315// PREVENT helper for NishiLang's literal-path syscall quirk.
316//
317// QUIRK: Passing a string-literal *u8 directly to sys_openat (via
318// sys_read_file / sys_openat_rd) silently fails -- the kernel cannot
319// read the path bytes, returning ENOENT even when the file exists.
320// Confirmed via runtime/nx_litpath_quirk_test.nx (commit f3cac8be).
321//
322// FIX: Copy the literal into a mmap'd buffer first. Bytes are
323// identical; syscall semantics differ because the kernel can read
324// from mmap'd memory but not from wherever NishiLang stores string
325// literals.
326//
327// USAGE:
328// let path: *u8 = nx_chem_path_from_literal("examples/foo.csv" as *u8)
329// let buf: *u8 = sys_read_file(path, &len)
330//
331// The substrate enforces this pattern in any code path that hands a
332// LITERAL string to a SYSCALL. Paths coming from argv don't need it
333// (they're already in mmap'd kernel-supplied memory).
334// =================================================================
335func nx_chem_path_from_literal(literal: *u8) -> *u8 {
336 let buf: *u8 = sys_mmap(512)
337 var i: nx_int = 0
338 // C-string copy -- bound by defensive 4096 max literal length.
339 let lp_lit: *NxLoopFrame = nx_loop_begin(4096)
340 while nx_loop_step(lp_lit) == 1 {
341 let c: nx_int = literal[i] as nx_int
342 if c == 0 {
343 nx_loop_break(lp_lit)
344 } else {
345 buf[i] = literal[i]
346 i = i + 1
347 }
348 }
349 buf[i] = 0
350 return buf
351}
352
353// =================================================================
354// Extract basename from a path string. Strips:
355// - everything before the last '/' or '\'
356// - the trailing extension (last '.')
357// Writes result into caller-provided buf (null-terminated).
358// Returns length of basename (excluding null terminator).
359//
360// Examples:
361// /tmp/SUSPECT-2026-001.txt -> "SUSPECT-2026-001"
362// sample.csv -> "sample"
363// no_extension -> "no_extension"
364// /trailing/slash/ -> "" (caller should treat as empty)
365// =================================================================
366func nx_chem_basename(path: *u8, out_buf: *u8) -> nx_int {
367 // Find length of input
368 var path_len: nx_int = 0
369 // Path length scan -- bound by defensive 4096 (PATH_MAX-class).
370 let lp_pl: *NxLoopFrame = nx_loop_begin(4096)
371 while nx_loop_step(lp_pl) == 1 {
372 if path[path_len] as nx_int == 0 {
373 nx_loop_break(lp_pl)
374 } else {
375 path_len = path_len + 1
376 }
377 }
378 // Find last '/' or '\'
379 var start: nx_int = 0
380 var i: nx_int = 0
381 while i < path_len {
382 let c: nx_int = path[i] as nx_int
383 if c == 47 { start = i + 1 } // '/'
384 if c == 92 { start = i + 1 } // '\\'
385 i = i + 1
386 }
387 // Find last '.' after start (extension separator)
388 var end: nx_int = path_len
389 var j: nx_int = start
390 while j < path_len {
391 if path[j] as nx_int == 46 { end = j } // '.'
392 j = j + 1
393 }
394 // Copy [start..end) into out_buf
395 var k: nx_int = 0
396 var src_idx: nx_int = start
397 while src_idx < end {
398 out_buf[k] = path[src_idx]
399 k = k + 1
400 src_idx = src_idx + 1
401 }
402 out_buf[k] = 0
403 return k
404}