The metrics card had one volume threshold doing two jobs. recMetricsLowVolume = 20 is a DISPLAY floor — below that a skip rate is anecdote — but the card then presented deltas as though it were also a DECISION floor. Those differ by an order of magnitude: detecting the ~13pp differences that matter needs ~133 plays per arm for 80% power at a=0.05. So Discover's taste-matched (59 plays) and random-unheard (70) both rendered as full-confidence rows with a bold delta beside them, and that comparison sits at p ~ 0.06. The card said "signal"; the arithmetic said "maybe". It produced a recommendation the data didn't support, and any reader with the same numbers would have made the same call. Deltas now carry a 95% margin of error and a `distinguishable` flag, computed server-side so both clients read the same arithmetic instead of each re-deriving it. Skip rate is a two-proportion difference; completion is Welch, which needs a variance — hence completion_sqsum in the query. It is the sum of squares rather than stddev_samp on purpose: raw source rows are merged into surface families in Go, and sums of squares combine across groups exactly whereas standard deviations cannot. recMetricsLowVolume is untouched. "Too thin to show" and "too thin to act on" are different questions. Web renders an indistinguishable delta as dimmed and prefixed "≈", with the range on hover and a legend explaining the glyph. Colour is withheld unless the delta clears its margin — colouring noise red is what made the old card misleading. Breakdown rows go through the same path; those are the thinnest samples on screen and where the old card misled most. Also fixes the admin trends view, which had the same problem worse: its "Latest skip"/"Latest completion" columns are one WEEK while the adjacent Plays column is the whole window. I misread exactly that and briefly concluded Deep cuts was the worst surface, from ~17 plays in a single week — over 180 days it is one of the best. Headers now name their period and the skip cell carries that week's play count. #2524: resolveArtist now recognises a duplicate-MBID unique violation as the expected condition it is, matching resolveAlbum. Two rows mapping to one MusicBrainz artist is a merge candidate, not a fault; without the branch it logged a generic warning plus a Postgres ERROR line on every scan, which teaches an operator to ignore database errors.
This commit is contained in:
@@ -39,6 +39,14 @@ type surfaceMetric struct {
|
||||
SkipRate float64 `json:"skip_rate"` // skips / plays, [0,1]
|
||||
AvgCompletion float64 `json:"avg_completion"` // mean completion ratio, [0,1]
|
||||
LowConfidence bool `json:"low_confidence"` // plays < recMetricsLowVolume
|
||||
// SkipDelta / CompletionDelta are this row's difference from the manual
|
||||
// baseline WITH its margin of error (#2495). nil on the baseline row
|
||||
// itself, and whenever the samples are too thin for a margin to mean
|
||||
// anything. Computed server-side so both clients read the same arithmetic
|
||||
// instead of each re-deriving it — and so `low_confidence` is no longer
|
||||
// mistaken for a decision threshold, which it never was.
|
||||
SkipDelta *metricDelta `json:"skip_delta,omitempty"`
|
||||
CompletionDelta *metricDelta `json:"completion_delta,omitempty"`
|
||||
// Breakdown splits the family into the pick-kind populations its
|
||||
// builder stamped (#1249, generalized #1270): For You's taste/fresh,
|
||||
// Discover's buckets, tier1-3 for tiered mixes — plus earlier plays
|
||||
@@ -119,6 +127,10 @@ type familyAccum struct {
|
||||
// completionSum is avg*count re-expanded, so merging N raw rows
|
||||
// reduces to a single weighted division at the end.
|
||||
completionSum float64
|
||||
// completionSqSum is the sum of squared completion ratios, which is what
|
||||
// makes the variance mergeable across raw source rows (#2495). Standard
|
||||
// deviations cannot be combined; sums of squares add exactly.
|
||||
completionSqSum float64
|
||||
}
|
||||
|
||||
func (a *familyAccum) add(row dbq.RecommendationSourceMetricsForUserRow) {
|
||||
@@ -126,6 +138,7 @@ func (a *familyAccum) add(row dbq.RecommendationSourceMetricsForUserRow) {
|
||||
a.skips += row.Skips
|
||||
a.completionN += row.CompletionN
|
||||
a.completionSum += row.AvgCompletion * float64(row.CompletionN)
|
||||
a.completionSqSum += row.CompletionSqsum
|
||||
}
|
||||
|
||||
func (a *familyAccum) metric() surfaceMetric {
|
||||
@@ -145,6 +158,33 @@ func (a *familyAccum) metric() surfaceMetric {
|
||||
return m
|
||||
}
|
||||
|
||||
// completionVariance is the sample variance of this family's completion ratios.
|
||||
func (a *familyAccum) completionVariance() float64 {
|
||||
return sampleVariance(a.completionSum, a.completionSqSum, a.completionN)
|
||||
}
|
||||
|
||||
// applyDeltas attaches baseline-relative deltas + margins to a metric.
|
||||
// Split out so every row — parent surfaces and breakdown rows alike — goes
|
||||
// through the identical arithmetic; a breakdown arm is exactly where the old
|
||||
// card was most misleading, because those are the thinnest samples on screen.
|
||||
func applyDeltas(m *surfaceMetric, acc *familyAccum, baseline *familyAccum) {
|
||||
if baseline == nil || baseline.plays == 0 {
|
||||
return
|
||||
}
|
||||
m.SkipDelta = proportionDelta(
|
||||
m.SkipRate, acc.plays,
|
||||
float64(baseline.skips)/float64(baseline.plays), baseline.plays,
|
||||
)
|
||||
baseMean := 0.0
|
||||
if baseline.completionN > 0 {
|
||||
baseMean = baseline.completionSum / float64(baseline.completionN)
|
||||
}
|
||||
m.CompletionDelta = meanDelta(
|
||||
m.AvgCompletion, acc.completionVariance(), acc.completionN,
|
||||
baseMean, baseline.completionVariance(), baseline.completionN,
|
||||
)
|
||||
}
|
||||
|
||||
// handleGetRecommendationMetrics implements GET /api/me/recommendation-metrics.
|
||||
// Bucketed per-surface-family outcomes for the caller over the last `days`
|
||||
// (default 30, capped at 365), grouped by surface intent and anchored by the
|
||||
@@ -214,7 +254,7 @@ func pickKindFamily(parent recFamily, kind string) recFamily {
|
||||
// Breakdown rows. Attached only when at least one attributed play
|
||||
// exists — an all-unattributed breakdown would just repeat the parent
|
||||
// row, and families that never stamp (radio, direct plays) stay flat.
|
||||
func pickKindBreakdown(picks map[string]*familyAccum) []surfaceMetric {
|
||||
func pickKindBreakdown(picks map[string]*familyAccum, baseline *familyAccum) []surfaceMetric {
|
||||
attributed := int64(0)
|
||||
for kind, acc := range picks {
|
||||
if kind != "" {
|
||||
@@ -227,7 +267,9 @@ func pickKindBreakdown(picks map[string]*familyAccum) []surfaceMetric {
|
||||
out := make([]surfaceMetric, 0, len(picks))
|
||||
for _, kind := range pickKindOrder {
|
||||
if acc, ok := picks[kind]; ok && acc.plays > 0 {
|
||||
out = append(out, acc.metric())
|
||||
m := acc.metric()
|
||||
applyDeltas(&m, acc, baseline)
|
||||
out = append(out, m)
|
||||
}
|
||||
}
|
||||
return out
|
||||
@@ -287,7 +329,8 @@ func bucketMetricsResponse(
|
||||
for _, acc := range families {
|
||||
if acc.fam.intent == g.intent {
|
||||
m := acc.metric()
|
||||
m.Breakdown = pickKindBreakdown(picks[acc.fam.key])
|
||||
applyDeltas(&m, acc, baseline)
|
||||
m.Breakdown = pickKindBreakdown(picks[acc.fam.key], baseline)
|
||||
group.Surfaces = append(group.Surfaces, m)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user