code wiki / (root) / sketch_kll.nx

sketch_kll.nx source

↩ module page · 325 lines · 11269 B

1// sketch_kll.nx -- compactor-hierarchy quantile sketch. 2// 3// MRL99-style (Manku-Rajagopalan-Lindsay 1999) "compactor cascade": 4// maintain levels 0, 1, 2, ... each holding up to k items. Items 5// at level h carry weight 2^h. When level h fills, sort it, flip 6// a coin, promote either even-indexed or odd-indexed items to 7// level h+1 (the other half discarded). 8// 9// Karnin-Lang-Liberty 2016 (FOCS) tightens this via geometric 10// per-level capacity decay; we use uniform capacity here for 11// simpler code at slightly looser bound. Rank-error declared 12// conservatively as ~1/sqrt(k) at conf 0.95. 13// 14// Versus Reservoir (sketch_reservoir.nx) HONEST COMPARISON 15// (2026-05-20 audit, sketch_kll_vs_reservoir_bench.nx): 16// - At MATCHED slot count, Reservoir wins on RANK-ERROR magnitude. 17// KLL k=64 declared eps=0.12 vs Reservoir cap=1024 declared 18// eps=0.0425 (both ~8KB). Empirically Reservoir wins on 19// uniform 1..10000 stream too. 20// - KLL's structural advantages live ELSEWHERE: 21// (1) DETERMINISTIC mergeable across instances (Reservoir 22// merge requires algorithmic choices that change semantics) 23// (2) O(1) amortized update at any N (Reservoir's update is 24// O(1/N) probability; quickly stops sampling at large N) 25// (3) Cascade preserves rank info from FULL stream history 26// (Reservoir's sample is a fixed random subset) 27// - Earlier doc claim of "tighter rank-error at fixed memory" was 28// a misframe (compared at same k, not same memory). Corrected. 29// 30// LOSSLESS-LANGUAGE DISCIPLINE (doc 20): 31// Same NX_ENV_RANK_ERROR envelope as Reservoir; substrate doesn't 32// double-count. Multiple primitives sharing an envelope kind is 33// fine -- the discriminator is about the SHAPE of the error, not 34// uniqueness. 35 36import "syscalls.nx" 37import "sketch_types.nx" 38 39const NX_KLL_K_MIN: i64 = 8 40const NX_KLL_K_MAX: i64 = 10000 41const NX_KLL_MAX_LEVELS: i64 = 16 42 43// LCG constants (same as Reservoir). 44const NX_KLL_LCG_A: i64 = 1103515245 45const NX_KLL_LCG_C: i64 = 12345 46const NX_KLL_LCG_MOD: i64 = 0x7FFFFFFF 47 48struct Kll { 49 k: i64, 50 n_levels: i64, // levels currently allocated 51 level_items: *i64, // contiguous: max_levels * k entries 52 level_count: *i64, // current item count per level 53 total_items: i64, 54 min_val: i64, 55 max_val: i64, 56 rng_state: i64, 57 view_vals: *i64, // bits-up: hoisted from nx_kll_quantile; 58 // sized max_levels * k once at alloc, avoids 59 // a per-query sys_mmap. 60 view_wts: *i64, 61} 62 63// === construction ================================================= 64 65func nx_kll_alloc(k: i64, seed: i64) -> *Kll { 66 if k < NX_KLL_K_MIN { return 0 as *Kll } 67 if k > NX_KLL_K_MAX { return 0 as *Kll } 68 let raw: *u8 = sys_mmap(80) 69 let s: *Kll = raw as *Kll 70 s.k = k 71 s.n_levels = 1 // level 0 always present 72 let items_bytes: i64 = NX_KLL_MAX_LEVELS * k * 8 73 let items_raw: *u8 = sys_mmap(items_bytes) 74 s.level_items = items_raw as *i64 75 let count_raw: *u8 = sys_mmap(NX_KLL_MAX_LEVELS * 8) 76 s.level_count = count_raw as *i64 77 let view_cap_bytes: i64 = NX_KLL_MAX_LEVELS * k * 8 78 s.view_vals = sys_mmap(view_cap_bytes) as *i64 79 s.view_wts = sys_mmap(view_cap_bytes) as *i64 80 var i: i64 = 0 81 while i < NX_KLL_MAX_LEVELS { 82 s.level_count[i] = 0 83 i = i + 1 84 } 85 s.total_items = 0 86 // Sentinels for min/max -- use first add to bootstrap. 87 s.min_val = 0x7FFFFFFFFFFFFFFF 88 s.max_val = -1 - 0x7FFFFFFFFFFFFFFF // i64 min 89 s.rng_state = seed | 1 90 return s 91} 92 93// LCG state mutation. 94func nx_kll_rng_next(s: *Kll) -> i64 { 95 let next: i64 = ((s.rng_state * NX_KLL_LCG_A) + NX_KLL_LCG_C) & NX_KLL_LCG_MOD 96 s.rng_state = next 97 return next 98} 99 100// === level-buffer access helpers ================================= 101// Buffer for level h starts at level_items[h * k]. 102 103func nx_kll_level_addr(s: *Kll, level: i64) -> *i64 { 104 return (s.level_items as i64 + level * s.k * 8) as *i64 105} 106 107func nx_kll_sort_level(s: *Kll, level: i64) -> i64 { 108 let count: i64 = s.level_count[level] 109 let buf: *i64 = nx_kll_level_addr(s, level) 110 var i: i64 = 1 111 while i < count { 112 let cur: i64 = buf[i] 113 var j: i64 = i - 1 114 var done: i64 = 0 115 while done == 0 { 116 if j < 0 { done = 1 } 117 if done == 0 { 118 let prev: i64 = buf[j] 119 if prev <= cur { done = 1 } 120 if done == 0 { 121 buf[j + 1] = prev 122 j = j - 1 123 } 124 } 125 } 126 buf[j + 1] = cur 127 i = i + 1 128 } 129 return 0 130} 131 132// === compact: sort + coin-flip + promote half ==================== 133 134func nx_kll_compact(s: *Kll, level: i64) -> i64 { 135 if level >= NX_KLL_MAX_LEVELS - 1 { 136 // No room to promote -- drop the level (rare; happens only 137 // at extreme stream sizes). 138 s.level_count[level] = 0 139 return 0 140 } 141 nx_kll_sort_level(s, level) 142 let count: i64 = s.level_count[level] 143 let buf: *i64 = nx_kll_level_addr(s, level) 144 // Coin flip: 0 = take even indices (0, 2, ...), 1 = take odd. 145 let pick_odd: i64 = nx_kll_rng_next(s) & 1 146 // Promote chosen half to level+1. 147 let next_level: i64 = level + 1 148 let next_buf: *i64 = nx_kll_level_addr(s, next_level) 149 let next_count_before: i64 = s.level_count[next_level] 150 var i: i64 = pick_odd // start at 0 or 1 151 var promoted: i64 = 0 152 while i < count { 153 let dst_idx: i64 = next_count_before + promoted 154 if dst_idx < s.k { 155 next_buf[dst_idx] = buf[i] 156 promoted = promoted + 1 157 } 158 i = i + 2 159 } 160 s.level_count[next_level] = next_count_before + promoted 161 s.level_count[level] = 0 162 // Grow n_levels if we just pushed into a new level. 163 if next_level >= s.n_levels { 164 s.n_levels = next_level + 1 165 } 166 // Cascade if next level is now full. 167 if s.level_count[next_level] >= s.k { 168 nx_kll_compact(s, next_level) 169 } 170 return 0 171} 172 173// === add ========================================================== 174 175func nx_kll_add(s: *Kll, value: i64) -> i64 { 176 if value < s.min_val { s.min_val = value } 177 if value > s.max_val { s.max_val = value } 178 s.total_items = s.total_items + 1 179 let buf: *i64 = nx_kll_level_addr(s, 0) 180 let count: i64 = s.level_count[0] 181 buf[count] = value 182 s.level_count[0] = count + 1 183 if s.level_count[0] >= s.k { 184 nx_kll_compact(s, 0) 185 } 186 return 0 187} 188 189// === total weight (for normalisation) ============================ 190 191func nx_kll_total_weight(s: *Kll) -> i64 { 192 var total: i64 = 0 193 var h: i64 = 0 194 while h < s.n_levels { 195 total = total + s.level_count[h] * (1 << h) 196 h = h + 1 197 } 198 return total 199} 200 201// === build sorted weighted view + query ========================== 202// 203// Concatenate every level's items with weight=2^h, sort by value, 204// scan accumulating cumulative weight, return value at target rank. 205// Sort is insertion-style over the combined view (O(N log N) at 206// query but N is small -- bounded by k * log(total_items)). 207 208func nx_kll_quantile(s: *Kll, p_milli: i64) -> i64 { 209 if s.total_items == 0 { return 0 } 210 let total_weight: i64 = nx_kll_total_weight(s) 211 if total_weight == 0 { return 0 } 212 // Materialise view: collect (value, weight) pairs. Cap entry 213 // count at max_levels * k. 214 // Bits-up: scratch hoisted to struct (was per-query sys_mmap). 215 let view_vals: *i64 = s.view_vals 216 let view_wts: *i64 = s.view_wts 217 var n: i64 = 0 218 var h: i64 = 0 219 while h < s.n_levels { 220 let buf: *i64 = nx_kll_level_addr(s, h) 221 let cnt: i64 = s.level_count[h] 222 let w: i64 = 1 << h 223 var i: i64 = 0 224 while i < cnt { 225 view_vals[n] = buf[i] 226 view_wts[n] = w 227 n = n + 1 228 i = i + 1 229 } 230 h = h + 1 231 } 232 // Sort the view by value (insertion sort; n is small). 233 var i: i64 = 1 234 while i < n { 235 let cur_v: i64 = view_vals[i] 236 let cur_w: i64 = view_wts[i] 237 var j: i64 = i - 1 238 var done: i64 = 0 239 while done == 0 { 240 if j < 0 { done = 1 } 241 if done == 0 { 242 if view_vals[j] <= cur_v { done = 1 } 243 if done == 0 { 244 view_vals[j + 1] = view_vals[j] 245 view_wts[j + 1] = view_wts[j] 246 j = j - 1 247 } 248 } 249 } 250 view_vals[j + 1] = cur_v 251 view_wts[j + 1] = cur_w 252 i = i + 1 253 } 254 // Walk sorted view, find target rank. 255 let target: i64 = (p_milli * total_weight) / 1000 256 var cum: i64 = 0 257 var k: i64 = 0 258 while k < n { 259 cum = cum + view_wts[k] 260 if cum >= target { return view_vals[k] } 261 k = k + 1 262 } 263 return view_vals[n - 1] 264} 265 266// === rank-of-value =============================================== 267 268func nx_kll_rank(s: *Kll, value: i64) -> i64 { 269 if s.total_items == 0 { return 0 } 270 if value < s.min_val { return 0 } 271 if value >= s.max_val { return 1000 } 272 let total_weight: i64 = nx_kll_total_weight(s) 273 if total_weight == 0 { return 0 } 274 // Sum weights of items with value <= query value. 275 var cum: i64 = 0 276 var h: i64 = 0 277 while h < s.n_levels { 278 let buf: *i64 = nx_kll_level_addr(s, h) 279 let cnt: i64 = s.level_count[h] 280 let w: i64 = 1 << h 281 var i: i64 = 0 282 while i < cnt { 283 if buf[i] <= value { 284 cum = cum + w 285 } 286 i = i + 1 287 } 288 h = h + 1 289 } 290 return (cum * 1000) / total_weight 291} 292 293// === error bound + query ========================================= 294// 295// MRL/KLL rank-error: ~1.36/sqrt(k) at 95% confidence (Hoeffding- 296// style conservative bound, same shape as Reservoir but k is the 297// per-level capacity, not the total reservoir size). 298 299func nx_kll_rank_error_ppb(k: i64) -> i64 { 300 if k <= 8 { return 480000000 } // 0.48 301 if k <= 32 { return 240000000 } 302 if k <= 128 { return 120000000 } // 0.12 303 if k <= 512 { return 60000000 } // 0.06 304 if k <= 2048 { return 30000000 } // 0.03 305 return 15000000 // 0.015 306} 307 308func nx_kll_query_quantile(s: *Kll, p_milli: i64) -> *ApproxI64 { 309 let v: i64 = nx_kll_quantile(s, p_milli) 310 return nx_approx_new(v, NX_ENV_RANK_ERROR, 311 nx_kll_rank_error_ppb(s.k), 312 950000000, 313 NX_MATURITY_REFERENCE_IMPL, 314 NX_ADV_HONEST) 315} 316 317// === memory introspection ======================================== 318 319func nx_kll_memory_bytes(s: *Kll) -> i64 { 320 return 64 + NX_KLL_MAX_LEVELS * s.k * 8 + NX_KLL_MAX_LEVELS * 8 321} 322 323func nx_kll_levels_used(s: *Kll) -> i64 { 324 return s.n_levels 325}