Skip to main content

wowlab_types/stats/summary/
aggregate.rs

1use hdrhistogram::Histogram;
2
3use crate::constants::{
4    HUNDRED, QUANTILE_P01, QUANTILE_P05, QUANTILE_P10, QUANTILE_P25, QUANTILE_P50, QUANTILE_P75,
5    QUANTILE_P90, QUANTILE_P95, QUANTILE_P99, TEN_THOUSAND,
6};
7
8const Z_SCORE_95: f64 = 1.96;
9
10/// Merge chunk DPS stats into the running aggregate via Chan/Golub/LeVeque pairwise merge.
11#[inline]
12#[expect(
13    clippy::too_many_arguments,
14    reason = "Welford merge requires the running and chunk state"
15)]
16#[expect(
17    clippy::cast_precision_loss,
18    reason = "Welford aggregation requires integer sample counts in the f64 calculation domain"
19)]
20// #t(rust_large_fn_params) pairwise merge needs all running + chunk accumulators for Welford's algorithm
21pub fn merge_dps_stats(
22    running_n: &mut u64,
23    running_wsum: &mut u64,
24    running_m2_bits: &mut u64,
25    running_min: &mut u32,
26    running_max: &mut u32,
27    chunk_n: u32,
28    chunk_mean_x10: u32,
29    chunk_m2_bits: u64,
30    chunk_min: u32,
31    chunk_max: u32,
32) {
33    if chunk_n == 0 {
34        return;
35    }
36
37    let n_a = *running_n;
38    let n_b = u64::from(chunk_n);
39
40    let m2_a = f64::from_bits(*running_m2_bits);
41    let m2_b = f64::from_bits(chunk_m2_bits);
42
43    let mean_a = if n_a == 0 {
44        0.0
45    } else {
46        *running_wsum as f64 / n_a as f64
47    };
48
49    let delta = f64::from(chunk_mean_x10) - mean_a;
50    let n_new = n_a + n_b;
51
52    let wsum_new_128 = u128::from(*running_wsum) + u128::from(n_b) * u128::from(chunk_mean_x10);
53    let wsum_new = u64::try_from(wsum_new_128).unwrap_or(u64::MAX);
54
55    let m2_new = m2_a + m2_b + delta * delta * n_a as f64 * n_b as f64 / n_new as f64;
56
57    *running_n = n_new;
58    *running_wsum = wsum_new;
59    *running_m2_bits = m2_new.to_bits();
60
61    if n_a == 0 {
62        *running_min = chunk_min;
63        *running_max = chunk_max;
64    } else {
65        *running_min = (*running_min).min(chunk_min);
66        *running_max = (*running_max).max(chunk_max);
67    }
68}
69
70/// Compute `(mean_dps_x10, std_dps_x10, ci95_half_pct_x10000)` for `ResultCoreV1`.
71#[must_use]
72#[expect(
73    clippy::cast_precision_loss,
74    reason = "result statistics require integer totals in the f64 calculation domain"
75)]
76pub fn compute_result_core(
77    iterations_total: u64,
78    weighted_mean_num_x10: u64,
79    m2_total_bits: u64,
80) -> (u32, u32, u32) {
81    if iterations_total == 0 {
82        return (0, 0, 0);
83    }
84
85    let n = iterations_total as f64;
86    let grand_mean_x10 = weighted_mean_num_x10 as f64 / n;
87    let m2 = f64::from_bits(m2_total_bits);
88
89    let mean_dps_x10 = crate::numeric::f64_to_u32_saturating_round(grand_mean_x10);
90
91    let var_sample = if iterations_total > 1 {
92        m2 / (n - 1.0)
93    } else {
94        0.0
95    };
96    let std = var_sample.sqrt();
97    let std_dps_x10 = crate::numeric::f64_to_u32_saturating_round(std);
98
99    let ci95_half_pct_x10000 = if iterations_total > 1 && grand_mean_x10 > 0.0 {
100        let ci95_half_fraction = Z_SCORE_95 * std / (n.sqrt() * grand_mean_x10);
101
102        crate::numeric::f64_to_u32_saturating_round(ci95_half_fraction * HUNDRED * TEN_THOUSAND)
103    } else {
104        0
105    };
106
107    (mean_dps_x10, std_dps_x10, ci95_half_pct_x10000)
108}
109
110/// Compute a percentile (`p` in 0.0..=1.0) from an HDR histogram, in x10 space.
111#[must_use]
112pub fn histogram_percentile_from_hdr(hdr: &Histogram<u64>, p: f64) -> Option<u32> {
113    if hdr.is_empty() {
114        return None;
115    }
116
117    let q = p.clamp(0.0, 1.0);
118    let value = hdr.value_at_quantile(q);
119
120    Some(u32::try_from(value).unwrap_or(u32::MAX))
121}
122
123/// Compute all standard percentiles from an HDR histogram.
124#[must_use]
125pub fn compute_all_percentiles_from_hdr(hdr: &Histogram<u64>) -> crate::proto::Percentiles {
126    let pct = |frac: f64| -> u32 { histogram_percentile_from_hdr(hdr, frac).unwrap_or(0) };
127
128    crate::proto::Percentiles {
129        p01_x10: pct(QUANTILE_P01),
130        p05_x10: pct(QUANTILE_P05),
131        p10_x10: pct(QUANTILE_P10),
132        p25_x10: pct(QUANTILE_P25),
133        p50_x10: pct(QUANTILE_P50),
134        p75_x10: pct(QUANTILE_P75),
135        p90_x10: pct(QUANTILE_P90),
136        p95_x10: pct(QUANTILE_P95),
137        p99_x10: pct(QUANTILE_P99),
138    }
139}
140
141/// Compute per-bucket `dps_per_bucket_x10` averages (`sum/samples`, 0 when `samples == 0`).
142#[must_use]
143#[expect(
144    clippy::cast_precision_loss,
145    reason = "bucket averages require integer totals in the f64 calculation domain"
146)]
147pub fn compute_bucket_averages_x10(sums_x10: &[u64], samples: &[u64]) -> Vec<u32> {
148    let len = sums_x10.len().max(samples.len());
149
150    (0..len)
151        .map(|i| {
152            let s = sums_x10.get(i).copied().unwrap_or(0);
153            let n = samples.get(i).copied().unwrap_or(0);
154
155            if n == 0 {
156                0
157            } else {
158                crate::numeric::f64_to_u32_saturating_round(s as f64 / n as f64)
159            }
160        })
161        .collect()
162}