Skip to main content

batuta_common/
math.rs

1//! Shared mathematical functions for the Batuta stack.
2//!
3//! Provides common math operations (statistics, special functions) used across
4//! pmat, trueno, aprender, and trueno-viz.
5
6// =============================================================================
7// ERROR FUNCTION (Abramowitz & Stegun approximation)
8// =============================================================================
9
10/// Compute the error function erf(x) using the Abramowitz & Stegun approximation.
11///
12/// Maximum error: |ε| < 1.5 × 10⁻⁷
13///
14/// # Examples
15/// ```
16/// use batuta_common::math::erf;
17/// assert!((erf(0.0) - 0.0).abs() < 1e-6);
18/// assert!((erf(1.0) - 0.842_700_8).abs() < 1e-5);
19/// assert!((erf(-1.0) + 0.842_700_8).abs() < 1e-5);
20/// ```
21#[must_use]
22pub fn erf(x: f64) -> f64 {
23    // Abramowitz and Stegun formula 7.1.26
24    const A1: f64 = 0.254_829_592;
25    const A2: f64 = -0.284_496_736;
26    const A3: f64 = 1.421_413_741;
27    const A4: f64 = -1.453_152_027;
28    const A5: f64 = 1.061_405_429;
29    const P: f64 = 0.327_591_1;
30
31    let sign = if x < 0.0 { -1.0 } else { 1.0 };
32    let x = x.abs();
33    let t = 1.0 / (1.0 + P * x);
34    let y = 1.0 - (((((A5 * t + A4) * t) + A3) * t + A2) * t + A1) * t * (-x * x).exp();
35
36    sign * y
37}
38
39/// Compute erf(x) with f32 precision.
40///
41/// Convenience wrapper for f32 callers; internally delegates to the f64 version.
42#[must_use]
43pub fn erf_f32(x: f32) -> f32 {
44    erf(f64::from(x)) as f32
45}
46
47// =============================================================================
48// HIGH-ACCURACY ERROR FUNCTION (W. J. Cody rational Chebyshev approximation)
49// =============================================================================
50//
51// Why this exists alongside `erf` above (plan 01-09, amendment A-03):
52//
53// `erf` uses Abramowitz & Stegun 7.1.26, whose max ABSOLUTE error is 1.5e-7. That is
54// fine for the statistics callers it was written for, but it is NOT sufficient for
55// `gelu_exact`, the FFN activation on the ENC-03 parity path. Measured: A&S-based
56// gelu_exact deviates from a high-precision reference by up to 4.77e-7 absolute, and —
57// far worse — by 129 f32 ulps of the LOCAL VALUE near x = -2.67, because
58// `1 + erf(x/sqrt(2))` cancels catastrophically in the negative tail (two ~1.0
59// quantities leaving ~0.0077). That is a SYSTEMATIC bias, not noise, and it would
60// compound across six FFN layers.
61//
62// `erf` above is deliberately left untouched so its existing callers keep their exact
63// current behavior.
64//
65// Reference: W. J. Cody, "Rational Chebyshev Approximation for the Error Function",
66// Math. Comp. 23 (1969), 631-637 — the CALERF algorithm also used by fdlibm/Cephes.
67// Accuracy is near machine precision in f64 (~1e-16 relative).
68
69/// 1/sqrt(pi), used by the large-argument asymptotic branch.
70const SQRPI: f64 = 0.564_189_583_547_756_3;
71
72/// Cody's branch threshold between the direct erf series and the erfc branches.
73const CODY_THRESH: f64 = 0.468_75;
74
75/// Above this magnitude erfc(x) underflows to 0 in f64.
76const CODY_XBIG: f64 = 26.543;
77
78// The six coefficient tables below are transcribed from Cody's published CALERF
79// values, written as the shortest round-tripping f64 literals so the stored bit
80// patterns are exactly the published constants.
81//
82// `unreadable_literal` is allowed here rather than adding digit separators: these are
83// a transcribed numerical table, and any grouping that satisfies `unreadable_literal`
84// trips `inconsistent_digit_grouping` (the integer and fractional parts have
85// different digit counts per row). Ungrouped literals keep them diffable against the
86// published source, which is the property that actually matters for a coefficient table.
87#[allow(clippy::unreadable_literal)]
88const CODY_A: [f64; 5] = [
89    3.1611237438705655,
90    113.86415415105016,
91    377.485237685302,
92    3209.3775891384694,
93    0.18577770618460315,
94];
95#[allow(clippy::unreadable_literal)]
96const CODY_B: [f64; 4] = [
97    23.601290952344122,
98    244.02463793444417,
99    1282.6165260773723,
100    2844.236833439171,
101];
102#[allow(clippy::unreadable_literal)]
103const CODY_C: [f64; 9] = [
104    0.5641884969886701,
105    8.883149794388377,
106    66.11919063714163,
107    298.6351381974001,
108    881.952221241769,
109    1712.0476126340707,
110    2051.0783778260716,
111    1230.3393547979972,
112    2.1531153547440383e-8,
113];
114#[allow(clippy::unreadable_literal)]
115const CODY_D: [f64; 8] = [
116    15.744926110709835,
117    117.6939508913125,
118    537.1811018620099,
119    1621.3895745666903,
120    3290.7992357334597,
121    4362.619090143247,
122    3439.3676741437216,
123    1230.3393548037495,
124];
125#[allow(clippy::unreadable_literal)]
126const CODY_P: [f64; 6] = [
127    0.30532663496123236,
128    0.36034489994980445,
129    0.12578172611122926,
130    0.016083785148742275,
131    0.0006587491615298378,
132    0.016315387137302097,
133];
134#[allow(clippy::unreadable_literal)]
135const CODY_Q: [f64; 5] = [
136    2.568520192289822,
137    1.8729528499234604,
138    0.5279051029514285,
139    0.06051834131244132,
140    0.0023352049762686918,
141];
142
143/// erf(x) for |x| <= `CODY_THRESH`, via the direct rational approximation.
144fn cody_erf_small(x: f64) -> f64 {
145    let z = x * x;
146    let mut xnum = CODY_A[4] * z;
147    let mut xden = z;
148    for i in 0..3 {
149        xnum = (xnum + CODY_A[i]) * z;
150        xden = (xden + CODY_B[i]) * z;
151    }
152    x * (xnum + CODY_A[3]) / (xden + CODY_B[3])
153}
154
155/// erfc(y) for y > `CODY_THRESH` (y strictly positive).
156///
157/// Uses Cody's middle branch for y <= 4 and the asymptotic branch beyond. Both apply
158/// Cody's split-exponential trick (`ysq` truncated to 1/16) so that `exp(-y*y)` is
159/// evaluated without losing low-order bits.
160fn cody_erfc_pos(y: f64) -> f64 {
161    if y >= CODY_XBIG {
162        return 0.0;
163    }
164
165    let result = if y <= 4.0 {
166        let mut xnum = CODY_C[8] * y;
167        let mut xden = y;
168        for i in 0..7 {
169            xnum = (xnum + CODY_C[i]) * y;
170            xden = (xden + CODY_D[i]) * y;
171        }
172        (xnum + CODY_C[7]) / (xden + CODY_D[7])
173    } else {
174        let z = 1.0 / (y * y);
175        let mut xnum = CODY_P[5] * z;
176        let mut xden = z;
177        for i in 0..4 {
178            xnum = (xnum + CODY_P[i]) * z;
179            xden = (xden + CODY_Q[i]) * z;
180        }
181        let r = z * (xnum + CODY_P[4]) / (xden + CODY_Q[4]);
182        (SQRPI - r) / y
183    };
184
185    // Split exp(-y^2) = exp(-ysq^2) * exp(-del) with ysq truncated to a 1/16 grid.
186    let ysq = (y * 16.0).trunc() / 16.0;
187    let del = (y - ysq) * (y + ysq);
188    (-ysq * ysq).exp() * (-del).exp() * result
189}
190
191/// High-accuracy error function, accurate to near f64 machine precision.
192///
193/// Use this instead of [`erf`] wherever the result feeds a numerical-parity gate.
194/// [`erf`] (Abramowitz & Stegun 7.1.26) is only accurate to 1.5e-7 absolute.
195///
196/// # Examples
197/// ```
198/// use batuta_common::math::erf_precise;
199/// assert!((erf_precise(1.0) - 0.842_700_792_949_714_9).abs() < 1e-15);
200/// assert!((erf_precise(-1.0) + 0.842_700_792_949_714_9).abs() < 1e-15);
201/// assert_eq!(erf_precise(0.0), 0.0);
202/// ```
203#[must_use]
204pub fn erf_precise(x: f64) -> f64 {
205    if x.is_nan() {
206        return x;
207    }
208    let y = x.abs();
209    if y <= CODY_THRESH {
210        return cody_erf_small(x);
211    }
212    let v = 1.0 - cody_erfc_pos(y);
213    if x < 0.0 { -v } else { v }
214}
215
216/// High-accuracy complementary error function `erfc(x) = 1 - erf(x)`.
217///
218/// Computing `1.0 - erf(x)` directly loses precision for large positive `x`, where
219/// erfc is tiny; this routine keeps full relative accuracy there. That matters for
220/// `gelu_exact(x) = 0.5 * x * erfc(-x / sqrt(2))`, whose negative tail is exactly
221/// that regime.
222///
223/// # Examples
224/// ```
225/// use batuta_common::math::erfc_precise;
226/// assert!((erfc_precise(0.0) - 1.0).abs() < 1e-15);
227/// // erfc stays accurate where 1 - erf(x) would cancel to nothing.
228/// assert!((erfc_precise(3.0) - 2.209_049_699_858_544e-5).abs() < 1e-19);
229/// ```
230#[must_use]
231pub fn erfc_precise(x: f64) -> f64 {
232    if x.is_nan() {
233        return x;
234    }
235    let y = x.abs();
236    if y <= CODY_THRESH {
237        return 1.0 - cody_erf_small(x);
238    }
239    if x > 0.0 {
240        cody_erfc_pos(y)
241    } else {
242        2.0 - cody_erfc_pos(y)
243    }
244}
245
246// =============================================================================
247// STANDARD DEVIATION
248// =============================================================================
249
250/// Compute sample standard deviation of a slice (Bessel's correction, n-1).
251///
252/// Returns 0.0 if fewer than 2 elements.
253///
254/// # Examples
255/// ```
256/// use batuta_common::math::std_dev;
257/// let data = [2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0];
258/// assert!((std_dev(&data) - 2.138).abs() < 0.01);
259/// assert_eq!(std_dev(&[1.0]), 0.0);
260/// assert_eq!(std_dev(&[]), 0.0);
261/// ```
262#[must_use]
263pub fn std_dev(samples: &[f64]) -> f64 {
264    if samples.len() < 2 {
265        return 0.0;
266    }
267    let n = samples.len() as f64;
268    let mean = samples.iter().sum::<f64>() / n;
269    let variance = samples.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / (n - 1.0);
270    variance.sqrt()
271}
272
273/// Compute sample standard deviation for f32 data.
274///
275/// Returns 0.0 if fewer than 2 elements.
276#[must_use]
277pub fn std_dev_f32(samples: &[f32]) -> f32 {
278    if samples.len() < 2 {
279        return 0.0;
280    }
281    let n = samples.len() as f32;
282    let mean = samples.iter().sum::<f32>() / n;
283    let variance = samples.iter().map(|x| (x - mean).powi(2)).sum::<f32>() / (n - 1.0);
284    variance.sqrt()
285}
286
287/// Compute sample standard deviation given a pre-computed mean.
288///
289/// Useful when the mean has already been calculated separately.
290#[must_use]
291pub fn std_dev_with_mean(samples: &[f64], mean: f64) -> f64 {
292    if samples.len() < 2 {
293        return 0.0;
294    }
295    let variance =
296        samples.iter().map(|x| (x - mean).powi(2)).sum::<f64>() / (samples.len() - 1) as f64;
297    variance.sqrt()
298}
299
300/// Compute sample standard deviation for f32 data given a pre-computed mean.
301#[must_use]
302pub fn std_dev_f32_with_mean(samples: &[f32], mean: f32) -> f32 {
303    if samples.len() < 2 {
304        return 0.0;
305    }
306    let variance =
307        samples.iter().map(|x| (x - mean).powi(2)).sum::<f32>() / (samples.len() - 1) as f32;
308    variance.sqrt()
309}
310
311// =============================================================================
312// COSINE SIMILARITY
313// =============================================================================
314
315/// Compute cosine similarity between two f32 vectors.
316///
317/// Returns 0.0 if either vector has zero norm.
318///
319/// # Examples
320/// ```
321/// use batuta_common::math::cosine_similarity_f32;
322/// let a = [1.0f32, 0.0, 0.0];
323/// let b = [0.0f32, 1.0, 0.0];
324/// assert!((cosine_similarity_f32(&a, &b) - 0.0).abs() < 1e-6);
325///
326/// let c = [1.0f32, 2.0, 3.0];
327/// assert!((cosine_similarity_f32(&c, &c) - 1.0).abs() < 1e-6);
328/// ```
329#[must_use]
330pub fn cosine_similarity_f32(a: &[f32], b: &[f32]) -> f32 {
331    let dot: f32 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
332    let norm_a: f32 = a.iter().map(|x| x * x).sum::<f32>().sqrt();
333    let norm_b: f32 = b.iter().map(|x| x * x).sum::<f32>().sqrt();
334
335    if norm_a == 0.0 || norm_b == 0.0 {
336        return 0.0;
337    }
338    dot / (norm_a * norm_b)
339}
340
341/// Compute cosine similarity between two f64 vectors.
342///
343/// Returns 0.0 if either vector has zero norm.
344#[must_use]
345pub fn cosine_similarity(a: &[f64], b: &[f64]) -> f64 {
346    let dot: f64 = a.iter().zip(b.iter()).map(|(x, y)| x * y).sum();
347    let norm_a: f64 = a.iter().map(|x| x * x).sum::<f64>().sqrt();
348    let norm_b: f64 = b.iter().map(|x| x * x).sum::<f64>().sqrt();
349
350    if norm_a == 0.0 || norm_b == 0.0 {
351        return 0.0;
352    }
353    dot / (norm_a * norm_b)
354}
355
356// =============================================================================
357// USAGE PERCENT
358// =============================================================================
359
360/// Compute usage percentage from used/total byte counts.
361///
362/// Returns 0.0 if `total` is 0 (avoids divide-by-zero).
363///
364/// # Examples
365/// ```
366/// use batuta_common::math::usage_percent;
367/// assert!((usage_percent(750, 1000) - 75.0).abs() < 1e-10);
368/// assert_eq!(usage_percent(0, 0), 0.0);
369/// assert!((usage_percent(1024, 4096) - 25.0).abs() < 1e-10);
370/// ```
371#[must_use]
372pub fn usage_percent(used: u64, total: u64) -> f64 {
373    if total == 0 {
374        return 0.0;
375    }
376    (used as f64 / total as f64) * 100.0
377}
378
379// =============================================================================
380// TESTS
381// =============================================================================
382
383#[cfg(test)]
384mod tests {
385    use super::*;
386
387    // --- erf ---
388
389    #[test]
390    fn test_erf_zero() {
391        assert!((erf(0.0) - 0.0).abs() < 1e-6);
392    }
393
394    #[test]
395    fn test_erf_positive() {
396        assert!((erf(1.0) - 0.842_700_793).abs() < 1e-6);
397    }
398
399    #[test]
400    fn test_erf_negative_symmetry() {
401        assert!((erf(-1.0) + erf(1.0)).abs() < 1e-10);
402    }
403
404    // --- erf_precise / erfc_precise (plan 01-09, amendment A-03) ---
405    //
406    // These are checked against an INDEPENDENTLY derived oracle (Maclaurin series for
407    // |t| <= 2, Laplace continued fraction beyond), not against `erf` above — that
408    // would only prove the two agree, which is precisely what must NOT be assumed.
409
410    fn oracle_erf_series(t: f64) -> f64 {
411        let mut u = t;
412        let mut sum = t;
413        let mut n = 1.0_f64;
414        while n <= 200.0 {
415            u *= -(t * t) / n;
416            let add = u / (2.0 * n + 1.0);
417            sum += add;
418            if add == 0.0 || add.abs() < 1e-18 * sum.abs() {
419                break;
420            }
421            n += 1.0;
422        }
423        sum * 2.0 / std::f64::consts::PI.sqrt()
424    }
425
426    fn oracle_erfc_cf(t: f64) -> f64 {
427        let mut cf = 0.0_f64;
428        let mut k = 80_i32;
429        while k >= 1 {
430            cf = (f64::from(k) / 2.0) / (t + cf);
431            k -= 1;
432        }
433        (-t * t).exp() / std::f64::consts::PI.sqrt() / (t + cf)
434    }
435
436    fn oracle_erf(t: f64) -> f64 {
437        let a = t.abs();
438        let v = if a <= 2.0 {
439            oracle_erf_series(a)
440        } else {
441            1.0 - oracle_erfc_cf(a)
442        };
443        if t < 0.0 { -v } else { v }
444    }
445
446    #[test]
447    fn erf_precise_matches_independent_oracle_across_the_range() {
448        let mut worst = 0.0_f64;
449        let mut worst_at = 0.0_f64;
450        for i in 0..=1200 {
451            let x = -6.0 + 0.01 * f64::from(i);
452            let d = (erf_precise(x) - oracle_erf(x)).abs();
453            if d > worst {
454                worst = d;
455                worst_at = x;
456            }
457        }
458        assert!(
459            worst < 1e-14,
460            "erf_precise deviates from the independent oracle by {worst:.3e} at x={worst_at}"
461        );
462    }
463
464    #[test]
465    fn erf_precise_is_far_more_accurate_than_the_abramowitz_stegun_erf() {
466        // Pins WHY erf_precise was added: A&S is ~1e-7, Cody is ~1e-15.
467        let mut worst_as = 0.0_f64;
468        let mut worst_precise = 0.0_f64;
469        for i in 0..=1200 {
470            let x = -6.0 + 0.01 * f64::from(i);
471            let want = oracle_erf(x);
472            worst_as = worst_as.max((erf(x) - want).abs());
473            worst_precise = worst_precise.max((erf_precise(x) - want).abs());
474        }
475        assert!(
476            worst_as > 1e-9,
477            "A&S erf unexpectedly accurate ({worst_as:.3e}) — the premise for erf_precise changed"
478        );
479        assert!(
480            worst_precise * 1e6 < worst_as,
481            "erf_precise (worst {worst_precise:.3e}) must be orders of magnitude better \
482             than A&S erf (worst {worst_as:.3e})"
483        );
484    }
485
486    #[test]
487    fn erfc_precise_keeps_relative_accuracy_in_the_far_tail() {
488        // The whole point: 1 - erf(x) cancels to nothing out here, erfc does not.
489        for &(x, want) in &[
490            (2.0_f64, 4.677_734_981_047_265e-3_f64),
491            (3.0, 2.209_049_699_858_544e-5),
492            (4.0, 1.541_725_790_028_002_6e-8),
493            (5.0, 1.537_459_794_428_035_4e-12),
494        ] {
495            let got = erfc_precise(x);
496            let rel = (got - want).abs() / want;
497            assert!(
498                rel < 1e-13,
499                "erfc_precise({x}) = {got:e}, expected {want:e} (rel {rel:.3e})"
500            );
501        }
502    }
503
504    #[test]
505    fn erfc_precise_and_erf_precise_are_consistent_and_symmetric() {
506        for i in 0..=120 {
507            let x = -6.0 + 0.1 * f64::from(i);
508            assert!(
509                (erfc_precise(x) - (1.0 - erf_precise(x))).abs() < 1e-14,
510                "erfc_precise({x}) inconsistent with 1 - erf_precise({x})"
511            );
512            assert!(
513                (erf_precise(-x) + erf_precise(x)).abs() < 1e-15,
514                "erf_precise must be odd, failed at {x}"
515            );
516        }
517        assert_eq!(erf_precise(0.0), 0.0);
518        assert!((erfc_precise(0.0) - 1.0).abs() < 1e-15);
519        assert!(erf_precise(f64::NAN).is_nan());
520        assert!(erfc_precise(f64::NAN).is_nan());
521        assert_eq!(erfc_precise(30.0), 0.0, "erfc underflows to 0 past XBIG");
522    }
523
524    #[test]
525    fn test_erf_large() {
526        assert!((erf(5.0) - 1.0).abs() < 1e-6);
527    }
528
529    #[test]
530    fn test_erf_f32_matches() {
531        let f32_val = erf_f32(1.0_f32);
532        let f64_val = erf(1.0) as f32;
533        assert!((f32_val - f64_val).abs() < 1e-6);
534    }
535
536    // --- std_dev ---
537
538    #[test]
539    fn test_std_dev_known_value() {
540        // Sample std_dev with Bessel's correction (n-1):
541        // Mean = 5.0, sum_sq_diff = 32, variance = 32/7 ≈ 4.571, sd ≈ 2.138
542        let data = [2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0];
543        assert!((std_dev(&data) - 2.138).abs() < 0.01);
544    }
545
546    #[test]
547    fn test_std_dev_single_element() {
548        assert_eq!(std_dev(&[42.0]), 0.0);
549    }
550
551    #[test]
552    fn test_std_dev_empty() {
553        assert_eq!(std_dev(&[]), 0.0);
554    }
555
556    #[test]
557    fn test_std_dev_identical_values() {
558        assert_eq!(std_dev(&[5.0, 5.0, 5.0, 5.0]), 0.0);
559    }
560
561    #[test]
562    fn test_std_dev_f32() {
563        let data: Vec<f32> = vec![2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0];
564        assert!((std_dev_f32(&data) - 2.138).abs() < 0.02);
565    }
566
567    #[test]
568    fn test_std_dev_with_mean_matches() {
569        let data = [2.0, 4.0, 4.0, 4.0, 5.0, 5.0, 7.0, 9.0];
570        let mean = data.iter().sum::<f64>() / data.len() as f64;
571        let sd1 = std_dev(&data);
572        let sd2 = std_dev_with_mean(&data, mean);
573        assert!((sd1 - sd2).abs() < 1e-10);
574    }
575
576    // --- cosine_similarity ---
577
578    #[test]
579    fn test_cosine_identical() {
580        let a = [1.0, 2.0, 3.0];
581        assert!((cosine_similarity(&a, &a) - 1.0).abs() < 1e-10);
582    }
583
584    #[test]
585    fn test_cosine_orthogonal() {
586        let a = [1.0, 0.0, 0.0];
587        let b = [0.0, 1.0, 0.0];
588        assert!(cosine_similarity(&a, &b).abs() < 1e-10);
589    }
590
591    #[test]
592    fn test_cosine_opposite() {
593        let a = [1.0, 0.0];
594        let b = [-1.0, 0.0];
595        assert!((cosine_similarity(&a, &b) + 1.0).abs() < 1e-10);
596    }
597
598    #[test]
599    fn test_cosine_zero_vector() {
600        let a = [0.0, 0.0, 0.0];
601        let b = [1.0, 2.0, 3.0];
602        assert_eq!(cosine_similarity(&a, &b), 0.0);
603    }
604
605    #[test]
606    fn test_cosine_f32() {
607        let a = [1.0f32, 0.0, 0.0];
608        let b = [0.0f32, 1.0, 0.0];
609        assert!(cosine_similarity_f32(&a, &b).abs() < 1e-6);
610    }
611
612    // --- usage_percent ---
613
614    #[test]
615    fn test_usage_percent_normal() {
616        assert!((usage_percent(750, 1000) - 75.0).abs() < 1e-10);
617    }
618
619    #[test]
620    fn test_usage_percent_zero_total() {
621        assert_eq!(usage_percent(0, 0), 0.0);
622    }
623
624    #[test]
625    fn test_usage_percent_full() {
626        assert!((usage_percent(1000, 1000) - 100.0).abs() < 1e-10);
627    }
628
629    #[test]
630    fn test_usage_percent_empty() {
631        assert!((usage_percent(0, 1000) - 0.0).abs() < 1e-10);
632    }
633}