code wiki / _hdl_build / nx_ltr_lib.nx

nx_ltr_lib.nx source

↩ module page · 225 lines · 9990 B

1// nx_ltr_lib.nx -- LEARNING-TO-RANK reranker as a library so a gate drives it in-process on a 2// synthetic fixture (search rung R0, 2026-09-14). COORDINATE ASCENT (Metzler-Croft) that directly 3// maximises training-fold nDCG@10 one weight at a time by step-halving line search, INITIALISED at 4// the BM25-only solution so it can never rank worse than BM25 on the folds it trains on; evaluated by 5// K-FOLD CROSS-VALIDATION so no query's own label ever trains the weights that score it (the 6// contamination-free path when only a test split exists). Integer/fixed-point end to end: features are 7// min-max normalised to LTR_SCALE per query, weights are i64, ranking is an integer dot product, so 8// the result is bit-reproducible. The harness (nx_beir_eval) stores per-candidate features + gold + 9// idcg during its BM25 pass and calls ltr_cv over the SAME candidate pool, so the learned arm is the 10// same controlled comparison as the semppmi and rrf arms. license_tier: ORIGINAL 11 12import "nx_syscalls.nx" 13 14const LTR_NFEAT: i64 = 10 // bm25 ppmi_maxsim cover covidf sumtf doclen dense_maxsim_centered dense_maxsim minwin bigram 15const LTR_SCALE: i64 = 1000 // per-query per-feature min-max target range (0..LTR_SCALE) 16const LTR_FOLDS: i64 = 5 // 5-fold cross-validation 17const LTR_TOPK: i64 = 10 // nDCG@10 18const LTR_CA_INIT: i64 = 1024 // initial bm25 (feature 0) weight -- ranking starts AS bm25 19const LTR_CA_STEP0: i64 = 1024 // line-search first step, halved to 0; comparable to the init so a feature can reach bm25 scale 20const LTR_CA_PASSES: i64 = 12 // coordinate sweeps; ends early when a whole sweep improves nothing 21 22// nDCG log2 discount, 1-based rank -> round(1000/log2(rank+1)); the metric definition, not a tunable. 23func ltr_disc(r: i64) -> i64 { 24 if r == 1 { return 1000 } 25 if r == 2 { return 631 } 26 if r == 3 { return 500 } 27 if r == 4 { return 431 } 28 if r == 5 { return 387 } 29 if r == 6 { return 356 } 30 if r == 7 { return 333 } 31 if r == 8 { return 315 } 32 if r == 9 { return 301 } 33 if r == 10 { return 289 } 34 return 0 35} 36 37// per-query per-feature min-max normalise feat -> fn (0..LTR_SCALE). feat and fn are flat arrays 38// indexed (q*maxcand + c)*LTR_NFEAT + k. A feature constant within a query normalises to 0 (no vote), 39// so a slot the harness leaves unfilled contributes nothing rather than a bias. Normalisation is 40// per-query and uses only that query's own features, never a label and never a cross-query statistic, 41// so a held-out query leaks nothing. 42func ltr_norm(feat: *i64, fn: *i64, nq: i64, ncand: *i64, maxcand: i64) -> i64 { 43 var q: i64 = 0 44 while q < nq { 45 let nc: i64 = ncand[q] 46 var k: i64 = 0 47 while k < LTR_NFEAT { 48 var mn: i64 = 0 49 var mx: i64 = 0 50 var first: i64 = 1 51 var c: i64 = 0 52 while c < nc { 53 let v: i64 = feat[(q * maxcand + c) * LTR_NFEAT + k] 54 if first == 1 { mn = v; mx = v; first = 0 } else { if v < mn { mn = v } if v > mx { mx = v } } 55 c = c + 1 56 } 57 let rng: i64 = mx - mn 58 c = 0 59 while c < nc { 60 let idx: i64 = (q * maxcand + c) * LTR_NFEAT + k 61 if rng > 0 { fn[idx] = (LTR_SCALE * (feat[idx] - mn)) / rng } else { fn[idx] = 0 } 62 c = c + 1 63 } 64 k = k + 1 65 } 66 q = q + 1 67 } 68 return 0 69} 70 71func ltr_dot(w: *i64, fn: *i64, base: i64) -> i64 { 72 var s: i64 = 0 73 var k: i64 = 0 74 while k < LTR_NFEAT { s = s + w[k] * fn[base + k]; k = k + 1 } 75 return s 76} 77 78// mean permil nDCG@10 over the queries selected by sel: sel==1 scores the held-out fold (fold==holdout), 79// sel==0 scores the training folds (fold!=holdout). cnt_out[0] = queries scored. holdout<0 with sel==1 80// scores EVERY query (the whole-set control path). 81func ltr_ndcg_sel(w: *i64, fn: *i64, gold: *i64, fold: *i64, nq: i64, ncand: *i64, maxcand: i64, idcg: *i64, holdout: i64, sel: i64, cnt_out: *i64) -> i64 { 82 let used: *u8 = sys_mmap(maxcand) 83 var sumperm: i64 = 0 84 var cnt: i64 = 0 85 var q: i64 = 0 86 while q < nq { 87 var take: i64 = 0 88 if sel == 1 { if holdout < 0 { take = 1 } else { if fold[q] == holdout { take = 1 } } } 89 else { if fold[q] != holdout { take = 1 } } 90 if take == 1 { 91 let nc: i64 = ncand[q] 92 var c: i64 = 0 93 while c < nc { used[c] = 0 as u8; c = c + 1 } 94 var dcg: i64 = 0 95 var r: i64 = 0 96 while r < LTR_TOPK { 97 var best: i64 = 0 - 1 98 var c2: i64 = 0 99 while c2 < nc { 100 if used[c2] == (0 as u8) { 101 if best < 0 { best = c2 } else { 102 if ltr_dot(w, fn, (q * maxcand + c2) * LTR_NFEAT) > ltr_dot(w, fn, (q * maxcand + best) * LTR_NFEAT) { best = c2 } 103 } 104 } 105 c2 = c2 + 1 106 } 107 if best >= 0 { used[best] = 1 as u8; dcg = dcg + gold[q * maxcand + best] * ltr_disc(r + 1) } 108 r = r + 1 109 } 110 if idcg[q] > 0 { sumperm = sumperm + (dcg * 1000) / idcg[q]; cnt = cnt + 1 } 111 } 112 q = q + 1 113 } 114 sys_munmap(used, maxcand) 115 cnt_out[0] = cnt 116 var m: i64 = 0 117 if cnt > 0 { m = sumperm / cnt } 118 return m 119} 120 121// COORDINATE ASCENT on training-fold nDCG@10, initialised at bm25-only. For each feature, a step-halving 122// line search moves its weight in whichever direction raises training nDCG, accepting only improvements, 123// so the training-fold score is monotone non-decreasing from the bm25 baseline. w_out gets the weights. 124func ltr_train_ca(fn: *i64, gold: *i64, fold: *i64, nq: i64, ncand: *i64, maxcand: i64, idcg: *i64, holdout: i64, w_out: *i64) -> i64 { 125 let w: *i64 = sys_mmap(LTR_NFEAT * 8) as *i64 126 let cb: *i64 = sys_mmap(8) as *i64 127 var k: i64 = 0 128 while k < LTR_NFEAT { w[k] = 0; k = k + 1 } 129 w[0] = LTR_CA_INIT 130 var cur: i64 = ltr_ndcg_sel(w, fn, gold, fold, nq, ncand, maxcand, idcg, holdout, 0, cb) 131 var pass: i64 = 0 132 var going: i64 = 1 133 while going == 1 { 134 var anyimp: i64 = 0 135 var kk: i64 = 0 136 while kk < LTR_NFEAT { 137 var step: i64 = LTR_CA_STEP0 138 while step >= 1 { 139 let orig: i64 = w[kk] 140 w[kk] = orig + step 141 var s: i64 = ltr_ndcg_sel(w, fn, gold, fold, nq, ncand, maxcand, idcg, holdout, 0, cb) 142 if s > cur { cur = s; anyimp = 1 } else { 143 w[kk] = orig - step 144 s = ltr_ndcg_sel(w, fn, gold, fold, nq, ncand, maxcand, idcg, holdout, 0, cb) 145 if s > cur { cur = s; anyimp = 1 } else { w[kk] = orig; step = step / 2 } 146 } 147 } 148 kk = kk + 1 149 } 150 pass = pass + 1 151 if anyimp == 0 { going = 0 } 152 if pass >= LTR_CA_PASSES { going = 0 } 153 } 154 k = 0 155 while k < LTR_NFEAT { w_out[k] = w[k]; k = k + 1 } 156 sys_munmap(w as *u8, LTR_NFEAT * 8) 157 sys_munmap(cb as *u8, 8) 158 return 0 159} 160 161// full k-fold CV. fold[q] must be set by the caller (q mod LTR_FOLDS is the standard, written by the 162// harness). Returns the mean permil nDCG@10 over every query (each held out exactly once). 163// cnt_out[0] = queries scored. This is nr_rerank's measured arm. 164func ltr_cv(feat: *i64, gold: *i64, fold: *i64, nq: i64, ncand: *i64, maxcand: i64, idcg: *i64, cnt_out: *i64) -> i64 { 165 let fn: *i64 = sys_mmap(nq * maxcand * LTR_NFEAT * 8) as *i64 166 ltr_norm(feat, fn, nq, ncand, maxcand) 167 let w: *i64 = sys_mmap(LTR_NFEAT * 8) as *i64 168 let c1: *i64 = sys_mmap(8) as *i64 169 var totalperm: i64 = 0 170 var totalcnt: i64 = 0 171 var f: i64 = 0 172 while f < LTR_FOLDS { 173 ltr_train_ca(fn, gold, fold, nq, ncand, maxcand, idcg, f, w) 174 // held-out sum: ltr_ndcg_sel returns a MEAN, so multiply back by its count to sum across folds 175 let holdmean: i64 = ltr_ndcg_sel(w, fn, gold, fold, nq, ncand, maxcand, idcg, f, 1, c1) 176 totalperm = totalperm + holdmean * c1[0] 177 totalcnt = totalcnt + c1[0] 178 f = f + 1 179 } 180 sys_munmap(fn as *u8, nq * maxcand * LTR_NFEAT * 8) 181 sys_munmap(w as *u8, LTR_NFEAT * 8) 182 sys_munmap(c1 as *u8, 8) 183 cnt_out[0] = totalcnt 184 var mean: i64 = 0 185 if totalcnt > 0 { mean = totalperm / totalcnt } 186 return mean 187} 188 189// CONTROL: rank each query by a SINGLE raw feature (no training) and return the mean permil nDCG@10. 190// Called with featk=0 (bm25) it must reproduce the harness's own BM25 arm -- the plumbing check that 191// separates a trainer that learns bad weights from a feature-capture bug. cnt_out[0] = queries scored. 192func ltr_ndcg_single(feat: *i64, gold: *i64, nq: i64, ncand: *i64, maxcand: i64, idcg: *i64, featk: i64, cnt_out: *i64) -> i64 { 193 let used: *u8 = sys_mmap(maxcand) 194 var sumperm: i64 = 0 195 var cnt: i64 = 0 196 var q: i64 = 0 197 while q < nq { 198 let nc: i64 = ncand[q] 199 var c: i64 = 0 200 while c < nc { used[c] = 0 as u8; c = c + 1 } 201 var dcg: i64 = 0 202 var r: i64 = 0 203 while r < LTR_TOPK { 204 var best: i64 = 0 - 1 205 var c2: i64 = 0 206 while c2 < nc { 207 if used[c2] == (0 as u8) { 208 if best < 0 { best = c2 } else { 209 if feat[(q * maxcand + c2) * LTR_NFEAT + featk] > feat[(q * maxcand + best) * LTR_NFEAT + featk] { best = c2 } 210 } 211 } 212 c2 = c2 + 1 213 } 214 if best >= 0 { used[best] = 1 as u8; dcg = dcg + gold[q * maxcand + best] * ltr_disc(r + 1) } 215 r = r + 1 216 } 217 if idcg[q] > 0 { sumperm = sumperm + (dcg * 1000) / idcg[q]; cnt = cnt + 1 } 218 q = q + 1 219 } 220 sys_munmap(used, maxcand) 221 cnt_out[0] = cnt 222 var m: i64 = 0 223 if cnt > 0 { m = sumperm / cnt } 224 return m 225}