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}