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}