Files
minstrel/internal/api/metrics_significance.go
T
bvandeusenandClaude Opus 5.5 08a2c06b85 docs: api comments say what the endpoints do, not how they came to be (#6277)
Rewrites the internal/api comment blocks to rule 32: task, milestone and
rule ids gone, the stories behind them restated as the constraint they
left, stale references corrected (the stream secret is wired from config,
the timezone handler already refreshes the scheduler, the Android app is
the native client, the design docs cited are not in the repo). Also
rewraps comment lines over 80 columns left by the earlier batches.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:23:58 -04:00

111 lines
4.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
package api
import "math"
// Uncertainty on the deltas the recommendation-metrics card shows.
//
// recMetricsLowVolume = 20 answers "is this worth DISPLAYING" — below that a
// skip rate is anecdote — but twenty plays is nowhere near enough to ACT on.
// Detecting the ~13pp differences that actually matter needs roughly 133
// plays per arm for 80% power at α=0.05, and two arms of 60-70 plays each
// can look like a bold difference while sitting at p ≈ 0.06.
//
// So each delta carries its margin of error, and a flag when the delta is
// smaller than it — not distinguishable from zero. Computed here,
// server-side, so both clients agree rather than each re-deriving it. "Too
// thin to show" and "too thin to act on" are different questions and get
// different answers.
// deltaZ is the two-sided 95% normal critical value. Normal rather than
// Student's t: at the sample sizes where a delta is worth acting on (n in the
// hundreds) the difference is immaterial, and a household dashboard does not
// need a t-table.
const deltaZ = 1.96
// metricDelta is a difference from the baseline, with its uncertainty.
//
// Both figures are in PERCENTAGE POINTS, matching how the card reads them out —
// a skip rate of 0.153 against a baseline of 0.270 is "-11.7", not "-0.117".
type metricDelta struct {
// DeltaPP is surface minus baseline. Negative skip is better; negative
// completion is worse. The client owns that colouring.
DeltaPP float64 `json:"delta_pp"`
// MarginPP is the 95% margin of error on DeltaPP. Read the delta as
// DeltaPP ± MarginPP.
MarginPP float64 `json:"margin_pp"`
// Distinguishable reports |DeltaPP| >= MarginPP: the interval excludes
// zero, so the difference is worth reading as a difference. When false the
// number may be pure noise no matter how large it looks.
Distinguishable bool `json:"distinguishable"`
}
// proportionDelta compares two rates (skips/plays) as a two-proportion
// difference. Returns nil when either sample is empty, or when either rate is
// degenerate (0 or 1) — a rate with no observed variation has an SE of 0 on its
// side, which would report a spuriously narrow margin rather than an honest
// one.
func proportionDelta(rate1 float64, n1 int64, rate2 float64, n2 int64) *metricDelta {
if n1 <= 0 || n2 <= 0 {
return nil
}
v1 := rate1 * (1 - rate1) / float64(n1)
v2 := rate2 * (1 - rate2) / float64(n2)
se := math.Sqrt(v1 + v2)
if se <= 0 {
// Both rates are 0 or both are 1. The delta is exactly zero and the
// margin is meaningless; reporting nothing is more honest than
// reporting certainty.
return nil
}
return newDelta((rate1-rate2)*100, deltaZ*se*100)
}
// meanDelta compares two means (average completion ratio) using Welch's
// standard error, which does not assume equal variances between the two groups.
//
// Note the margins here are wider than intuition suggests, and that is correct:
// completion is strongly bimodal — a play is either abandoned early (≈0.05) or
// finished (≈1.0), with little in between — so its standard deviation is large
// (~0.4) even though the mean looks stable.
func meanDelta(mean1 float64, variance1 float64, n1 int64, mean2 float64, variance2 float64, n2 int64) *metricDelta {
// Two observations minimum per side: a sample variance needs n-1 > 0.
if n1 < 2 || n2 < 2 {
return nil
}
se := math.Sqrt(variance1/float64(n1) + variance2/float64(n2))
if se <= 0 || math.IsNaN(se) || math.IsInf(se, 0) {
return nil
}
return newDelta((mean1-mean2)*100, deltaZ*se*100)
}
func newDelta(deltaPP, marginPP float64) *metricDelta {
return &metricDelta{
DeltaPP: deltaPP,
MarginPP: marginPP,
// >= rather than >: a delta exactly equal to its margin sits on the
// boundary, and calling the boundary "distinguishable" is the
// conventional reading of a 95% interval that just excludes zero.
Distinguishable: math.Abs(deltaPP) >= marginPP,
}
}
// sampleVariance recovers the sample variance from the aggregates the SQL
// returns. sum is mean×n rather than a selected column, which keeps the query
// to one extra expression.
//
// The subtraction can go very slightly negative through floating-point
// cancellation when every observation is identical, so the result is clamped —
// a negative variance would produce NaN downstream.
func sampleVariance(sum, sqSum float64, n int64) float64 {
if n < 2 {
return 0
}
nf := float64(n)
v := (sqSum - (sum * sum / nf)) / (nf - 1)
if v < 0 {
return 0
}
return v
}