nx_chem_report_json.nx source
↩ module page · 312 lines · 12925 B
1// nx_chem_report_json.nx -- C6.0 milestone: structured JSON report
2// emitter for LIMS (Laboratory Information Management System) integration.
3//
4// Real lab workflow:
5// 1. Lab runs adulterant-detection pipeline
6// 2. Substrate emits JSON report to a file or stdout
7// 3. LIMS ingests JSON, applies business rules:
8// - banned_count > 0 -> auto-reject lot, alert QA
9// - restricted_count > 0 + label_doesnt_mention -> escalate
10// - unknown_count > threshold -> retest with method 2
11// 4. Audit trail: JSON archived with sample record + citation
12//
13// Format (one-line; nx_chem_report_emit_json):
14// {"sample_id":"...","method":"...","tolerance_ppm":N,
15// "peaks_analyzed":N,
16// "matches":[
17// {"mz":NNN.NNNN,"rt_seconds":N,"compound_id":N,
18// "name":"...","regulatory":"...","delta_ppm":N,
19// "citation":"..."},
20// ...
21// ],
22// "verdict":{"banned":N,"restricted":N,"approved":N,
23// "unknown":N,"recommendation":"..."}}
24//
25// Bits-up emitter:
26// - No JSON library; manual byte emission to caller-provided buffer
27// - String escape: handles " and \ ; control chars not expected in
28// citations or compound names
29// - Q4 decimal -> "DDD.MMMM" zero-padded form
30// - All emit_* functions return bytes written
31
32import "nx_chem.nx"
33import "nx_chem_molecule.nx"
34import "nx_chem_adulterant_db.nx"
35import "nx_chem_peak_list.nx"
36
37// =================================================================
38// Emit raw ASCII bytes from src into buf at offset.
39// src is null-terminated. Returns bytes written.
40// =================================================================
41func nx_chem_emit_str(buf: *u8, offset: nx_int, src: *u8) -> nx_int {
42 var w: nx_int = 0
43 var done: nx_int = 0
44 while done == 0 {
45 let c: nx_int = src[w] as nx_int
46 if c == 0 { done = 1 }
47 else {
48 buf[offset + w] = src[w]
49 w = w + 1
50 }
51 }
52 return w
53}
54
55// =================================================================
56// Emit JSON-escaped string (" wrapped, \\ for \, \" for "
57// internal control chars not expected -- bare ASCII assumption).
58// Returns bytes written.
59// =================================================================
60func nx_chem_emit_str_escaped(buf: *u8, offset: nx_int, src: *u8) -> nx_int {
61 var w: nx_int = 0
62 buf[offset + w] = 0x22; w = w + 1 // opening "
63 var si: nx_int = 0
64 var done: nx_int = 0
65 while done == 0 {
66 let c: nx_int = src[si] as nx_int
67 if c == 0 { done = 1 }
68 else {
69 if c == 34 {
70 buf[offset + w] = 0x5C; w = w + 1
71 buf[offset + w] = 0x22; w = w + 1
72 }
73 else { if c == 92 {
74 buf[offset + w] = 0x5C; w = w + 1
75 buf[offset + w] = 0x5C; w = w + 1
76 }
77 else {
78 buf[offset + w] = src[si]
79 w = w + 1
80 } }
81 si = si + 1
82 }
83 }
84 buf[offset + w] = 0x22; w = w + 1 // closing "
85 return w
86}
87
88// =================================================================
89// Emit nx_int as ASCII decimal digits. Returns bytes written.
90// =================================================================
91func nx_chem_emit_int(buf: *u8, offset: nx_int, value: nx_int) -> nx_int {
92 if value == 0 {
93 buf[offset] = 0x30
94 return 1
95 }
96 var v: nx_int = value
97 var negative: nx_int = 0
98 if v < 0 { negative = 1; v = 0 - v }
99 // Find length
100 var tmp: nx_int = v
101 var n_digits: nx_int = 0
102 while tmp > 0 {
103 tmp = tmp / 10
104 n_digits = n_digits + 1
105 }
106 var pos: nx_int = offset
107 if negative == 1 {
108 buf[pos] = 0x2D
109 pos = pos + 1
110 }
111 // Write digits from end to start
112 var i: nx_int = n_digits - 1
113 while i >= 0 {
114 let d: nx_int = v % 10
115 buf[pos + i] = (0x30 + d) as u8
116 v = v / 10
117 i = i - 1
118 }
119 var written: nx_int = n_digits
120 if negative == 1 { written = written + 1 }
121 return written
122}
123
124// =================================================================
125// Emit Q4 micro-AMU as "DDD.MMMM" (4 fractional digits zero-padded).
126// Returns bytes written.
127// =================================================================
128func nx_chem_emit_q4(buf: *u8, offset: nx_int, value: nx_int) -> nx_int {
129 let whole: nx_int = value / 10000
130 var frac: nx_int = value - (whole * 10000)
131 if frac < 0 { frac = 0 - frac }
132 var pos: nx_int = offset
133 let n_whole: nx_int = nx_chem_emit_int(buf, pos, whole)
134 pos = pos + n_whole
135 buf[pos] = 0x2E; pos = pos + 1 // '.'
136 // Zero-pad fractional to 4 digits
137 var pad_left: nx_int = 0
138 if frac < 1000 { pad_left = pad_left + 1 }
139 if frac < 100 { pad_left = pad_left + 1 }
140 if frac < 10 { pad_left = pad_left + 1 }
141 var pi: nx_int = 0
142 while pi < pad_left {
143 buf[pos] = 0x30; pos = pos + 1
144 pi = pi + 1
145 }
146 let n_frac: nx_int = nx_chem_emit_int(buf, pos, frac)
147 pos = pos + n_frac
148 return pos - offset
149}
150
151// =================================================================
152// Emit a literal char into the buffer.
153// =================================================================
154func nx_chem_emit_char(buf: *u8, offset: nx_int, ch: nx_int) -> nx_int {
155 buf[offset] = ch as u8
156 return 1
157}
158
159// =================================================================
160// Map regulatory enum to its JSON string ("BANNED", "RESTRICTED",
161// "APPROVED", "RX-ONLY", "UNKNOWN"). This wraps the existing display
162// helper to keep the JSON emitter self-contained.
163// =================================================================
164func nx_chem_emit_regulatory(buf: *u8, offset: nx_int, reg: nx_int) -> nx_int {
165 let s: *u8 = nx_chem_regulatory_str(reg)
166 return nx_chem_emit_str_escaped(buf, offset, s)
167}
168
169// =================================================================
170// Compute the verdict recommendation string for given counts.
171// =================================================================
172func nx_chem_verdict_recommendation(n_banned: nx_int, n_restricted: nx_int, n_unknown: nx_int) -> *u8 {
173 if n_banned > 0 { return "REJECT" as *u8 }
174 if n_restricted > 0 { return "REVIEW" as *u8 }
175 if n_unknown > 0 { return "RETEST" as *u8 }
176 return "ACCEPT" as *u8
177}
178
179// =================================================================
180// Main entry point: emit a complete JSON adulterant-detection report
181// into the caller-provided buffer. Returns bytes written.
182//
183// Caller responsibility:
184// - buf must have capacity for the full report (recommend 16 KiB)
185// - sample_id should be a null-terminated string
186// - peaks must be parsed via C5.0
187// - db must be seeded via C4.0
188// =================================================================
189func nx_chem_emit_report_json(buf: *u8, peaks: *PeakList, db: *AdulterantDB, sample_id: *u8, tolerance_ppm: nx_int) -> nx_int {
190 var pos: nx_int = 0
191 // {
192 pos = pos + nx_chem_emit_char(buf, pos, 0x7B)
193 // "sample_id":"...",
194 pos = pos + nx_chem_emit_str(buf, pos, "\"sample_id\":" as *u8)
195 pos = pos + nx_chem_emit_str_escaped(buf, pos, sample_id)
196 pos = pos + nx_chem_emit_char(buf, pos, 0x2C)
197 // "method":"LC-ESI+-MS",
198 pos = pos + nx_chem_emit_str(buf, pos, "\"method\":\"LC-ESI+-MS\"," as *u8)
199 // "tolerance_ppm":N,
200 pos = pos + nx_chem_emit_str(buf, pos, "\"tolerance_ppm\":" as *u8)
201 pos = pos + nx_chem_emit_int(buf, pos, tolerance_ppm)
202 pos = pos + nx_chem_emit_char(buf, pos, 0x2C)
203 // "peaks_analyzed":N,
204 pos = pos + nx_chem_emit_str(buf, pos, "\"peaks_analyzed\":" as *u8)
205 pos = pos + nx_chem_emit_int(buf, pos, peaks.n)
206 pos = pos + nx_chem_emit_char(buf, pos, 0x2C)
207 // "matches":[
208 pos = pos + nx_chem_emit_str(buf, pos, "\"matches\":[" as *u8)
209 var n_banned: nx_int = 0
210 var n_restricted: nx_int = 0
211 var n_approved: nx_int = 0
212 var n_unknown: nx_int = 0
213 var first_match: nx_int = 1
214 var pi: nx_int = 0
215 while pi < peaks.n {
216 let p: *PeakObservation = ((peaks.peaks as nx_int) + (pi * NX_PEAK_OBS_BYTES)) as *PeakObservation
217 let outs: *MatchResult = (sys_mmap((4 * NX_MATCH_RESULT_BYTES) as i64)) as *MatchResult
218 // Pass observed RT to lookup_with_rt; falls back to mass-only when
219 // entry.ref_rt_q3 == 0 (uncalibrated) or p.rt_q3 == 0 (no RT info).
220 let nm: nx_int = nx_chem_adulterant_db_lookup_with_rt(db, p.mz_q4, p.rt_q3, tolerance_ppm, outs, 4)
221 if nm == 0 {
222 n_unknown = n_unknown + 1
223 }
224 if nm > 0 {
225 var mi: nx_int = 0
226 while mi < nm {
227 let rr: *MatchResult = ((outs as nx_int) + (mi * NX_MATCH_RESULT_BYTES)) as *MatchResult
228 // C4.3 fix: rr.entry_id is the compound's user-supplied
229 // id (not array index). Use entry_by_id helper to handle
230 // both seed (id==index) and loaded (arbitrary id) entries.
231 let ee: *AdulterantEntry = nx_chem_adulterant_entry_by_id(db, rr.entry_id)
232 if first_match == 0 {
233 pos = pos + nx_chem_emit_char(buf, pos, 0x2C)
234 }
235 first_match = 0
236 pos = pos + nx_chem_emit_char(buf, pos, 0x7B)
237 pos = pos + nx_chem_emit_str(buf, pos, "\"mz\":" as *u8)
238 pos = pos + nx_chem_emit_q4(buf, pos, p.mz_q4)
239 pos = pos + nx_chem_emit_str(buf, pos, ",\"rt_seconds\":" as *u8)
240 pos = pos + nx_chem_emit_int(buf, pos, p.rt_q3 / 1000)
241 pos = pos + nx_chem_emit_str(buf, pos, ",\"compound_id\":" as *u8)
242 pos = pos + nx_chem_emit_int(buf, pos, rr.entry_id)
243 pos = pos + nx_chem_emit_str(buf, pos, ",\"name\":" as *u8)
244 pos = pos + nx_chem_emit_str_escaped(buf, pos, ee.name_ptr)
245 pos = pos + nx_chem_emit_str(buf, pos, ",\"regulatory\":" as *u8)
246 pos = pos + nx_chem_emit_regulatory(buf, pos, ee.regulatory)
247 pos = pos + nx_chem_emit_str(buf, pos, ",\"delta_ppm\":" as *u8)
248 pos = pos + nx_chem_emit_int(buf, pos, rr.delta_ppm_q1 / 10)
249 pos = pos + nx_chem_emit_str(buf, pos, ",\"confidence_pct\":" as *u8)
250 pos = pos + nx_chem_emit_int(buf, pos, rr.confidence_pct)
251 pos = pos + nx_chem_emit_str(buf, pos, ",\"citation\":" as *u8)
252 pos = pos + nx_chem_emit_str_escaped(buf, pos, ee.citation_ptr)
253 pos = pos + nx_chem_emit_char(buf, pos, 0x7D)
254 if ee.regulatory == NX_REG_BANNED { n_banned = n_banned + 1 }
255 if ee.regulatory == NX_REG_RESTRICTED { n_restricted = n_restricted + 1 }
256 if ee.regulatory == NX_REG_APPROVED { n_approved = n_approved + 1 }
257 mi = mi + 1
258 }
259 }
260 pi = pi + 1
261 }
262 // ],
263 pos = pos + nx_chem_emit_str(buf, pos, "]," as *u8)
264 // "verdict":{...}
265 pos = pos + nx_chem_emit_str(buf, pos, "\"verdict\":{\"banned\":" as *u8)
266 pos = pos + nx_chem_emit_int(buf, pos, n_banned)
267 pos = pos + nx_chem_emit_str(buf, pos, ",\"restricted\":" as *u8)
268 pos = pos + nx_chem_emit_int(buf, pos, n_restricted)
269 pos = pos + nx_chem_emit_str(buf, pos, ",\"approved\":" as *u8)
270 pos = pos + nx_chem_emit_int(buf, pos, n_approved)
271 pos = pos + nx_chem_emit_str(buf, pos, ",\"unknown\":" as *u8)
272 pos = pos + nx_chem_emit_int(buf, pos, n_unknown)
273 pos = pos + nx_chem_emit_str(buf, pos, ",\"recommendation\":" as *u8)
274 pos = pos + nx_chem_emit_str_escaped(buf, pos, nx_chem_verdict_recommendation(n_banned, n_restricted, n_unknown))
275 pos = pos + nx_chem_emit_str(buf, pos, "}}" as *u8)
276 // null-terminate
277 buf[pos] = 0
278 return pos
279}
280
281// =================================================================
282// Substring search in buf. Returns offset of first occurrence of
283// needle (null-terminated) in haystack[0..len], or -1 if not found.
284// Useful for KAT validation of emitted JSON.
285// =================================================================
286func nx_chem_buf_find(haystack: *u8, len: nx_int, needle: *u8) -> nx_int {
287 // Compute needle length
288 var nlen: nx_int = 0
289 var done_l: nx_int = 0
290 while done_l == 0 {
291 if needle[nlen] as nx_int == 0 { done_l = 1 }
292 else { nlen = nlen + 1 }
293 }
294 if nlen == 0 { return 0 }
295 if len < nlen { return -1 }
296 var i: nx_int = 0
297 let upper: nx_int = len - nlen
298 while i <= upper {
299 var matched: nx_int = 1
300 var j: nx_int = 0
301 while j < nlen {
302 if (haystack[i + j] as nx_int) != (needle[j] as nx_int) {
303 matched = 0
304 j = nlen
305 }
306 else { j = j + 1 }
307 }
308 if matched == 1 { return i }
309 i = i + 1
310 }
311 return -1
312}