feat(metrics): provenance as standard — pick_kind for all system mixes
test-go / test (push) Successful in 31s
test-web / test (push) Successful in 38s
test-go / integration (push) Successful in 4m36s

The #1249 mechanism (stamp WHY a track is in the snapshot at build
time, freeze it onto the play at ingestion, break it down in metrics)
generalizes from a For You one-off to the standard for every system
mix (#1270):

- Migration 0039 widens both pick_kind CHECKs (drop + re-add in the
  same change) to taste/fresh + Discover's dormant/cross_user/random
  + tier1-3 for the rule-#131 eligibility ladders.
- GetForYouPickKindForTrack becomes GetSystemPickKindForTrack
  (user, variant, track); ingestion stamps any systemPlaylistSources
  play from its own variant's live snapshot, live + offline paths.
- Discover stamps its candidate bucket on discoverTrack before the
  interleave, making the 40/30/30 allocation measurable; dedup keeps
  the taking bucket's stamp.
- Metrics replace the for_you special-case with one pick-kind
  vocabulary — any family with attributed plays gets a breakdown,
  future stamping mixes need no metrics change.
- Web: breakdown sub-rows are now toggled per surface (collapsed by
  default) so eight stamping mixes don't swamp the card.

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 23:26:52 -04:00
parent fb4431207d
commit 5faa57634b
14 changed files with 410 additions and 156 deletions
+18 -5
View File
@@ -20,11 +20,15 @@ const (
// discoverTrack is the common shape used by the bucket allocator. The
// three sqlc-generated row types collapse into this internal struct so
// downstream functions don't need to be generic over them.
// downstream functions don't need to be generic over them. PickKind is
// the originating bucket (dormant/cross_user/random, #1270), stamped in
// the row adapters so it survives the interleave — bucket identity is
// what makes the 40/30/30 allocation measurable in the metrics.
type discoverTrack struct {
ID pgtype.UUID
AlbumID pgtype.UUID
ArtistID pgtype.UUID
PickKind string
}
// buildDiscoverCandidates assembles the Discover playlist track list.
@@ -91,7 +95,7 @@ func buildDiscoverCandidates(ctx context.Context, q *dbq.Queries, logger *slog.L
// already gave us the ranking.
ranked := make([]rankedCandidate, 0, len(out))
for _, t := range out {
ranked = append(ranked, rankedCandidate{TrackID: t.ID})
ranked = append(ranked, rankedCandidate{TrackID: t.ID, PickKind: t.PickKind})
}
return ranked, nil
}
@@ -99,7 +103,10 @@ func buildDiscoverCandidates(ctx context.Context, q *dbq.Queries, logger *slog.L
func dormantRowsToTracks(rows []dbq.ListDormantArtistTracksForDiscoverRow) []discoverTrack {
out := make([]discoverTrack, len(rows))
for i, r := range rows {
out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
out[i] = discoverTrack{
ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID,
PickKind: pickKindDormant,
}
}
return out
}
@@ -107,7 +114,10 @@ func dormantRowsToTracks(rows []dbq.ListDormantArtistTracksForDiscoverRow) []dis
func crossUserRowsToTracks(rows []dbq.ListCrossUserLikedTracksForDiscoverRow) []discoverTrack {
out := make([]discoverTrack, len(rows))
for i, r := range rows {
out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
out[i] = discoverTrack{
ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID,
PickKind: pickKindCrossUser,
}
}
return out
}
@@ -115,7 +125,10 @@ func crossUserRowsToTracks(rows []dbq.ListCrossUserLikedTracksForDiscoverRow) []
func randomRowsToTracks(rows []dbq.ListRandomUnheardTracksForDiscoverRow) []discoverTrack {
out := make([]discoverTrack, len(rows))
for i, r := range rows {
out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
out[i] = discoverTrack{
ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID,
PickKind: pickKindRandom,
}
}
return out
}
+24
View File
@@ -161,3 +161,27 @@ func TestInterleaveBuckets_DedupsAcrossBuckets(t *testing.T) {
}
}
}
func TestInterleaveBuckets_PreservesBucketPickKind(t *testing.T) {
// Bucket provenance (#1270) is stamped on discoverTrack before the
// interleave and must survive it — including dedup, where a track in
// two buckets keeps the stamp of the bucket it was taken from.
dormant := []discoverTrack{
{ID: uuidN(1), PickKind: pickKindDormant},
{ID: uuidN(2), PickKind: pickKindDormant},
}
random := []discoverTrack{
{ID: uuidN(1), PickKind: pickKindRandom}, // shared with dormant
{ID: uuidN(3), PickKind: pickKindRandom},
}
got := interleaveBuckets(dormant, nil, random)
if len(got) != 3 {
t.Fatalf("len = %d, want 3", len(got))
}
want := map[byte]string{1: pickKindDormant, 2: pickKindDormant, 3: pickKindRandom}
for _, tr := range got {
if w := want[tr.ID.Bytes[15]]; tr.PickKind != w {
t.Errorf("track %d pick_kind = %q, want %q", tr.ID.Bytes[15], tr.PickKind, w)
}
}
}
+13 -7
View File
@@ -127,20 +127,26 @@ 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. PickKind is set only by For-You's
// head/tail composition (#1249); empty persists as NULL.
// recommendation.Candidate scores. PickKind is the track's provenance
// within its mix — For-You's head/tail split (#1249) and Discover's
// buckets (#1270); empty persists as NULL (variant doesn't stamp yet).
type rankedCandidate struct {
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.
// Pick kinds, persisted on playlist_tracks.pick_kind so plays can
// attribute skips to the population that sourced the track — For You's
// "taste engine missing" vs "freshness tax", Discover's three buckets
// (#1270). Values mirror the CHECK in migration 0039; the tier1-3
// values there land with the tiered-mix rebuilds.
const (
pickKindTaste = "taste"
pickKindFresh = "fresh"
pickKindTaste = "taste"
pickKindFresh = "fresh"
pickKindDormant = "dormant"
pickKindCrossUser = "cross_user"
pickKindRandom = "random"
)
const systemMixLength = 25