code wiki / (root) / sketch_tdigest.nx

sketch_tdigest.nx source

↩ module page · 340 lines · 12612 B

1// sketch_tdigest.nx -- T-Digest (Dunning 2019) tail-tight quantile sketch. 2// 3// Maintain centroids (mean, weight) sorted by mean. Each centroid covers 4// a window of the cumulative-distribution domain [q_left, q_right]. The 5// MAX WEIGHT a centroid may absorb depends on its q-position via a scale 6// function that gives more room to centroids in the middle of the 7// distribution and TIGHTLY BOUNDS centroids near the tails -- the 8// headline T-Digest property. 9// 10// COMPLEMENTS KLL: 11// - KLL: provable uniform rank-error bound, lighter at middle 12// quantiles, looser at tails. 13// - T-Digest: empirically tail-tight (p99/p99.9 accuracy is the 14// headline), no global rank-error guarantee. 15// Together: both shipping means callers pick the right tool per query. 16// 17// DUNNING k1 vs SIMPLIFIED SCALE FUNCTION (v1 trade-off): 18// Dunning's k1(q) = (delta / 2π) · arcsin(2q - 1) 19// → w_max_norm(q) = k1_inv(k1(q) + 1) - q 20// exact: tail w_max ~ sqrt-like; middle ~ delta-bounded. 21// v1 here: w_max_norm(q) = 4·q·(1-q) · π / delta 22// The simplified scale UNDERESTIMATES tail capacity (more conservative 23// = MORE centroids near tails, better tail accuracy than spec). At 24// q=0.5 it gives 0.0314·N matching Dunning within 1%. Trade-off: 25// memory uses slightly more centroids than necessary; queried accuracy 26// is no worse than Dunning's bound, often better at tails. 27// v2 will swap in a tabulated arcsin-based scale. 28// 29// LOSSLESS-LANGUAGE DISCIPLINE (doc 20): 30// Query returns ApproxI64 with envelope_kind = NX_ENV_RANK_ERROR, 31// param_a = ~1% absolute rank error (conservative bound for delta=100), 32// maturity = ReferenceImpl, adv = Honest. 33 34import "syscalls.nx" 35import "sketch_types.nx" 36 37const NX_TD_DELTA_DEFAULT: i64 = 100 38const NX_TD_BUFFER_CAP: i64 = 1000 39const NX_TD_MAX_CENTROIDS: i64 = 2000 // bits-up scale: was 800; now supports 40 // delta up to ~1000 on N up to ~2000 41 // streams. Memory cost: +19200 B per 42 // instance. Bumped 2026-05-20 to honor 43 // matched-memory comparison vs DDSketch. 44 45const NX_TD_PI_PPB: i64 = 3141592654 // π · 10^9 46 47// Centroid = (mean, weight) packed as 16 bytes. 48struct Centroid { 49 mean: i64, 50 weight: i64, 51} 52 53struct TDigest { 54 centroids: *Centroid, 55 n_centroids: i64, 56 buffer: *i64, 57 n_buf: i64, 58 total_weight: i64, 59 delta: i64, 60 merge_scratch: *Centroid, // bits-up: hoisted from nx_tdigest_merge 61 // (was per-merge sys_mmap of MAX_CENTROIDS*16) 62} 63 64// === construction ================================================= 65 66func nx_tdigest_alloc(delta: i64) -> *TDigest { 67 if delta < 10 { return 0 as *TDigest } 68 if delta > 1000 { return 0 as *TDigest } // bits-up: was 500 69 let raw: *u8 = sys_mmap(56) 70 let td: *TDigest = raw as *TDigest 71 let cent_raw: *u8 = sys_mmap(NX_TD_MAX_CENTROIDS * 16) 72 td.centroids = cent_raw as *Centroid 73 td.n_centroids = 0 74 let buf_raw: *u8 = sys_mmap(NX_TD_BUFFER_CAP * 8) 75 td.buffer = buf_raw as *i64 76 td.n_buf = 0 77 td.total_weight = 0 78 td.delta = delta 79 let scratch_raw: *u8 = sys_mmap(NX_TD_MAX_CENTROIDS * 16) 80 td.merge_scratch = scratch_raw as *Centroid 81 return td 82} 83 84func nx_tdigest_centroid_at(td: *TDigest, i: i64) -> *Centroid { 85 return (td.centroids as i64 + i * 16) as *Centroid 86} 87 88// === scale function (simplified) ================================== 89// 90// w_max(q, N) returns the max weight a centroid covering q_left=q 91// may absorb in a sketch of total weight N. 92// 93// w_max_norm(q) = 4·q·(1-q) · π / delta (in ppb) 94// w_max(q, N) = w_max_norm(q) · N / 1e9 95// 96// Floor: 1 (else degenerate single-point centroids would multiply). 97 98func nx_tdigest_w_max(td: *TDigest, cum_w: i64, total: i64) -> i64 { 99 if total == 0 { return 1 } 100 // q in [0, 1] represented as (cum_w / total). 101 // 4q(1-q) = 4 · cum_w · (total - cum_w) / total^2 102 // Scaled to ppb: ((4 · cum_w · (total - cum_w)) · π_ppb) / (delta · total^2) 103 // For overflow safety with int64 arithmetic: 104 // max cum_w · (total-cum_w) ~ total^2/4 105 // then · π_ppb (3.14e9) could overflow at total^2/4 · 3.14e9 > 2^63 ≈ 9.2e18 106 // so total^2 < 1.17e10 → total < 108k. For larger streams we'd 107 // need a different fixed-point layout. At total > 100k we use 108 // a degraded but safe path: w_max_norm fixed at 0.01·N (1%). 109 if total > 100000 { 110 // Safe path: 1% of N per centroid. 111 let w_safe: i64 = total / 100 112 if w_safe < 1 { return 1 } 113 return w_safe 114 } 115 let qmul: i64 = cum_w * (total - cum_w) 116 if qmul == 0 { return 1 } 117 let num: i64 = 4 * qmul * NX_TD_PI_PPB 118 let denom: i64 = td.delta * total * total 119 if denom == 0 { return 1 } 120 let w_norm_ppb: i64 = num / denom 121 let w_max: i64 = (w_norm_ppb * total) / 1000000000 122 if w_max < 1 { return 1 } 123 return w_max 124} 125 126// === insertion sort over an i64 array ============================= 127 128func nx_tdigest_sort_buffer(td: *TDigest) -> i64 { 129 var i: i64 = 1 130 while i < td.n_buf { 131 let cur: i64 = td.buffer[i] 132 var j: i64 = i - 1 133 var done: i64 = 0 134 while done == 0 { 135 if j < 0 { done = 1 } 136 if done == 0 { 137 if td.buffer[j] <= cur { done = 1 } 138 if done == 0 { 139 td.buffer[j + 1] = td.buffer[j] 140 j = j - 1 141 } 142 } 143 } 144 td.buffer[j + 1] = cur 145 i = i + 1 146 } 147 return 0 148} 149 150// === merge buffer + centroids ===================================== 151// 152// Sort buffer; merge into the centroid stream. Walk both sorted 153// inputs in tandem, accumulating weight into a "current centroid" 154// until its weight would exceed w_max(q_left); then commit it and 155// start a new centroid. Output replaces the existing centroid list. 156 157func nx_tdigest_merge(td: *TDigest) -> i64 { 158 if td.n_buf == 0 { return 0 } 159 nx_tdigest_sort_buffer(td) 160 let new_total: i64 = td.total_weight + td.n_buf 161 162 // Bits-up: merge_scratch hoisted to struct (was per-merge sys_mmap). 163 let scratch: *Centroid = td.merge_scratch 164 165 var n_scratch: i64 = 0 166 var bi: i64 = 0 167 var ci: i64 = 0 168 var cum_w: i64 = 0 169 170 // Current centroid being built. cur_started=0 means empty. 171 var cur_sum: i64 = 0 172 var cur_w: i64 = 0 173 var cur_q_left: i64 = 0 174 var cur_started: i64 = 0 175 176 var loop_done: i64 = 0 177 while loop_done == 0 { 178 let b_done: i64 = bi >= td.n_buf 179 let c_done: i64 = ci >= td.n_centroids 180 if b_done == 1 { 181 if c_done == 1 { loop_done = 1 } 182 } 183 if loop_done == 0 { 184 // Pick next (mean, weight) from the smaller of the two 185 // sorted heads. 186 var nm: i64 = 0 187 var nw: i64 = 0 188 if b_done == 1 { 189 let c: *Centroid = nx_tdigest_centroid_at(td, ci) 190 nm = c.mean 191 nw = c.weight 192 ci = ci + 1 193 } 194 if b_done == 0 { 195 if c_done == 1 { 196 nm = td.buffer[bi] 197 nw = 1 198 bi = bi + 1 199 } 200 if c_done == 0 { 201 let c: *Centroid = nx_tdigest_centroid_at(td, ci) 202 if td.buffer[bi] <= c.mean { 203 nm = td.buffer[bi] 204 nw = 1 205 bi = bi + 1 206 } 207 if td.buffer[bi] > c.mean { 208 nm = c.mean 209 nw = c.weight 210 ci = ci + 1 211 } 212 } 213 } 214 215 // Process the pulled item. 216 var just_init: i64 = 0 217 if cur_started == 0 { 218 cur_sum = nm * nw 219 cur_w = nw 220 cur_q_left = cum_w 221 cur_started = 1 222 just_init = 1 223 } 224 if just_init == 0 { 225 // Centroid was already running; decide absorb vs flush. 226 let w_after: i64 = cur_w + nw 227 let w_max: i64 = nx_tdigest_w_max(td, cur_q_left, new_total) 228 if w_after <= w_max { 229 cur_sum = cur_sum + nm * nw 230 cur_w = cur_w + nw 231 } 232 if w_after > w_max { 233 // Flush current centroid to scratch. 234 let cent: *Centroid = (scratch as i64 + n_scratch * 16) as *Centroid 235 cent.mean = cur_sum / cur_w 236 cent.weight = cur_w 237 n_scratch = n_scratch + 1 238 cum_w = cum_w + cur_w 239 // Start a new centroid with the pulled item. 240 cur_sum = nm * nw 241 cur_w = nw 242 cur_q_left = cum_w 243 } 244 } 245 } 246 } 247 // Flush trailing centroid. 248 if cur_started == 1 { 249 let cent: *Centroid = (scratch as i64 + n_scratch * 16) as *Centroid 250 cent.mean = cur_sum / cur_w 251 cent.weight = cur_w 252 n_scratch = n_scratch + 1 253 } 254 // Copy scratch back into td.centroids. 255 var k: i64 = 0 256 while k < n_scratch { 257 let src: *Centroid = (scratch as i64 + k * 16) as *Centroid 258 let dst: *Centroid = nx_tdigest_centroid_at(td, k) 259 dst.mean = src.mean 260 dst.weight = src.weight 261 k = k + 1 262 } 263 td.n_centroids = n_scratch 264 td.total_weight = new_total 265 td.n_buf = 0 266 return 0 267} 268 269// === add ========================================================== 270 271func nx_tdigest_add(td: *TDigest, value: i64) -> i64 { 272 td.buffer[td.n_buf] = value 273 td.n_buf = td.n_buf + 1 274 if td.n_buf >= NX_TD_BUFFER_CAP { 275 nx_tdigest_merge(td) 276 } 277 return 0 278} 279 280// === quantile ===================================================== 281// 282// p_milli: 0..1000. Walk centroids accumulating weight; locate 283// the cumulative-weight target p · total / 1000; return the 284// centroid mean covering that point. 285 286func nx_tdigest_quantile(td: *TDigest, p_milli: i64) -> i64 { 287 // Always flush before querying so buffer is incorporated. 288 if td.n_buf > 0 { nx_tdigest_merge(td) } 289 if td.n_centroids == 0 { return 0 } 290 if td.total_weight == 0 { return 0 } 291 let target_w: i64 = (p_milli * td.total_weight) / 1000 292 var cum: i64 = 0 293 var i: i64 = 0 294 while i < td.n_centroids { 295 let c: *Centroid = nx_tdigest_centroid_at(td, i) 296 let next_cum: i64 = cum + c.weight 297 if next_cum >= target_w { 298 return c.mean 299 } 300 cum = next_cum 301 i = i + 1 302 } 303 // Past end: return last centroid mean. 304 let last: *Centroid = nx_tdigest_centroid_at(td, td.n_centroids - 1) 305 return last.mean 306} 307 308// === typed query ================================================== 309// 310// Rank-error envelope. For delta=100, expected absolute rank error 311// is ~1% at p=0.5, tightening to ~0.1% at p=0.99 -- the tail-tight 312// property. We declare the GLOBAL bound (~1%) as the envelope, but 313// callers querying tail quantiles get much better accuracy in 314// practice. 315 316func nx_tdigest_rank_error_ppb(delta: i64) -> i64 { 317 if delta >= 200 { return 5000000 } // 0.5% 318 if delta >= 100 { return 10000000 } // 1.0% 319 if delta >= 50 { return 20000000 } // 2.0% 320 return 30000000 // 3.0% for very-low delta 321} 322 323func nx_tdigest_query(td: *TDigest, p_milli: i64) -> *ApproxI64 { 324 let v: i64 = nx_tdigest_quantile(td, p_milli) 325 return nx_approx_new(v, NX_ENV_RANK_ERROR, 326 nx_tdigest_rank_error_ppb(td.delta), 327 950000000, 328 NX_MATURITY_REFERENCE_IMPL, 329 NX_ADV_HONEST) 330} 331 332// === introspection ================================================ 333 334func nx_tdigest_memory_bytes(td: *TDigest) -> i64 { 335 return 48 + NX_TD_MAX_CENTROIDS * 16 + NX_TD_BUFFER_CAP * 8 336} 337 338func nx_tdigest_n_centroids(td: *TDigest) -> i64 { 339 return td.n_centroids 340}