code wiki / (root) / nx_chem_report_json.nx

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}