mirror of
https://github.com/lightninglabs/faraday.git
synced 2026-08-13 12:33:35 +02:00
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:
parent
be671db05f
commit
41ce984244
3 changed files with 218 additions and 1 deletions
55
chanevents/quantile.go
Normal file
55
chanevents/quantile.go
Normal 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
162
chanevents/quantile_test.go
Normal 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
2
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
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue