feat: initial release
Release / gates (push) Successful in 4m38s
Test / test (push) Successful in 5m16s
Release / release (push) Successful in 35s

Assisted-by: GLM 5.3 Flash
This commit is contained in:
2026-09-03 10:00:00 +02:00
commit af4ee19703
617 changed files with 191195 additions and 0 deletions
+278
View File
@@ -0,0 +1,278 @@
// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
package stats
import (
"math"
"testing"
)
// TestGammaIncompleteClosed checks the incomplete gamma against
// closed forms: P(1, x) = 1 − e^{−x}, P(0.5, x) = erf(√x),
// Q(2, 1) = 3/e and the complement identity.
func TestGammaIncompleteClosed(t *testing.T) {
p, err := GammaLower(1, 2.5)
if err != nil {
t.Fatalf("GammaLower: %v", err)
}
if math.Abs(p-(1-math.Exp(-2.5))) > 1e-14 {
t.Fatalf("P(1, 2.5) = %.16g, want %.16g", p, 1-math.Exp(-2.5))
}
p, err = GammaLower(0.5, 1)
if err != nil {
t.Fatalf("GammaLower: %v", err)
}
if math.Abs(p-math.Erf(1)) > 1e-14 {
t.Fatalf("P(0.5, 1) = %.16g, want erf(1) = %.16g", p, math.Erf(1))
}
q, err := GammaUpper(2, 1)
if err != nil {
t.Fatalf("GammaUpper: %v", err)
}
if math.Abs(q-2/math.E) > 1e-14 {
t.Fatalf("Q(2, 1) = %.16g, want 2/e = %.16g", q, 2/math.E)
}
p, err = GammaLower(3, 0.7)
if err != nil {
t.Fatalf("GammaLower: %v", err)
}
q, err = GammaUpper(3, 0.7)
if err != nil {
t.Fatalf("GammaUpper: %v", err)
}
if math.Abs(p+q-1) > 1e-14 {
t.Fatalf("P + Q = %.17g, want 1", p+q)
}
// The series side of a large shape and the fraction side of a
// small one must agree with the complement to rounding level.
p, _ = GammaLower(8.5, 9.3)
q, _ = GammaUpper(8.5, 9.3)
if math.Abs(p+q-1) > 1e-13 {
t.Fatalf("P(8.5, 9.3) + Q(8.5, 9.3) = %.17g, want 1", p+q)
}
if _, err := GammaLower(0, 1); err == nil {
t.Fatal("a = 0: want an error")
}
if _, err := GammaLower(1, -1); err == nil {
t.Fatal("negative x: want an error")
}
}
// TestBetaIncompleteClosed checks the incomplete beta against closed
// forms and the symmetry I_x(a,b) = 1 − I_{1−x}(b,a).
func TestBetaIncompleteClosed(t *testing.T) {
cases := []struct {
x, a, b, want float64
}{
{0.3, 1, 1, 0.3}, // uniform
{0.2, 1, 3, 0.488}, // 1 − (1−x)^b
{0.09, 2, 1, 0.0081}, // x^a
{0.5, 2, 2, 0.5}, // symmetric
{0.5, 0.5, 0.5, 0.5}, // arcsine, symmetric
{0.25, 0.5, 1, 0.5}, // I_x(1/2,1) = √x
}
for _, c := range cases {
got, err := BetaIncomplete(c.x, c.a, c.b)
if err != nil {
t.Fatalf("BetaIncomplete(%g, %g, %g): %v", c.x, c.a, c.b, err)
}
if math.Abs(got-c.want) > 1e-13 {
t.Fatalf("I_%g(%g, %g) = %.16g, want %.16g", c.x, c.a, c.b, got, c.want)
}
}
got, err := BetaIncomplete(0.7, 2, 3)
if err != nil {
t.Fatalf("BetaIncomplete: %v", err)
}
mirror, err := BetaIncomplete(0.3, 3, 2)
if err != nil {
t.Fatalf("BetaIncomplete mirror: %v", err)
}
if math.Abs(got+mirror-1) > 1e-13 {
t.Fatalf("symmetry broken: %.17g + %.17g != 1", got, mirror)
}
if _, err := BetaIncomplete(1.5, 1, 1); err == nil {
t.Fatal("x outside [0, 1]: want an error")
}
}
// TestContinuousCDFs pins each continuous CDF on exact or tabulated
// values.
func TestContinuousCDFs(t *testing.T) {
if v := NormalCDF(1); math.Abs(v-0.8413447460685429) > 1e-15 {
t.Fatalf("Φ(1) = %.16g", v)
}
if v := NormalCDF(1.959963984540054); math.Abs(v-0.975) > 1e-14 {
t.Fatalf("Φ(1.96…) = %.16g, want 0.975", v)
}
v, err := ExponentialCDF(1, 1)
if err != nil || math.Abs(v-(1-1/math.E)) > 1e-15 {
t.Fatalf("ExponentialCDF(1, 1) = %v, %v", v, err)
}
// Gamma(2, 1) CDF: 1 − e^{−x}(1 + x).
v, err = GammaCDF(1, 2, 1)
if err != nil || math.Abs(v-(1-2/math.E)) > 1e-14 {
t.Fatalf("GammaCDF(1, 2, 1) = %v, %v", v, err)
}
// χ² with df 2 is the exponential with mean 2: 1 − e^{−x/2}.
v, err = ChiSquareCDF(1, 2)
if err != nil || math.Abs(v-(1-math.Exp(-0.5))) > 1e-14 {
t.Fatalf("ChiSquareCDF(1, 2) = %v, %v", v, err)
}
// χ² 95 % critical value with df 1: 3.841458820694124.
v, err = ChiSquareCDF(3.841458820694124, 1)
if err != nil || math.Abs(v-0.95) > 1e-13 {
t.Fatalf("ChiSquareCDF at the tabulated 95 %% point = %v, %v", v, err)
}
// Student t with df 1 is the Cauchy: 0.5 + atan(t)/π.
v, err = StudentTCDF(1, 1)
if err != nil || math.Abs(v-(0.5+math.Atan(1)/math.Pi)) > 1e-14 {
t.Fatalf("StudentTCDF(1, 1) = %v, %v", v, err)
}
// Student t with df 2: 0.5 + t/(2√(2 + t²)).
v, err = StudentTCDF(1, 2)
if err != nil || math.Abs(v-(0.5+1/(2*math.Sqrt(3)))) > 1e-14 {
t.Fatalf("StudentTCDF(1, 2) = %v, %v", v, err)
}
}
// TestDiscreteCDFs pins the Poisson and binomial CDFs on exact sums.
func TestDiscreteCDFs(t *testing.T) {
v, err := PoissonCDF(1, 1)
if err != nil || math.Abs(v-2/math.E) > 1e-14 {
t.Fatalf("PoissonCDF(1, 1) = %v, %v, want 2/e", v, err)
}
// P(N ≤ 4) for λ = 4: e^{−4}·Σ_{k≤4} 4^k/k!.
sum := 0.0
for k, term := 0, 1.0; k <= 4; k++ {
if k > 0 {
term *= 4 / float64(k)
}
sum += term
}
v, err = PoissonCDF(4, 4)
if err != nil || math.Abs(v-sum*math.Exp(-4)) > 1e-14 {
t.Fatalf("PoissonCDF(4, 4) = %v, %v, want %.16g", v, err, sum*math.Exp(-4))
}
// Binomial(10, 0.5) at 5: 638/1024 by symmetry of the row.
v, err = BinomialCDF(5, 10, 0.5)
if err != nil || math.Abs(v-638.0/1024) > 1e-14 {
t.Fatalf("BinomialCDF(5, 10, 0.5) = %v, %v, want 0.623046875", v, err)
}
v, err = BinomialCDF(-1, 10, 0.5)
if err != nil || v != 0 {
t.Fatalf("BinomialCDF(-1, …) = %v, %v", v, err)
}
v, err = BinomialCDF(10, 10, 0.5)
if err != nil || v != 1 {
t.Fatalf("BinomialCDF(10, 10, 0.5) = %v, %v", v, err)
}
}
// TestContinuousQuantiles inverts every continuous CDF and pins the
// tabulated critical values.
func TestContinuousQuantiles(t *testing.T) {
v, err := NormalQuantile(0.975)
if err != nil || math.Abs(v-1.959963984540054) > 1e-12 {
t.Fatalf("NormalQuantile(0.975) = %v, %v", v, err)
}
v, err = StudentTQuantile(0.975, 10)
if err != nil || math.Abs(v-2.228138851986273) > 1e-9 {
t.Fatalf("StudentTQuantile(0.975, 10) = %v, %v, want 2.2281…", v, err)
}
neg, err := StudentTQuantile(0.025, 10)
if err != nil || math.Abs(neg+2.228138851986273) > 1e-9 {
t.Fatalf("StudentTQuantile(0.025, 10) = %v, %v", neg, err)
}
v, err = ChiSquareQuantile(0.95, 1)
if err != nil || math.Abs(v-3.841458820694124) > 1e-9 {
t.Fatalf("ChiSquareQuantile(0.95, 1) = %v, %v", v, err)
}
v, err = ExponentialQuantile(0.6321205588285577, 1)
if err != nil || math.Abs(v-1) > 1e-12 {
t.Fatalf("ExponentialQuantile(1−1/e, 1) = %v, %v", v, err)
}
v, err = GammaQuantile(0.5, 2, 1)
if err != nil || math.Abs(v-1.678346990016661) > 1e-9 {
t.Fatalf("GammaQuantile(0.5, 2, 1) = %v, %v, want 1.67834…", v, err)
}
// Round trip: the CDF at every quantile must return q.
for _, q := range []float64{0.01, 0.1, 0.5, 0.9, 0.999} {
v, err := GammaQuantile(q, 3.5, 2)
if err != nil {
t.Fatalf("GammaQuantile(%g): %v", q, err)
}
back, err := GammaCDF(v, 3.5, 2)
if err != nil || math.Abs(back-q) > 1e-11 {
t.Fatalf("round trip q = %g: CDF(quantile) = %v, %v", q, back, err)
}
}
if _, err := NormalQuantile(1.5); err == nil {
t.Fatal("q outside [0, 1]: want an error")
}
if _, err := NormalQuantile(0); err == nil {
t.Fatal("q = 0: want an error")
}
}
// TestDiscreteQuantiles checks the smallest-k rule on exact cases.
func TestDiscreteQuantiles(t *testing.T) {
v, err := PoissonQuantile(0.5, 1)
if err != nil || v != 1 {
t.Fatalf("PoissonQuantile(0.5, 1) = %v, %v, want 1", v, err)
}
// P(N ≤ 2) = 5/(2e) ≈ 0.9197, P(N ≤ 1) = 2/e ≈ 0.7358 for λ = 1:
// the 0.8 quantile is the smallest k reaching it, k = 2.
v, err = PoissonQuantile(0.8, 1)
if err != nil || v != 2 {
t.Fatalf("PoissonQuantile(0.8, 1) = %v, %v, want 2", v, err)
}
// Binomial(10, 0.5) median: smallest k with CDF ≥ 0.5 is 5.
v, err = BinomialQuantile(0.5, 0.5, 10)
if err != nil || v != 5 {
t.Fatalf("BinomialQuantile(0.5, 0.5, 10) = %v, %v, want 5", v, err)
}
if _, err := PoissonQuantile(0.5, 0); err == nil {
t.Fatal("lambda = 0: want an error")
}
}
// TestGammaLowerLargeShape pins the large-shape region: both the power
// series (x < a+1) and the continued fraction (x ≥ a+1) must deliver
// accurate values near x ≈ a where the shape makes √a-sized iteration
// counts necessary, instead of silently returning truncated sums.
// The reference is the Wilson-Hilferty normal approximation, good to
// a few digits at these shapes.
func TestGammaLowerLargeShape(t *testing.T) {
wh := func(a, x float64) float64 {
z := 3 * math.Sqrt(a) * (math.Cbrt(x/a) - (1 - 1/(9*a)))
return 0.5 * math.Erfc(-z/math.Sqrt2)
}
cases := []struct {
a, x, tol float64
}{
{50000, 50000, 1e-4}, // series branch, √a ≈ 224
{50000, 49500, 5e-4}, // lower tail, series
{50000, 50500, 5e-4}, // upper tail, continued fraction
{200000, 200000, 1e-4},
{1000, 1000, 1e-5},
}
for _, tc := range cases {
p, err := GammaLower(tc.a, tc.x)
if err != nil {
t.Fatalf("GammaLower(%g, %g): %v", tc.a, tc.x, err)
}
want := wh(tc.a, tc.x)
if math.Abs(p-want) > tc.tol {
t.Fatalf("GammaLower(%g, %g) = %.10g, want ≈ %.10g (tol %g)", tc.a, tc.x, p, want, tc.tol)
}
q, err := GammaUpper(tc.a, tc.x)
if err != nil {
t.Fatalf("GammaUpper(%g, %g): %v", tc.a, tc.x, err)
}
if p+q != 1 {
t.Fatalf("GammaLower + GammaUpper = %.17g, want exactly 1", p+q)
}
}
}