code wiki / (root) / nx_learn_rank.nx

nx_learn_rank.nx source

↩ module page · 424 lines · 19972 B

1// nx_learn_rank.nx -- the learnings-rail ranker, COMPOSED not rebuilt. 2// 3// module: nishi-core.learn.rank 4// depends: fx.nx, syscalls.nx, nx_bm25.nx, nx_rrf.nx, nx_rank_fused.nx 5// capability: CORE_COMPUTE 6// 7// WHY THIS EXISTS AND WHY IT IS SMALL: nishi-ops/learn/nx_rank.ps1 implements BM25 + RRF + a severity 8// blend in PowerShell -- judgement in shell, which the standing order forbids. The obvious remedy is 9// "write a ranking organ", and that is WRONG: measured 2026-08-16 (corpus_complete=1) the estate 10// already ships nx_bm25 (+_lib,+_gate), nx_rrf, nx_rank_fused (+test), nx_intlog (+gate) and 11// nx_bm25_naive as a brute-force oracle. A third BM25 would be the duplicate-ruler defect -- the very 12// one nx_rank.ps1's OWN header cites (nx_ppmi_lib's two silently-diverged copies of one routine) as 13// its reason for existing. So this file contains NO ranking mathematics. It is a DRIVER: read a 14// corpus, call the incumbents, apply the declared blend, print an order. 15// 16// CONTRACT (pre-declared BEFORE the first run, so the result cannot rationalise the design): 17// the top-N ORDER emitted here must equal nx_rank.ps1's top-N order on the same fixture. 18// SCORES may differ -- Q16.16 fixed point here vs IEEE doubles there -- but ORDER is the contract. 19// 20// INPUT corpus file, one doc per line: <rank>\t<slug>\t<text> 21// rank is the severity 1..star_max; a malformed line is SKIPPED AND COUNTED, never guessed. 22// OUTPUT one line per hit: <rank>\t<slug>\t<q16.16 score> then a DECLARED envelope line. 23// 24// nx_learn_rank <corpus> <topN> <conf> <query words...> 25// exit: 0 ranked (possibly zero hits) | 2 usage | 4 corpus unreadable 26// license_tier: ORIGINAL No hw writes (Rule 26). 27 28// ONE import, deliberately. nx_rank_fused already carries fx, syscalls, nx_source_tier, nx_bm25 and 29// nx_rrf, so naming any of them again here is not belt-and-braces -- it risks a second copy of every 30// symbol they define. ★AN IMPORT LIST THAT RESTATES A DEPENDENCY'S OWN DEPENDENCIES IS A DUPLICATE, 31// NOT A SAFEGUARD -- import the capstone and let it carry its graph. 32// ⚠PROVENANCE OF THAT RULE, stated because the evidence is NOT reproducible on this tree: it was 33// measured 2026-08-16 on a LAPTOP-LOCAL FORK of nx_bm25.nx (a Q16.16 variant that also defined 34// nx_bm25_tf, which nx_search_inverted.nx already defines) where the duplicate import stopped the 35// build outright. THIS tree's nx_bm25.nx is a different implementation -- milli/micro integer units, 36// no nx_bm25_tf -- so that exact error cannot occur here. The rule stands; the anecdote is local. 37// ★A COMMENT THAT REPORTS A CONDITION FROM ANOTHER TREE AS IF IT WERE THIS ONE'S SENDS THE NEXT 38// READER HUNTING A DEFECT THAT IS NOT THERE. 39import "nx_rank_fused.nx" 40 41// Rule 11: every weight is DATA. These are FALLBACKS used only when the conf omits a key, and they 42// mirror learn_markers.conf so the two cannot disagree silently. star_max is the severity ceiling and 43// has two consumers (the miner's rank scale and this blend) -- see that file's star_max row. 44const LR_DEF_BLEND_REL_NUM: i64 = 100 // blend_relevance 1.00, held as hundredths so the conf's 45const LR_DEF_BLEND_SEV_NUM: i64 = 35 // blend_severity 0.35, decimal text parses without floats 46const LR_DEF_STAR_MAX: i64 = 6 47// Prefix-stem length for the SECOND retrieval view, mirroring learn_markers.conf's stem_prefix_len. 48// This is what lets a query for "verification" reach a law that says "verify": exact BM25 structurally 49// cannot, and nx_rank.ps1's own suite proves it (T8 asserts the miss, T9 asserts fusion recovers it). 50// A single-view organ would silently REGRESS that recall, which is why order-equality in exact mode was 51// necessary but not sufficient to call this a replacement. 52const LR_DEF_STEM_PREFIX: i64 = 6 53const LR_HUNDRED: i64 = 100 54const LR_TAB: i64 = 9 55const LR_NL: i64 = 10 56const LR_MAXDOCS: i64 = 20000 57const LR_MAXTERMS: i64 = 64 58 59func lr_p(s: *u8) -> i64 { var n: i64 = 0; while s[n] != (0 as u8) { n = n + 1 } sys_write(1, s, n); return 0 } 60func lr_e(s: *u8) -> i64 { var n: i64 = 0; while s[n] != (0 as u8) { n = n + 1 } sys_write(2, s, n); return 0 } 61func lr_n(fd: i64, v: i64) -> i64 { 62 let t: *u8 = sys_mmap(32) 63 var m: i64 = v 64 if m < 0 { sys_write(fd, "-" as *u8, 1); m = 0 - m } 65 var k: i64 = 0 66 if m == 0 { t[0] = 48 as u8; k = 1 } 67 while m > 0 { t[k] = (48 + (m % 10)) as u8; m = m / 10; k = k + 1 } 68 let b: *u8 = sys_mmap(32) 69 var i: i64 = 0 70 while i < k { b[i] = t[k - 1 - i]; i = i + 1 } 71 sys_write(fd, b, k) 72 return 0 73} 74func lr_byte(fd: i64, c: i64) -> i64 { let b: *u8 = sys_mmap(1); b[0] = c as u8; sys_write(fd, b, 1); return 0 } 75func lr_slen(s: *u8) -> i64 { var n: i64 = 0; while s[n] != (0 as u8) { n = n + 1 } return n } 76 77// decimal text -> integer hundredths, so "0.35" becomes 35 with no float anywhere. 78// Accepts "N", "N.D", "N.DD". Trailing digits beyond two are TRUNCATED, not rounded, and that choice 79// is stated rather than left for a reader to infer. 80func lr_hundredths(s: *u8, n: i64) -> i64 { 81 var whole: i64 = 0 82 var frac: i64 = 0 83 var digits: i64 = 0 84 var seen_dot: i64 = 0 85 var i: i64 = 0 86 while i < n { 87 let c: i64 = s[i] as i64 88 if c == 46 { seen_dot = 1 } else { 89 if c >= 48 { if c <= 57 { 90 if seen_dot == 0 { whole = whole * 10 + (c - 48) } else { 91 if digits < 2 { frac = frac * 10 + (c - 48); digits = digits + 1 } 92 } 93 } } 94 } 95 i = i + 1 96 } 97 while digits < 2 { frac = frac * 10; digits = digits + 1 } 98 return whole * LR_HUNDRED + frac 99} 100 101// one `key=value` lookup over a conf buffer. Comments and blanks skipped; FIRST '=' splits, matching 102// learn_markers.conf's stated format exactly rather than inventing a second grammar. 103func lr_conf_get(buf: *u8, n: i64, key: *u8, out: *u8, outcap: i64) -> i64 { 104 let kl: i64 = lr_slen(key) 105 var i: i64 = 0 106 while i < n { 107 // same flag discipline as the corpus scanner below -- no sentinel in the cursor 108 var le: i64 = i 109 var eol: i64 = 0 110 while eol == 0 { 111 if le >= n { eol = 1 } else { 112 if buf[le] == (LR_NL as u8) { eol = 1 } else { le = le + 1 } 113 } 114 } 115 var s: i64 = i 116 var lead: i64 = 0 117 while lead == 0 { 118 if s >= le { lead = 1 } else { 119 if buf[s] == (32 as u8) { s = s + 1 } else { lead = 1 } 120 } 121 } 122 // s == le means a blank/whitespace-only line: there is no byte to classify, and reading 123 // buf[s] there would inspect the newline (or one past the buffer on the final line). 124 if s < le { if buf[s] != (35 as u8) { 125 var eq: i64 = 0 - 1 126 var j: i64 = s 127 while j < le { if buf[j] == (61 as u8) { if eq < 0 { eq = j } } j = j + 1 } 128 if eq > s { 129 var m: i64 = 1 130 if eq - s != kl { m = 0 } 131 if m == 1 { 132 var t: i64 = 0 133 while t < kl { if buf[s + t] != key[t] { m = 0 } t = t + 1 } 134 } 135 if m == 1 { 136 var vs: i64 = eq + 1 137 var vn: i64 = le - vs 138 if vn >= outcap { vn = outcap - 1 } 139 var w: i64 = 0 140 while w < vn { out[w] = buf[vs + w]; w = w + 1 } 141 out[vn] = 0 as u8 142 return vn 143 } 144 } 145 } } 146 i = le + 1 147 } 148 return 0 - 1 149} 150 151// Is c a token byte? Must agree with what nx_bm25 counts as a token, or the stemmed view would be 152// tokenised on one boundary and scored on another. Alphanumeric only, matching the estate's tokenizers. 153func lr_is_tok(c: i64) -> i64 { 154 if c >= 48 { if c <= 57 { return 1 } } 155 if c >= 65 { if c <= 90 { return 1 } } 156 if c >= 97 { if c <= 122 { return 1 } } 157 return 0 158} 159 160// Write a PREFIX-STEMMED copy of src[0..n) into dst: every token truncated to `plen` bytes, separators 161// collapsed to a single space. Returns bytes written. 162// The output is never longer than the input (tokens only shrink), so the caller sizes dst from the 163// SOURCE and there is no capacity to guess. Truncation is the whole point here, not a limitation: it 164// is the morphological view. 165func lr_stem_into(dst: *u8, src: *u8, n: i64, plen: i64) -> i64 { 166 var o: i64 = 0 167 var i: i64 = 0 168 while i < n { 169 if lr_is_tok(src[i] as i64) == 0 { 170 if o > 0 { if dst[o - 1] != (32 as u8) { dst[o] = 32 as u8; o = o + 1 } } 171 i = i + 1 172 } else { 173 // SEPARATE FLAG, NOT A SENTINEL IN THE CURSOR. The first cut of this loop ended a token by 174 // assigning `i = n + 1`, which does not end the token -- it ends the WHOLE SCAN, so only the 175 // first word of every document was ever stemmed and the second view matched nothing. That 176 // is the identical cursor-clobber defect this same file already hit in its corpus scanner 177 // (docs=1 on a 5-line fixture), written a second time within the hour. 178 // A LAW YOU JUST APPLIED IN ONE LOOP DOES NOT TRANSFER ITSELF TO THE NEXT LOOP YOU WRITE. 179 // Caught only because the T9 fixture was re-run with the exact-matching term REMOVED: with 180 // "reversibility firmware" it passed on the strength of "firmware" alone. 181 var k: i64 = 0 182 var tokend: i64 = 0 183 while tokend == 0 { 184 if i >= n { tokend = 1 } else { 185 if lr_is_tok(src[i] as i64) == 1 { 186 if k < plen { dst[o] = src[i]; o = o + 1; k = k + 1 } 187 i = i + 1 188 } else { tokend = 1 } 189 } 190 } 191 if o > 0 { dst[o] = 32 as u8; o = o + 1 } 192 } 193 } 194 return o 195} 196 197func main(argc: i64, argv: *i64) -> i64 { 198 if argc < 5 { 199 lr_e("usage: nx_learn_rank <corpus> <topN> <conf> <query words...>\n" as *u8) 200 sys_exit(2) 201 return 2 202 } 203 let corpus_path: *u8 = argv[1] as *u8 204 let topn: i64 = lr_hundredths(argv[2] as *u8, lr_slen(argv[2] as *u8)) / LR_HUNDRED 205 let conf_path: *u8 = argv[3] as *u8 206 207 // --- conf: every weight is data (rule 11). Missing keys fall back to the constants above. 208 var blend_rel: i64 = LR_DEF_BLEND_REL_NUM 209 var blend_sev: i64 = LR_DEF_BLEND_SEV_NUM 210 var star_max: i64 = LR_DEF_STAR_MAX 211 var stem_prefix: i64 = LR_DEF_STEM_PREFIX 212 let cszp: *i64 = sys_mmap(16) as *i64 213 let cbuf: *u8 = sys_read_file(conf_path, cszp) 214 if (cbuf as i64) != 0 { 215 let cn: i64 = cszp[0] 216 let vb: *u8 = sys_mmap(64) 217 if lr_conf_get(cbuf, cn, "blend_relevance" as *u8, vb, 64) >= 0 { blend_rel = lr_hundredths(vb, lr_slen(vb)) } 218 if lr_conf_get(cbuf, cn, "blend_severity" as *u8, vb, 64) >= 0 { blend_sev = lr_hundredths(vb, lr_slen(vb)) } 219 if lr_conf_get(cbuf, cn, "stem_prefix_len" as *u8, vb, 64) >= 0 { 220 let sp: i64 = lr_hundredths(vb, lr_slen(vb)) / LR_HUNDRED 221 if sp > 0 { stem_prefix = sp } 222 } 223 if lr_conf_get(cbuf, cn, "star_max" as *u8, vb, 64) >= 0 { 224 let sm: i64 = lr_hundredths(vb, lr_slen(vb)) / LR_HUNDRED 225 // a zero or negative scale is a broken conf, not a tuning choice: refuse it and keep the 226 // fallback rather than propagate a divide-by-zero into every score. 227 if sm > 0 { star_max = sm } 228 } 229 } 230 231 // --- corpus: <rank>\t<slug>\t<text> per line. Buffer sized from the FILE by sys_read_file, so 232 // there is no read ceiling to guess and no short read to detect. 233 let szp: *i64 = sys_mmap(16) as *i64 234 let buf: *u8 = sys_read_file(corpus_path, szp) 235 if (buf as i64) == 0 { 236 lr_e("CORPUS UNREADABLE: " as *u8); lr_e(corpus_path); lr_e("\n" as *u8) 237 sys_exit(4) 238 return 4 239 } 240 let n: i64 = szp[0] 241 242 let texts: **u8 = sys_mmap(LR_MAXDOCS * 8) as **u8 243 let tlens: *i64 = sys_mmap(LR_MAXDOCS * 8) as *i64 244 let ranks: *i64 = sys_mmap(LR_MAXDOCS * 8) as *i64 245 let slugs: **u8 = sys_mmap(LR_MAXDOCS * 8) as **u8 246 let sllen: *i64 = sys_mmap(LR_MAXDOCS * 8) as *i64 247 var ndocs: i64 = 0 248 var malformed: i64 = 0 249 var capped: i64 = 0 250 251 var i: i64 = 0 252 while i < n { 253 // SEPARATE FLAG, never a sentinel written into the cursor. Writing `le = n + 1` to break out 254 // erases the very position being searched for -- the estate has banked that defect four times 255 // in one day, and this organ reproduced it on its first run: a 5-line fixture parsed as 256 // docs=1. The declared envelope is what exposed it; a bare "0 results" would have read as 257 // "no matches" rather than "the scanner is broken". 258 var le: i64 = i 259 var eol: i64 = 0 260 while eol == 0 { 261 if le >= n { eol = 1 } else { 262 if buf[le] == (LR_NL as u8) { eol = 1 } else { le = le + 1 } 263 } 264 } 265 let end: i64 = le 266 if end > i { 267 var t1: i64 = 0 - 1 268 var t2: i64 = 0 - 1 269 var j: i64 = i 270 while j < end { 271 if buf[j] == (LR_TAB as u8) { 272 if t1 < 0 { t1 = j } else { if t2 < 0 { t2 = j } } 273 } 274 j = j + 1 275 } 276 if t1 < 0 { malformed = malformed + 1 } else { 277 if t2 < 0 { malformed = malformed + 1 } else { 278 if ndocs >= LR_MAXDOCS { capped = 1 } else { 279 var r: i64 = 0 280 var k: i64 = i 281 while k < t1 { 282 let c: i64 = buf[k] as i64 283 if c >= 48 { if c <= 57 { r = r * 10 + (c - 48) } } 284 k = k + 1 285 } 286 ranks[ndocs] = r 287 slugs[ndocs] = (buf as i64 + t1 + 1) as *u8 288 sllen[ndocs] = t2 - t1 - 1 289 texts[ndocs] = (buf as i64 + t2 + 1) as *u8 290 tlens[ndocs] = end - t2 - 1 291 ndocs = ndocs + 1 292 } 293 } 294 } 295 } 296 i = le + 1 297 } 298 299 // NO SILENT CAP: if the corpus outgrew the table say so on stderr, because a truncated ranking that 300 // prints normally is a measurement nobody knows is partial. 301 if capped == 1 { 302 lr_e("REFUSED: corpus exceeds LR_MAXDOCS -- the printed order would be a PREFIX, not a ranking\n" as *u8) 303 sys_exit(4) 304 return 4 305 } 306 307 // --- query terms straight from argv (already tokenized by the caller's shell split) 308 let qterms: **u8 = sys_mmap(LR_MAXTERMS * 8) as **u8 309 let qlens: *i64 = sys_mmap(LR_MAXTERMS * 8) as *i64 310 var nterms: i64 = 0 311 var a: i64 = 4 312 while a < argc { 313 if nterms < LR_MAXTERMS { 314 qterms[nterms] = argv[a] as *u8 315 qlens[nterms] = lr_slen(argv[a] as *u8) 316 nterms = nterms + 1 317 } 318 a = a + 1 319 } 320 321 // --- THE INCUMBENTS DO THE MATHEMATICS. This file only sequences them. 322 let scores: *i64 = sys_mmap(LR_MAXDOCS * 8) as *i64 323 nx_bm25_score(texts, tlens, ndocs, qterms, qlens, nterms, scores) 324 325 let order: *i64 = sys_mmap(LR_MAXDOCS * 8) as *i64 326 nx_rank_order_desc(scores, ndocs, order) 327 328 let rrf: *i64 = sys_mmap(LR_MAXDOCS * 8) as *i64 329 var z: i64 = 0 330 while z < ndocs { rrf[z] = 0; z = z + 1 } 331 nx_rrf_add(order, ndocs, rrf) 332 333 // ---- SECOND VIEW: prefix-stemmed BM25, fused by RRF ------------------------------------------ 334 // WHY THIS IS NOT OPTIONAL. Exact BM25 cannot match "reversibility" to a law that says 335 // "reversible" -- nx_rank.ps1's suite asserts that miss (T8) and then asserts fusion recovers it 336 // (T9). An exact-only organ would pass an order-equality check in `exact` mode and silently lose 337 // that recall in the `fused` mode production actually runs. RRF fuses by RANK, so adding a view is 338 // one more nx_rrf_add over one more order -- which is why the incumbent's header says swapping in a 339 // dense view later is "a one-view change, not a rewrite". This is that same seam, used once. 340 // The stem arena is sized from the CORPUS (stemming only shrinks tokens), so no capacity is guessed. 341 let sarena: *u8 = sys_mmap(n + ndocs + 1) 342 let stexts: **u8 = sys_mmap(LR_MAXDOCS * 8) as **u8 343 let slens: *i64 = sys_mmap(LR_MAXDOCS * 8) as *i64 344 var soff: i64 = 0 345 var sd: i64 = 0 346 while sd < ndocs { 347 let dstp: *u8 = (sarena as i64 + soff) as *u8 348 let wrote: i64 = lr_stem_into(dstp, texts[sd], tlens[sd], stem_prefix) 349 stexts[sd] = dstp 350 slens[sd] = wrote 351 soff = soff + wrote + 1 352 sd = sd + 1 353 } 354 // the query is stemmed with the SAME transform, or the two sides would not meet 355 let qarena: *u8 = sys_mmap(LR_MAXTERMS * 256) 356 let sqterms: **u8 = sys_mmap(LR_MAXTERMS * 8) as **u8 357 let sqlens: *i64 = sys_mmap(LR_MAXTERMS * 8) as *i64 358 var qoff: i64 = 0 359 var qi: i64 = 0 360 while qi < nterms { 361 let qd: *u8 = (qarena as i64 + qoff) as *u8 362 var w: i64 = 0 363 while w < qlens[qi] { if w < stem_prefix { qd[w] = qterms[qi][w] } w = w + 1 } 364 var kl: i64 = qlens[qi] 365 if kl > stem_prefix { kl = stem_prefix } 366 qd[kl] = 0 as u8 367 sqterms[qi] = qd 368 sqlens[qi] = kl 369 qoff = qoff + kl + 1 370 qi = qi + 1 371 } 372 let sscores: *i64 = sys_mmap(LR_MAXDOCS * 8) as *i64 373 nx_bm25_score(stexts, slens, ndocs, sqterms, sqlens, nterms, sscores) 374 let sorder: *i64 = sys_mmap(LR_MAXDOCS * 8) as *i64 375 nx_rank_order_desc(sscores, ndocs, sorder) 376 nx_rrf_add(sorder, ndocs, rrf) 377 378 // severity SCALES relevance, never adds to it (learn_markers.conf states the same rule): 379 // final = rrf * (blend_relevance + blend_severity * rank / star_max) 380 // Multiplicative keeps zero relevance at zero, which is what lets the ranker honestly return 381 // NOTHING rather than surfacing a severe-but-irrelevant law on any query. 382 let fin: *i64 = sys_mmap(LR_MAXDOCS * 8) as *i64 383 var d: i64 = 0 384 while d < ndocs { 385 let sevfrac: i64 = fx_from_frac(ranks[d], star_max) 386 let factor: i64 = fx_from_frac(blend_rel, LR_HUNDRED) + fx_mul(fx_from_frac(blend_sev, LR_HUNDRED), sevfrac) 387 fin[d] = fx_mul(rrf[d], factor) 388 // A doc must stay out of the order only if it matched NOTHING in EITHER view. Testing the 389 // exact view alone would zero precisely the docs the stemmed view exists to recover -- the 390 // T9 case ("reversibility" reaching "reversible") would be found and then discarded one line 391 // later. AN EXCLUSION RULE WRITTEN FOR ONE VIEW SILENTLY CANCELS EVERY VIEW ADDED AFTER IT. 392 if scores[d] <= 0 { if sscores[d] <= 0 { fin[d] = 0 } } 393 d = d + 1 394 } 395 396 let ford: *i64 = sys_mmap(LR_MAXDOCS * 8) as *i64 397 nx_rank_order_desc(fin, ndocs, ford) 398 399 var shown: i64 = 0 400 var p: i64 = 0 401 while p < ndocs { 402 let id: i64 = ford[p] 403 if fin[id] > 0 { if shown < topn { 404 lr_n(1, ranks[id]); lr_byte(1, LR_TAB) 405 sys_write(1, slugs[id], sllen[id]); lr_byte(1, LR_TAB) 406 lr_n(1, fin[id]); lr_byte(1, LR_NL) 407 shown = shown + 1 408 } } 409 p = p + 1 410 } 411 412 // DECLARED ENVELOPE: the counts a reader needs to know whether this answer is complete, including 413 // the lines this organ refused to guess at. 414 lr_e("LEARN-RANK docs=" as *u8); lr_n(2, ndocs) 415 lr_e(" terms=" as *u8); lr_n(2, nterms) 416 lr_e(" shown=" as *u8); lr_n(2, shown) 417 lr_e(" malformed_skipped=" as *u8); lr_n(2, malformed) 418 lr_e(" blend_rel_hundredths=" as *u8); lr_n(2, blend_rel) 419 lr_e(" blend_sev_hundredths=" as *u8); lr_n(2, blend_sev) 420 lr_e(" star_max=" as *u8); lr_n(2, star_max) 421 lr_e("\n" as *u8) 422 sys_exit(0) 423 return 0 424}