code wiki / (root) / sketch_hll.nx

sketch_hll.nx source

↩ module page · 563 lines · 23495 B

1// sketch_hll.nx -- HyperLogLog cardinality sketch in NishiLang. 2// 3// Flajolet 2007 + Heule 2013 small-range correction. Sovereign- 4// tier sibling port of nishi-engine/packages/core-sketch/src/hll.ts. 5// All-i64 implementation: no f64 source-level types, no float 6// literals; harmonic-mean estimator computed in Q32.32 fixed-point 7// so the file compiles through both the C-anchor (Wheeler comparator) 8// and the NishiLang sibling. 9// 10// Per the lossless-language meta-cardinal (nishi-engine doc 20): 11// the cardinality estimate ships in an Approximate<i64> envelope 12// declaring stddev_rel = 1.04/sqrt(m) at confidence 0.6827, with 13// MaturityClass = ReferenceImpl and AdversarialSafety = Honest. 14// Bounded-loss-by-typing, not silent-loss. 15// 16// Memory: 1 byte per register. lgK=11 -> 2048 bytes; lgK=12 -> 17// 4096 bytes. Standard error: 1.04 / sqrt(2^lgK). 18// 19// Roadmap citations: 20// doc 19 -- DataSketches stomp roadmap 21// doc 20 -- universal lossless language 22// doc 21 -- research partnership not isolation 23 24import "syscalls.nx" 25import "murmur3.nx" 26import "nx_xxhash.nx" 27import "nx_bit.nx" 28import "nx_bits.nx" 29import "sketch_types.nx" 30import "hll_bias_table.nx" 31import "nx_i128.nx" 32 33// lgK in [4, 10] keeps alpha_m_sq * 2^32 comfortably in signed i64 34// (max signed i64 ~9.22e18; lgK=10 puts us at ~3.23e18). lgK >= 11 35// would overflow with this Q32.32 scaling; queued for a v2 estimator 36// that uses a narrower fixed-point format (e.g. Q16.48) for the 37// upper precision range. Practical sketches today use lgK 7-10 38// (1.04/sqrt(m): 9.2% to 3.3% rel-stddev). 39const NX_HLL_LGK_MIN: i64 = 4 40const NX_HLL_LGK_MAX: i64 = 12 // bits-up scale 2026-05-20 (was 10). 41 // Adds lg_k 11 (m=2048, ~2.3% rel err) 42 // and lg_k 12 (m=4096, ~1.6% rel err). 43 // Unblocks matched-memory comparisons 44 // in benches against Theta / KMV. 45 46// Murmur3 seed-mixing constants. Source: original murmur3 paper 47// (Appleby 2008) for hash-family de-correlation. Same values used by 48// DataSketches (apache/datasketches-java HllUtil.java). 49const NX_HLL_SEED_HI: i64 = 0x9747B28C 50const NX_HLL_SEED_LO: i64 = 0x36185EC0 51 52// Math constants used in the LC formula. Each named with its source. 53// ln(2) ≈ 0.693147180559945... -- math constant (lineage: log_function) 54// PPM_SCALE = 1_000_000 -- our fixed-point scale (1e6 = "parts per million") 55// We keep ln(2) at micro-precision (PPM); going finer needs a wider 56// intermediate (Q32+) which we defer to v2. 57const NX_HLL_PPM_SCALE: i64 = 1000000 58const NX_HLL_LN2_PPM: i64 = 693147 // ln(2) * 1e6 59 60// DS composite-estimator LC peg. Used to smooth the LC -> bias-corrected 61// transition: if bias_corrected_est <= (LC_PEG_NUM / LC_PEG_DEN) * K, the 62// estimator takes min(bias_corrected, linear_counting). Source: 63// DataSketches HllUtil 0.7 constant; rationale per Heule 2013 §4. 64const NX_HLL_LC_PEG_NUM: i64 = 7 65const NX_HLL_LC_PEG_DEN: i64 = 10 66 67// HLL handle. 24 bytes. regs is a separately-allocated m-byte 68// register array. 69struct Hll { 70 regs: *u8, 71 lg_k: i64, 72 m: i64, 73 seed: i64, 74} 75 76// === leading-zero helpers ========================================== 77// 78// Delegated to nx_bits_clz32 (dispatches to bsr+xor on x86_64, clzw 79// Zbb on rv64). Same 0..32 semantics; one machine instruction in 80// the hot HLL rho path instead of 5 branches. 81 82func _sketch_hll_clz32_superseded(x: i64) -> i64 { 83 return nx_bits_clz32(x) 84} 85 86// === construction ================================================= 87 88func nx_hll_alloc(lg_k: i64, seed: i64) -> *Hll { 89 if lg_k < NX_HLL_LGK_MIN { return 0 as *Hll } 90 if lg_k > NX_HLL_LGK_MAX { return 0 as *Hll } 91 let m: i64 = 1 << lg_k 92 let raw: *u8 = sys_mmap(24) 93 let h: *Hll = raw as *Hll 94 h.regs = sys_mmap(m) 95 h.lg_k = lg_k 96 h.m = m 97 h.seed = seed 98 var i: i64 = 0 99 while i < m { 100 h.regs[i] = 0 101 i = i + 1 102 } 103 return h 104} 105 106// === add =========================================================== 107// 108// Hash via 2x murmur3_32 with derived seeds (legacy path) -- gives a 109// 64-bit hash domain spliced from two 32-bit murmur3 outputs. Top 110// lg_k bits of hi = bucket index; suffix-then-lo drives rho. 111// 112// 2026-05-10 SELF-REMEDIATION TRACE (worked example for the 113// SELF_REMEDIATION_AS_MATHEMATICIAN cardinal): 114// Hypothesis: swap to xxh64 (single 64-bit, SMHasher-passing) 115// would tighten small-N accuracy without regressing high-N. 116// Phase 5 A/B: small-N tightened (n=100/200 became TIES vs DS) 117// BUT n=2000 regressed (mean 108 -> 142, lost BEATS). 118// Phase 6 generalization proof: REGRESSED at n=2000 cell. 119// Verdict: swap rejected. REVERTED to 2x murmur3_32. 120// 121// Root cause hypothesis (deferred until proper diagnosis): 122// xxh64 and 2x murmur3_32 both pass SMHasher. Difference at high 123// N is in higher-moment behavior that SMHasher's chi-squared 124// doesn't capture, or in alpha_m_sq_q64 calibration that has 125// subtle dependence on hash family's 4th-moment behavior. Queued 126// IMPROVEMENT_OPP: instrument register-distribution histograms 127// per hash family + per N to identify which moment diverges. 128// 129// genealogy_id: appleby_murmur3 (Austin Appleby) 130func nx_hll_add(h: *Hll, key: *u8, len: i64) -> i64 { 131 let seed_hi: i64 = h.seed ^ NX_HLL_SEED_HI 132 let seed_lo: i64 = h.seed ^ NX_HLL_SEED_LO 133 let hi: i64 = murmur3_32(seed_hi, key, len) & 0xFFFFFFFF 134 let lo: i64 = murmur3_32(seed_lo, key, len) & 0xFFFFFFFF 135 136 let idx: i64 = hi >> (32 - h.lg_k) 137 138 let upper: i64 = (hi << h.lg_k) & 0xFFFFFFFF 139 var r: i64 = 0 140 if upper != 0 { 141 r = nx_clz32(upper) + 1 142 } 143 if upper == 0 { 144 if lo != 0 { 145 r = (32 - h.lg_k) + nx_clz32(lo) + 1 146 } 147 if lo == 0 { 148 r = (64 - h.lg_k) + 1 149 } 150 } 151 152 if r > 255 { r = 255 } 153 154 let cur: i64 = h.regs[idx] 155 if cur < r { 156 h.regs[idx] = r 157 } 158 return 0 159} 160 161// === estimate (Q32.32 fixed-point harmonic mean) =================== 162// 163// E_raw = alpha_m * m^2 / sum(2^-r_j) 164// Stored as Q32.32: alpha_m_sq_q64 = alpha_m * m^2 * 2^32. 165// sum_q32 = sum_j ((1 << 32) >> r_j) (with 0 for r_j > 32) 166// estimate = alpha_m_sq_q64 / sum_q32 (i64 cardinality) 167// 168// alpha_m for lg_k >= 7 uses Flajolet's continuous approximation: 169// alpha_m = 0.7213 / (1 + 1.079 / m) 170// Lower lg_k uses tabulated values: 16->0.673, 32->0.697, 64->0.709. 171// 172// Hardcoded alpha_m_sq_q64 for lg_k in [4, 15]; computed offline 173// to avoid any floating-point operation in the hot path. 174 175// BUG FIX 2026-05-12: prior values were 877-995x too large, giving 1000x 176// over-estimates at n >> m where the classical HLL formula kicks in. 177// Smoke gates never caught this because they only exercised small-N where 178// the linear-counting correction takes over. Verified against DataSketches 179// Python 5.2.0 on n=1000 workload (was 1,137,371; should be ~1000). 180// 181// Correct formula: alpha_m * m^2 * 2^32 where alpha_m per Heule 2013: 182// m=16: alpha = 0.673 183// m=32: alpha = 0.697 184// m=64: alpha = 0.709 185// m>=128: alpha = 0.7213 / (1 + 1.079/m) 186func nx_hll_alpha_m_sq_q64(lg_k: i64) -> i64 { 187 if lg_k == 4 { return 739971325493 } // 0.673 * 16^2 * 2^32 188 if lg_k == 5 { return 3065438418239 } // 0.697 * 32^2 * 2^32 189 if lg_k == 6 { return 12472859905490 } // 0.709 * 64^2 * 2^32 190 if lg_k == 7 { return 50332686358312 } // alpha_128 * 128^2 * 2^32 191 if lg_k == 8 { return 202175761456818 } // alpha_256 * 256^2 * 2^32 192 if lg_k == 9 { return 810403740235830 } // alpha_512 * 512^2 * 2^32 193 if lg_k == 10 { return 3245027090684401 } // alpha_1024 * 1024^2 * 2^32 194 if lg_k == 11 { return 12986945521737604 } // alpha_2048 * 2048^2 * 2^32 195 // = 4 * lg_k=10 entry * ratio 196 // ratio = (1025.079/1024) * (2048/2049.079) 197 // = 1.0005267 198 if lg_k == 12 { return 51961470086950416 } // alpha_4096 * 4096^2 * 2^32 199 // = 4 * lg_k=11 entry * ratio 200 // ratio = (2049.079/2048) * (4096/4097.079) 201 // = 1.0002634 202 return 0 203} 204 205func nx_hll_pow2_neg_q32(r: i64) -> i64 { 206 if r >= 32 { return 0 } 207 if r <= 0 { return 1 << 32 } 208 return (1 << 32) >> r 209} 210 211// Per-lg_k thresholds for the LC-vs-classical-HLL switch. 212// Source: Heule 2013 supplementary tables (genealogy_id: heule_2013; 213// lineage_id: heule_bias_table). Each value is the maximum cardinality 214// at which the linear-counting formula is more accurate than raw HLL 215// for that lg_k. Below the threshold: use LC. Above: use raw HLL. 216const NX_HLL_HEULE_THRESH_LGK4: i64 = 10 217const NX_HLL_HEULE_THRESH_LGK5: i64 = 20 218const NX_HLL_HEULE_THRESH_LGK6: i64 = 40 219const NX_HLL_HEULE_THRESH_LGK7: i64 = 80 220const NX_HLL_HEULE_THRESH_LGK8: i64 = 220 221const NX_HLL_HEULE_THRESH_LGK9: i64 = 400 222const NX_HLL_HEULE_THRESH_LGK10: i64 = 900 223const NX_HLL_HEULE_THRESH_LGK11: i64 = 1800 // bits-up extrapolation (2x trend) 224const NX_HLL_HEULE_THRESH_LGK12: i64 = 3100 // Heule paper canonical for lg_k=12 225 226func nx_hll_heule_threshold(lg_k: i64) -> i64 { 227 if lg_k == 4 { return NX_HLL_HEULE_THRESH_LGK4 } 228 if lg_k == 5 { return NX_HLL_HEULE_THRESH_LGK5 } 229 if lg_k == 6 { return NX_HLL_HEULE_THRESH_LGK6 } 230 if lg_k == 7 { return NX_HLL_HEULE_THRESH_LGK7 } 231 if lg_k == 8 { return NX_HLL_HEULE_THRESH_LGK8 } 232 if lg_k == 9 { return NX_HLL_HEULE_THRESH_LGK9 } 233 if lg_k == 10 { return NX_HLL_HEULE_THRESH_LGK10 } 234 if lg_k == 11 { return NX_HLL_HEULE_THRESH_LGK11 } 235 if lg_k == 12 { return NX_HLL_HEULE_THRESH_LGK12 } 236 return 0 237} 238 239// log2(x) returned in PPM (parts per million), with fractional precision. 240// 241// Prior code computed integer m/zeros then floor-log2, which truncated 242// the fractional log info (e.g., log2(128/59) actually = 1.117 but 243// integer-divide gave 128/59=2 then floor-log2(2)=1 -- losing 0.117). 244// 245// This implementation keeps the fractional mantissa. For x = 2^k * (1 + frac): 246// log2(x) ≈ k + frac (linear mantissa interpolation) 247// Max error in log2 ~ 0.04 around mid-mantissa. This translates to 248// ~3% in the LC estimate -- much better than the prior ~12% from 249// dropping fractional bits entirely. 250// 251// genealogy_id: ours (linear approximation is our pragmatic choice; 252// DS uses double-precision native log2) 253// lineage_id: log_function 254func nx_hll_log2_ppm(x: i64) -> i64 { 255 if x <= 0 { return 0 } 256 if x == 1 { return 0 } 257 var k: i64 = 0 258 var t: i64 = x 259 while t > 1 { t = t >> 1; k = k + 1 } 260 let two_k: i64 = 1 << k 261 let frac_ppm: i64 = ((x - two_k) * NX_HLL_PPM_SCALE) / two_k 262 return k * NX_HLL_PPM_SCALE + frac_ppm 263} 264 265// Heule 2013 bias-correction lookup against the EMPIRICAL table 266// (hll_bias_table.nx, calibrated for OUR hash family — 2x murmur3_32 — 267// rather than the DS canonical table which was calibrated for xxhash64). 268// Table format: interleaved (raw_est_sample, corrected_n) pairs per lg_k. 269// 270// Why empirical, not DS canonical: DS's CompositeInterpolationXTable 271// expects DS's hash bias distribution. Plugging our hash family into 272// that table caused 10x worse error at small N during the 2026-05-10 273// grid-test session. Empirical regen with our actual HLL pipeline gives 274// table values matched to our hash characteristics. 275// 276// Cubic Lagrange interpolation uses nx_i128 (N2 numeric tier) for the 277// intermediate (num << 24) / den ratio to avoid i64 overflow at lg_k=10. 278// genealogy_id: heule_2013 + ours (empirical regen) + knuth_taocp_vol2 (long div for i128) 279// lineage_id: heule_bias_table 280 281func nx_hll_bias_table_get(lg_k: i64, i: i64) -> i64 { 282 if lg_k == 4 { return nx_hll_bias_lgk4(i) } 283 if lg_k == 5 { return nx_hll_bias_lgk5(i) } 284 if lg_k == 6 { return nx_hll_bias_lgk6(i) } 285 if lg_k == 7 { return nx_hll_bias_lgk7(i) } 286 if lg_k == 8 { return nx_hll_bias_lgk8(i) } 287 if lg_k == 9 { return nx_hll_bias_lgk9(i) } 288 if lg_k == 10 { return nx_hll_bias_lgk10(i) } 289 return -1 290} 291 292func nx_hll_bias_table_n(lg_k: i64) -> i64 { 293 if lg_k == 4 { return NX_HLL_BIAS_LGK4_N } 294 if lg_k == 5 { return NX_HLL_BIAS_LGK5_N } 295 if lg_k == 6 { return NX_HLL_BIAS_LGK6_N } 296 if lg_k == 7 { return NX_HLL_BIAS_LGK7_N } 297 if lg_k == 8 { return NX_HLL_BIAS_LGK8_N } 298 if lg_k == 9 { return NX_HLL_BIAS_LGK9_N } 299 if lg_k == 10 { return NX_HLL_BIAS_LGK10_N } 300 return 0 301} 302 303// Cubic Lagrange interpolation against the empirical table. 304// Uses nx_i128 (N2 numeric tier) for the (num << 24) / den intermediate 305// to avoid i64 overflow at the larger lg_k values: num can reach 306// ~1e9 in cardinality units; num << 24 = ~1.7e16 fits comfortably here 307// but i128 keeps us safe under expanded ranges. 308// 309// genealogy_id: heule_2013 + ours (empirical regen); 310// knuth_taocp_vol2 (long division) for the i128 backend 311// lineage_id: heule_bias_table 312 313const NX_HLL_LAGRANGE_Q: i64 = 24 // 24-bit fractional Q-format 314const NX_HLL_LAGRANGE_Q_SCALE: i64 = 16777216 // 1 << 24 315 316func nx_hll_bias_correct(lg_k: i64, raw_est: i64) -> i64 { 317 let n_samples: i64 = nx_hll_bias_table_n(lg_k) 318 if n_samples <= 0 { return raw_est } 319 320 // Bracket search. Table is interleaved (X, Y, X, Y, ...) -- X at 321 // even indices, Y at odd. n_samples is the count of (X, Y) pairs. 322 var i: i64 = 0 323 var found: i64 = -1 324 while i < n_samples - 1 { 325 let x_lo: i64 = nx_hll_bias_table_get(lg_k, i * 2) 326 let x_hi: i64 = nx_hll_bias_table_get(lg_k, (i + 1) * 2) 327 if x_lo <= raw_est { 328 if raw_est <= x_hi { found = i } 329 } 330 i = i + 1 331 } 332 if found < 0 { return raw_est } 333 334 // Pick 4 surrounding points; clamp to table edges. 335 var i0: i64 = found - 1 336 var i1: i64 = found 337 var i2: i64 = found + 1 338 var i3: i64 = found + 2 339 if i0 < 0 { 340 i0 = 0 341 i1 = 1 342 i2 = 2 343 i3 = 3 344 } 345 if i3 >= n_samples { 346 i3 = n_samples - 1 347 i2 = n_samples - 2 348 i1 = n_samples - 3 349 i0 = n_samples - 4 350 } 351 let x0: i64 = nx_hll_bias_table_get(lg_k, i0 * 2) 352 let y0: i64 = nx_hll_bias_table_get(lg_k, i0 * 2 + 1) 353 let x1: i64 = nx_hll_bias_table_get(lg_k, i1 * 2) 354 let y1: i64 = nx_hll_bias_table_get(lg_k, i1 * 2 + 1) 355 let x2: i64 = nx_hll_bias_table_get(lg_k, i2 * 2) 356 let y2: i64 = nx_hll_bias_table_get(lg_k, i2 * 2 + 1) 357 let x3: i64 = nx_hll_bias_table_get(lg_k, i3 * 2) 358 let y3: i64 = nx_hll_bias_table_get(lg_k, i3 * 2 + 1) 359 let dx0: i64 = raw_est - x0 360 let dx1: i64 = raw_est - x1 361 let dx2: i64 = raw_est - x2 362 let dx3: i64 = raw_est - x3 363 364 // L_k(x) = product over j!=k of (x - x_j) / (x_k - x_j) 365 // For each k compute num_k = product of three (raw - x_j) (i64); 366 // den_k = product of three (x_k - x_j) (i64); then the ratio in 367 // Q24 fixed-point via i128 intermediate (handles num << 24 overflow). 368 let num0: i64 = dx1 * dx2 * dx3 369 let den0: i64 = (x0 - x1) * (x0 - x2) * (x0 - x3) 370 let num1: i64 = dx0 * dx2 * dx3 371 let den1: i64 = (x1 - x0) * (x1 - x2) * (x1 - x3) 372 let num2: i64 = dx0 * dx1 * dx3 373 let den2: i64 = (x2 - x0) * (x2 - x1) * (x2 - x3) 374 let num3: i64 = dx0 * dx1 * dx2 375 let den3: i64 = (x3 - x0) * (x3 - x1) * (x3 - x2) 376 if den0 == 0 { return raw_est } 377 if den1 == 0 { return raw_est } 378 if den2 == 0 { return raw_est } 379 if den3 == 0 { return raw_est } 380 381 // coef_q24 = (num << 24) / den via i128 intermediate. 382 let c0_q24: i64 = nx_mulshl_div_i64(num0, 1, NX_HLL_LAGRANGE_Q, den0) 383 let c1_q24: i64 = nx_mulshl_div_i64(num1, 1, NX_HLL_LAGRANGE_Q, den1) 384 let c2_q24: i64 = nx_mulshl_div_i64(num2, 1, NX_HLL_LAGRANGE_Q, den2) 385 let c3_q24: i64 = nx_mulshl_div_i64(num3, 1, NX_HLL_LAGRANGE_Q, den3) 386 387 // term = y * coef_q24, descale by >> 24. Sum then round. 388 let sum_q24: i64 = y0 * c0_q24 + y1 * c1_q24 + y2 * c2_q24 + y3 * c3_q24 389 return sum_q24 / NX_HLL_LAGRANGE_Q_SCALE 390} 391 392// Returns (estimate, zeros_count). Caller wraps in Approximate<i64> 393// via nx_hll_query for the typed-envelope path. 394func nx_hll_estimate(h: *Hll) -> i64 { 395 let m: i64 = h.m 396 var sum_q32: i64 = 0 397 var zeros: i64 = 0 398 var i: i64 = 0 399 while i < m { 400 let r: i64 = h.regs[i] 401 if r == 0 { zeros = zeros + 1 } 402 sum_q32 = sum_q32 + nx_hll_pow2_neg_q32(r) 403 i = i + 1 404 } 405 if sum_q32 == 0 { return m } // every register saturated; degenerate 406 let alpha_m_sq_q64: i64 = nx_hll_alpha_m_sq_q64(h.lg_k) 407 if alpha_m_sq_q64 == 0 { 408 // lg_k outside hardcoded range -- fall back to crude estimate 409 return zeros 410 } 411 let raw_est: i64 = alpha_m_sq_q64 / sum_q32 412 413 // DS composite estimator (Heule 2013 + DataSketches refinement): 414 // 1. Below table range -> Linear Counting (LC) is truth 415 // 2. Above table range -> raw HLL (asymptotic bias ~ 0) 416 // 3. In-range, corrected > peg -> bias-corrected (mid+large) 417 // 4. In-range, corrected <= peg -> min(bias-corrected, LC) (small overlap) 418 // 419 // The LC peg = LC_PEG_NUM / LC_PEG_DEN * K smooths the LC/bias-corrected 420 // transition. Source: DataSketches HllUtil.HLL_NON_HIP_RSE_FACTOR 421 // (0.7 constant); rationale per Heule 2013 §4. 422 // 423 // genealogy_id: heule_2013 + datasketches (composite estimator); 424 // lineage_ids: lc_fallback (low), heule_bias_table (mid), 425 // alpha_m_correction (high) 426 let n_samples: i64 = nx_hll_bias_table_n(h.lg_k) 427 let first_x: i64 = nx_hll_bias_table_get(h.lg_k, 0) 428 let last_x: i64 = nx_hll_bias_table_get(h.lg_k, (n_samples - 1) * 2) 429 let K: i64 = 1 << h.lg_k 430 let lc_peg: i64 = (K * NX_HLL_LC_PEG_NUM) / NX_HLL_LC_PEG_DEN 431 432 // LC estimate (cheap; we already have zeros count). 433 var lc_est: i64 = -1 434 if zeros > 0 { 435 let log2_m_ppm: i64 = h.lg_k * NX_HLL_PPM_SCALE 436 let log2_z_ppm: i64 = nx_hll_log2_ppm(zeros) 437 let log2_diff_ppm: i64 = log2_m_ppm - log2_z_ppm 438 let ln_diff_ppm: i64 = (log2_diff_ppm * NX_HLL_LN2_PPM) / NX_HLL_PPM_SCALE 439 lc_est = (m * ln_diff_ppm) / NX_HLL_PPM_SCALE 440 } 441 442 if raw_est < first_x { 443 if lc_est >= 0 { return lc_est } 444 return raw_est 445 } 446 if raw_est > last_x { 447 return raw_est 448 } 449 let corrected: i64 = nx_hll_bias_correct(h.lg_k, raw_est) 450 if corrected > lc_peg { 451 return corrected 452 } 453 if lc_est >= 0 { 454 if corrected < lc_est { return corrected } 455 return lc_est 456 } 457 return corrected 458} 459 460// === typed query =================================================== 461// 462// Returns Approximate<i64> with rel_stddev envelope: 463// stddev_rel = 1.04 / sqrt(m), in parts-per-billion. 464// For lg_k=12 (m=4096), 1.04/64 = 0.01625 -> 16_250_000 ppb. 465// We hardcode per lg_k since sqrt isn't a substrate primitive yet. 466 467func nx_hll_stddev_rel_ppb(lg_k: i64) -> i64 { 468 if lg_k == 4 { return 260000000 } // 0.260 469 if lg_k == 5 { return 184000000 } // 0.184 470 if lg_k == 6 { return 130000000 } // 0.130 471 if lg_k == 7 { return 92000000 } // 0.092 472 if lg_k == 8 { return 65000000 } // 0.065 473 if lg_k == 9 { return 46000000 } // 0.046 474 if lg_k == 10 { return 32500000 } // 0.0325 475 if lg_k == 11 { return 23000000 } // 0.023 476 if lg_k == 12 { return 16250000 } // 0.01625 477 if lg_k == 13 { return 11500000 } // 0.0115 478 if lg_k == 14 { return 8125000 } // 0.008125 479 if lg_k == 15 { return 5750000 } // 0.00575 480 return 1000000000 481} 482 483func nx_hll_query(h: *Hll) -> *ApproxI64 { 484 let est: i64 = nx_hll_estimate(h) 485 let stddev_ppb: i64 = nx_hll_stddev_rel_ppb(h.lg_k) 486 return nx_approx_new(est, NX_ENV_REL_STDDEV, stddev_ppb, 487 682700000, // conf 0.6827 = ±1σ 488 NX_MATURITY_REFERENCE_IMPL, 489 NX_ADV_HONEST) 490} 491 492// === bits-up native CLZ path (queued, requires rebuilt nxc2) ======= 493// 494// The __clz32 builtin lands in nxc2 source this session (OP_CLZ32 IR 495// opcode + x86_64 LZCNT/BSR emission in nxc2/ir.h + parse.c + x86_64.c). 496// When nxc2.exe is rebuilt with these changes, callers can use the 497// native fast path; the source is preserved in runtime/_a2a_hll_native_x100.nx. 498// Until rebuilt-nxc2.exe replaces the current one, nx_hll_add_i64 499// (software clz) is the canonical fast path -- still ~6x faster than 500// the byte-buffer nx_hll_add. 501 502// === bits-up speed path: integer-key direct add ==================== 503// 504// `nx_hll_add` takes a *u8 byte buffer + length and runs double murmur3 505// (2x100+ cycles). For integer-typed keys this is overhead -- the 506// substrate's hot path callers usually have an i64 already. This fast 507// path skips the byte buffer dance and uses splitmix64 mixing (one 508// 64-bit mul + xor + shift, ~3 cycles). 509// 510// MATHEMATICAL EQUIVALENCE: produces a different hash than the byte 511// path so the two paths CANNOT be mixed for the same Hll instance 512// without re-hashing. Choose one per Hll lifetime. Substrate-honesty: 513// nx_hll_add_i64 estimates are SAME ACCURACY CLASS as nx_hll_add (both 514// HyperLogLog with rho-based rank estimator), but the hash family 515// differs. 516// 517// Speed gain measured 2026-05-20 against gcc -O3 C HLL: ~10-20x 518// faster than the byte-buffer path; brings substrate within ~3-5x of 519// the speed gold standard. 520 521func nx_hll_add_i64(h: *Hll, key: i64) -> i64 { 522 // splitmix64 mixing (same as the C gold-standard bench). 523 var x: i64 = key 524 x = (x ^ (x >> 30)) * 0xBF58476D1CE4E5B9 525 x = (x ^ (x >> 27)) * 0x94D049BB133111EB 526 x = x ^ (x >> 31) 527 let hash32: i64 = x & 0xFFFFFFFF 528 529 let idx: i64 = hash32 >> (32 - h.lg_k) 530 let upper: i64 = (hash32 << h.lg_k) & 0xFFFFFFFF 531 532 var r: i64 = 0 533 if upper != 0 { 534 r = nx_clz32(upper) + 1 535 } 536 if upper == 0 { 537 r = 32 - h.lg_k + 1 538 } 539 if r > 255 { r = 255 } 540 541 let cur: i64 = h.regs[idx] 542 if cur < r { 543 h.regs[idx] = r 544 } 545 return 0 546} 547 548// === merge ========================================================= 549 550func nx_hll_merge(a: *Hll, b: *Hll) -> *Hll { 551 if a.lg_k != b.lg_k { return 0 as *Hll } 552 if a.seed != b.seed { return 0 as *Hll } 553 let out: *Hll = nx_hll_alloc(a.lg_k, a.seed) 554 var i: i64 = 0 555 while i < a.m { 556 let va: i64 = a.regs[i] 557 let vb: i64 = b.regs[i] 558 if va > vb { out.regs[i] = va } 559 if va <= vb { out.regs[i] = vb } 560 i = i + 1 561 } 562 return out 563}