Files
petrbalvin af4ee19703
Release / gates (push) Successful in 4m38s
Test / test (push) Successful in 5m16s
Release / release (push) Successful in 35s
feat: initial release
Assisted-by: GLM 5.3 Flash
2026-09-03 10:00:00 +02:00

1007 lines
40 KiB
Go

// Copyright (c) 2026 Petr Balvín <opensource@petrbalvin.org> (https://petrbalvin.org)
// SPDX-License-Identifier: MIT
// Behaviour pins for the package's entry points: every test here
// holds a corrected defect to the answer it must keep, among them the
// NaN quantile hang, the rolling dtype panics, the Quantile NaN
// panic, the Histogram2D complex panic and unbounded bin counts, the
// lower-half NormalQuantile bracket, the saturated GLM likelihood,
// the weighted regression intercept statistics, the missing
// finiteness and symmetry refusals, the BinomialDraws count guard,
// the Kolmogorov series' truncation, the NaN-parameter and
// complex-input sweeps, the KolmogorovSmirnovTest NaN loop and the
// degenerate-response F statistic. Each one fails with the old
// behaviour restored.
package stats
import (
"math"
"math/big"
"testing"
"time"
"sourcedock.dev/petrbalvin/tensor/internal/core"
)
// pinVector builds a rank-1 float array.
func pinVector(t *testing.T, vals ...float64) *core.Array {
t.Helper()
a, err := core.FromFloats(vals, len(vals))
if err != nil {
t.Fatalf("FromFloats(%v): %v", vals, err)
}
return a
}
// pinWatchdog runs f in its own goroutine and fails the test if it
// has not returned within limit. It is what keeps the hang regression a
// failure rather than a stuck test binary.
func pinWatchdog(t *testing.T, limit time.Duration, what string, f func()) {
t.Helper()
done := make(chan struct{})
go func() {
defer close(done)
f()
}()
select {
case <-done:
case <-time.After(limit):
t.Fatalf("%s has not returned after %v: the loop does not terminate", what, limit)
}
}
// TestDiscreteQuantileNaNQuantileDoesNotHang pins the P0: q = NaN passed
// the written-out range guard (`q < 0 || q > 1` is false for NaN), every
// bracket comparison below was then false too, and the doubling loop
// never terminated. Both discrete quantiles must refuse NaN instead.
func TestDiscreteQuantileNaNQuantileDoesNotHang(t *testing.T) {
for _, tc := range []struct {
name string
call func() (float64, error)
}{
{"PoissonQuantile", func() (float64, error) { return PoissonQuantile(math.NaN(), 3) }},
{"BinomialQuantile", func() (float64, error) { return BinomialQuantile(math.NaN(), 0.5, 5) }},
} {
t.Run(tc.name, func(t *testing.T) {
var err error
pinWatchdog(t, 10*time.Second, tc.name+"(NaN)", func() {
_, err = tc.call()
})
if err == nil {
t.Fatalf("%s(NaN) returned a value, want a refusal", tc.name)
}
})
}
}
// bigSqrt2 is sqrt(2) at the working precision.
func bigSqrt2() *big.Float {
const prec = 256
return new(big.Float).SetPrec(prec).Sqrt(new(big.Float).SetPrec(prec).SetInt64(2))
}
// pinIntArray builds a rank-1 int array.
func pinIntArray(t *testing.T, vals ...int64) *core.Array {
t.Helper()
a, err := core.FromInts(vals, len(vals))
if err != nil {
t.Fatalf("FromInts(%v): %v", vals, err)
}
return a
}
// pinFloat32Array builds a rank-1 float32 array.
func pinFloat32Array(t *testing.T, vals ...float32) *core.Array {
t.Helper()
a, err := core.FromFloat32s(vals, len(vals))
if err != nil {
t.Fatalf("FromFloat32s(%v): %v", vals, err)
}
return a
}
// pinSameFloats pins two float slices to the bit for the dtype tests.
func pinSameFloats(t *testing.T, label string, got, want []float64) {
t.Helper()
if len(got) != len(want) {
t.Fatalf("%s has length %d, want %d", label, len(got), len(want))
}
for i := range got {
if got[i] != want[i] {
t.Fatalf("%s[%d] = %.17g, want %.17g", label, i, got[i], want[i])
}
}
}
// TestRollingFamilyAcceptsIntAndFloat32 pins the P0: rollingCheck
// returned RawFloats()[:n], which is a nil slice with capacity 0 for an
// int or a float32 payload, so all four reductions panicked. The int,
// float32 and float routes must agree value for value.
func TestRollingFamilyAcceptsIntAndFloat32(t *testing.T) {
vals := []float64{1, 4, 2, 8, 5, 7}
floats := pinVector(t, vals...)
ints := pinIntArray(t, 1, 4, 2, 8, 5, 7)
f32 := pinFloat32Array(t, 1, 4, 2, 8, 5, 7)
cases := []struct {
name string
call func(a *core.Array) (*core.Array, error)
}{
{"RollingMean", func(a *core.Array) (*core.Array, error) { return RollingMean(a, 3) }},
{"RollingSum", func(a *core.Array) (*core.Array, error) { return RollingSum(a, 2) }},
{"RollingMax", func(a *core.Array) (*core.Array, error) { return RollingMax(a, 3) }},
{"RollingMin", func(a *core.Array) (*core.Array, error) { return RollingMin(a, 2) }},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
want, err := tc.call(floats)
if err != nil {
t.Fatalf("float route: %v", err)
}
wantVals := make([]float64, want.Len())
for i := range wantVals {
wantVals[i] = want.FloatAt(i)
}
for _, dt := range []struct {
name string
a *core.Array
}{{"int", ints}, {"float32", f32}} {
got, err := tc.call(dt.a)
if err != nil {
t.Fatalf("%s route: %v", dt.name, err)
}
gotVals := make([]float64, got.Len())
for i := range gotVals {
gotVals[i] = got.FloatAt(i)
}
pinSameFloats(t, dt.name, gotVals, wantVals)
}
})
}
}
// TestRollingExtremesAllNaNWindow pins the P2: the window fold seeded
// its running best with ±Inf and only replaced it on a strict
// comparison, so an all-NaN window answered -Inf/+Inf where core.Min
// and core.Max answer NaN. A NaN that shares a window with a number
// must still lose.
func TestRollingExtremesAllNaNWindow(t *testing.T) {
allNaN := pinVector(t, math.NaN(), math.NaN())
max, err := RollingMax(allNaN, 2)
if err != nil {
t.Fatalf("RollingMax: %v", err)
}
if !math.IsNaN(max.FloatAt(0)) {
t.Fatalf("RollingMax of an all-NaN window = %v, want NaN as core.Max gives", max.FloatAt(0))
}
min, err := RollingMin(allNaN, 2)
if err != nil {
t.Fatalf("RollingMin: %v", err)
}
if !math.IsNaN(min.FloatAt(0)) {
t.Fatalf("RollingMin of an all-NaN window = %v, want NaN as core.Min gives", min.FloatAt(0))
}
// A NaN never wins against a number.
mixed := pinVector(t, math.NaN(), 5, 2, math.NaN())
wantMax := []float64{5, 5, 2}
wantMin := []float64{5, 2, 2}
gotMax, err := RollingMax(mixed, 2)
if err != nil {
t.Fatalf("RollingMax: %v", err)
}
gotMin, err := RollingMin(mixed, 2)
if err != nil {
t.Fatalf("RollingMin: %v", err)
}
for i := range wantMax {
if gotMax.FloatAt(i) != wantMax[i] {
t.Fatalf("RollingMax[%d] = %v, want %v (NaN never wins)", i, gotMax.FloatAt(i), wantMax[i])
}
if gotMin.FloatAt(i) != wantMin[i] {
t.Fatalf("RollingMin[%d] = %v, want %v (NaN never wins)", i, gotMin.FloatAt(i), wantMin[i])
}
}
}
// TestQuantileRejectsNaNQuantile pins the P0: `q < 0 || q > 1` is
// false for NaN, so the quantile reached int(math.Floor(NaN)) and
// indexed the sorted values with it, panicking instead of refusing.
func TestQuantileRejectsNaNQuantile(t *testing.T) {
a := pinVector(t, 1, 2, 3, 4)
for _, qs := range [][]float64{{math.NaN()}, {0.5, math.NaN()}, {math.Inf(1)}, {math.Inf(-1)}} {
out, err := Quantile(a, qs)
if err == nil {
t.Fatalf("Quantile(%v) = %v, want a refusal", qs, out)
}
}
// The documented domain still works.
out, err := Quantile(a, []float64{0.25, 0.5, 0.75})
if err != nil {
t.Fatalf("Quantile: %v", err)
}
pinSameFloats(t, "Quantile", []float64{out.FloatAt(0), out.FloatAt(1), out.FloatAt(2)},
[]float64{1.75, 2.5, 3.25})
}
// TestHistogram2DRejectsComplexInput pins the P0: every other entry
// point of the package refuses complex input by name, while Histogram2D
// walked its float accessor into a nil int payload and panicked.
func TestHistogram2DRejectsComplexInput(t *testing.T) {
real := pinVector(t, 1, 2, 3)
cx, err := core.FromComplexes([]complex128{1, 2, 3}, 3)
if err != nil {
t.Fatalf("FromComplexes: %v", err)
}
if _, _, _, err := Histogram2D(cx, real, 2, 2); err == nil {
t.Fatal("complex x accepted")
}
if _, _, _, err := Histogram2D(real, cx, 2, 2); err == nil {
t.Fatal("complex y accepted")
}
// The real path is untouched.
counts, _, _, err := Histogram2D(real, real, 2, 2)
if err != nil {
t.Fatalf("Histogram2D: %v", err)
}
if counts.Shape()[0] != 2 || counts.Shape()[1] != 2 {
t.Fatalf("shape %v, want [2 2]", counts.Shape())
}
}
// TestHistogramBinCountsAreBounded pins the P1: neither Histogram nor
// Histogram2D bounded its bin count, so a hostile request either asked
// the allocator for an unbounded buffer or, with a product that wraps,
// produced an empty count payload the first write then indexed. The
// guard must fire before anything is allocated.
func TestHistogramBinCountsAreBounded(t *testing.T) {
a := pinVector(t, 0, 1)
if _, _, err := Histogram(a, maxHistBins+1); err == nil {
t.Fatal("a bin count past the limit was accepted")
}
if _, _, err := Histogram(a, 1<<21); err == nil {
t.Fatal("a 2^21-bin request was accepted")
}
counts, edges, err := Histogram(a, 4)
if err != nil {
t.Fatalf("a modest histogram: %v", err)
}
if counts.Len() != 4 || edges.Len() != 5 {
t.Fatalf("lengths %d and %d, want 4 and 5", counts.Len(), edges.Len())
}
if _, _, _, err := Histogram2D(a, a, maxHistBins+1, 1); err == nil {
t.Fatal("a per-axis bin count past the limit was accepted")
}
// 1025 * 1024 = 1049600 is past the total, each axis alone is not.
if _, _, _, err := Histogram2D(a, a, 1025, 1024); err == nil {
t.Fatal("a bin product past the limit was accepted")
}
// A wide but legal request goes through: the guard refuses what is
// past the limit, not what is merely large.
if _, _, _, err := Histogram2D(a, a, 512, 512); err != nil {
t.Fatalf("a 512 x 512 histogram: %v", err)
}
}
// TestNormalQuantileLowerTail pins the P1: continuousQuantile brackets
// upwards from the seed only, and Phi(0) = 0.5 is the infimum it can
// reach, so every q < 0.5 failed to bracket. The mirrored tail must
// reproduce an independent high-precision inverse of Phi.
func TestNormalQuantileLowerTail(t *testing.T) {
qs := []float64{1e-6, 1e-5, 1e-4, 1e-3, 0.01, 0.025, 0.05, 0.1, 0.25, 0.4, 0.4999}
worst := 0.0
for _, q := range qs {
z, err := NormalQuantile(q)
if err != nil {
t.Fatalf("NormalQuantile(%g): %v", q, err)
}
if z >= 0 {
t.Fatalf("NormalQuantile(%g) = %v, want a negative quantile", q, z)
}
ref := bigNormalQuantile(q)
dev := math.Abs(z - ref)
if dev > worst {
worst = dev
}
if dev > 1e-9 {
t.Fatalf("NormalQuantile(%g) = %.17g, the 256-bit bisection reference is %.17g (off by %.3g)",
q, z, ref, dev)
}
// The symmetry is exact in the implementation and must hold to
// the last bit: the mirrored call returns the negated value.
mirror, err := NormalQuantile(1 - q)
if err != nil {
t.Fatalf("NormalQuantile(%g): %v", 1-q, err)
}
if z+mirror != 0 {
t.Fatalf("NormalQuantile(%g) + NormalQuantile(%g) = %.17g, want exactly 0", q, 1-q, z+mirror)
}
// And the library's own CDF inverts the result.
if dev := math.Abs(NormalCDF(z) - q); dev > 1e-12 {
t.Fatalf("NormalCDF(NormalQuantile(%g)) is off by %.3g", q, dev)
}
}
t.Logf("worst deviation from the 256-bit reference: %.3g", worst)
// The upper half was already correct and must stay so.
upper, err := NormalQuantile(0.975)
if err != nil {
t.Fatalf("NormalQuantile(0.975): %v", err)
}
if math.Abs(upper-1.9599639845400532) > 1e-12 {
t.Fatalf("NormalQuantile(0.975) = %.17g, want 1.9599639845400532", upper)
}
}
// TestNormalQuantileNaNRejected pins the P2: NaN passed the written-out
// range guard and every comparison of the bisection returned false, so
// the seed came back as the answer (v = 1 with a nil error).
func TestNormalQuantileNaNRejected(t *testing.T) {
for _, q := range []float64{math.NaN(), math.Inf(1), math.Inf(-1), -0.5, 1.5, 0, 1} {
v, err := NormalQuantile(q)
if err == nil {
t.Fatalf("NormalQuantile(%v) = %v, want a refusal", q, v)
}
}
if v, err := StudentTQuantile(math.NaN(), 5); err == nil {
t.Fatalf("StudentTQuantile(NaN, 5) = %v, want a refusal", v)
}
}
// TestLogisticRegressionSaturatedLikelihoodFinite pins the P1: the
// convergence exit recomputed the sigmoid without the clamp the loop
// relies on, so one far-out covariate saturated Fitted to exactly 0/1
// and turned LogLikelihood into 0·log(0) = NaN while Converged was
// true. The reported values must be the ones the loop maximised.
func TestLogisticRegressionSaturatedLikelihoodFinite(t *testing.T) {
design, err := core.FromFloats([]float64{
1, 0, 1, 1, 1, 2, 1, 3, 1, 4, 1, 5, 1, 1e3,
}, 7, 2)
if err != nil {
t.Fatalf("FromFloats: %v", err)
}
y := pinVector(t, 0, 1, 0, 1, 1, 0, 1)
res, err := LogisticRegression(design, y)
if err != nil {
t.Fatalf("LogisticRegression: %v", err)
}
if !res.Converged {
t.Fatal("the fit did not converge")
}
manual := 0.0
for i, p := range res.Fitted {
if p <= 0 || p >= 1 {
t.Fatalf("Fitted[%d] = %v, want a probability strictly inside (0, 1)", i, p)
}
yv := y.FloatAt(i)
manual += yv*math.Log(p) + (1-yv)*math.Log(1-p)
}
if math.IsNaN(res.LogLikelihood) || math.IsInf(res.LogLikelihood, 0) {
t.Fatalf("LogLikelihood = %v, want the finite value the loop maximised", res.LogLikelihood)
}
if math.Abs(res.LogLikelihood-manual) > 1e-12 {
t.Fatalf("LogLikelihood = %.17g, recomputing from Fitted gives %.17g",
res.LogLikelihood, manual)
}
// The inference must stay finite as well.
for j := range res.Coefficients {
mustFinite := []float64{res.Coefficients[j], res.StandardErrors[j],
res.ZStatistics[j], res.PValues[j]}
for _, v := range mustFinite {
if math.IsNaN(v) || math.IsInf(v, 0) {
t.Fatalf("coefficient %d has a non-finite statistic: %v", j, mustFinite)
}
}
}
}
// TestWeightedLinearRegressionInterceptStatistics pins the P1: the
// intercept column of the unweighted design stops being constant once
// the sqrt weights are applied, so the delegated fit reported the
// no-intercept conventions (uncentred TSS, DModel = p, an F test
// against the zero model) for a regression the caller described with an
// intercept. The statistics must be the weighted-theory ones, against
// the weighted mean.
func TestWeightedLinearRegressionInterceptStatistics(t *testing.T) {
design, err := core.FromFloats([]float64{1, 0, 1, 1, 1, 2, 1, 3, 1, 4, 1, 5}, 6, 2)
if err != nil {
t.Fatalf("FromFloats: %v", err)
}
yVals := []float64{10, 10.1, 9.9, 10, 10.2, 9.8}
y := pinVector(t, yVals...)
plain, err := LinearRegression(design, y)
if err != nil {
t.Fatalf("LinearRegression: %v", err)
}
// Uniform weights are the ordinary fit, statistic for statistic.
uniform, err := WeightedLinearRegression(design, y, pinVector(t, 1, 1, 1, 1, 1, 1))
if err != nil {
t.Fatalf("WeightedLinearRegression: %v", err)
}
if uniform.DModel != plain.DModel || uniform.RSquared != plain.RSquared ||
uniform.FStatistic != plain.FStatistic || uniform.FPValue != plain.FPValue {
t.Fatalf("uniform weights report DModel=%d R2=%.17g F=%.17g p=%.17g, the ordinary fit DModel=%d R2=%.17g F=%.17g p=%.17g",
uniform.DModel, uniform.RSquared, uniform.FStatistic, uniform.FPValue,
plain.DModel, plain.RSquared, plain.FStatistic, plain.FPValue)
}
for _, w6 := range []float64{1.0000001, 2} {
w := pinVector(t, 1, 1, 1, 1, 1, w6)
res, err := WeightedLinearRegression(design, y, w)
if err != nil {
t.Fatalf("WeightedLinearRegression(w6=%g): %v", w6, err)
}
// The reference from first principles, on the residuals the call
// itself reports: sumW, the weighted mean, the weighted centred
// total and the weighted residual sum.
sumW, sumWY, rss := 0.0, 0.0, 0.0
weights := []float64{1, 1, 1, 1, 1, w6}
for i, wi := range weights {
sumW += wi
sumWY += wi * yVals[i]
rss += wi * res.Residuals[i] * res.Residuals[i]
}
meanW := sumWY / sumW
tss := 0.0
for i, wi := range weights {
d := yVals[i] - meanW
tss += wi * d * d
}
wantR2 := 1 - rss/tss
wantF := (tss - rss) / 1 / (rss / 4)
if res.DModel != 1 {
t.Fatalf("w6=%g: DModel = %d, want 1 (the design carries the intercept)", w6, res.DModel)
}
if math.Abs(res.RSquared-wantR2) > 1e-12 {
t.Fatalf("w6=%g: R2 = %.17g, the weighted-centred reference is %.17g", w6, res.RSquared, wantR2)
}
if math.Abs(res.FStatistic-wantF) > 1e-9*wantF {
t.Fatalf("w6=%g: F = %.17g, the weighted-centred reference is %.17g", w6, res.FStatistic, wantF)
}
// For a single slope F = t^2, so the model p-value is the
// slope's own two-sided tail.
if math.Abs(res.FPValue-res.PValues[1]) > 1e-9 {
t.Fatalf("w6=%g: model p = %.17g, the slope's two-sided tail is %.17g",
w6, res.FPValue, res.PValues[1])
}
}
// The report's exact acceptance values on this data with w6 = 2, the
// weighted-centred values (R2 0.172938829787234, F 0.836401640003216,
// p 0.4121703772248073 from a numerical integration of F(1,4)):
// the defect reported 0.9998404595, 12534.0045019697 and
// 2.54531602909285e-08 instead.
res, err := WeightedLinearRegression(design, y, pinVector(t, 1, 1, 1, 1, 1, 2))
if err != nil {
t.Fatalf("WeightedLinearRegression: %v", err)
}
if math.Abs(res.RSquared-0.172938829787234) > 1e-9 {
t.Fatalf("R2 = %.17g, the weighted-centred value is 0.172938829787234", res.RSquared)
}
if math.Abs(res.FStatistic-0.836401640003216) > 1e-9 {
t.Fatalf("F = %.17g, the weighted-centred value is 0.836401640003216", res.FStatistic)
}
if math.Abs(res.FPValue-0.4121703772248073) > 1e-9 {
t.Fatalf("model p = %.17g, the weighted-centred value is 0.4121703772248073", res.FPValue)
}
if res.DModel != 1 || res.DResidual != 4 {
t.Fatalf("degrees of freedom (%d, %d), want (1, 4)", res.DModel, res.DResidual)
}
// A single weight a hair away from uniform must not move the
// statistics: the flip the defect produced was 0.05 to 0.9998.
hair, err := WeightedLinearRegression(design, y, pinVector(t, 1, 1, 1, 1, 1, 1.0000001))
if err != nil {
t.Fatalf("WeightedLinearRegression: %v", err)
}
if math.Abs(hair.RSquared-uniform.RSquared) > 1e-6 {
t.Fatalf("a 1e-7 weight change moved R2 from %.17g to %.17g", uniform.RSquared, hair.RSquared)
}
if math.Abs(hair.FPValue-uniform.FPValue) > 1e-6 {
t.Fatalf("a 1e-7 weight change moved the model p from %.17g to %.17g",
uniform.FPValue, hair.FPValue)
}
}
// TestBinomialDrawsRejectsNonPositiveCount pins the P2: the only draw
// function in the file without the n < 1 guard returned a nil array
// with a nil error, which a caller checking only the error dereferences.
func TestBinomialDrawsRejectsNonPositiveCount(t *testing.T) {
g := core.NewGenerator(1)
for _, n := range []int{-1, 0} {
out, err := BinomialDraws(g, n, 4, 0.5)
if err == nil {
t.Fatalf("BinomialDraws(n=%d) = %v, want a refusal", n, out)
}
if out != nil {
t.Fatalf("BinomialDraws(n=%d) returned a non-nil array with the refusal", n)
}
}
out, err := BinomialDraws(g, 3, 4, 0.5)
if err != nil {
t.Fatalf("BinomialDraws: %v", err)
}
if out.Len() != 3 {
t.Fatalf("length %d, want 3", out.Len())
}
}
// TestKolmogorovSmirnovRejectsNonFiniteSamples pins an unbounded loop
// found while verifying the package's finiteness policy: the merge walk
// in KolmogorovSmirnovTest advances only past values that compare true
// against the current support point, and a NaN never does, so the walk
// re-reads the same element for ever. A +Inf sample walks through but
// distorts the distance. The sibling tests (MannWhitneyU, ANOVAOneWay)
// refuse a non-finite sample by name and so must this one.
func TestKolmogorovSmirnovRejectsNonFiniteSamples(t *testing.T) {
clean := pinVector(t, 1, 2, 3, 4, 5)
nan := pinVector(t, 1, 2, math.NaN(), 4, 5)
inf := pinVector(t, 1, 2, math.Inf(1), 4, 5)
for _, tc := range []struct {
name string
a, b *core.Array
}{
{"NaN first sample", nan, clean},
{"NaN second sample", clean, nan},
{"+Inf first sample", inf, clean},
{"+Inf second sample", clean, inf},
} {
t.Run(tc.name, func(t *testing.T) {
var err error
pinWatchdog(t, 5*time.Second, "KolmogorovSmirnovTest("+tc.name+")", func() {
_, _, err = KolmogorovSmirnovTest(tc.a, tc.b)
})
if err == nil {
t.Fatal("a non-finite sample was accepted")
}
})
}
// The finite path is untouched: identical samples are distance zero
// with p = 1, and the report's {1,2,3} against {1.5,2.5,3.5} case
// gives d = 1/3 exactly.
d, p, err := KolmogorovSmirnovTest(clean, clean)
if err != nil {
t.Fatalf("KolmogorovSmirnovTest: %v", err)
}
if d != 0 || p != 1 {
t.Fatalf("identical samples give d = %g, p = %g, want 0 and 1", d, p)
}
d, _, err = KolmogorovSmirnovTest(pinVector(t, 1, 2, 3), pinVector(t, 1.5, 2.5, 3.5))
if err != nil {
t.Fatalf("KolmogorovSmirnovTest: %v", err)
}
// The largest gap is the first support point's 1/3, computed from
// integer counts: allow the couple of ulps that division costs.
if math.Abs(d-1.0/3.0) > 1e-15 {
t.Fatalf("d = %.17g, want 1/3", d)
}
}
// TestKernelDensityRejectsNonFiniteInput pins the first half of the
// finiteness gap: KernelDensity never scanned the sample or the
// evaluation points, so a NaN came back as a NaN estimate with a nil
// error and a +Inf silently dropped that sample from the average (the
// estimate was then the one of the remaining n-1 points). The clean
// path is pinned against the hand-written KDE definition.
func TestKernelDensityRejectsNonFiniteInput(t *testing.T) {
sample := pinVector(t, 0, 1, 2, 3)
points := pinVector(t, 0.5, 2.5)
for _, tc := range []struct {
name string
s, p *core.Array
}{
{"NaN sample", pinVector(t, 0, 1, math.NaN(), 3), points},
{"+Inf sample", pinVector(t, 0, 1, math.Inf(1), 3), points},
{"NaN point", sample, pinVector(t, 0.5, math.NaN())},
{"-Inf point", sample, pinVector(t, 0, math.Inf(-1))},
} {
t.Run(tc.name, func(t *testing.T) {
if out, err := KernelDensity(tc.s, 0.5, tc.p); err == nil {
t.Fatalf("a non-finite input was accepted, out = %v", out)
}
})
}
// The definition, written out: 1/(n h sqrt(2 pi)) * sum_k
// exp(-((x - sample[k])/h)^2 / 2) at x = 0.5 and x = 2.5, which are
// mirror images of each other in this sample and so agree exactly.
for _, x := range []float64{0.5, 2.5} {
want := 0.0
for _, s := range []float64{0, 1, 2, 3} {
z := (x - s) / 0.5
want += math.Exp(-0.5 * z * z)
}
want /= 4 * 0.5 * math.Sqrt(2*math.Pi)
out, err := KernelDensity(sample, 0.5, pinVector(t, x))
if err != nil {
t.Fatalf("KernelDensity(%g): %v", x, err)
}
if got := out.FloatAt(0); got != want {
t.Fatalf("KernelDensity at %g = %.17g, the hand-written KDE is %.17g", x, got, want)
}
}
}
// TestMultivariateNormalRejectsNonFiniteInput pins the second half of
// the finiteness gap: the covariance was scanned for non-finite
// entries, the mean and the point were not, so a NaN mean or point came
// back as a NaN log density and drew NaN vectors, all with a nil error.
func TestMultivariateNormalRejectsNonFiniteInput(t *testing.T) {
mean := pinVector(t, 0, 0)
cov, err := core.FromFloats([]float64{1, 0.5, 0.5, 1}, 2, 2)
if err != nil {
t.Fatalf("FromFloats: %v", err)
}
nanCov, err := core.FromFloats([]float64{1, math.NaN(), math.NaN(), 1}, 2, 2)
if err != nil {
t.Fatalf("FromFloats: %v", err)
}
x := pinVector(t, 1, 1)
nanMean := pinVector(t, math.NaN(), 0)
infMean := pinVector(t, math.Inf(1), 0)
nanPoint := pinVector(t, 1, math.NaN())
g := core.NewGenerator(7)
for _, tc := range []struct {
name string
call func() error
}{
{"density NaN mean", func() error { _, err := MultivariateNormalLogDensity(nanMean, cov, x); return err }},
{"density +Inf mean", func() error { _, err := MultivariateNormalLogDensity(infMean, cov, x); return err }},
{"density NaN covariance", func() error { _, err := MultivariateNormalLogDensity(mean, nanCov, x); return err }},
{"density NaN point", func() error { _, err := MultivariateNormalLogDensity(mean, cov, nanPoint); return err }},
{"draws NaN mean", func() error { _, err := MultivariateNormalDraws(g, 2, nanMean, cov); return err }},
{"draws NaN covariance", func() error { _, err := MultivariateNormalDraws(g, 2, mean, nanCov); return err }},
} {
t.Run(tc.name, func(t *testing.T) {
if err := tc.call(); err == nil {
t.Fatal("a non-finite input was accepted")
}
})
}
// The clean path is pinned by hand: with mean 0, cov [[1,.5],[.5,1]]
// and x = (1,1), the log density is -ln(2 pi) - 0.5 ln(0.75) - 2/3.
got, err := MultivariateNormalLogDensity(mean, cov, x)
if err != nil {
t.Fatalf("MultivariateNormalLogDensity: %v", err)
}
want := -math.Log(2*math.Pi) - 0.5*math.Log(0.75) - 2.0/3.0
if math.Abs(got-want) > 1e-12 {
t.Fatalf("log density = %.17g, hand value %.17g", got, want)
}
draws, err := MultivariateNormalDraws(g, 3, mean, cov)
if err != nil {
t.Fatalf("MultivariateNormalDraws: %v", err)
}
if draws.Len() != 6 {
t.Fatalf("draws length %d, want 6", draws.Len())
}
}
// TestMultivariateNormalRejectsAsymmetricCovariance pins the P2: the
// Cholesky factor read only the lower triangle, so an asymmetric matrix
// was silently symmetrised and the reported density described a
// different distribution from the one handed in. A mirror that differs
// beyond a relative tolerance is refused by name; a mirror that differs
// by rounding only is still accepted.
func TestMultivariateNormalRejectsAsymmetricCovariance(t *testing.T) {
mean := pinVector(t, 0, 0)
x := pinVector(t, 1, 1)
asym, err := core.FromFloats([]float64{1, 100, 0.5, 1}, 2, 2)
if err != nil {
t.Fatalf("FromFloats: %v", err)
}
for _, tc := range []struct {
name string
call func() error
}{
{"density", func() error { _, err := MultivariateNormalLogDensity(mean, asym, x); return err }},
{"draws", func() error { _, err := MultivariateNormalDraws(core.NewGenerator(3), 2, mean, asym); return err }},
} {
t.Run(tc.name, func(t *testing.T) {
if err := tc.call(); err == nil {
t.Fatal("the asymmetric covariance was accepted")
}
})
}
// Rounding-level asymmetry is not asymmetry: 0.5 + 1e-15 against 0.5
// is a mirror pair an A*A^T assembly can produce.
sym, err := core.FromFloats([]float64{1, 0.5 + 1e-15, 0.5, 1}, 2, 2)
if err != nil {
t.Fatalf("FromFloats: %v", err)
}
got, err := MultivariateNormalLogDensity(mean, sym, x)
if err != nil {
t.Fatalf("a rounding-level mirror difference was refused: %v", err)
}
want := -math.Log(2*math.Pi) - 0.5*math.Log(0.75) - 2.0/3.0
if math.Abs(got-want) > 1e-12 {
t.Fatalf("log density = %.17g, the symmetric hand value is %.17g", got, want)
}
}
// TestKolmogorovSeriesTruncationRule pins the inert defect: the early
// exit asked whether the current term was 1e-14 times the next, which is
// exp(2(2k+1)l^2) > 1 for every l > 0, so it could never fire and the
// comment described a termination check that was not there. The
// truncation is now the first term below the rounding level; the count
// must be exactly that term, and the summed series must agree with a
// directly summed one to the last bit.
func TestKolmogorovSeriesTruncationRule(t *testing.T) {
term := func(k int, lambda float64) float64 {
return 2 * math.Exp(-2*float64(k)*float64(k)*lambda*lambda)
}
for _, lambda := range []float64{0.2, 0.3, 0.5, 0.75, 1, 1.5, 2, 3, 5} {
n := kolmogorovTermCount(lambda)
if n < 1 || n > maxKolmogorovTerms {
t.Fatalf("lambda = %g: %d terms is outside [1, %d]", lambda, n, maxKolmogorovTerms)
}
if term(n, lambda) >= kolmogorovEps {
t.Fatalf("lambda = %g: term %d is %g, still above the %g rounding level: the series stops too early",
lambda, n, term(n, lambda), kolmogorovEps)
}
if n > 1 && term(n-1, lambda) < kolmogorovEps {
t.Fatalf("lambda = %g: term %d is already below the rounding level: the series adds terms that cannot move the sum",
lambda, n-1)
}
direct := 0.0
for k := 1; k <= 400; k++ {
if k%2 == 0 {
direct -= term(k, lambda)
} else {
direct += term(k, lambda)
}
}
want := min(1, max(0, direct))
if got := kolmogorovTail(lambda); got != want {
t.Fatalf("lambda = %g: tail = %.17g, a directly summed 400-term series gives %.17g", lambda, got, want)
}
}
// The documented short circuit below lambda = 0.2 is untouched.
if got := kolmogorovTail(0.1); got != 1 {
t.Fatalf("kolmogorovTail(0.1) = %g, want 1", got)
}
}
// bootstrapMean is the bootstrap statistic the sweeps below resample with.
func bootstrapMean(a *core.Array) (float64, error) {
return core.Mean(a)
}
// TestNaNParametersRejected pins the class sweep over the package's
// float parameters. Each guard used to be written out (`x < 0 || x > 1`
// and friends), which a NaN walks through because every comparison
// against a NaN is false: the parameter reached the algorithm and the
// call answered a value, a NaN, or nothing at all. The NaN-rejecting
// form `!(x >= 0 && x <= 1)` must turn every one of them into the
// refusal the caller deserves. The watchdog turns a regression to the
// unbounded discrete-quantile loop into a failure rather than a stuck
// test binary.
func TestNaNParametersRejected(t *testing.T) {
g := core.NewGenerator(1)
clean := pinVector(t, 1, 2, 3, 4, 5)
nanSample := pinVector(t, 1, 2, math.NaN(), 4, 5)
cases := []struct {
name string
call func() error
}{
{"GammaLower(NaN shape)", func() error { _, err := GammaLower(math.NaN(), 1); return err }},
{"GammaLower(NaN x)", func() error { _, err := GammaLower(1, math.NaN()); return err }},
{"GammaUpper(NaN shape)", func() error { _, err := GammaUpper(math.NaN(), 1); return err }},
{"GammaUpper(NaN x)", func() error { _, err := GammaUpper(1, math.NaN()); return err }},
{"BetaIncomplete(NaN x)", func() error { _, err := BetaIncomplete(math.NaN(), 2, 2); return err }},
{"BetaIncomplete(NaN a)", func() error { _, err := BetaIncomplete(0.5, math.NaN(), 2); return err }},
{"BetaIncomplete(NaN b)", func() error { _, err := BetaIncomplete(0.5, 2, math.NaN()); return err }},
{"ExponentialCDF(NaN x)", func() error { _, err := ExponentialCDF(math.NaN(), 2); return err }},
{"ExponentialCDF(NaN rate)", func() error { _, err := ExponentialCDF(1, math.NaN()); return err }},
{"GammaCDF(NaN x)", func() error { _, err := GammaCDF(math.NaN(), 2, 3); return err }},
{"GammaCDF(NaN shape)", func() error { _, err := GammaCDF(1, math.NaN(), 3); return err }},
{"GammaCDF(NaN rate)", func() error { _, err := GammaCDF(1, 2, math.NaN()); return err }},
{"ChiSquareCDF(NaN x)", func() error { _, err := ChiSquareCDF(math.NaN(), 2); return err }},
{"StudentTCDF(NaN t)", func() error { _, err := StudentTCDF(math.NaN(), 5); return err }},
{"PoissonCDF(NaN lambda)", func() error { _, err := PoissonCDF(3, math.NaN()); return err }},
{"BinomialCDF(NaN p)", func() error { _, err := BinomialCDF(3, 10, math.NaN()); return err }},
{"ExponentialQuantile(NaN q)", func() error { _, err := ExponentialQuantile(math.NaN(), 2); return err }},
{"ExponentialQuantile(NaN rate)", func() error { _, err := ExponentialQuantile(0.5, math.NaN()); return err }},
{"GammaQuantile(NaN q)", func() error { _, err := GammaQuantile(math.NaN(), 2, 3); return err }},
{"GammaQuantile(NaN shape)", func() error { _, err := GammaQuantile(0.5, math.NaN(), 3); return err }},
{"GammaQuantile(NaN rate)", func() error { _, err := GammaQuantile(0.5, 2, math.NaN()); return err }},
{"ChiSquareQuantile(NaN q)", func() error { _, err := ChiSquareQuantile(math.NaN(), 2); return err }},
{"StudentTQuantile(NaN q)", func() error { _, err := StudentTQuantile(math.NaN(), 5); return err }},
{"PoissonQuantile(NaN q)", func() error { _, err := PoissonQuantile(math.NaN(), 3); return err }},
{"PoissonQuantile(NaN lambda)", func() error { _, err := PoissonQuantile(0.5, math.NaN()); return err }},
{"BinomialQuantile(NaN q)", func() error { _, err := BinomialQuantile(math.NaN(), 0.5, 5); return err }},
{"BinomialQuantile(NaN p)", func() error { _, err := BinomialQuantile(0.5, math.NaN(), 5); return err }},
{"ExponentialDraws(NaN rate)", func() error { _, err := ExponentialDraws(g, 3, math.NaN()); return err }},
{"GammaDraws(NaN shape)", func() error { _, err := GammaDraws(g, 3, math.NaN(), 2); return err }},
{"GammaDraws(NaN rate)", func() error { _, err := GammaDraws(g, 3, 2, math.NaN()); return err }},
{"PoissonDraws(NaN lambda)", func() error { _, err := PoissonDraws(g, 3, math.NaN()); return err }},
{"BinomialDraws(NaN p)", func() error { _, err := BinomialDraws(g, 3, 4, math.NaN()); return err }},
{"ChiSquareGoodnessOfFit(NaN observed)", func() error {
_, _, _, err := ChiSquareGoodnessOfFit(nanSample, clean)
return err
}},
{"ChiSquareGoodnessOfFit(NaN expected)", func() error {
_, _, _, err := ChiSquareGoodnessOfFit(clean, nanSample)
return err
}},
{"BootstrapCI(NaN level)", func() error { _, _, err := BootstrapCI(clean, bootstrapMean, math.NaN(), 20, 1); return err }},
{"TrimmedMean(NaN fraction)", func() error { _, err := TrimmedMean(clean, math.NaN()); return err }},
{"KernelDensity(NaN bandwidth)", func() error { _, err := KernelDensity(clean, math.NaN(), clean); return err }},
{"WelchTTest(NaN sample)", func() error { _, _, _, err := WelchTTest(nanSample, clean); return err }},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
var err error
pinWatchdog(t, 5*time.Second, tc.name, func() { err = tc.call() })
if err == nil {
t.Fatalf("%s answered with no error for a NaN parameter", tc.name)
}
})
}
// No guard overreaches: the legal domain still answers, and the two
// values below are hand-checkable. The incomplete beta is the
// continued fraction's own rounding level, a few ulps at 0.5.
if p, err := BetaIncomplete(0.5, 2, 2); err != nil || math.Abs(p-0.5) > 1e-15 {
t.Fatalf("BetaIncomplete(0.5, 2, 2) = %v, %v, want 0.5 by symmetry", p, err)
}
if p, err := GammaLower(1, 1); err != nil || math.Abs(p-(1-math.Exp(-1))) > 1e-15 {
t.Fatalf("GammaLower(1, 1) = %v, %v, want 1 - e^-1", p, err)
}
if q, err := ExponentialQuantile(0.5, 3); err != nil || math.Abs(q-math.Ln2/3) > 1e-15 {
t.Fatalf("ExponentialQuantile(0.5, 3) = %v, %v, want ln 2 / 3", q, err)
}
if p, err := PoissonQuantile(0.5, 3); err != nil || p != 3 {
t.Fatalf("PoissonQuantile(0.5, 3) = %v, %v, want 3", p, err)
}
}
// TestComplexInputsRejected pins the dtype sweep: every entry point in
// the package refuses a complex array by name, and the ones exercised
// here (the three MVN inputs, BootstrapCI's data, KernelDensity's
// sample and points) walked the float accessor into a nil payload and
// panicked instead.
func TestComplexInputsRejected(t *testing.T) {
cx2, err := core.FromComplexes([]complex128{1 + 1i, 2 - 1i}, 2)
if err != nil {
t.Fatalf("FromComplexes: %v", err)
}
cx5, err := core.FromComplexes([]complex128{1 + 1i, 2, 3, 4, 5}, 5)
if err != nil {
t.Fatalf("FromComplexes: %v", err)
}
cxMat, err := core.FromComplexes([]complex128{1, 0, 0, 1}, 2, 2)
if err != nil {
t.Fatalf("FromComplexes: %v", err)
}
mean := pinVector(t, 0, 0)
x := pinVector(t, 1, 1)
cov, err := core.FromFloats([]float64{1, 0.5, 0.5, 1}, 2, 2)
if err != nil {
t.Fatalf("FromFloats: %v", err)
}
g := core.NewGenerator(3)
for _, tc := range []struct {
name string
call func() error
}{
{"MVN density complex mean", func() error { _, err := MultivariateNormalLogDensity(cx2, cov, x); return err }},
{"MVN density complex covariance", func() error { _, err := MultivariateNormalLogDensity(mean, cxMat, x); return err }},
{"MVN density complex point", func() error { _, err := MultivariateNormalLogDensity(mean, cov, cx2); return err }},
{"MVN draws complex mean", func() error { _, err := MultivariateNormalDraws(g, 2, cx2, cov); return err }},
{"MVN draws complex covariance", func() error { _, err := MultivariateNormalDraws(g, 2, mean, cxMat); return err }},
{"BootstrapCI complex data", func() error { _, _, err := BootstrapCI(cx5, bootstrapMean, 0.95, 20, 1); return err }},
{"KernelDensity complex sample", func() error { _, err := KernelDensity(cx5, 0.5, cx5); return err }},
{"KernelDensity complex points", func() error { _, err := KernelDensity(pinVector(t, 1, 2, 3, 4, 5), 0.5, cx5); return err }},
{"Histogram2D complex sample", func() error { _, _, _, err := Histogram2D(cx5, cx5, 2, 2); return err }},
} {
t.Run(tc.name, func(t *testing.T) {
if err := tc.call(); err == nil {
t.Fatal("a complex input was accepted")
}
})
}
// The real path is untouched: a constant sample has a degenerate
// bootstrap interval at its own value.
lo, hi, err := BootstrapCI(pinVector(t, 5, 5, 5, 5), bootstrapMean, 0.95, 50, 1)
if err != nil {
t.Fatalf("BootstrapCI: %v", err)
}
if lo != 5 || hi != 5 {
t.Fatalf("the constant sample gives [%g, %g], want [5, 5]", lo, hi)
}
}
// TestLinearRegressionConstantResponseStatistics pins the NaN guard a
// degenerate response needs: with no variation in y the fit reproduces
// it exactly, so the explained and residual sums are both zero and the
// F statistic is 0/0. A NaN there would spread through every consumer
// of the result, where the finite zero is the value the tail reads as
// p = 1: there is no evidence of a model.
func TestLinearRegressionConstantResponseStatistics(t *testing.T) {
design, err := core.FromFloats([]float64{1, 0, 1, 1, 1, 2, 1, 3}, 4, 2)
if err != nil {
t.Fatalf("FromFloats: %v", err)
}
y := pinVector(t, 5, 5, 5, 5)
for _, tc := range []struct {
name string
fit func() (*LinearRegressionResult, error)
}{
{"ordinary", func() (*LinearRegressionResult, error) { return LinearRegression(design, y) }},
{"weighted", func() (*LinearRegressionResult, error) {
return WeightedLinearRegression(design, y, pinVector(t, 1, 1, 1, 2))
}},
} {
t.Run(tc.name, func(t *testing.T) {
res, err := tc.fit()
if err != nil {
t.Fatalf("the constant-response fit: %v", err)
}
if math.IsNaN(res.FStatistic) || res.FStatistic != 0 {
t.Fatalf("FStatistic = %v, want the finite 0", res.FStatistic)
}
if res.FPValue != 1 {
t.Fatalf("FPValue = %v, want 1", res.FPValue)
}
if res.DModel != 1 || res.DResidual != 2 {
t.Fatalf("degrees of freedom (%d, %d), want (1, 2)", res.DModel, res.DResidual)
}
})
}
}
// bigPhi returns Phi(x) in 256-bit arithmetic through the Taylor
// series of the error function at x/sqrt(2). Nothing here calls the
// library, so it is an independent reference for the quantile tests.
func bigPhi(x *big.Float) *big.Float {
const prec = 256
one := new(big.Float).SetPrec(prec).SetInt64(1)
y := new(big.Float).SetPrec(prec).Quo(new(big.Float).SetPrec(prec).Set(x), bigSqrt2())
neg := y.Sign() < 0
ay := new(big.Float).SetPrec(prec).Abs(y)
// erf(ay) = (2/sqrt(pi)) * sum_{n>=0} (-1)^n ay^(2n+1) / (n! (2n+1)).
term := new(big.Float).SetPrec(prec).Set(ay) // ay^(2n+1)/n! at n = 0
sum := new(big.Float).SetPrec(prec).Set(ay)
ay2 := new(big.Float).SetPrec(prec).Mul(ay, ay)
for n := 1; ; n++ {
term.Mul(term, ay2)
term.Quo(term, new(big.Float).SetPrec(prec).SetInt64(int64(n)))
term.Neg(term)
contrib := new(big.Float).SetPrec(prec).Quo(term,
new(big.Float).SetPrec(prec).SetInt64(int64(2*n+1)))
sum.Add(sum, contrib)
if contrib.Sign() == 0 || contrib.MantExp(nil) < -400 {
break
}
}
// 2/sqrt(pi).
twoOverSqrtPi := new(big.Float).SetPrec(prec).Quo(
new(big.Float).SetPrec(prec).SetInt64(2),
new(big.Float).SetPrec(prec).Sqrt(new(big.Float).SetPrec(prec).SetFloat64(math.Pi)))
erf := new(big.Float).SetPrec(prec).Mul(sum, twoOverSqrtPi)
if neg {
erf.Neg(erf)
}
// Phi(x) = (1 + erf(x/sqrt(2)))/2.
return new(big.Float).SetPrec(prec).Quo(
new(big.Float).SetPrec(prec).Add(one, erf),
new(big.Float).SetPrec(prec).SetInt64(2))
}
// bigNormalQuantile inverts bigPhi by bisection on [-12, 12], the
// independent reference the NormalQuantile test compares against.
func bigNormalQuantile(q float64) float64 {
const prec = 256
target := new(big.Float).SetPrec(prec).SetFloat64(q)
lo := new(big.Float).SetPrec(prec).SetFloat64(-12)
hi := new(big.Float).SetPrec(prec).SetFloat64(12)
mid := new(big.Float).SetPrec(prec)
for range 220 {
mid.Add(lo, hi)
mid.Quo(mid, new(big.Float).SetPrec(prec).SetInt64(2))
if bigPhi(mid).Cmp(target) < 0 {
lo.Set(mid)
} else {
hi.Set(mid)
}
}
mid.Add(lo, hi)
mid.Quo(mid, new(big.Float).SetPrec(prec).SetInt64(2))
out, _ := mid.Float64()
return out
}