feat(metrics): provenance as standard — pick_kind for all system mixes
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:
@@ -39,10 +39,12 @@ type surfaceMetric struct {
|
|||||||
SkipRate float64 `json:"skip_rate"` // skips / plays, [0,1]
|
SkipRate float64 `json:"skip_rate"` // skips / plays, [0,1]
|
||||||
AvgCompletion float64 `json:"avg_completion"` // mean completion ratio, [0,1]
|
AvgCompletion float64 `json:"avg_completion"` // mean completion ratio, [0,1]
|
||||||
LowConfidence bool `json:"low_confidence"` // plays < recMetricsLowVolume
|
LowConfidence bool `json:"low_confidence"` // plays < recMetricsLowVolume
|
||||||
// Breakdown splits the family into sub-populations. Only For You
|
// Breakdown splits the family into the pick-kind populations its
|
||||||
// carries one (#1249): taste picks vs fresh picks (the deliberate
|
// builder stamped (#1249, generalized #1270): For You's taste/fresh,
|
||||||
// freshness injection), plus earlier plays that predate attribution.
|
// Discover's buckets, tier1-3 for tiered mixes — plus earlier plays
|
||||||
// The parent row remains the sum of its breakdown.
|
// that predate attribution. Present only when the family has at
|
||||||
|
// least one attributed play; the parent row remains the sum of its
|
||||||
|
// breakdown.
|
||||||
Breakdown []surfaceMetric `json:"breakdown,omitempty"`
|
Breakdown []surfaceMetric `json:"breakdown,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,22 +170,50 @@ func (h *handlers) handleGetRecommendationMetrics(w http.ResponseWriter, r *http
|
|||||||
writeJSON(w, http.StatusOK, bucketMetricsResponse(days, rows))
|
writeJSON(w, http.StatusOK, bucketMetricsResponse(days, rows))
|
||||||
}
|
}
|
||||||
|
|
||||||
// forYouPickFamilies defines the For You breakdown rows (#1249), keyed
|
// pickKindLabels is the display vocabulary for play_events.pick_kind
|
||||||
// by play_events.pick_kind. "" (NULL pick_kind) = plays recorded before
|
// values (mirrors the CHECK in migration 0039). Every system mix that
|
||||||
// attribution shipped, or whose track had already rotated out of the
|
// stamps provenance gets its breakdown from this one map — adding a
|
||||||
// snapshot at ingestion — kept visible so the parent row's sums stay
|
// stamping mix needs no metrics change.
|
||||||
// transparent instead of silently shrinking.
|
var pickKindLabels = map[string]string{
|
||||||
var forYouPickFamilies = map[string]recFamily{
|
"taste": "Taste picks",
|
||||||
"taste": {"for_you_taste", "Taste picks", intentGoTo},
|
"fresh": "Fresh picks",
|
||||||
"fresh": {"for_you_fresh", "Fresh picks", intentGoTo},
|
"dormant": "Dormant artists",
|
||||||
"": {"for_you_unattributed", "Earlier plays", intentGoTo},
|
"cross_user": "Liked by others",
|
||||||
|
"random": "Random unheard",
|
||||||
|
"tier1": "Tier 1 (exact)",
|
||||||
|
"tier2": "Tier 2 (relaxed)",
|
||||||
|
"tier3": "Tier 3 (stretched)",
|
||||||
}
|
}
|
||||||
|
|
||||||
// forYouBreakdown folds pick-kind accums into the parent metric's
|
// pickKindOrder fixes breakdown row order; unattributed ("", i.e. NULL
|
||||||
// Breakdown. Attached only when at least one attributed (taste/fresh)
|
// pick_kind — plays recorded before the mix stamped provenance, or
|
||||||
// play exists — an all-unattributed breakdown would just repeat the
|
// whose track had rotated out of the snapshot at ingestion) renders
|
||||||
// parent row.
|
// last, kept visible so the parent row's sums stay transparent instead
|
||||||
func forYouBreakdown(picks map[string]*familyAccum) []surfaceMetric {
|
// of silently shrinking. The DB CHECK gates pick_kind to exactly this
|
||||||
|
// vocabulary, so iterating the list is exhaustive.
|
||||||
|
var pickKindOrder = []string{
|
||||||
|
"taste", "fresh", "dormant", "cross_user", "random",
|
||||||
|
"tier1", "tier2", "tier3", "",
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickKindFamily derives the sub-family for one (family, pick_kind)
|
||||||
|
// population, e.g. ("for_you", "taste") → for_you_taste "Taste picks".
|
||||||
|
func pickKindFamily(parent recFamily, kind string) recFamily {
|
||||||
|
if kind == "" {
|
||||||
|
return recFamily{parent.key + "_unattributed", "Earlier plays", parent.intent}
|
||||||
|
}
|
||||||
|
label, ok := pickKindLabels[kind]
|
||||||
|
if !ok {
|
||||||
|
label = kind
|
||||||
|
}
|
||||||
|
return recFamily{parent.key + "_" + kind, label, parent.intent}
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickKindBreakdown folds a family's per-pick-kind accums into its
|
||||||
|
// 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 {
|
||||||
attributed := int64(0)
|
attributed := int64(0)
|
||||||
for kind, acc := range picks {
|
for kind, acc := range picks {
|
||||||
if kind != "" {
|
if kind != "" {
|
||||||
@@ -194,7 +224,7 @@ func forYouBreakdown(picks map[string]*familyAccum) []surfaceMetric {
|
|||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
out := make([]surfaceMetric, 0, len(picks))
|
out := make([]surfaceMetric, 0, len(picks))
|
||||||
for _, kind := range []string{"taste", "fresh", ""} {
|
for _, kind := range pickKindOrder {
|
||||||
if acc, ok := picks[kind]; ok && acc.plays > 0 {
|
if acc, ok := picks[kind]; ok && acc.plays > 0 {
|
||||||
out = append(out, acc.metric())
|
out = append(out, acc.metric())
|
||||||
}
|
}
|
||||||
@@ -210,7 +240,7 @@ func bucketMetricsResponse(
|
|||||||
) recommendationMetricsResp {
|
) recommendationMetricsResp {
|
||||||
baseline := &familyAccum{fam: recFamily{"manual", "Manual library plays", ""}}
|
baseline := &familyAccum{fam: recFamily{"manual", "Manual library plays", ""}}
|
||||||
families := map[string]*familyAccum{}
|
families := map[string]*familyAccum{}
|
||||||
forYouPicks := map[string]*familyAccum{}
|
picks := map[string]map[string]*familyAccum{}
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
if row.Source == nil || *row.Source == "" {
|
if row.Source == nil || *row.Source == "" {
|
||||||
baseline.add(row)
|
baseline.add(row)
|
||||||
@@ -223,18 +253,23 @@ func bucketMetricsResponse(
|
|||||||
families[fam.key] = acc
|
families[fam.key] = acc
|
||||||
}
|
}
|
||||||
acc.add(row)
|
acc.add(row)
|
||||||
if fam.key == "for_you" {
|
// Accumulate the pick-kind population unconditionally; families
|
||||||
kind := ""
|
// that never stamp end up all-unattributed and get no breakdown.
|
||||||
if row.PickKind != nil {
|
kind := ""
|
||||||
kind = *row.PickKind
|
if row.PickKind != nil {
|
||||||
}
|
kind = *row.PickKind
|
||||||
pick, ok := forYouPicks[kind]
|
|
||||||
if !ok {
|
|
||||||
pick = &familyAccum{fam: forYouPickFamilies[kind]}
|
|
||||||
forYouPicks[kind] = pick
|
|
||||||
}
|
|
||||||
pick.add(row)
|
|
||||||
}
|
}
|
||||||
|
byKind, ok := picks[fam.key]
|
||||||
|
if !ok {
|
||||||
|
byKind = map[string]*familyAccum{}
|
||||||
|
picks[fam.key] = byKind
|
||||||
|
}
|
||||||
|
pick, ok := byKind[kind]
|
||||||
|
if !ok {
|
||||||
|
pick = &familyAccum{fam: pickKindFamily(fam, kind)}
|
||||||
|
byKind[kind] = pick
|
||||||
|
}
|
||||||
|
pick.add(row)
|
||||||
}
|
}
|
||||||
|
|
||||||
resp := recommendationMetricsResp{WindowDays: days, Groups: []surfaceGroup{}}
|
resp := recommendationMetricsResp{WindowDays: days, Groups: []surfaceGroup{}}
|
||||||
@@ -251,9 +286,7 @@ func bucketMetricsResponse(
|
|||||||
for _, acc := range families {
|
for _, acc := range families {
|
||||||
if acc.fam.intent == g.intent {
|
if acc.fam.intent == g.intent {
|
||||||
m := acc.metric()
|
m := acc.metric()
|
||||||
if acc.fam.key == "for_you" {
|
m.Breakdown = pickKindBreakdown(picks[acc.fam.key])
|
||||||
m.Breakdown = forYouBreakdown(forYouPicks)
|
|
||||||
}
|
|
||||||
group.Surfaces = append(group.Surfaces, m)
|
group.Surfaces = append(group.Surfaces, m)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -168,6 +168,48 @@ func TestBucketMetricsResponse_ForYouBreakdown(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestBucketMetricsResponse_DiscoverBucketBreakdown(t *testing.T) {
|
||||||
|
// Provenance is standard (#1270): Discover's bucket stamps surface as
|
||||||
|
// a breakdown exactly like For You's taste/fresh — this is what makes
|
||||||
|
// the 40/30/30 allocation judgeable instead of a guess.
|
||||||
|
src := func(s string) *string { return &s }
|
||||||
|
kind := func(s string) *string { return &s }
|
||||||
|
rows := []dbq.RecommendationSourceMetricsForUserRow{
|
||||||
|
{Source: src("discover"), PickKind: kind("dormant"), Plays: 8, Skips: 2,
|
||||||
|
CompletionN: 8, AvgCompletion: 0.8},
|
||||||
|
{Source: src("discover"), PickKind: kind("cross_user"), Plays: 6, Skips: 3,
|
||||||
|
CompletionN: 6, AvgCompletion: 0.6},
|
||||||
|
{Source: src("discover"), PickKind: kind("random"), Plays: 4, Skips: 3,
|
||||||
|
CompletionN: 4, AvgCompletion: 0.4},
|
||||||
|
// NULL pick_kind = plays that predate bucket stamping.
|
||||||
|
{Source: src("discover"), Plays: 2, Skips: 0, CompletionN: 2, AvgCompletion: 0.9},
|
||||||
|
}
|
||||||
|
resp := bucketMetricsResponse(recMetricsDefaultDays, rows)
|
||||||
|
|
||||||
|
d := findSurface(resp, "discover")
|
||||||
|
if d == nil {
|
||||||
|
t.Fatal("discover family missing")
|
||||||
|
}
|
||||||
|
if d.Plays != 20 || d.Skips != 8 {
|
||||||
|
t.Errorf("discover plays/skips = %d/%d, want 20/8", d.Plays, d.Skips)
|
||||||
|
}
|
||||||
|
wantKeys := []string{
|
||||||
|
"discover_dormant", "discover_cross_user", "discover_random",
|
||||||
|
"discover_unattributed",
|
||||||
|
}
|
||||||
|
if len(d.Breakdown) != len(wantKeys) {
|
||||||
|
t.Fatalf("breakdown rows = %d, want %d", len(d.Breakdown), len(wantKeys))
|
||||||
|
}
|
||||||
|
for i, k := range wantKeys {
|
||||||
|
if d.Breakdown[i].Key != k {
|
||||||
|
t.Errorf("breakdown[%d].key = %s, want %s", i, d.Breakdown[i].Key, k)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if b := d.Breakdown[0]; b.Label != "Dormant artists" || b.Plays != 8 {
|
||||||
|
t.Errorf("dormant row = %+v, want label=Dormant artists plays=8", b)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestRecommendationMetrics_BucketsWithBaseline(t *testing.T) {
|
func TestRecommendationMetrics_BucketsWithBaseline(t *testing.T) {
|
||||||
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
if os.Getenv("MINSTREL_TEST_DATABASE_URL") == "" {
|
||||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||||
|
|||||||
@@ -31,33 +31,6 @@ func (q *Queries) GetCurrentSessionVectorForUser(ctx context.Context, userID pgt
|
|||||||
return session_vector_at_play, err
|
return session_vector_at_play, err
|
||||||
}
|
}
|
||||||
|
|
||||||
const getForYouPickKindForTrack = `-- name: GetForYouPickKindForTrack :one
|
|
||||||
SELECT pt.pick_kind
|
|
||||||
FROM playlist_tracks pt
|
|
||||||
JOIN playlists p ON p.id = pt.playlist_id
|
|
||||||
WHERE p.user_id = $1
|
|
||||||
AND p.system_variant = 'for_you'
|
|
||||||
AND pt.track_id = $2
|
|
||||||
LIMIT 1
|
|
||||||
`
|
|
||||||
|
|
||||||
type GetForYouPickKindForTrackParams struct {
|
|
||||||
UserID pgtype.UUID
|
|
||||||
TrackID pgtype.UUID
|
|
||||||
}
|
|
||||||
|
|
||||||
// Looks a track up in the user's CURRENT For You snapshot and returns
|
|
||||||
// its pick_kind ('taste'/'fresh'). Used at play-ingestion time to
|
|
||||||
// freeze exploration attribution onto the play_event — the snapshot is
|
|
||||||
// rebuilt daily, so attribution can't be reconstructed at read time
|
|
||||||
// (#1249). No row = track not in today's snapshot (caller stores NULL).
|
|
||||||
func (q *Queries) GetForYouPickKindForTrack(ctx context.Context, arg GetForYouPickKindForTrackParams) (*string, error) {
|
|
||||||
row := q.db.QueryRow(ctx, getForYouPickKindForTrack, arg.UserID, arg.TrackID)
|
|
||||||
var pick_kind *string
|
|
||||||
err := row.Scan(&pick_kind)
|
|
||||||
return pick_kind, err
|
|
||||||
}
|
|
||||||
|
|
||||||
const getMostRecentPlaySessionForUser = `-- name: GetMostRecentPlaySessionForUser :one
|
const getMostRecentPlaySessionForUser = `-- name: GetMostRecentPlaySessionForUser :one
|
||||||
SELECT id, user_id, started_at, ended_at, last_event_at, track_count, client_id FROM play_sessions
|
SELECT id, user_id, started_at, ended_at, last_event_at, track_count, client_id FROM play_sessions
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
@@ -137,6 +110,38 @@ func (q *Queries) GetPlayEventByID(ctx context.Context, id pgtype.UUID) (PlayEve
|
|||||||
return i, err
|
return i, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getSystemPickKindForTrack = `-- name: GetSystemPickKindForTrack :one
|
||||||
|
SELECT pt.pick_kind
|
||||||
|
FROM playlist_tracks pt
|
||||||
|
JOIN playlists p ON p.id = pt.playlist_id
|
||||||
|
WHERE p.user_id = $1
|
||||||
|
AND p.system_variant = $2
|
||||||
|
AND pt.track_id = $3
|
||||||
|
LIMIT 1
|
||||||
|
`
|
||||||
|
|
||||||
|
type GetSystemPickKindForTrackParams struct {
|
||||||
|
UserID pgtype.UUID
|
||||||
|
SystemVariant *string
|
||||||
|
TrackID pgtype.UUID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Looks a track up in the user's CURRENT snapshot of the given system
|
||||||
|
// variant and returns its pick_kind. Used at play-ingestion time to
|
||||||
|
// freeze provenance onto the play_event — snapshots rebuild daily, so
|
||||||
|
// attribution can't be reconstructed at read time (#1249/#1270). No
|
||||||
|
// row = track not in today's snapshot (caller stores NULL); a row with
|
||||||
|
// NULL pick_kind = the variant doesn't stamp (yet). songs_like_artist
|
||||||
|
// is non-singleton (up to 3 mixes/user); a track in two of them takes
|
||||||
|
// whichever LIMIT 1 hits — acceptable, tier stamps there describe the
|
||||||
|
// same eligibility ladder.
|
||||||
|
func (q *Queries) GetSystemPickKindForTrack(ctx context.Context, arg GetSystemPickKindForTrackParams) (*string, error) {
|
||||||
|
row := q.db.QueryRow(ctx, getSystemPickKindForTrack, arg.UserID, arg.SystemVariant, arg.TrackID)
|
||||||
|
var pick_kind *string
|
||||||
|
err := row.Scan(&pick_kind)
|
||||||
|
return pick_kind, err
|
||||||
|
}
|
||||||
|
|
||||||
const insertPlayEvent = `-- name: InsertPlayEvent :one
|
const insertPlayEvent = `-- name: InsertPlayEvent :one
|
||||||
INSERT INTO play_events (
|
INSERT INTO play_events (
|
||||||
user_id, track_id, session_id, started_at, client_id, source, pick_kind
|
user_id, track_id, session_id, started_at, client_id, source, pick_kind
|
||||||
@@ -154,8 +159,9 @@ type InsertPlayEventParams struct {
|
|||||||
PickKind *string
|
PickKind *string
|
||||||
}
|
}
|
||||||
|
|
||||||
// pick_kind is non-NULL only for source='for_you' plays whose track was
|
// pick_kind is non-NULL only for system-playlist plays whose track was
|
||||||
// found in the user's live For You snapshot at ingestion time (#1249).
|
// found (with a stamped kind) in the user's live snapshot for that
|
||||||
|
// variant at ingestion time (#1249, generalized in #1270).
|
||||||
func (q *Queries) InsertPlayEvent(ctx context.Context, arg InsertPlayEventParams) (PlayEvent, error) {
|
func (q *Queries) InsertPlayEvent(ctx context.Context, arg InsertPlayEventParams) (PlayEvent, error) {
|
||||||
row := q.db.QueryRow(ctx, insertPlayEvent,
|
row := q.db.QueryRow(ctx, insertPlayEvent,
|
||||||
arg.UserID,
|
arg.UserID,
|
||||||
|
|||||||
@@ -0,0 +1,19 @@
|
|||||||
|
-- Rows stamped with the widened vocabulary must be cleared before the
|
||||||
|
-- narrow CHECK can be re-added; NULL reads as "unattributed", which is
|
||||||
|
-- the honest downgrade.
|
||||||
|
UPDATE play_events SET pick_kind = NULL
|
||||||
|
WHERE pick_kind NOT IN ('taste', 'fresh');
|
||||||
|
UPDATE playlist_tracks SET pick_kind = NULL
|
||||||
|
WHERE pick_kind NOT IN ('taste', 'fresh');
|
||||||
|
|
||||||
|
ALTER TABLE play_events
|
||||||
|
DROP CONSTRAINT play_events_pick_kind_check;
|
||||||
|
ALTER TABLE play_events
|
||||||
|
ADD CONSTRAINT play_events_pick_kind_check
|
||||||
|
CHECK (pick_kind IN ('taste', 'fresh'));
|
||||||
|
|
||||||
|
ALTER TABLE playlist_tracks
|
||||||
|
DROP CONSTRAINT playlist_tracks_pick_kind_check;
|
||||||
|
ALTER TABLE playlist_tracks
|
||||||
|
ADD CONSTRAINT playlist_tracks_pick_kind_check
|
||||||
|
CHECK (pick_kind IN ('taste', 'fresh'));
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
-- Provenance as standard (milestone #127, Scribe #1270).
|
||||||
|
--
|
||||||
|
-- #1249 introduced pick_kind as a For You one-off ('taste'/'fresh').
|
||||||
|
-- The mechanism — stamp WHY a track is in the snapshot at build time,
|
||||||
|
-- freeze it onto the play at ingestion, break it down in metrics — is
|
||||||
|
-- now the standard for every system mix, so the CHECK vocabulary
|
||||||
|
-- widens to cover:
|
||||||
|
--
|
||||||
|
-- taste / fresh For You head vs freshness-injection tail
|
||||||
|
-- dormant / cross_user / random
|
||||||
|
-- Discover's three candidate buckets — makes
|
||||||
|
-- the 40/30/30 allocation measurable instead
|
||||||
|
-- of a guess
|
||||||
|
-- tier1 / tier2 / tier3 the tiered-eligibility ladder (project rule
|
||||||
|
-- #131): tier 1 pins the mix's exact desire,
|
||||||
|
-- each higher tier steps back a little. Tier
|
||||||
|
-- provenance is how we measure what the
|
||||||
|
-- step-back trades away.
|
||||||
|
--
|
||||||
|
-- Postgres CHECK whitelists can't be altered in place: DROP + re-ADD
|
||||||
|
-- with the expanded list, in the same migration.
|
||||||
|
|
||||||
|
ALTER TABLE playlist_tracks
|
||||||
|
DROP CONSTRAINT playlist_tracks_pick_kind_check;
|
||||||
|
ALTER TABLE playlist_tracks
|
||||||
|
ADD CONSTRAINT playlist_tracks_pick_kind_check
|
||||||
|
CHECK (pick_kind IN (
|
||||||
|
'taste', 'fresh',
|
||||||
|
'dormant', 'cross_user', 'random',
|
||||||
|
'tier1', 'tier2', 'tier3'
|
||||||
|
));
|
||||||
|
|
||||||
|
ALTER TABLE play_events
|
||||||
|
DROP CONSTRAINT play_events_pick_kind_check;
|
||||||
|
ALTER TABLE play_events
|
||||||
|
ADD CONSTRAINT play_events_pick_kind_check
|
||||||
|
CHECK (pick_kind IN (
|
||||||
|
'taste', 'fresh',
|
||||||
|
'dormant', 'cross_user', 'random',
|
||||||
|
'tier1', 'tier2', 'tier3'
|
||||||
|
));
|
||||||
@@ -24,25 +24,30 @@ ORDER BY started_at DESC
|
|||||||
LIMIT 1;
|
LIMIT 1;
|
||||||
|
|
||||||
-- name: InsertPlayEvent :one
|
-- name: InsertPlayEvent :one
|
||||||
-- pick_kind is non-NULL only for source='for_you' plays whose track was
|
-- pick_kind is non-NULL only for system-playlist plays whose track was
|
||||||
-- found in the user's live For You snapshot at ingestion time (#1249).
|
-- found (with a stamped kind) in the user's live snapshot for that
|
||||||
|
-- variant at ingestion time (#1249, generalized in #1270).
|
||||||
INSERT INTO play_events (
|
INSERT INTO play_events (
|
||||||
user_id, track_id, session_id, started_at, client_id, source, pick_kind
|
user_id, track_id, session_id, started_at, client_id, source, pick_kind
|
||||||
) VALUES ($1, $2, $3, $4, $5, $6, sqlc.narg(pick_kind)::text)
|
) VALUES ($1, $2, $3, $4, $5, $6, sqlc.narg(pick_kind)::text)
|
||||||
RETURNING *;
|
RETURNING *;
|
||||||
|
|
||||||
-- name: GetForYouPickKindForTrack :one
|
-- name: GetSystemPickKindForTrack :one
|
||||||
-- Looks a track up in the user's CURRENT For You snapshot and returns
|
-- Looks a track up in the user's CURRENT snapshot of the given system
|
||||||
-- its pick_kind ('taste'/'fresh'). Used at play-ingestion time to
|
-- variant and returns its pick_kind. Used at play-ingestion time to
|
||||||
-- freeze exploration attribution onto the play_event — the snapshot is
|
-- freeze provenance onto the play_event — snapshots rebuild daily, so
|
||||||
-- rebuilt daily, so attribution can't be reconstructed at read time
|
-- attribution can't be reconstructed at read time (#1249/#1270). No
|
||||||
-- (#1249). No row = track not in today's snapshot (caller stores NULL).
|
-- row = track not in today's snapshot (caller stores NULL); a row with
|
||||||
|
-- NULL pick_kind = the variant doesn't stamp (yet). songs_like_artist
|
||||||
|
-- is non-singleton (up to 3 mixes/user); a track in two of them takes
|
||||||
|
-- whichever LIMIT 1 hits — acceptable, tier stamps there describe the
|
||||||
|
-- same eligibility ladder.
|
||||||
SELECT pt.pick_kind
|
SELECT pt.pick_kind
|
||||||
FROM playlist_tracks pt
|
FROM playlist_tracks pt
|
||||||
JOIN playlists p ON p.id = pt.playlist_id
|
JOIN playlists p ON p.id = pt.playlist_id
|
||||||
WHERE p.user_id = $1
|
WHERE p.user_id = $1
|
||||||
AND p.system_variant = 'for_you'
|
AND p.system_variant = $2
|
||||||
AND pt.track_id = $2
|
AND pt.track_id = $3
|
||||||
LIMIT 1;
|
LIMIT 1;
|
||||||
|
|
||||||
-- name: UpdatePlayEventEnded :one
|
-- name: UpdatePlayEventEnded :one
|
||||||
|
|||||||
@@ -97,25 +97,26 @@ var systemPlaylistSources = map[string]bool{
|
|||||||
"songs_like_artist": true,
|
"songs_like_artist": true,
|
||||||
}
|
}
|
||||||
|
|
||||||
// sourceForYou is the one source whose plays carry exploration
|
// lookupSystemPickKind resolves a system-playlist play's pick_kind
|
||||||
// attribution (taste vs fresh pick, #1249).
|
// (taste/fresh, Discover's bucket, or a rule-#131 tier) from the user's
|
||||||
const sourceForYou = "for_you"
|
// live snapshot of that variant at ingestion time. Attribution must be
|
||||||
|
// frozen now — snapshots rebuild daily, so it can't be reconstructed at
|
||||||
// lookupForYouPickKind resolves a For-You play's pick_kind ('taste' or
|
// metrics-read time (#1249, generalized in #1270). Source strings
|
||||||
// 'fresh') from the user's live snapshot at ingestion time. Attribution
|
// double as system_variant values (see systemPlaylistSources). Returns
|
||||||
// must be frozen now — the snapshot rebuilds daily, so it can't be
|
// nil (unattributed) when the track isn't in the current snapshot —
|
||||||
// reconstructed at metrics-read time. Returns nil (unattributed) when
|
// plays predating the feature, or an offline replay arriving after the
|
||||||
// the track isn't in the current snapshot: plays predating the feature,
|
// track rotated out — and when the variant doesn't stamp yet. Real DB
|
||||||
// or an offline replay arriving after the track rotated out. Real DB
|
|
||||||
// errors propagate and fail the caller's transaction.
|
// errors propagate and fail the caller's transaction.
|
||||||
func lookupForYouPickKind(
|
func lookupSystemPickKind(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
q *dbq.Queries,
|
q *dbq.Queries,
|
||||||
userID, trackID pgtype.UUID,
|
userID, trackID pgtype.UUID,
|
||||||
|
variant string,
|
||||||
) (*string, error) {
|
) (*string, error) {
|
||||||
kind, err := q.GetForYouPickKindForTrack(ctx, dbq.GetForYouPickKindForTrackParams{
|
kind, err := q.GetSystemPickKindForTrack(ctx, dbq.GetSystemPickKindForTrackParams{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
TrackID: trackID,
|
SystemVariant: &variant,
|
||||||
|
TrackID: trackID,
|
||||||
})
|
})
|
||||||
if errors.Is(err, pgx.ErrNoRows) {
|
if errors.Is(err, pgx.ErrNoRows) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -157,8 +158,8 @@ func (w *Writer) RecordPlayStartedWithSource(
|
|||||||
sourcePtr = &source
|
sourcePtr = &source
|
||||||
}
|
}
|
||||||
var pickKind *string
|
var pickKind *string
|
||||||
if source == sourceForYou {
|
if systemPlaylistSources[source] {
|
||||||
pickKind, err = lookupForYouPickKind(ctx, q, userID, trackID)
|
pickKind, err = lookupSystemPickKind(ctx, q, userID, trackID, source)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
@@ -433,8 +434,8 @@ func (w *Writer) RecordOfflinePlay(
|
|||||||
sourcePtr = &source
|
sourcePtr = &source
|
||||||
}
|
}
|
||||||
var pickKind *string
|
var pickKind *string
|
||||||
if source == sourceForYou {
|
if systemPlaylistSources[source] {
|
||||||
pickKind, err = lookupForYouPickKind(ctx, q, userID, trackID)
|
pickKind, err = lookupSystemPickKind(ctx, q, userID, trackID, source)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -489,14 +489,13 @@ func TestRecordPlayStarted_NoRotationWithoutSource(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// seedForYouSnapshot creates a system For You playlist containing the
|
// seedSystemSnapshot creates a system playlist of the given variant
|
||||||
// fixture track with the given pick_kind, mimicking what the builder
|
// containing the fixture track with the given pick_kind, mimicking
|
||||||
// persists (#1249).
|
// what the builder persists (#1249, generalized #1270).
|
||||||
func seedForYouSnapshot(t *testing.T, f fixture, pickKind string) {
|
func seedSystemSnapshot(t *testing.T, f fixture, variant, pickKind string) {
|
||||||
t.Helper()
|
t.Helper()
|
||||||
variant := "for_you"
|
|
||||||
pl, err := f.q.CreateSystemPlaylist(context.Background(), dbq.CreateSystemPlaylistParams{
|
pl, err := f.q.CreateSystemPlaylist(context.Background(), dbq.CreateSystemPlaylistParams{
|
||||||
UserID: f.user, Name: "For You", SystemVariant: &variant,
|
UserID: f.user, Name: variant, SystemVariant: &variant,
|
||||||
})
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("CreateSystemPlaylist: %v", err)
|
t.Fatalf("CreateSystemPlaylist: %v", err)
|
||||||
@@ -510,7 +509,7 @@ func seedForYouSnapshot(t *testing.T, f fixture, pickKind string) {
|
|||||||
|
|
||||||
func TestRecordPlayStartedWithSource_StampsForYouPickKind(t *testing.T) {
|
func TestRecordPlayStartedWithSource_StampsForYouPickKind(t *testing.T) {
|
||||||
f := newFixture(t, 200_000)
|
f := newFixture(t, 200_000)
|
||||||
seedForYouSnapshot(t, f, "fresh")
|
seedSystemSnapshot(t, f, "for_you", "fresh")
|
||||||
res, err := f.w.RecordPlayStartedWithSource(
|
res, err := f.w.RecordPlayStartedWithSource(
|
||||||
context.Background(), f.user, f.track, "c", "for_you", time.Now().UTC(),
|
context.Background(), f.user, f.track, "c", "for_you", time.Now().UTC(),
|
||||||
)
|
)
|
||||||
@@ -545,11 +544,13 @@ func TestRecordPlayStartedWithSource_NoSnapshotMatch_PickKindNull(t *testing.T)
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestRecordPlayStartedWithSource_NonForYouSource_PickKindNull(t *testing.T) {
|
func TestRecordPlayStartedWithSource_CrossVariant_PickKindNull(t *testing.T) {
|
||||||
// Even with a For You snapshot containing the track, a play from a
|
// Attribution is variant-scoped: even with a For You snapshot
|
||||||
// different surface must not pick up For You attribution.
|
// containing the track, a play from a different surface must not
|
||||||
|
// pick up For You's stamp — it reads its OWN variant's snapshot,
|
||||||
|
// which here doesn't exist.
|
||||||
f := newFixture(t, 200_000)
|
f := newFixture(t, 200_000)
|
||||||
seedForYouSnapshot(t, f, "taste")
|
seedSystemSnapshot(t, f, "for_you", "taste")
|
||||||
res, err := f.w.RecordPlayStartedWithSource(
|
res, err := f.w.RecordPlayStartedWithSource(
|
||||||
context.Background(), f.user, f.track, "c", "discover", time.Now().UTC(),
|
context.Background(), f.user, f.track, "c", "discover", time.Now().UTC(),
|
||||||
)
|
)
|
||||||
@@ -561,7 +562,27 @@ func TestRecordPlayStartedWithSource_NonForYouSource_PickKindNull(t *testing.T)
|
|||||||
t.Fatalf("get: %v", err)
|
t.Fatalf("get: %v", err)
|
||||||
}
|
}
|
||||||
if got.PickKind != nil {
|
if got.PickKind != nil {
|
||||||
t.Errorf("pick_kind = %q, want NULL for non-for_you source", *got.PickKind)
|
t.Errorf("pick_kind = %q, want NULL for a cross-variant play", *got.PickKind)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRecordPlayStartedWithSource_StampsDiscoverBucket(t *testing.T) {
|
||||||
|
// Discover stamps its candidate bucket (#1270); a discover play whose
|
||||||
|
// track is in the live snapshot freezes that bucket onto the play.
|
||||||
|
f := newFixture(t, 200_000)
|
||||||
|
seedSystemSnapshot(t, f, "discover", "dormant")
|
||||||
|
res, err := f.w.RecordPlayStartedWithSource(
|
||||||
|
context.Background(), f.user, f.track, "c", "discover", time.Now().UTC(),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("RecordPlayStartedWithSource: %v", err)
|
||||||
|
}
|
||||||
|
got, err := f.q.GetPlayEventByID(context.Background(), res.PlayEventID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get: %v", err)
|
||||||
|
}
|
||||||
|
if got.PickKind == nil || *got.PickKind != "dormant" {
|
||||||
|
t.Errorf("pick_kind = %v, want dormant (frozen from live snapshot)", got.PickKind)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -569,7 +590,7 @@ func TestRecordOfflinePlay_StampsForYouPickKind(t *testing.T) {
|
|||||||
// The offline replay path threads source the same way; a replayed
|
// The offline replay path threads source the same way; a replayed
|
||||||
// for_you play whose track is still in the snapshot gets attributed.
|
// for_you play whose track is still in the snapshot gets attributed.
|
||||||
f := newFixture(t, 200_000)
|
f := newFixture(t, 200_000)
|
||||||
seedForYouSnapshot(t, f, "taste")
|
seedSystemSnapshot(t, f, "for_you", "taste")
|
||||||
at := time.Now().UTC().Add(-time.Hour)
|
at := time.Now().UTC().Add(-time.Hour)
|
||||||
if err := f.w.RecordOfflinePlay(
|
if err := f.w.RecordOfflinePlay(
|
||||||
context.Background(), f.user, f.track, "c", "for_you", at, 180_000,
|
context.Background(), f.user, f.track, "c", "for_you", at, 180_000,
|
||||||
|
|||||||
@@ -20,11 +20,15 @@ const (
|
|||||||
|
|
||||||
// discoverTrack is the common shape used by the bucket allocator. The
|
// discoverTrack is the common shape used by the bucket allocator. The
|
||||||
// three sqlc-generated row types collapse into this internal struct so
|
// 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 {
|
type discoverTrack struct {
|
||||||
ID pgtype.UUID
|
ID pgtype.UUID
|
||||||
AlbumID pgtype.UUID
|
AlbumID pgtype.UUID
|
||||||
ArtistID pgtype.UUID
|
ArtistID pgtype.UUID
|
||||||
|
PickKind string
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildDiscoverCandidates assembles the Discover playlist track list.
|
// 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.
|
// already gave us the ranking.
|
||||||
ranked := make([]rankedCandidate, 0, len(out))
|
ranked := make([]rankedCandidate, 0, len(out))
|
||||||
for _, t := range 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
|
return ranked, nil
|
||||||
}
|
}
|
||||||
@@ -99,7 +103,10 @@ func buildDiscoverCandidates(ctx context.Context, q *dbq.Queries, logger *slog.L
|
|||||||
func dormantRowsToTracks(rows []dbq.ListDormantArtistTracksForDiscoverRow) []discoverTrack {
|
func dormantRowsToTracks(rows []dbq.ListDormantArtistTracksForDiscoverRow) []discoverTrack {
|
||||||
out := make([]discoverTrack, len(rows))
|
out := make([]discoverTrack, len(rows))
|
||||||
for i, r := range 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
|
return out
|
||||||
}
|
}
|
||||||
@@ -107,7 +114,10 @@ func dormantRowsToTracks(rows []dbq.ListDormantArtistTracksForDiscoverRow) []dis
|
|||||||
func crossUserRowsToTracks(rows []dbq.ListCrossUserLikedTracksForDiscoverRow) []discoverTrack {
|
func crossUserRowsToTracks(rows []dbq.ListCrossUserLikedTracksForDiscoverRow) []discoverTrack {
|
||||||
out := make([]discoverTrack, len(rows))
|
out := make([]discoverTrack, len(rows))
|
||||||
for i, r := range 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
|
return out
|
||||||
}
|
}
|
||||||
@@ -115,7 +125,10 @@ func crossUserRowsToTracks(rows []dbq.ListCrossUserLikedTracksForDiscoverRow) []
|
|||||||
func randomRowsToTracks(rows []dbq.ListRandomUnheardTracksForDiscoverRow) []discoverTrack {
|
func randomRowsToTracks(rows []dbq.ListRandomUnheardTracksForDiscoverRow) []discoverTrack {
|
||||||
out := make([]discoverTrack, len(rows))
|
out := make([]discoverTrack, len(rows))
|
||||||
for i, r := range 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
|
return out
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
// rankedCandidate is a (track_id, score) pair used during in-memory
|
||||||
// sorting before insert into playlist_tracks. T5 fills these from
|
// sorting before insert into playlist_tracks. T5 fills these from
|
||||||
// recommendation.Candidate scores. PickKind is set only by For-You's
|
// recommendation.Candidate scores. PickKind is the track's provenance
|
||||||
// head/tail composition (#1249); empty persists as NULL.
|
// 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 {
|
type rankedCandidate struct {
|
||||||
TrackID pgtype.UUID
|
TrackID pgtype.UUID
|
||||||
Score float64
|
Score float64
|
||||||
PickKind string
|
PickKind string
|
||||||
}
|
}
|
||||||
|
|
||||||
// For-You pick kinds, persisted on playlist_tracks.pick_kind so plays
|
// Pick kinds, persisted on playlist_tracks.pick_kind so plays can
|
||||||
// can attribute skips to "the taste engine missing" vs "the freshness
|
// attribute skips to the population that sourced the track — For You's
|
||||||
// tax" (#1249). Values mirror the CHECK in migration 0038.
|
// "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 (
|
const (
|
||||||
pickKindTaste = "taste"
|
pickKindTaste = "taste"
|
||||||
pickKindFresh = "fresh"
|
pickKindFresh = "fresh"
|
||||||
|
pickKindDormant = "dormant"
|
||||||
|
pickKindCrossUser = "cross_user"
|
||||||
|
pickKindRandom = "random"
|
||||||
)
|
)
|
||||||
|
|
||||||
const systemMixLength = 25
|
const systemMixLength = 25
|
||||||
|
|||||||
@@ -12,8 +12,10 @@ export type SurfaceMetric = {
|
|||||||
skip_rate: number;
|
skip_rate: number;
|
||||||
avg_completion: number;
|
avg_completion: number;
|
||||||
low_confidence: boolean;
|
low_confidence: boolean;
|
||||||
// Only For You carries a breakdown (#1249): taste picks vs fresh picks
|
// Present when the surface's builder stamps pick-kind provenance and
|
||||||
// (the deliberate freshness injection), plus pre-attribution plays.
|
// the window holds attributed plays (#1249, generalized #1270): For
|
||||||
|
// You's taste/fresh split, Discover's candidate buckets, the tiered
|
||||||
|
// mixes' tier1-3 — plus pre-attribution plays.
|
||||||
breakdown?: SurfaceMetric[];
|
breakdown?: SurfaceMetric[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -59,6 +59,18 @@
|
|||||||
function completionDeltaClass(m: SurfaceMetric, baseline: SurfaceMetric): string {
|
function completionDeltaClass(m: SurfaceMetric, baseline: SurfaceMetric): string {
|
||||||
return m.avg_completion < baseline.avg_completion ? 'text-danger' : 'text-text-secondary';
|
return m.avg_completion < baseline.avg_completion ? 'text-danger' : 'text-text-secondary';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Pick-kind breakdowns are collapsed by default (#1270): with every
|
||||||
|
// system mix stamping provenance, always-open sub-rows would triple
|
||||||
|
// the table height and bury the surface-level comparison.
|
||||||
|
let expandedBreakdowns = $state(new Set<string>());
|
||||||
|
|
||||||
|
function toggleBreakdown(key: string) {
|
||||||
|
const next = new Set(expandedBreakdowns);
|
||||||
|
if (next.has(key)) next.delete(key);
|
||||||
|
else next.add(key);
|
||||||
|
expandedBreakdowns = next;
|
||||||
|
}
|
||||||
const tokenMutation = createTokenMutation(queryClient);
|
const tokenMutation = createTokenMutation(queryClient);
|
||||||
const enabledMutation = createEnabledMutation(queryClient);
|
const enabledMutation = createEnabledMutation(queryClient);
|
||||||
|
|
||||||
@@ -352,7 +364,21 @@
|
|||||||
: undefined}
|
: undefined}
|
||||||
>
|
>
|
||||||
<td class="py-1">
|
<td class="py-1">
|
||||||
{m.label}{#if m.low_confidence}<span class="ml-1 text-xs text-text-secondary">· low data</span>{/if}
|
{#if (m.breakdown ?? []).length > 0}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="inline-flex items-center gap-1 rounded outline-none focus-visible:ring-1 focus-visible:ring-accent"
|
||||||
|
aria-expanded={expandedBreakdowns.has(m.key)}
|
||||||
|
onclick={() => toggleBreakdown(m.key)}
|
||||||
|
>
|
||||||
|
<span class="text-xs text-text-secondary" aria-hidden="true">
|
||||||
|
{expandedBreakdowns.has(m.key) ? '▾' : '▸'}
|
||||||
|
</span>
|
||||||
|
{m.label}
|
||||||
|
</button>
|
||||||
|
{:else}
|
||||||
|
{m.label}
|
||||||
|
{/if}{#if m.low_confidence}<span class="ml-1 text-xs text-text-secondary">· low data</span>{/if}
|
||||||
</td>
|
</td>
|
||||||
<td class="py-1 text-right tabular-nums">{m.plays}</td>
|
<td class="py-1 text-right tabular-nums">{m.plays}</td>
|
||||||
<td class="py-1 text-right tabular-nums">
|
<td class="py-1 text-right tabular-nums">
|
||||||
@@ -372,40 +398,44 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<!-- For You splits into taste picks vs the deliberate
|
<!-- Pick-kind provenance sub-rows (#1249/#1270): each
|
||||||
freshness injection (#1249) — the number that says
|
surface splits into the populations its builder
|
||||||
whether skips come from the taste engine missing or
|
stamped — For You's taste vs freshness injection,
|
||||||
from the exploration tax. -->
|
Discover's buckets, the tiered mixes' tiers — the
|
||||||
{#each m.breakdown ?? [] as b (b.key)}
|
numbers that say WHERE a surface's skips come from.
|
||||||
<tr
|
Toggled per surface to keep the default view flat. -->
|
||||||
class="border-t border-border/50 text-text-secondary"
|
{#if expandedBreakdowns.has(m.key)}
|
||||||
class:opacity-60={b.low_confidence}
|
{#each m.breakdown ?? [] as b (b.key)}
|
||||||
title={b.low_confidence
|
<tr
|
||||||
? 'Fewer than 20 plays — treat these rates as anecdote, not signal.'
|
class="border-t border-border/50 text-text-secondary"
|
||||||
: undefined}
|
class:opacity-60={b.low_confidence}
|
||||||
>
|
title={b.low_confidence
|
||||||
<td class="py-1 pl-4 text-xs">
|
? 'Fewer than 20 plays — treat these rates as anecdote, not signal.'
|
||||||
↳ {b.label}{#if b.low_confidence}<span class="ml-1">· low data</span>{/if}
|
: undefined}
|
||||||
</td>
|
>
|
||||||
<td class="py-1 text-right text-xs tabular-nums">{b.plays}</td>
|
<td class="py-1 pl-4 text-xs">
|
||||||
<td class="py-1 text-right text-xs tabular-nums">
|
↳ {b.label}{#if b.low_confidence}<span class="ml-1">· low data</span>{/if}
|
||||||
{pct(b.skip_rate)}
|
</td>
|
||||||
{#if baseline}
|
<td class="py-1 text-right text-xs tabular-nums">{b.plays}</td>
|
||||||
<span class="ml-1 {skipDeltaClass(b, baseline)}">
|
<td class="py-1 text-right text-xs tabular-nums">
|
||||||
{deltaPts(b.skip_rate, baseline.skip_rate)}
|
{pct(b.skip_rate)}
|
||||||
</span>
|
{#if baseline}
|
||||||
{/if}
|
<span class="ml-1 {skipDeltaClass(b, baseline)}">
|
||||||
</td>
|
{deltaPts(b.skip_rate, baseline.skip_rate)}
|
||||||
<td class="py-1 text-right text-xs tabular-nums">
|
</span>
|
||||||
{pct(b.avg_completion)}
|
{/if}
|
||||||
{#if baseline}
|
</td>
|
||||||
<span class="ml-1 {completionDeltaClass(b, baseline)}">
|
<td class="py-1 text-right text-xs tabular-nums">
|
||||||
{deltaPts(b.avg_completion, baseline.avg_completion)}
|
{pct(b.avg_completion)}
|
||||||
</span>
|
{#if baseline}
|
||||||
{/if}
|
<span class="ml-1 {completionDeltaClass(b, baseline)}">
|
||||||
</td>
|
{deltaPts(b.avg_completion, baseline.avg_completion)}
|
||||||
</tr>
|
</span>
|
||||||
{/each}
|
{/if}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
|||||||
@@ -198,7 +198,7 @@ describe('Settings page — Recommendation metrics card', () => {
|
|||||||
...over
|
...over
|
||||||
});
|
});
|
||||||
|
|
||||||
test('renders the For You taste/fresh breakdown rows (#1249)', async () => {
|
test('breakdown rows are collapsed by default and expand on toggle (#1249/#1270)', async () => {
|
||||||
setupPage();
|
setupPage();
|
||||||
metricsMock.data = {
|
metricsMock.data = {
|
||||||
window_days: 30,
|
window_days: 30,
|
||||||
@@ -225,13 +225,23 @@ describe('Settings page — Recommendation metrics card', () => {
|
|||||||
]
|
]
|
||||||
};
|
};
|
||||||
render(SettingsPage);
|
render(SettingsPage);
|
||||||
await waitFor(() => expect(screen.getByText('For You')).toBeInTheDocument());
|
const toggle = await screen.findByRole('button', { name: /for you/i });
|
||||||
|
// Collapsed by default: every stamping mix now carries a breakdown,
|
||||||
|
// so always-open sub-rows would swamp the surface-level view.
|
||||||
|
expect(toggle).toHaveAttribute('aria-expanded', 'false');
|
||||||
|
expect(screen.queryByText(/Taste picks/)).not.toBeInTheDocument();
|
||||||
|
|
||||||
|
await fireEvent.click(toggle);
|
||||||
|
expect(toggle).toHaveAttribute('aria-expanded', 'true');
|
||||||
expect(screen.getByText(/Taste picks/)).toBeInTheDocument();
|
expect(screen.getByText(/Taste picks/)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/Fresh picks/)).toBeInTheDocument();
|
expect(screen.getByText(/Fresh picks/)).toBeInTheDocument();
|
||||||
expect(screen.getByText(/Earlier plays/)).toBeInTheDocument();
|
expect(screen.getByText(/Earlier plays/)).toBeInTheDocument();
|
||||||
|
|
||||||
|
await fireEvent.click(toggle);
|
||||||
|
expect(screen.queryByText(/Taste picks/)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
|
||||||
test('surfaces without a breakdown render no sub-rows', async () => {
|
test('surfaces without a breakdown render no toggle and no sub-rows', async () => {
|
||||||
setupPage();
|
setupPage();
|
||||||
metricsMock.data = {
|
metricsMock.data = {
|
||||||
window_days: 30,
|
window_days: 30,
|
||||||
@@ -246,6 +256,7 @@ describe('Settings page — Recommendation metrics card', () => {
|
|||||||
};
|
};
|
||||||
render(SettingsPage);
|
render(SettingsPage);
|
||||||
await waitFor(() => expect(screen.getByText('Radio')).toBeInTheDocument());
|
await waitFor(() => expect(screen.getByText('Radio')).toBeInTheDocument());
|
||||||
|
expect(screen.queryByRole('button', { name: /radio/i })).not.toBeInTheDocument();
|
||||||
expect(screen.queryByText(/Taste picks/)).not.toBeInTheDocument();
|
expect(screen.queryByText(/Taste picks/)).not.toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user