Skip to main content

polydat_nodes/sampling/
icd.rs

1// Copyright 2024-2026 Jonathan Shook
2// SPDX-License-Identifier: Apache-2.0
3
4//! Inverse CDF distribution builders.
5//!
6//! Each `dist_*` helper builds a [`LutF64`] containing the precomputed
7//! inverse CDF for a specific distribution. The DSL surface
8//! (`#[polydat_node]` form below) wraps each helper in a node that
9//! caches the LUT at construction via `#[poly_const]` and samples on
10//! every cycle.
11//!
12//! This module also provides the [`UnitInterval`] and [`ClampF64`]
13//! conversion nodes that are typically composed with LUT sampling in
14//! a DAG.
15//!
16//! # Supported Distributions
17//!
18//! **Continuous:**
19//! Normal, Exponential, Uniform, Pareto, LogNormal, Weibull, Cauchy,
20//! Laplace, Beta, Gamma
21//!
22//! **Discrete:**
23//! Zipf, Poisson, Binomial, Geometric
24
25use crate::sampling::lut::LutF64;
26
27/// Default interpolation table resolution.
28pub const DEFAULT_RESOLUTION: usize = 1000;
29
30// =================================================================
31// Utility: UnitInterval node
32// =================================================================
33
34/// Normalize a u64 to a uniform f64 in [0.0, 1.0).
35///
36/// Signature: `unit_interval(input: u64) -> (f64)`
37///
38/// The bridge between the integer hash domain and the continuous
39/// probability domain. Place after `hash` and before any node that
40/// expects a [0,1) input: distribution LUT samplers, `lerp`, or
41/// `inv_lerp`. The mapping is `input as f64 / u64::MAX as f64`, so
42/// 0 maps to 0.0 and u64::MAX maps to ~1.0.
43///
44/// JIT level: P3 (`JitOp::UnitInterval`, inline u64→f64 convert and
45/// multiply).
46#[polydat::polydat_node(category = Conversions)]
47fn unit_interval(input: u64) -> f64 {
48    input as f64 / u64::MAX as f64
49}
50
51// =================================================================
52// Utility: ClampF64 node
53// =================================================================
54
55/// Clamp an f64 value to [min, max].
56///
57/// Signature: `clamp_f64(input: f64, min: f64, max: f64) -> (f64)`
58///
59/// Hard-limits an f64 to the given bounds. Use after distributions
60/// with unbounded tails (normal, Cauchy) to enforce domain
61/// constraints: `clamp_f64(normal(72.0, 5.0), 0.0, 100.0)` prevents
62/// negative scores. Also useful for guarding against non-finite LUT
63/// edge values before downstream arithmetic.
64///
65/// JIT level: P3 (auto-emitted `compiled_u64` + `jit_constants`
66/// via the `#[polydat_node]` macro — the two `Const<f64>` fields
67/// are folded into the JIT constant pool as bit-encoded f64s).
68#[polydat::polydat_node(category = Conversions)]
69fn clamp_f64(
70    input: f64,
71    #[poly_default(f64::MIN)] min: Const<f64>,
72    #[poly_default(f64::MAX)] max: Const<f64>,
73) -> f64 {
74    input.clamp(*min, *max)
75}
76
77// =================================================================
78// Normal inverse CDF (probit function)
79// =================================================================
80
81/// Rational approximation of the standard normal quantile function.
82/// Accurate to ~1e-9 for p in (1e-15, 1-1e-15).
83fn probit(p: f64) -> f64 {
84    if p <= 0.0 {
85        return f64::NEG_INFINITY;
86    }
87    if p >= 1.0 {
88        return f64::INFINITY;
89    }
90
91    let t = if p < 0.5 {
92        (-2.0 * p.ln()).sqrt()
93    } else {
94        (-2.0 * (1.0 - p).ln()).sqrt()
95    };
96
97    let c0 = 2.515517;
98    let c1 = 0.802853;
99    let c2 = 0.010328;
100    let d1 = 1.432788;
101    let d2 = 0.189269;
102    let d3 = 0.001308;
103
104    let result = t - (c0 + c1 * t + c2 * t * t) / (1.0 + d1 * t + d2 * t * t + d3 * t * t * t);
105
106    if p < 0.5 { -result } else { result }
107}
108
109// =================================================================
110// Gamma function utilities (for Beta and Gamma distributions)
111// =================================================================
112
113/// Lanczos approximation of ln(Gamma(x)) for x > 0.
114fn ln_gamma(x: f64) -> f64 {
115    let g = 7.0;
116    let c = [
117        0.999_999_999_999_809_9,
118        676.5203681218851,
119        -1259.1392167224028,
120        771.323_428_777_653_1,
121        -176.615_029_162_140_6,
122        12.507343278686905,
123        -0.13857109526572012,
124        9.984_369_578_019_572e-6,
125        1.5056327351493116e-7,
126    ];
127
128    if x < 0.5 {
129        let pi = std::f64::consts::PI;
130        return (pi / (pi * x).sin()).ln() - ln_gamma(1.0 - x);
131    }
132
133    let x = x - 1.0;
134    let mut sum = c[0];
135    for (i, &coeff) in c[1..].iter().enumerate() {
136        sum += coeff / (x + i as f64 + 1.0);
137    }
138
139    let t = x + g + 0.5;
140    0.5 * (2.0 * std::f64::consts::PI).ln() + (t.ln() * (x + 0.5)) - t + sum.ln()
141}
142
143/// Regularized incomplete beta function I_x(a, b) via series expansion.
144fn regularized_beta(x: f64, a: f64, b: f64) -> f64 {
145    if x <= 0.0 {
146        return 0.0;
147    }
148    if x >= 1.0 {
149        return 1.0;
150    }
151
152    // Use symmetry relation for better convergence when x > 0.5
153    if x > (a + 1.0) / (a + b + 2.0) {
154        return 1.0 - regularized_beta(1.0 - x, b, a);
155    }
156
157    let ln_prefix = ln_gamma(a + b) - ln_gamma(a) - ln_gamma(b) + a * x.ln() + b * (1.0 - x).ln();
158    let prefix = ln_prefix.exp();
159
160    // Series expansion: I_x(a,b) = (x^a * (1-x)^b) / (a * B(a,b)) * sum
161    let mut sum = 0.0;
162    let mut term = 1.0;
163    for n in 0..300 {
164        sum += term;
165        term *= x * (a + b + n as f64) / (a + 1.0 + n as f64);
166        if term.abs() < 1e-15 * sum.abs() {
167            break;
168        }
169    }
170
171    (prefix * sum / a).clamp(0.0, 1.0)
172}
173
174/// Inverse regularized beta via bisection.
175fn inv_regularized_beta(p: f64, a: f64, b: f64) -> f64 {
176    if p <= 0.0 {
177        return 0.0;
178    }
179    if p >= 1.0 {
180        return 1.0;
181    }
182
183    let mut lo = 0.0_f64;
184    let mut hi = 1.0_f64;
185    for _ in 0..100 {
186        let mid = (lo + hi) / 2.0;
187        if regularized_beta(mid, a, b) < p {
188            lo = mid;
189        } else {
190            hi = mid;
191        }
192    }
193    (lo + hi) / 2.0
194}
195
196/// Regularized lower incomplete gamma function P(a, x) via series.
197fn regularized_gamma_p(a: f64, x: f64) -> f64 {
198    if x <= 0.0 {
199        return 0.0;
200    }
201    if x > a + 50.0 {
202        return 1.0;
203    } // far in the tail
204
205    let mut sum = 1.0 / a;
206    let mut term = 1.0 / a;
207    for n in 1..300 {
208        term *= x / (a + n as f64);
209        sum += term;
210        if term.abs() < 1e-14 * sum.abs() {
211            break;
212        }
213    }
214    (a * x.ln() - x - ln_gamma(a)).exp() * sum
215}
216
217/// Inverse regularized gamma P via bisection.
218fn inv_regularized_gamma_p(p: f64, a: f64) -> f64 {
219    if p <= 0.0 {
220        return 0.0;
221    }
222    if p >= 1.0 {
223        return f64::INFINITY;
224    }
225
226    // Bracket: upper bound heuristic
227    let mut hi = a.max(1.0);
228    while regularized_gamma_p(a, hi) < p {
229        hi *= 2.0;
230    }
231    let mut lo = 0.0_f64;
232
233    for _ in 0..100 {
234        let mid = (lo + hi) / 2.0;
235        if regularized_gamma_p(a, mid) < p {
236            lo = mid;
237        } else {
238            hi = mid;
239        }
240    }
241    (lo + hi) / 2.0
242}
243
244// =================================================================
245// Continuous distribution LUT builders (free fns reused by nodes)
246// =================================================================
247
248/// Normal distribution: N(mean, stddev).
249pub fn dist_normal_lut(mean: f64, stddev: f64, resolution: usize) -> LutF64 {
250    LutF64::from_fn(|p| mean + stddev * probit(p), resolution)
251}
252
253/// Exponential distribution: Exp(rate). Support: [0, +∞).
254pub fn dist_exponential_lut(rate: f64, resolution: usize) -> LutF64 {
255    LutF64::from_fn(|p| -(1.0 - p).ln() / rate, resolution)
256}
257
258/// Uniform continuous distribution: U(min, max).
259pub fn dist_uniform_lut(min: f64, max: f64, resolution: usize) -> LutF64 {
260    LutF64::from_fn(|p| min + p * (max - min), resolution)
261}
262
263/// Pareto distribution: Pareto(scale, shape). Support: [scale, +∞).
264pub fn dist_pareto_lut(scale: f64, shape: f64, resolution: usize) -> LutF64 {
265    LutF64::from_fn(|p| scale / (1.0 - p).powf(1.0 / shape), resolution)
266}
267
268/// Log-normal distribution: LogN(mean, stddev). Support: (0, +∞).
269pub fn dist_lognormal_lut(mean: f64, stddev: f64, resolution: usize) -> LutF64 {
270    LutF64::from_fn(|p| (mean + stddev * probit(p)).exp(), resolution)
271}
272
273/// Weibull distribution: Weibull(shape, scale). Support: [0, +∞).
274pub fn dist_weibull_lut(shape: f64, scale: f64, resolution: usize) -> LutF64 {
275    LutF64::from_fn(|p| scale * (-(1.0 - p).ln()).powf(1.0 / shape), resolution)
276}
277
278/// Cauchy distribution: Cauchy(location, scale). Support: (-∞, +∞).
279pub fn dist_cauchy_lut(location: f64, scale: f64, resolution: usize) -> LutF64 {
280    LutF64::from_fn(
281        |p| location + scale * (std::f64::consts::PI * (p - 0.5)).tan(),
282        resolution,
283    )
284}
285
286/// Laplace distribution: Laplace(location, scale). Support: (-∞, +∞).
287pub fn dist_laplace_lut(location: f64, scale: f64, resolution: usize) -> LutF64 {
288    LutF64::from_fn(
289        |p| {
290            if p <= 0.5 {
291                location + scale * (2.0 * p).ln()
292            } else {
293                location - scale * (2.0 * (1.0 - p)).ln()
294            }
295        },
296        resolution,
297    )
298}
299
300/// Beta distribution: Beta(alpha, beta). Support: [0, 1].
301pub fn dist_beta_lut(alpha: f64, beta: f64, resolution: usize) -> LutF64 {
302    LutF64::from_fn(|p| inv_regularized_beta(p, alpha, beta), resolution)
303}
304
305/// Gamma distribution: Gamma(shape, scale). Support: (0, +∞).
306pub fn dist_gamma_lut(shape: f64, scale: f64, resolution: usize) -> LutF64 {
307    LutF64::from_fn(|p| scale * inv_regularized_gamma_p(p, shape), resolution)
308}
309
310// =================================================================
311// Discrete distribution LUT builders
312// =================================================================
313
314/// Zipf distribution: Zipf(n, exponent). Support: [1, n].
315///
316/// The LUT maps [0, 1] → float, which is then truncated to an integer
317/// by the caller. The CDF is computed from the PMF:
318///   P(k) = (1/k^s) / H(n,s)  where H(n,s) = sum_{i=1}^{n} 1/i^s
319pub fn dist_zipf_lut(n: u64, exponent: f64, resolution: usize) -> LutF64 {
320    // Precompute CDF
321    let harmonic: f64 = (1..=n).map(|k| 1.0 / (k as f64).powf(exponent)).sum();
322    let mut cdf = Vec::with_capacity(n as usize + 1);
323    cdf.push(0.0);
324    let mut cumulative = 0.0;
325    for k in 1..=n {
326        cumulative += (1.0 / (k as f64).powf(exponent)) / harmonic;
327        cdf.push(cumulative);
328    }
329
330    // Inverse CDF by binary search
331    LutF64::from_fn(
332        |p| {
333            let p = p.clamp(0.0, 1.0);
334            match cdf.binary_search_by(|v| v.partial_cmp(&p).unwrap()) {
335                Ok(idx) => idx as f64,
336                Err(idx) => (idx as f64).max(1.0).min(n as f64),
337            }
338        },
339        resolution,
340    )
341}
342
343/// Poisson distribution: Poisson(lambda). Support: [0, +∞).
344///
345/// Precompute CDF up to a reasonable upper bound, then invert.
346pub fn dist_poisson_lut(lambda: f64, resolution: usize) -> LutF64 {
347    let upper = (lambda + 6.0 * lambda.sqrt() + 10.0).ceil() as usize;
348
349    // Precompute CDF via PMF: P(k) = e^(-λ) * λ^k / k!
350    let mut cdf = Vec::with_capacity(upper + 2);
351    cdf.push(0.0);
352    let mut cumulative = 0.0;
353    let mut pmf = (-lambda).exp(); // P(0)
354    for k in 0..=upper {
355        cumulative += pmf;
356        cdf.push(cumulative.min(1.0));
357        pmf *= lambda / (k + 1) as f64;
358    }
359
360    LutF64::from_fn(
361        |p| {
362            let p = p.clamp(0.0, 1.0);
363            match cdf.binary_search_by(|v| v.partial_cmp(&p).unwrap()) {
364                Ok(idx) => idx.saturating_sub(1) as f64,
365                Err(idx) => idx.saturating_sub(1) as f64,
366            }
367        },
368        resolution,
369    )
370}
371
372/// Binomial distribution: Binomial(trials, p). Support: [0, trials].
373pub fn dist_binomial_lut(trials: u64, prob: f64, resolution: usize) -> LutF64 {
374    let n = trials as usize;
375
376    // Precompute CDF via PMF
377    let mut cdf = Vec::with_capacity(n + 2);
378    cdf.push(0.0);
379    let mut cumulative = 0.0;
380    let mut pmf = (1.0 - prob).powi(n as i32); // P(0) = (1-p)^n
381    for k in 0..=n {
382        cumulative += pmf;
383        cdf.push(cumulative.min(1.0));
384        if k < n {
385            pmf *= prob / (1.0 - prob) * ((n - k) as f64) / ((k + 1) as f64);
386        }
387    }
388
389    LutF64::from_fn(
390        |p| {
391            let p = p.clamp(0.0, 1.0);
392            match cdf.binary_search_by(|v| v.partial_cmp(&p).unwrap()) {
393                Ok(idx) => idx.saturating_sub(1) as f64,
394                Err(idx) => idx.saturating_sub(1) as f64,
395            }
396        },
397        resolution,
398    )
399}
400
401/// Geometric distribution: Geometric(p). Support: [1, +∞).
402///
403/// P(X=k) = (1-p)^(k-1) * p, inverse CDF: ceil(ln(1-u) / ln(1-p)).
404pub fn dist_geometric_lut(prob: f64, resolution: usize) -> LutF64 {
405    let ln_q = (1.0 - prob).ln();
406    LutF64::from_fn(
407        |p| {
408            if p <= 0.0 {
409                return 1.0;
410            }
411            if p >= 1.0 {
412                return f64::INFINITY;
413            }
414            ((1.0 - p).ln() / ln_q).ceil().max(1.0)
415        },
416        resolution,
417    )
418}
419
420// =================================================================
421// Empirical distribution builders (Rust helpers; the DSL surface for
422// `dist_empirical` lives in `crate::sampling::lut`).
423// =================================================================
424
425/// Build a LUT from raw data points (continuous empirical distribution).
426///
427/// The data points are sorted and used directly as the inverse CDF.
428/// Linear interpolation between observed values.
429pub fn dist_empirical_lut(data: &[f64], resolution: usize) -> LutF64 {
430    assert!(!data.is_empty(), "data must not be empty");
431    let mut sorted = data.to_vec();
432    sorted.sort_by(|a, b| a.partial_cmp(b).unwrap());
433
434    LutF64::from_fn(
435        |p| {
436            let pos = p * (sorted.len() - 1) as f64;
437            let idx = pos as usize;
438            let idx = idx.min(sorted.len() - 2);
439            let frac = pos - idx as f64;
440            sorted[idx] * (1.0 - frac) + sorted[idx + 1] * frac
441        },
442        resolution,
443    )
444}
445
446/// Build a LUT from weighted value-frequency pairs.
447///
448/// Each (value, weight) pair contributes proportionally to the CDF.
449pub fn dist_empirical_weighted_lut(values: &[f64], weights: &[f64], resolution: usize) -> LutF64 {
450    assert_eq!(values.len(), weights.len());
451    assert!(!values.is_empty());
452
453    // Sort by value, accumulate CDF
454    let mut pairs: Vec<(f64, f64)> = values
455        .iter()
456        .copied()
457        .zip(weights.iter().copied())
458        .collect();
459    pairs.sort_by(|a, b| a.0.partial_cmp(&b.0).unwrap());
460
461    let total: f64 = pairs.iter().map(|(_, w)| w).sum();
462    let mut cdf_points: Vec<(f64, f64)> = Vec::new(); // (cumulative_prob, value)
463    let mut cumulative = 0.0;
464    for (val, weight) in &pairs {
465        cumulative += weight / total;
466        cdf_points.push((cumulative, *val));
467    }
468
469    // Inverse CDF by binary search
470    LutF64::from_fn(
471        |p| match cdf_points.binary_search_by(|&(cp, _)| cp.partial_cmp(&p).unwrap()) {
472            Ok(idx) => cdf_points[idx].1,
473            Err(idx) => {
474                if idx >= cdf_points.len() {
475                    cdf_points.last().unwrap().1
476                } else {
477                    cdf_points[idx].1
478                }
479            }
480        },
481        resolution,
482    )
483}
484
485// =================================================================
486// DSL nodes: each dist_* / icd_* function caches a LUT via
487// `#[poly_const]` and samples on every cycle. The `PolydatSetup`
488// impl for `LutF64` lives in the `lut` module alongside the type.
489// =================================================================
490
491fn build_normal_lut(mean: f64, stddev: f64) -> LutF64 {
492    dist_normal_lut(mean, stddev, DEFAULT_RESOLUTION)
493}
494
495/// Sample from a normal distribution `N(mean, stddev)`.
496///
497/// Signature: `dist_normal(input: f64, mean: f64, stddev: f64) -> f64`
498fn dist_normal_jit_constants(node: &DistNormal) -> Vec<u64> {
499    vec![node.lut.as_ptr() as u64, node.lut.len() as u64]
500}
501
502/// Sample from a standard normal distribution `N(mean, stddev)`.
503///
504/// `input` is a uniform value in `[0, 1)` (typically from
505/// `unit_interval(hash(cycle))`). The LUT is precomputed at
506/// construction; the per-cycle cost is one LUT sample.
507#[polydat::polydat_node(category = Distributions, jit_constants = dist_normal_jit_constants)]
508fn dist_normal(
509    input: f64,
510    mean: Const<f64>,
511    stddev: Const<f64>,
512    #[poly_const(build_normal_lut, from = (mean, stddev))] lut: &LutF64,
513) -> f64 {
514    let _ = mean;
515    let _ = stddev;
516    lut.sample(input)
517}
518
519fn icd_normal_jit_constants(node: &IcdNormal) -> Vec<u64> {
520    vec![node.lut.as_ptr() as u64, node.lut.len() as u64]
521}
522
523/// Alias of `dist_normal`. Preserved because workload examples
524/// and the host's distribution binding both surface
525/// `icd_normal` as the public DSL name.
526#[polydat::polydat_node(category = Distributions, jit_constants = icd_normal_jit_constants)]
527fn icd_normal(
528    input: f64,
529    mean: Const<f64>,
530    stddev: Const<f64>,
531    #[poly_const(build_normal_lut, from = (mean, stddev))] lut: &LutF64,
532) -> f64 {
533    let _ = mean;
534    let _ = stddev;
535    lut.sample(input)
536}
537
538fn build_exponential_lut(rate: f64) -> LutF64 {
539    dist_exponential_lut(rate, DEFAULT_RESOLUTION)
540}
541
542fn dist_exponential_jit_constants(node: &DistExponential) -> Vec<u64> {
543    vec![node.lut.as_ptr() as u64, node.lut.len() as u64]
544}
545
546/// Sample from an exponential distribution `Exp(rate)`.
547#[polydat::polydat_node(category = Distributions, jit_constants = dist_exponential_jit_constants)]
548fn dist_exponential(
549    input: f64,
550    rate: Const<f64>,
551    #[poly_const(build_exponential_lut, from = rate)] lut: &LutF64,
552) -> f64 {
553    let _ = rate;
554    lut.sample(input)
555}
556
557fn icd_exponential_jit_constants(node: &IcdExponential) -> Vec<u64> {
558    vec![node.lut.as_ptr() as u64, node.lut.len() as u64]
559}
560
561/// Alias of `dist_exponential`.
562#[polydat::polydat_node(category = Distributions, jit_constants = icd_exponential_jit_constants)]
563fn icd_exponential(
564    input: f64,
565    rate: Const<f64>,
566    #[poly_const(build_exponential_lut, from = rate)] lut: &LutF64,
567) -> f64 {
568    let _ = rate;
569    lut.sample(input)
570}
571
572fn build_uniform_lut(min: f64, max: f64) -> LutF64 {
573    dist_uniform_lut(min, max, DEFAULT_RESOLUTION)
574}
575
576fn dist_uniform_jit_constants(node: &DistUniform) -> Vec<u64> {
577    vec![node.lut.as_ptr() as u64, node.lut.len() as u64]
578}
579
580/// Sample from a continuous uniform distribution `U(min, max)`.
581#[polydat::polydat_node(category = Distributions, jit_constants = dist_uniform_jit_constants)]
582fn dist_uniform(
583    input: f64,
584    min: Const<f64>,
585    max: Const<f64>,
586    #[poly_const(build_uniform_lut, from = (min, max))] lut: &LutF64,
587) -> f64 {
588    let _ = min;
589    let _ = max;
590    lut.sample(input)
591}
592
593fn build_pareto_lut(scale: f64, shape: f64) -> LutF64 {
594    dist_pareto_lut(scale, shape, DEFAULT_RESOLUTION)
595}
596
597fn dist_pareto_jit_constants(node: &DistPareto) -> Vec<u64> {
598    vec![node.lut.as_ptr() as u64, node.lut.len() as u64]
599}
600
601/// Sample from a Pareto distribution `Pareto(scale, shape)`.
602#[polydat::polydat_node(category = Distributions, jit_constants = dist_pareto_jit_constants)]
603fn dist_pareto(
604    input: f64,
605    scale: Const<f64>,
606    shape: Const<f64>,
607    #[poly_const(build_pareto_lut, from = (scale, shape))] lut: &LutF64,
608) -> f64 {
609    let _ = scale;
610    let _ = shape;
611    lut.sample(input)
612}
613
614fn build_zipf_lut(n: u64, exponent: f64) -> LutF64 {
615    dist_zipf_lut(n, exponent, DEFAULT_RESOLUTION)
616}
617
618fn dist_zipf_jit_constants(node: &DistZipf) -> Vec<u64> {
619    vec![node.lut.as_ptr() as u64, node.lut.len() as u64]
620}
621
622/// Sample from a Zipf distribution `Zipf(n, exponent)`.
623#[polydat::polydat_node(category = Distributions, jit_constants = dist_zipf_jit_constants)]
624fn dist_zipf(
625    input: f64,
626    n: Const<u64>,
627    exponent: Const<f64>,
628    #[poly_const(build_zipf_lut, from = (n, exponent))] lut: &LutF64,
629) -> f64 {
630    let _ = n;
631    let _ = exponent;
632    lut.sample(input)
633}
634
635// ---------------------------------------------------------------------------
636// Inventory stub: histribution and dist_empirical register themselves
637// via their own modules; `LutSample` (lut.rs) is a programmatic node
638// (not DSL-registered). This module no longer hand-rolls a
639// signatures() vec — every DSL node here self-registers via
640// `#[polydat_node]`.
641// ---------------------------------------------------------------------------
642
643#[cfg(test)]
644mod tests {
645    use super::*;
646    use polydat::ast::{PolydatNode, Value};
647
648    #[test]
649    fn unit_interval_range() {
650        let node = UnitInterval::new();
651        let mut out = [Value::None];
652        node.eval(&[Value::U64(0)], &mut out);
653        assert_eq!(out[0].as_f64(), 0.0);
654        node.eval(&[Value::U64(u64::MAX)], &mut out);
655        assert!((0.999..=1.0).contains(&out[0].as_f64()));
656    }
657
658    #[test]
659    fn normal_symmetry() {
660        let lut = dist_normal_lut(0.0, 1.0, 1000);
661        assert!(lut.sample(0.5).abs() < 0.01);
662        assert!((lut.sample(0.25) + lut.sample(0.75)).abs() < 0.01);
663    }
664
665    #[test]
666    fn normal_mean_stddev() {
667        let lut = dist_normal_lut(100.0, 10.0, 1000);
668        assert!((lut.sample(0.5) - 100.0).abs() < 0.5);
669    }
670
671    #[test]
672    fn exponential_median() {
673        let lut = dist_exponential_lut(1.0, 1000);
674        assert!((lut.sample(0.5) - 0.693).abs() < 0.01);
675    }
676
677    #[test]
678    fn exponential_positive() {
679        let lut = dist_exponential_lut(1.0, 1000);
680        for i in 1..1000 {
681            assert!(lut.sample(i as f64 / 1000.0) >= 0.0);
682        }
683    }
684
685    #[test]
686    fn uniform_linear() {
687        let lut = dist_uniform_lut(10.0, 20.0, 1000);
688        assert!((lut.sample(0.0) - 10.0).abs() < 0.1);
689        assert!((lut.sample(0.5) - 15.0).abs() < 0.1);
690        assert!((lut.sample(0.999) - 20.0).abs() < 0.1);
691    }
692
693    #[test]
694    fn pareto_heavy_tail() {
695        let lut = dist_pareto_lut(1.0, 1.0, 1000);
696        assert!((lut.sample(0.5) - 2.0).abs() < 0.1);
697        assert!(lut.sample(0.99) > 50.0);
698    }
699
700    #[test]
701    fn cauchy_symmetric() {
702        let lut = dist_cauchy_lut(0.0, 1.0, 1000);
703        assert!(lut.sample(0.5).abs() < 0.1);
704        assert!((lut.sample(0.25) + lut.sample(0.75)).abs() < 0.1);
705    }
706
707    #[test]
708    fn laplace_symmetric() {
709        let lut = dist_laplace_lut(5.0, 2.0, 1000);
710        assert!((lut.sample(0.5) - 5.0).abs() < 0.1);
711    }
712
713    #[test]
714    fn beta_bounded_01() {
715        let lut = dist_beta_lut(2.0, 5.0, 1000);
716        for i in 0..=1000 {
717            let v = lut.sample(i as f64 / 1000.0);
718            assert!((0.0..=1.0).contains(&v), "beta out of [0,1]: {v}");
719        }
720    }
721
722    #[test]
723    fn beta_symmetric_at_half() {
724        // Beta(2, 2) is symmetric around 0.5
725        let lut = dist_beta_lut(2.0, 2.0, 1000);
726        assert!(
727            (lut.sample(0.5) - 0.5).abs() < 0.1,
728            "beta(2,2) median={}, expected ~0.5",
729            lut.sample(0.5)
730        );
731    }
732
733    #[test]
734    fn gamma_positive() {
735        let lut = dist_gamma_lut(2.0, 1.0, 1000);
736        for i in 1..1000 {
737            assert!(lut.sample(i as f64 / 1000.0) > 0.0);
738        }
739    }
740
741    #[test]
742    fn gamma_mean() {
743        // Gamma(shape=3, scale=2) has mean = shape * scale = 6
744        let lut = dist_gamma_lut(3.0, 2.0, 1000);
745        assert!((lut.sample(0.5) - 5.0).abs() < 1.5); // median ≈ mean for shape>1
746    }
747
748    #[test]
749    fn weibull_positive() {
750        let lut = dist_weibull_lut(2.0, 1.0, 1000);
751        for i in 1..1000 {
752            assert!(lut.sample(i as f64 / 1000.0) >= 0.0);
753        }
754    }
755
756    #[test]
757    fn zipf_range() {
758        let lut = dist_zipf_lut(100, 1.0, 1000);
759        for i in 1..1000 {
760            let v = lut.sample(i as f64 / 1000.0);
761            assert!((1.0..=100.0).contains(&v), "zipf out of [1,100]: {v}");
762        }
763    }
764
765    #[test]
766    fn zipf_skewed() {
767        // Low ranks should be much more common
768        let lut = dist_zipf_lut(100, 1.0, 1000);
769        let low_quantile = lut.sample(0.5);
770        assert!(
771            low_quantile < 20.0,
772            "median of Zipf(100,1) should be low, got {low_quantile}"
773        );
774    }
775
776    #[test]
777    fn poisson_mean() {
778        // Poisson(5): mean and median ≈ 5
779        let lut = dist_poisson_lut(5.0, 1000);
780        let median = lut.sample(0.5);
781        assert!(
782            (median - 5.0).abs() < 1.0,
783            "poisson median={median}, expected ~5"
784        );
785    }
786
787    #[test]
788    fn poisson_nonnegative() {
789        let lut = dist_poisson_lut(3.0, 1000);
790        for i in 0..=1000 {
791            assert!(lut.sample(i as f64 / 1000.0) >= 0.0);
792        }
793    }
794
795    #[test]
796    fn binomial_range() {
797        let lut = dist_binomial_lut(20, 0.5, 1000);
798        for i in 0..=1000 {
799            let v = lut.sample(i as f64 / 1000.0);
800            assert!((0.0..=20.0).contains(&v), "binomial out of [0,20]: {v}");
801        }
802    }
803
804    #[test]
805    fn binomial_mean() {
806        // Binomial(20, 0.5): mean = 10
807        let lut = dist_binomial_lut(20, 0.5, 1000);
808        let median = lut.sample(0.5);
809        assert!(
810            (median - 10.0).abs() < 1.5,
811            "binomial median={median}, expected ~10"
812        );
813    }
814
815    #[test]
816    fn geometric_starts_at_one() {
817        let lut = dist_geometric_lut(0.5, 1000);
818        assert!(lut.sample(0.001) >= 1.0);
819    }
820
821    #[test]
822    fn geometric_mean() {
823        // Geometric(0.5): mean = 1/p = 2
824        let lut = dist_geometric_lut(0.5, 1000);
825        let median = lut.sample(0.5);
826        assert!(
827            (median - 1.0).abs() < 1.0,
828            "geometric median={median}, expected ~1-2"
829        );
830    }
831
832    #[test]
833    fn dist_normal_node_eval() {
834        let node = DistNormal::new(0.0, 1.0);
835        let mut out = [Value::None];
836        node.eval(&[Value::F64(0.5)], &mut out);
837        assert!(out[0].as_f64().abs() < 0.01);
838    }
839
840    #[test]
841    fn full_pipeline_hash_normalize_sample() {
842        use xxhash_rust::xxh3::xxh3_64;
843
844        let lut = dist_normal_lut(72.0, 5.0, 1000);
845        let mut values = Vec::new();
846        for i in 0..10_000u64 {
847            let hashed = xxh3_64(&i.to_le_bytes());
848            let u = hashed as f64 / u64::MAX as f64;
849            values.push(lut.sample(u));
850        }
851        let mean = values.iter().sum::<f64>() / values.len() as f64;
852        let variance = values.iter().map(|v| (v - mean).powi(2)).sum::<f64>() / values.len() as f64;
853        let stddev = variance.sqrt();
854        assert!((mean - 72.0).abs() < 0.5, "mean={mean}");
855        assert!((stddev - 5.0).abs() < 0.5, "stddev={stddev}");
856    }
857}