package stats import ( "math" "math/rand/v2" "sort" "testing" ) // A histogram has to answer the same questions a sort did, closely enough that no verdict changes. The bound // is the bin width, so the test is against a real sort of the same data. func TestHistogramMatchesASort(t *testing.T) { r := rand.New(rand.NewPCG(7, 11)) vals := make([]float64, 200000) h := NewHistogram(0, 90, 2048) for i := range vals { // A slope-like distribution: mostly gentle, with a tail. v := math.Abs(r.NormFloat64()) * 7 if v > 89.9 { v = 89.9 } vals[i] = v h.Add(v) } sort.Float64s(vals) q := func(p float64) float64 { return vals[int(p*float64(len(vals)-1))] } width := 90.0 / 2048 for _, p := range []float64{0.1, 0.25, 0.5, 0.75, 0.9, 0.99} { got, want := h.Quantile(p), q(p) if math.Abs(got-want) > width { t.Errorf("quantile %.2f: histogram %.4f, sort %.4f, wider than one %.4f bin", p, got, want, width) } } for _, x := range []float64{1, 5, 15, 30, 50} { want := float64(sort.SearchFloat64s(vals, x)) / float64(len(vals)) if got := h.FracBelow(x); math.Abs(got-want) > 0.002 { t.Errorf("fraction below %.0f: histogram %.4f, sort %.4f", x, got, want) } } // The mean and the extremes are carried exactly, not read off bins. var sum float64 for _, v := range vals { sum += v } if math.Abs(h.Mean()-sum/float64(len(vals))) > 1e-9 { t.Errorf("mean %v against %v", h.Mean(), sum/float64(len(vals))) } if h.MinV != vals[0] || h.MaxV != vals[len(vals)-1] { t.Errorf("extremes %v..%v against %v..%v", h.MinV, h.MaxV, vals[0], vals[len(vals)-1]) } } // The property the whole per-region design rests on: summing two regions' bins and taking the quantile of the // sum is exactly the quantile of the two together. A median of medians would not be, which is why the // histogram is here and not a smaller summary. func TestMergingIsExactlyPooling(t *testing.T) { r := rand.New(rand.NewPCG(3, 5)) a, b, both := NewHistogram(0, 90, 512), NewHistogram(0, 90, 512), NewHistogram(0, 90, 512) for i := 0; i < 30000; i++ { v := r.Float64() * 40 a.Add(v) both.Add(v) } for i := 0; i < 70000; i++ { // A different distribution, so a mean of the two would not do. v := 50 + r.Float64()*30 b.Add(v) both.Add(v) } a.Merge(b) if a.Count != both.Count { t.Fatalf("merged count %d against %d", a.Count, both.Count) } for i := range a.Bins { if a.Bins[i] != both.Bins[i] { t.Fatalf("bin %d: merged %d against %d", i, a.Bins[i], both.Bins[i]) } } for _, p := range []float64{0.1, 0.5, 0.9} { if got, want := a.Quantile(p), both.Quantile(p); got != want { t.Errorf("quantile %.1f: merged %v, together %v", p, got, want) } } // The extremes pool exactly; the mean is a float sum and so is associativity-bound, which is a // 1e-16 effect and not a property worth asserting to the bit. if a.MinV != both.MinV || a.MaxV != both.MaxV { t.Errorf("the extremes did not pool: %v..%v against %v..%v", a.MinV, a.MaxV, both.MinV, both.MaxV) } if rel := math.Abs(a.Mean()-both.Mean()) / both.Mean(); rel > 1e-12 { t.Errorf("the mean did not pool: %v against %v", a.Mean(), both.Mean()) } } // Out of range is counted, not clamped: a range set wrong has to be visible rather than piling up on an end // bin and quietly moving every quantile. func TestOutOfRangeIsCountedRatherThanClamped(t *testing.T) { h := NewHistogram(0, 10, 10) for _, v := range []float64{-5, -1, 3, 3, 3, 12, 20} { h.Add(v) } if h.Under != 2 || h.Over != 2 { t.Fatalf("under %d over %d, want 2 and 2", h.Under, h.Over) } if h.Count != 7 { t.Fatalf("count %d", h.Count) } if h.MinV != -5 || h.MaxV != 20 { t.Errorf("extremes %v..%v", h.MinV, h.MaxV) } // Three of seven are below 4, plus the two under the bottom: five sevenths. if got := h.FracBelow(4); math.Abs(got-5.0/7) > 1e-9 { t.Errorf("FracBelow(4) = %v, want %v", got, 5.0/7) } // An empty histogram answers zero rather than dividing by nothing. e := NewHistogram(0, 1, 4) if e.Quantile(0.5) != 0 || e.Mean() != 0 || e.FracBelow(0.5) != 0 { t.Error("an empty histogram should answer zero everywhere") } } // Merging refuses a mismatch rather than inventing an answer, because every real merge here is between // histograms one constructor made. func TestMergeRefusesADifferentShape(t *testing.T) { a := NewHistogram(0, 10, 10) a.Add(5) for _, b := range []*Histogram{NewHistogram(0, 10, 20), NewHistogram(0, 20, 10), nil} { if b != nil { b.Add(5) } a.Merge(b) } if a.Count != 1 { t.Errorf("a mismatched merge changed the histogram: count %d", a.Count) } }