chanevents: add quantile function

Add Quantile, a generic linear-interpolation q-quantile over a slice of
sortable numeric values. The forwarding-ability analyzer needs to
characterise the distribution of historical forwarded amounts and uses a
configurable percentile as the headline statistic; lifting the
computation into its own helper keeps the analyzer focused on forwarding
logic and gives the quantile contract its own table-driven test that
covers the interpolation rule and the empty/out-of-bounds error paths.
This commit is contained in:
bitromortac 2026-05-07 15:28:52 +02:00
parent be671db05f
commit 41ce984244
3 changed files with 218 additions and 1 deletions

55
chanevents/quantile.go Normal file
View file

@ -0,0 +1,55 @@
package chanevents
import (
"errors"
"sort"
"golang.org/x/exp/constraints"
)
// number is the type constraint Quantile accepts: any sortable numeric type.
type number interface {
constraints.Integer | constraints.Float
}
// Quantile computes the q-quantile of a slice of comparable values. This can be
// used to compute the median (q=0.5) or the min (q=0) or max (q=1).
func Quantile[T number](xs []T, q float64) (float64, error) {
if q < 0 || q > 1 {
return 0, errors.New("quantile must be between 0 and 1")
}
if len(xs) == 0 {
return 0, errors.New("cannot compute quantile of empty slice")
}
if len(xs) == 1 {
return float64(xs[0]), nil
}
// Create a copy of the slice to avoid mutating the original.
ys := make([]T, len(xs))
copy(ys, xs)
sort.Slice(ys, func(i, j int) bool {
return ys[i] < ys[j]
})
// Compute fractional index of q-quantile.
if q == 1.0 {
return float64(ys[len(ys)-1]), nil
}
i := q * float64(len(ys)-1)
// Interpolate between the two consecutive values, depending on the
// fractional index position in between.
lowerIdx := int(i)
upperIdx := lowerIdx + 1
lowerVal := float64(ys[lowerIdx])
upperVal := float64(ys[upperIdx])
indexDiff := i - float64(lowerIdx)
return lowerVal + (upperVal-lowerVal)*indexDiff, nil
}

162
chanevents/quantile_test.go Normal file
View file

@ -0,0 +1,162 @@
package chanevents
import (
"testing"
"github.com/stretchr/testify/require"
)
// TestQuantile pins the interpolation contract and the error paths Quantile
// surfaces to callers.
func TestQuantile(t *testing.T) {
t.Parallel()
tests := []struct {
name string
q float64
xs []float64
want float64
expectErr bool
}{
{
name: "empty slice",
xs: []float64{},
expectErr: true,
},
{
name: "single value",
xs: []float64{
1,
},
want: 1.0,
},
{
name: "single value median",
xs: []float64{
1,
},
q: 0.5,
want: 1.0,
},
{
name: "quantile out of bound below",
xs: []float64{},
q: -0.1,
expectErr: true,
},
{
name: "quantile out of bound above",
xs: []float64{},
q: 1.1,
expectErr: true,
},
{
name: "median odd values",
q: 0.5,
xs: []float64{
1,
2,
3,
4,
5,
},
want: 3.0,
},
{
name: "median even values",
q: 0.5,
xs: []float64{
1,
2,
3,
4,
},
want: 2.5,
},
{
name: "median unsorted",
q: 0.5,
xs: []float64{
1,
3,
2,
4,
},
want: 2.5,
},
{
name: "0 percentile",
q: 0,
xs: []float64{
1,
2,
3,
4,
5,
},
want: 1.0,
},
{
name: "25 percentile",
q: 0.25,
xs: []float64{
1,
2,
3,
4,
5,
},
want: 2.0,
},
{
name: "75 percentile",
q: 0.75,
xs: []float64{
1,
2,
3,
4,
5,
},
want: 4.0,
},
{
name: "0.875 percentile",
q: 0.875,
xs: []float64{
1,
2,
3,
4,
5,
},
want: 4.5,
},
{
name: "100 percentile",
q: 1.0,
xs: []float64{
1,
2,
3,
4,
5,
},
want: 5.0,
},
}
for _, tc := range tests {
t.Run(tc.name, func(tt *testing.T) {
tt.Parallel()
got, err := Quantile(tc.xs, tc.q)
if tc.expectErr {
require.Error(tt, err)
return
}
require.InDelta(tt, tc.want, got, 1e-6)
})
}
}

2
go.mod
View file

@ -20,6 +20,7 @@ require (
github.com/shopspring/decimal v1.2.0
github.com/stretchr/testify v1.10.0
github.com/urfave/cli v1.22.14
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b
google.golang.org/grpc v1.65.0
google.golang.org/protobuf v1.34.2
gopkg.in/macaroon-bakery.v2 v2.0.1
@ -163,7 +164,6 @@ require (
go.uber.org/multierr v1.6.0 // indirect
go.uber.org/zap v1.17.0 // indirect
golang.org/x/crypto v0.39.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/net v0.41.0 // indirect
golang.org/x/sync v0.15.0 // indirect
golang.org/x/sys v0.34.0 // indirect