feat(recommendation): For You exploration attribution — taste vs fresh picks
test-go / test (push) Successful in 31s
test-web / test (push) Successful in 37s
test-go / integration (push) Successful in 4m37s

Milestone #127 step 2 (#1249). For You deliberately blends two
populations — a head of top-scored taste picks and a tail sampled from
deeper ranking (the freshness injection) — but the metrics judged it as
one blob, so its skip rate couldn't distinguish "the taste engine is
missing" from "the freshness tax is too high". That number decides the
exploration share before we tune it.

- Migration 0038: nullable pick_kind ('taste'|'fresh') on both
  playlist_tracks (stamped at snapshot build) and play_events (frozen at
  play-ingestion — the snapshot rebuilds daily, so attribution cannot be
  reconstructed at read time).
- Builder: pickHeadAndTail marks head=taste / tail=fresh; the small-pool
  fallback is all taste (top-N-by-score IS the taste mechanism). Other
  variants persist NULL.
- Ingestion: for_you plays (live + offline replay) look the track up in
  the user's current snapshot; not found → unattributed, never guessed.
- Metrics: For You's row gains a breakdown (taste / fresh / earlier
  unattributed plays), parent row stays the sum; web card renders the
  sub-rows indented with the same baseline deltas + low-data dimming.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
