From 41ce9842440c9f30a6518005b4bb991066e20665 Mon Sep 17 00:00:00 2001 From: bitromortac Date: Thu, 7 May 2026 15:28:52 +0200 Subject: [PATCH] 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. --- chanevents/quantile.go | 55 ++++++++++++ chanevents/quantile_test.go | 162 ++++++++++++++++++++++++++++++++++++ go.mod | 2 +- 3 files changed, 218 insertions(+), 1 deletion(-) create mode 100644 chanevents/quantile.go create mode 100644 chanevents/quantile_test.go diff --git a/chanevents/quantile.go b/chanevents/quantile.go new file mode 100644 index 0000000..d0562eb --- /dev/null +++ b/chanevents/quantile.go @@ -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 +} diff --git a/chanevents/quantile_test.go b/chanevents/quantile_test.go new file mode 100644 index 0000000..c5c61f8 --- /dev/null +++ b/chanevents/quantile_test.go @@ -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) + }) + } +} diff --git a/go.mod b/go.mod index f0459a0..4f03fea 100644 --- a/go.mod +++ b/go.mod @@ -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