feat(metrics): bucketed surface families + manual-plays baseline (#1248, milestone 127)
The recommendation metrics table was observable but not actionable: raw source strings (album:<uuid> one-offs) drowned the stable surfaces, and manual plays were excluded so skip rates had no control group. - SQL: include NULL-source rows (the baseline) and carry completion_n so family merges can weight avg_completion correctly. - Handler buckets raw sources into stable families (radio:<uuid> → Radio, album:/artist: → direct plays, etc.) grouped by surface intent: go-to / discovery / direct — each band judged against its job, since discovery mixes are expected to skip hotter. Families under 20 plays are flagged low-confidence, not hidden. - Settings card renders the baseline row and per-surface deltas in percentage points vs baseline (worse-than-baseline deltas in danger color), intent hint copy per group, low-data rows dimmed. - Pure-unit test for the bucketing/merge; DB test updated to the new contract (baseline included, radio:<uuid> collapse). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
This commit is contained in:
@@ -2,7 +2,9 @@ package api
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/apierror"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
@@ -11,28 +13,135 @@ import (
|
||||
const (
|
||||
recMetricsDefaultDays = 30
|
||||
recMetricsMaxDays = 365
|
||||
|
||||
// recMetricsLowVolume marks a family as low-confidence rather than
|
||||
// hiding it: with fewer plays than this a skip rate is anecdote, not
|
||||
// signal, but silently dropping the row would misread as "surface
|
||||
// unused". The web renders low-confidence rows dimmed.
|
||||
recMetricsLowVolume = 20
|
||||
)
|
||||
|
||||
// recommendationMetric is one recommendation surface's outcomes.
|
||||
type recommendationMetric struct {
|
||||
Source string `json:"source"` // 'for_you' | 'discover' | mixes
|
||||
Plays int64 `json:"plays"` // plays launched from this surface
|
||||
// Surface intents (milestone #127): each family is judged against its
|
||||
// job, not one global bar — discovery mixes are EXPECTED to run higher
|
||||
// skip rates than the go-to surfaces.
|
||||
const (
|
||||
intentGoTo = "go_to"
|
||||
intentDiscovery = "discovery"
|
||||
intentDirect = "direct"
|
||||
)
|
||||
|
||||
// surfaceMetric is one bucketed surface family's outcomes.
|
||||
type surfaceMetric struct {
|
||||
Key string `json:"key"` // stable family key ("for_you", "radio", …)
|
||||
Label string `json:"label"` // display label
|
||||
Plays int64 `json:"plays"` // plays launched from this family
|
||||
Skips int64 `json:"skips"` // of those, marked skipped
|
||||
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
|
||||
}
|
||||
|
||||
// surfaceGroup is one intent band of surface families.
|
||||
type surfaceGroup struct {
|
||||
Intent string `json:"intent"` // go_to | discovery | direct
|
||||
Label string `json:"label"`
|
||||
Surfaces []surfaceMetric `json:"surfaces"`
|
||||
}
|
||||
|
||||
type recommendationMetricsResp struct {
|
||||
WindowDays int `json:"window_days"`
|
||||
Sources []recommendationMetric `json:"sources"`
|
||||
WindowDays int `json:"window_days"`
|
||||
// Baseline is the control group: plays the user picked manually
|
||||
// (source IS NULL). Surfaces are judged as deltas against it; nil
|
||||
// when the window holds no manual plays.
|
||||
Baseline *surfaceMetric `json:"baseline"`
|
||||
Groups []surfaceGroup `json:"groups"`
|
||||
}
|
||||
|
||||
// recFamily is the bucketing target for a raw play_events.source value.
|
||||
type recFamily struct {
|
||||
key string
|
||||
label string
|
||||
intent string
|
||||
}
|
||||
|
||||
// bucketRecSource maps a raw client-stamped source string to its stable
|
||||
// family. One-off sources (album:<uuid>, radio:<uuid>) collapse into
|
||||
// their family so the table stays readable at any library size.
|
||||
func bucketRecSource(src string) recFamily {
|
||||
switch {
|
||||
case src == "for_you":
|
||||
return recFamily{"for_you", "For You", intentGoTo}
|
||||
case src == "songs_like_artist":
|
||||
return recFamily{"songs_like_artist", "Songs like…", intentGoTo}
|
||||
case src == "radio" || strings.HasPrefix(src, "radio:"):
|
||||
return recFamily{"radio", "Radio", intentGoTo}
|
||||
case src == "discover":
|
||||
return recFamily{"discover", "Discover", intentDiscovery}
|
||||
case src == "deep_cuts":
|
||||
return recFamily{"deep_cuts", "Deep cuts", intentDiscovery}
|
||||
case src == "rediscover":
|
||||
return recFamily{"rediscover", "Rediscover", intentDiscovery}
|
||||
case src == "new_for_you":
|
||||
return recFamily{"new_for_you", "New for you", intentDiscovery}
|
||||
case src == "on_this_day":
|
||||
return recFamily{"on_this_day", "On this day", intentDiscovery}
|
||||
case src == "first_listens":
|
||||
return recFamily{"first_listens", "First listens", intentDiscovery}
|
||||
case strings.HasPrefix(src, "album:"):
|
||||
return recFamily{"direct_album", "Album plays", intentDirect}
|
||||
case strings.HasPrefix(src, "artist:"):
|
||||
return recFamily{"direct_artist", "Artist plays", intentDirect}
|
||||
case strings.HasPrefix(src, "offline:"):
|
||||
return recFamily{"offline", "Offline pools", intentDirect}
|
||||
case strings.HasPrefix(src, "home:"):
|
||||
return recFamily{"home", "Home sections", intentDirect}
|
||||
case src == "history":
|
||||
return recFamily{"history", "History", intentDirect}
|
||||
default:
|
||||
return recFamily{"other", "Other", intentDirect}
|
||||
}
|
||||
}
|
||||
|
||||
// familyAccum merges raw source rows into one family, carrying the
|
||||
// completion sample count so the merged average stays play-weighted.
|
||||
type familyAccum struct {
|
||||
fam recFamily
|
||||
plays int64
|
||||
skips int64
|
||||
completionN int64
|
||||
// completionSum is avg*count re-expanded, so merging N raw rows
|
||||
// reduces to a single weighted division at the end.
|
||||
completionSum float64
|
||||
}
|
||||
|
||||
func (a *familyAccum) add(row dbq.RecommendationSourceMetricsForUserRow) {
|
||||
a.plays += row.Plays
|
||||
a.skips += row.Skips
|
||||
a.completionN += row.CompletionN
|
||||
a.completionSum += row.AvgCompletion * float64(row.CompletionN)
|
||||
}
|
||||
|
||||
func (a *familyAccum) metric() surfaceMetric {
|
||||
m := surfaceMetric{
|
||||
Key: a.fam.key,
|
||||
Label: a.fam.label,
|
||||
Plays: a.plays,
|
||||
Skips: a.skips,
|
||||
LowConfidence: a.plays < recMetricsLowVolume,
|
||||
}
|
||||
if a.plays > 0 {
|
||||
m.SkipRate = float64(a.skips) / float64(a.plays)
|
||||
}
|
||||
if a.completionN > 0 {
|
||||
m.AvgCompletion = a.completionSum / float64(a.completionN)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// handleGetRecommendationMetrics implements GET /api/me/recommendation-metrics.
|
||||
// Per-source play outcomes (plays / skips / skip-rate / avg-completion) for the
|
||||
// caller over the last `days` (default 30, capped at 365), so the operator can
|
||||
// see which recommendation surfaces are landing and tune the taste weights.
|
||||
// Only plays tagged with a system-playlist source count; library/radio plays
|
||||
// (no source) are excluded.
|
||||
// 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
|
||||
// manual-plays baseline so the numbers are judgeable, not just observable.
|
||||
func (h *handlers) handleGetRecommendationMetrics(w http.ResponseWriter, r *http.Request) {
|
||||
caller, ok := requireUser(w, r)
|
||||
if !ok {
|
||||
@@ -51,28 +160,59 @@ func (h *handlers) handleGetRecommendationMetrics(w http.ResponseWriter, r *http
|
||||
return
|
||||
}
|
||||
|
||||
out := recommendationMetricsResp{
|
||||
WindowDays: days,
|
||||
Sources: make([]recommendationMetric, 0, len(rows)),
|
||||
}
|
||||
writeJSON(w, http.StatusOK, bucketMetricsResponse(days, rows))
|
||||
}
|
||||
|
||||
// bucketMetricsResponse folds the raw per-source rows into the grouped,
|
||||
// baseline-anchored response shape. Split from the handler for pure-unit
|
||||
// testability.
|
||||
func bucketMetricsResponse(
|
||||
days int, rows []dbq.RecommendationSourceMetricsForUserRow,
|
||||
) recommendationMetricsResp {
|
||||
baseline := &familyAccum{fam: recFamily{"manual", "Manual library plays", ""}}
|
||||
families := map[string]*familyAccum{}
|
||||
for _, row := range rows {
|
||||
source := ""
|
||||
if row.Source != nil {
|
||||
source = *row.Source
|
||||
if row.Source == nil || *row.Source == "" {
|
||||
baseline.add(row)
|
||||
continue
|
||||
}
|
||||
var skipRate float64
|
||||
if row.Plays > 0 {
|
||||
skipRate = float64(row.Skips) / float64(row.Plays)
|
||||
fam := bucketRecSource(*row.Source)
|
||||
acc, exists := families[fam.key]
|
||||
if !exists {
|
||||
acc = &familyAccum{fam: fam}
|
||||
families[fam.key] = acc
|
||||
}
|
||||
out.Sources = append(out.Sources, recommendationMetric{
|
||||
Source: source,
|
||||
Plays: row.Plays,
|
||||
Skips: row.Skips,
|
||||
SkipRate: skipRate,
|
||||
AvgCompletion: row.AvgCompletion,
|
||||
})
|
||||
acc.add(row)
|
||||
}
|
||||
writeJSON(w, http.StatusOK, out)
|
||||
|
||||
resp := recommendationMetricsResp{WindowDays: days, Groups: []surfaceGroup{}}
|
||||
if baseline.plays > 0 {
|
||||
m := baseline.metric()
|
||||
resp.Baseline = &m
|
||||
}
|
||||
for _, g := range []struct{ intent, label string }{
|
||||
{intentGoTo, "Go-to surfaces"},
|
||||
{intentDiscovery, "Discovery mixes"},
|
||||
{intentDirect, "Direct plays"},
|
||||
} {
|
||||
group := surfaceGroup{Intent: g.intent, Label: g.label}
|
||||
for _, acc := range families {
|
||||
if acc.fam.intent == g.intent {
|
||||
group.Surfaces = append(group.Surfaces, acc.metric())
|
||||
}
|
||||
}
|
||||
if len(group.Surfaces) == 0 {
|
||||
continue
|
||||
}
|
||||
sort.Slice(group.Surfaces, func(i, j int) bool {
|
||||
if group.Surfaces[i].Plays != group.Surfaces[j].Plays {
|
||||
return group.Surfaces[i].Plays > group.Surfaces[j].Plays
|
||||
}
|
||||
return group.Surfaces[i].Key < group.Surfaces[j].Key
|
||||
})
|
||||
resp.Groups = append(resp.Groups, group)
|
||||
}
|
||||
return resp
|
||||
}
|
||||
|
||||
// parseMetricsDays reads the `days` query param (default 30, capped at 365).
|
||||
|
||||
@@ -11,6 +11,8 @@ import (
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
func newMetricsRouter(h *handlers) chi.Router {
|
||||
@@ -20,7 +22,7 @@ func newMetricsRouter(h *handlers) chi.Router {
|
||||
}
|
||||
|
||||
// seedSourcedPlay inserts a play_event with an explicit source + completion +
|
||||
// skip flag. A nil source inserts NULL (library/radio play).
|
||||
// skip flag. A nil source inserts NULL (manual library play → baseline).
|
||||
func seedSourcedPlay(
|
||||
t *testing.T, h *handlers, userID, trackID, sessionID pgtype.UUID,
|
||||
source *string, completion float64, skipped bool,
|
||||
@@ -45,7 +47,77 @@ func TestRecommendationMetrics_NoSession401(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommendationMetrics_AggregatesBySourceExcludingNull(t *testing.T) {
|
||||
// findSurface returns the named family from any group, or nil.
|
||||
func findSurface(resp recommendationMetricsResp, key string) *surfaceMetric {
|
||||
for _, g := range resp.Groups {
|
||||
for i := range g.Surfaces {
|
||||
if g.Surfaces[i].Key == key {
|
||||
return &g.Surfaces[i]
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func TestBucketMetricsResponse_FamiliesGroupsBaseline(t *testing.T) {
|
||||
src := func(s string) *string { return &s }
|
||||
rows := []dbq.RecommendationSourceMetricsForUserRow{
|
||||
{Source: src("for_you"), Plays: 3, Skips: 1, CompletionN: 3, AvgCompletion: 2.0 / 3},
|
||||
// Two radio sessions collapse into one "radio" family; weighted
|
||||
// completion = (0.2*1 + 0.8*1) / 2 = 0.5.
|
||||
{Source: src("radio:aaaa"), Plays: 1, Skips: 1, CompletionN: 1, AvgCompletion: 0.2},
|
||||
{Source: src("radio:bbbb"), Plays: 1, Skips: 0, CompletionN: 1, AvgCompletion: 0.8},
|
||||
{Source: src("album:cccc"), Plays: 1, Skips: 0, CompletionN: 0, AvgCompletion: 0},
|
||||
{Source: src("discover"), Plays: 1, Skips: 0, CompletionN: 1, AvgCompletion: 0.8},
|
||||
// NULL source = manual plays → baseline, not a group row.
|
||||
{Source: nil, Plays: 25, Skips: 5, CompletionN: 20, AvgCompletion: 0.9},
|
||||
}
|
||||
resp := bucketMetricsResponse(recMetricsDefaultDays, rows)
|
||||
|
||||
if resp.Baseline == nil {
|
||||
t.Fatal("baseline missing")
|
||||
}
|
||||
if resp.Baseline.Plays != 25 || resp.Baseline.SkipRate != 0.2 {
|
||||
t.Errorf("baseline = %+v, want plays=25 skip_rate=0.2", resp.Baseline)
|
||||
}
|
||||
if resp.Baseline.LowConfidence {
|
||||
t.Error("baseline with 25 plays should not be low-confidence")
|
||||
}
|
||||
|
||||
radio := findSurface(resp, "radio")
|
||||
if radio == nil {
|
||||
t.Fatal("radio family missing")
|
||||
}
|
||||
if radio.Plays != 2 || radio.Skips != 1 {
|
||||
t.Errorf("radio plays/skips = %d/%d, want 2/1", radio.Plays, radio.Skips)
|
||||
}
|
||||
if radio.AvgCompletion < 0.49 || radio.AvgCompletion > 0.51 {
|
||||
t.Errorf("radio avg_completion = %.3f, want 0.5 (play-weighted merge)", radio.AvgCompletion)
|
||||
}
|
||||
if !radio.LowConfidence {
|
||||
t.Error("radio with 2 plays should be low-confidence")
|
||||
}
|
||||
|
||||
if s := findSurface(resp, "direct_album"); s == nil || s.Plays != 1 {
|
||||
t.Errorf("direct_album = %+v, want plays=1", s)
|
||||
}
|
||||
if s := findSurface(resp, ""); s != nil {
|
||||
t.Error("NULL source must not appear as a surface family")
|
||||
}
|
||||
|
||||
// Group ordering is intent-banded: go_to before discovery before direct.
|
||||
wantOrder := []string{intentGoTo, intentDiscovery, intentDirect}
|
||||
if len(resp.Groups) != len(wantOrder) {
|
||||
t.Fatalf("groups = %d, want %d", len(resp.Groups), len(wantOrder))
|
||||
}
|
||||
for i, g := range resp.Groups {
|
||||
if g.Intent != wantOrder[i] {
|
||||
t.Errorf("group[%d].intent = %s, want %s", i, g.Intent, wantOrder[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecommendationMetrics_BucketsWithBaseline(t *testing.T) {
|
||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
@@ -57,14 +129,14 @@ func TestRecommendationMetrics_AggregatesBySourceExcludingNull(t *testing.T) {
|
||||
session := seedPlaySession(t, pool, user.ID, time.Now())
|
||||
|
||||
forYou := "for_you"
|
||||
discover := "discover"
|
||||
radioA := "radio:11111111-1111-1111-1111-111111111111"
|
||||
// for_you: 3 plays, 1 skipped; completions 1.0, 0.95, 0.05 → mean 0.6667.
|
||||
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, 1.0, false)
|
||||
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, 0.95, false)
|
||||
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, 0.05, true)
|
||||
// discover: 1 play.
|
||||
seedSourcedPlay(t, h, user.ID, tk.ID, session, &discover, 0.8, false)
|
||||
// library play (NULL source) — must be excluded.
|
||||
// A radio session play collapses into the "radio" family.
|
||||
seedSourcedPlay(t, h, user.ID, tk.ID, session, &radioA, 0.8, false)
|
||||
// Manual play (NULL source) — the baseline row.
|
||||
seedSourcedPlay(t, h, user.ID, tk.ID, session, nil, 1.0, false)
|
||||
|
||||
req := httptest.NewRequest(http.MethodGet, "/api/me/recommendation-metrics", nil)
|
||||
@@ -82,15 +154,11 @@ func TestRecommendationMetrics_AggregatesBySourceExcludingNull(t *testing.T) {
|
||||
if resp.WindowDays != recMetricsDefaultDays {
|
||||
t.Errorf("window_days = %d, want %d", resp.WindowDays, recMetricsDefaultDays)
|
||||
}
|
||||
bySource := map[string]recommendationMetric{}
|
||||
for _, m := range resp.Sources {
|
||||
bySource[m.Source] = m
|
||||
if resp.Baseline == nil || resp.Baseline.Plays != 1 {
|
||||
t.Fatalf("baseline = %+v, want plays=1", resp.Baseline)
|
||||
}
|
||||
if _, present := bySource[""]; present {
|
||||
t.Error("NULL-source (library) plays should be excluded")
|
||||
}
|
||||
fy, ok := bySource["for_you"]
|
||||
if !ok {
|
||||
fy := findSurface(resp, "for_you")
|
||||
if fy == nil {
|
||||
t.Fatal("for_you metrics missing")
|
||||
}
|
||||
if fy.Plays != 3 || fy.Skips != 1 {
|
||||
@@ -102,7 +170,7 @@ func TestRecommendationMetrics_AggregatesBySourceExcludingNull(t *testing.T) {
|
||||
if fy.AvgCompletion < 0.66 || fy.AvgCompletion > 0.67 {
|
||||
t.Errorf("for_you avg_completion = %.4f, want ~0.6667", fy.AvgCompletion)
|
||||
}
|
||||
if d, ok := bySource["discover"]; !ok || d.Plays != 1 || d.Skips != 0 {
|
||||
t.Errorf("discover metrics = %+v, want plays=1 skips=0", d)
|
||||
if radio := findSurface(resp, "radio"); radio == nil || radio.Plays != 1 {
|
||||
t.Errorf("radio family = %+v, want plays=1 (collapsed from radio:<uuid>)", radio)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user