This commit is contained in:
2026-07-02 20:39:41 -04:00
parent 60533073ad
commit fb4431207d
18 changed files with 593 additions and 39 deletions
+57 -1
View File
@@ -39,6 +39,11 @@ 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
// Breakdown splits the family into sub-populations. Only For You
// carries one (#1249): taste picks vs fresh picks (the deliberate
// freshness injection), plus earlier plays that predate attribution.
// The parent row remains the sum of its breakdown.
Breakdown []surfaceMetric `json:"breakdown,omitempty"`
}
// surfaceGroup is one intent band of surface families.
@@ -163,6 +168,40 @@ func (h *handlers) handleGetRecommendationMetrics(w http.ResponseWriter, r *http
writeJSON(w, http.StatusOK, bucketMetricsResponse(days, rows))
}
// forYouPickFamilies defines the For You breakdown rows (#1249), keyed
// by play_events.pick_kind. "" (NULL pick_kind) = plays recorded before
// attribution shipped, or whose track had already rotated out of the
// snapshot at ingestion — kept visible so the parent row's sums stay
// transparent instead of silently shrinking.
var forYouPickFamilies = map[string]recFamily{
"taste": {"for_you_taste", "Taste picks", intentGoTo},
"fresh": {"for_you_fresh", "Fresh picks", intentGoTo},
"": {"for_you_unattributed", "Earlier plays", intentGoTo},
}
// forYouBreakdown folds pick-kind accums into the parent metric's
// Breakdown. Attached only when at least one attributed (taste/fresh)
// play exists — an all-unattributed breakdown would just repeat the
// parent row.
func forYouBreakdown(picks map[string]*familyAccum) []surfaceMetric {
attributed := int64(0)
for kind, acc := range picks {
if kind != "" {
attributed += acc.plays
}
}
if attributed == 0 {
return nil
}
out := make([]surfaceMetric, 0, len(picks))
for _, kind := range []string{"taste", "fresh", ""} {
if acc, ok := picks[kind]; ok && acc.plays > 0 {
out = append(out, acc.metric())
}
}
return out
}
// bucketMetricsResponse folds the raw per-source rows into the grouped,
// baseline-anchored response shape. Split from the handler for pure-unit
// testability.
@@ -171,6 +210,7 @@ func bucketMetricsResponse(
) recommendationMetricsResp {
baseline := &familyAccum{fam: recFamily{"manual", "Manual library plays", ""}}
families := map[string]*familyAccum{}
forYouPicks := map[string]*familyAccum{}
for _, row := range rows {
if row.Source == nil || *row.Source == "" {
baseline.add(row)
@@ -183,6 +223,18 @@ func bucketMetricsResponse(
families[fam.key] = acc
}
acc.add(row)
if fam.key == "for_you" {
kind := ""
if row.PickKind != nil {
kind = *row.PickKind
}
pick, ok := forYouPicks[kind]
if !ok {
pick = &familyAccum{fam: forYouPickFamilies[kind]}
forYouPicks[kind] = pick
}
pick.add(row)
}
}
resp := recommendationMetricsResp{WindowDays: days, Groups: []surfaceGroup{}}
@@ -198,7 +250,11 @@ func bucketMetricsResponse(
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())
m := acc.metric()
if acc.fam.key == "for_you" {
m.Breakdown = forYouBreakdown(forYouPicks)
}
group.Surfaces = append(group.Surfaces, m)
}
}
if len(group.Surfaces) == 0 {
+72 -9
View File
@@ -23,16 +23,18 @@ func newMetricsRouter(h *handlers) chi.Router {
// seedSourcedPlay inserts a play_event with an explicit source + completion +
// skip flag. A nil source inserts NULL (manual library play → baseline).
// pickKind is the For You taste/fresh attribution (#1249); nil everywhere
// except attributed for_you plays.
func seedSourcedPlay(
t *testing.T, h *handlers, userID, trackID, sessionID pgtype.UUID,
source *string, completion float64, skipped bool,
source, pickKind *string, completion float64, skipped bool,
) {
t.Helper()
if _, err := h.pool.Exec(context.Background(),
`INSERT INTO play_events
(user_id, track_id, session_id, started_at, source, completion_ratio, was_skipped)
VALUES ($1, $2, $3, now(), $4, $5, $6)`,
userID, trackID, sessionID, source, completion, skipped); err != nil {
(user_id, track_id, session_id, started_at, source, pick_kind, completion_ratio, was_skipped)
VALUES ($1, $2, $3, now(), $4, $5, $6, $7)`,
userID, trackID, sessionID, source, pickKind, completion, skipped); err != nil {
t.Fatalf("seed sourced play: %v", err)
}
}
@@ -105,6 +107,12 @@ func TestBucketMetricsResponse_FamiliesGroupsBaseline(t *testing.T) {
t.Error("NULL source must not appear as a surface family")
}
// No attributed (taste/fresh) plays in this fixture → no breakdown;
// an all-unattributed breakdown would just repeat the parent row.
if fy := findSurface(resp, "for_you"); fy == nil || fy.Breakdown != nil {
t.Errorf("for_you breakdown = %+v, want nil without attributed plays", fy)
}
// Group ordering is intent-banded: go_to before discovery before direct.
wantOrder := []string{intentGoTo, intentDiscovery, intentDirect}
if len(resp.Groups) != len(wantOrder) {
@@ -117,6 +125,49 @@ func TestBucketMetricsResponse_FamiliesGroupsBaseline(t *testing.T) {
}
}
func TestBucketMetricsResponse_ForYouBreakdown(t *testing.T) {
src := func(s string) *string { return &s }
kind := func(s string) *string { return &s }
rows := []dbq.RecommendationSourceMetricsForUserRow{
{Source: src("for_you"), PickKind: kind("taste"), Plays: 30, Skips: 3,
CompletionN: 30, AvgCompletion: 0.9},
{Source: src("for_you"), PickKind: kind("fresh"), Plays: 10, Skips: 4,
CompletionN: 10, AvgCompletion: 0.5},
// NULL pick_kind = plays that predate attribution.
{Source: src("for_you"), Plays: 5, Skips: 1, CompletionN: 5, AvgCompletion: 0.7},
}
resp := bucketMetricsResponse(recMetricsDefaultDays, rows)
fy := findSurface(resp, "for_you")
if fy == nil {
t.Fatal("for_you family missing")
}
// The parent row stays the sum of its breakdown.
if fy.Plays != 45 || fy.Skips != 8 {
t.Errorf("for_you plays/skips = %d/%d, want 45/8", fy.Plays, fy.Skips)
}
if len(fy.Breakdown) != 3 {
t.Fatalf("breakdown rows = %d, want 3 (taste, fresh, earlier)", len(fy.Breakdown))
}
wantKeys := []string{"for_you_taste", "for_you_fresh", "for_you_unattributed"}
for i, k := range wantKeys {
if fy.Breakdown[i].Key != k {
t.Errorf("breakdown[%d].key = %s, want %s", i, fy.Breakdown[i].Key, k)
}
}
taste, fresh := fy.Breakdown[0], fy.Breakdown[1]
if taste.Plays != 30 || taste.SkipRate != 0.1 || taste.LowConfidence {
t.Errorf("taste = %+v, want plays=30 skip_rate=0.1 confident", taste)
}
if fresh.Plays != 10 || fresh.SkipRate != 0.4 || !fresh.LowConfidence {
t.Errorf("fresh = %+v, want plays=10 skip_rate=0.4 low-confidence", fresh)
}
// Breakdown rows never appear as their own surface families.
if s := findSurface(resp, "for_you_taste"); s != nil {
t.Error("for_you_taste must not be a top-level surface")
}
}
func TestRecommendationMetrics_BucketsWithBaseline(t *testing.T) {
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
@@ -130,14 +181,16 @@ func TestRecommendationMetrics_BucketsWithBaseline(t *testing.T) {
forYou := "for_you"
radioA := "radio:11111111-1111-1111-1111-111111111111"
taste, fresh := "taste", "fresh"
// 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)
// Two attributed as taste picks, the skipped one as a fresh pick (#1249).
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, &taste, 1.0, false)
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, &taste, 0.95, false)
seedSourcedPlay(t, h, user.ID, tk.ID, session, &forYou, &fresh, 0.05, true)
// A radio session play collapses into the "radio" family.
seedSourcedPlay(t, h, user.ID, tk.ID, session, &radioA, 0.8, false)
seedSourcedPlay(t, h, user.ID, tk.ID, session, &radioA, nil, 0.8, false)
// Manual play (NULL source) — the baseline row.
seedSourcedPlay(t, h, user.ID, tk.ID, session, nil, 1.0, false)
seedSourcedPlay(t, h, user.ID, tk.ID, session, nil, nil, 1.0, false)
req := httptest.NewRequest(http.MethodGet, "/api/me/recommendation-metrics", nil)
req = withUser(req, user)
@@ -170,6 +223,16 @@ func TestRecommendationMetrics_BucketsWithBaseline(t *testing.T) {
if fy.AvgCompletion < 0.66 || fy.AvgCompletion > 0.67 {
t.Errorf("for_you avg_completion = %.4f, want ~0.6667", fy.AvgCompletion)
}
// Pick-kind attribution surfaces as the For You breakdown (#1249).
if len(fy.Breakdown) != 2 {
t.Fatalf("for_you breakdown rows = %d, want 2 (taste, fresh)", len(fy.Breakdown))
}
if b := fy.Breakdown[0]; b.Key != "for_you_taste" || b.Plays != 2 || b.Skips != 0 {
t.Errorf("breakdown[0] = %+v, want for_you_taste plays=2 skips=0", b)
}
if b := fy.Breakdown[1]; b.Key != "for_you_fresh" || b.Plays != 1 || b.Skips != 1 {
t.Errorf("breakdown[1] = %+v, want for_you_fresh plays=1 skips=1", b)
}
if radio := findSurface(resp, "radio"); radio == nil || radio.Plays != 1 {
t.Errorf("radio family = %+v, want plays=1 (collapsed from radio:<uuid>)", radio)
}