feat(recommendation): For You exploration attribution — taste vs fresh picks
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:
@@ -243,3 +243,53 @@ func TestPickTopN_DiversityCap(t *testing.T) {
|
||||
t.Errorf("len = %d, want 3 (artist 100 capped at 3)", len(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickHeadAndTail_MarksPickKinds(t *testing.T) {
|
||||
// 100-deep pool with headN=20, tailN=5: the 20 head entries are the
|
||||
// taste picks, the 5 tail entries the freshness injection (#1249).
|
||||
in := make([]recommendation.Candidate, 0, 100)
|
||||
for i := 0; i < 100; i++ {
|
||||
in = append(in, makeCand(i+1, i+1, i+1, float64(100-i)))
|
||||
}
|
||||
got := pickHeadAndTail(in, testUserID, "2026-05-07", time.Now(), 20, 5)
|
||||
if len(got) != 25 {
|
||||
t.Fatalf("len = %d, want 25", len(got))
|
||||
}
|
||||
for i := 0; i < 20; i++ {
|
||||
if got[i].PickKind != pickKindTaste {
|
||||
t.Errorf("head[%d].PickKind = %q, want %q", i, got[i].PickKind, pickKindTaste)
|
||||
}
|
||||
}
|
||||
for i := 20; i < 25; i++ {
|
||||
if got[i].PickKind != pickKindFresh {
|
||||
t.Errorf("tail[%d].PickKind = %q, want %q", i-20, got[i].PickKind, pickKindFresh)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickHeadAndTail_SmallPoolAllTaste(t *testing.T) {
|
||||
// The small-pool fallback is pure top-N-by-score — that IS the taste
|
||||
// mechanism, so nothing on this path is an exploration pick.
|
||||
in := []recommendation.Candidate{
|
||||
makeCand(1, 10, 100, 1.0),
|
||||
makeCand(2, 11, 101, 0.9),
|
||||
makeCand(3, 12, 102, 0.8),
|
||||
}
|
||||
got := pickHeadAndTail(in, testUserID, "2026-05-07", time.Now(), 5, 2)
|
||||
for i, rc := range got {
|
||||
if rc.PickKind != pickKindTaste {
|
||||
t.Errorf("got[%d].PickKind = %q, want %q (fallback is all taste)",
|
||||
i, rc.PickKind, pickKindTaste)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickTopN_NoPickKind(t *testing.T) {
|
||||
// Songs-like-X and the discovery mixes don't split; their rows must
|
||||
// persist pick_kind NULL (empty string here).
|
||||
in := []recommendation.Candidate{makeCand(1, 10, 100, 1.0)}
|
||||
got := pickTopN(in, testUserID, "2026-05-07", time.Now(), 25)
|
||||
if len(got) != 1 || got[0].PickKind != "" {
|
||||
t.Errorf("pickTopN PickKind = %q, want empty (persists as NULL)", got[0].PickKind)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,12 +127,22 @@ func pickSeedArtistsForDay(pool []pgtype.UUID, userID pgtype.UUID, dateStr strin
|
||||
|
||||
// rankedCandidate is a (track_id, score) pair used during in-memory
|
||||
// sorting before insert into playlist_tracks. T5 fills these from
|
||||
// recommendation.Candidate scores.
|
||||
// recommendation.Candidate scores. PickKind is set only by For-You's
|
||||
// head/tail composition (#1249); empty persists as NULL.
|
||||
type rankedCandidate struct {
|
||||
TrackID pgtype.UUID
|
||||
Score float64
|
||||
TrackID pgtype.UUID
|
||||
Score float64
|
||||
PickKind string
|
||||
}
|
||||
|
||||
// For-You pick kinds, persisted on playlist_tracks.pick_kind so plays
|
||||
// can attribute skips to "the taste engine missing" vs "the freshness
|
||||
// tax" (#1249). Values mirror the CHECK in migration 0038.
|
||||
const (
|
||||
pickKindTaste = "taste"
|
||||
pickKindFresh = "fresh"
|
||||
)
|
||||
|
||||
const systemMixLength = 25
|
||||
|
||||
// systemMixWeights are the fixed scoring weights used by the cron worker.
|
||||
@@ -632,14 +642,17 @@ func pickHeadAndTail(cands []recommendation.Candidate, userID pgtype.UUID, dateS
|
||||
total := headN + tailN
|
||||
if len(capped) <= total {
|
||||
// Pool too small for a head/tail split — return up to total entries.
|
||||
// All marked taste: top-N-by-score IS the taste mechanism; no
|
||||
// exploration sampling happens on this path.
|
||||
if len(capped) < total {
|
||||
total = len(capped)
|
||||
}
|
||||
out := make([]rankedCandidate, total)
|
||||
for i := 0; i < total; i++ {
|
||||
out[i] = rankedCandidate{
|
||||
TrackID: capped[i].Track.ID,
|
||||
Score: recommendation.Score(capped[i].Inputs, systemMixWeights, now, rng.Float64),
|
||||
TrackID: capped[i].Track.ID,
|
||||
Score: recommendation.Score(capped[i].Inputs, systemMixWeights, now, rng.Float64),
|
||||
PickKind: pickKindTaste,
|
||||
}
|
||||
}
|
||||
return out
|
||||
@@ -667,15 +680,22 @@ func pickHeadAndTail(cands []recommendation.Candidate, userID pgtype.UUID, dateS
|
||||
|
||||
// Combine: head order preserved (score-sorted), tail in tieBreakHash
|
||||
// order. "First similar, then surprise" reads naturally in playback.
|
||||
// Head entries are the taste picks; tail entries are the freshness
|
||||
// injection (#1249) — the split the metrics page attributes skips to.
|
||||
combined := make([]recommendation.Candidate, 0, len(head)+len(tail))
|
||||
combined = append(combined, head...)
|
||||
combined = append(combined, tail...)
|
||||
|
||||
out := make([]rankedCandidate, len(combined))
|
||||
for i, c := range combined {
|
||||
kind := pickKindTaste
|
||||
if i >= len(head) {
|
||||
kind = pickKindFresh
|
||||
}
|
||||
out[i] = rankedCandidate{
|
||||
TrackID: c.Track.ID,
|
||||
Score: recommendation.Score(c.Inputs, systemMixWeights, now, rng.Float64),
|
||||
TrackID: c.Track.ID,
|
||||
Score: recommendation.Score(c.Inputs, systemMixWeights, now, rng.Float64),
|
||||
PickKind: kind,
|
||||
}
|
||||
}
|
||||
return out
|
||||
@@ -709,9 +729,15 @@ func insertSystemPlaylist(ctx context.Context, qtx *dbq.Queries, userID pgtype.U
|
||||
}
|
||||
|
||||
for _, t := range tracks {
|
||||
var pickKind *string
|
||||
if t.PickKind != "" {
|
||||
k := t.PickKind
|
||||
pickKind = &k
|
||||
}
|
||||
if _, err := qtx.AppendPlaylistTrack(ctx, dbq.AppendPlaylistTrackParams{
|
||||
PlaylistID: p.ID,
|
||||
TrackID: t.TrackID,
|
||||
PickKind: pickKind,
|
||||
}); err != nil {
|
||||
// Track may have been deleted between candidate-load and insert;
|
||||
// skip silently rather than failing the whole build.
|
||||
|
||||
Reference in New Issue
Block a user