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:
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -31,6 +31,33 @@ func (q *Queries) GetCurrentSessionVectorForUser(ctx context.Context, userID pgt
|
||||
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
|
||||
SELECT id, user_id, started_at, ended_at, last_event_at, track_count, client_id FROM play_sessions
|
||||
WHERE user_id = $1
|
||||
@@ -54,7 +81,7 @@ func (q *Queries) GetMostRecentPlaySessionForUser(ctx context.Context, userID pg
|
||||
}
|
||||
|
||||
const getOpenPlayEventForUser = `-- name: GetOpenPlayEventForUser :one
|
||||
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source FROM play_events
|
||||
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind FROM play_events
|
||||
WHERE user_id = $1 AND ended_at IS NULL
|
||||
ORDER BY started_at DESC
|
||||
LIMIT 1
|
||||
@@ -79,12 +106,13 @@ func (q *Queries) GetOpenPlayEventForUser(ctx context.Context, userID pgtype.UUI
|
||||
&i.SessionVectorAtPlay,
|
||||
&i.ScrobbledAt,
|
||||
&i.Source,
|
||||
&i.PickKind,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const getPlayEventByID = `-- name: GetPlayEventByID :one
|
||||
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source FROM play_events WHERE id = $1
|
||||
SELECT id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind FROM play_events WHERE id = $1
|
||||
`
|
||||
|
||||
func (q *Queries) GetPlayEventByID(ctx context.Context, id pgtype.UUID) (PlayEvent, error) {
|
||||
@@ -104,15 +132,16 @@ func (q *Queries) GetPlayEventByID(ctx context.Context, id pgtype.UUID) (PlayEve
|
||||
&i.SessionVectorAtPlay,
|
||||
&i.ScrobbledAt,
|
||||
&i.Source,
|
||||
&i.PickKind,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
const insertPlayEvent = `-- name: InsertPlayEvent :one
|
||||
INSERT INTO play_events (
|
||||
user_id, track_id, session_id, started_at, client_id, source
|
||||
) VALUES ($1, $2, $3, $4, $5, $6)
|
||||
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source
|
||||
user_id, track_id, session_id, started_at, client_id, source, pick_kind
|
||||
) VALUES ($1, $2, $3, $4, $5, $6, $7::text)
|
||||
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind
|
||||
`
|
||||
|
||||
type InsertPlayEventParams struct {
|
||||
@@ -122,8 +151,11 @@ type InsertPlayEventParams struct {
|
||||
StartedAt pgtype.Timestamptz
|
||||
ClientID *string
|
||||
Source *string
|
||||
PickKind *string
|
||||
}
|
||||
|
||||
// pick_kind is non-NULL only for source='for_you' plays whose track was
|
||||
// found in the user's live For You snapshot at ingestion time (#1249).
|
||||
func (q *Queries) InsertPlayEvent(ctx context.Context, arg InsertPlayEventParams) (PlayEvent, error) {
|
||||
row := q.db.QueryRow(ctx, insertPlayEvent,
|
||||
arg.UserID,
|
||||
@@ -132,6 +164,7 @@ func (q *Queries) InsertPlayEvent(ctx context.Context, arg InsertPlayEventParams
|
||||
arg.StartedAt,
|
||||
arg.ClientID,
|
||||
arg.Source,
|
||||
arg.PickKind,
|
||||
)
|
||||
var i PlayEvent
|
||||
err := row.Scan(
|
||||
@@ -148,6 +181,7 @@ func (q *Queries) InsertPlayEvent(ctx context.Context, arg InsertPlayEventParams
|
||||
&i.SessionVectorAtPlay,
|
||||
&i.ScrobbledAt,
|
||||
&i.Source,
|
||||
&i.PickKind,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -291,7 +325,7 @@ SET ended_at = $2,
|
||||
completion_ratio = $4,
|
||||
was_skipped = $5
|
||||
WHERE id = $1
|
||||
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source
|
||||
RETURNING id, user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped, client_id, session_vector_at_play, scrobbled_at, source, pick_kind
|
||||
`
|
||||
|
||||
type UpdatePlayEventEndedParams struct {
|
||||
@@ -328,6 +362,7 @@ func (q *Queries) UpdatePlayEventEnded(ctx context.Context, arg UpdatePlayEventE
|
||||
&i.SessionVectorAtPlay,
|
||||
&i.ScrobbledAt,
|
||||
&i.Source,
|
||||
&i.PickKind,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
|
||||
@@ -383,6 +383,7 @@ type PlayEvent struct {
|
||||
SessionVectorAtPlay []byte
|
||||
ScrobbledAt pgtype.Timestamptz
|
||||
Source *string
|
||||
PickKind *string
|
||||
}
|
||||
|
||||
type PlaySession struct {
|
||||
@@ -433,6 +434,7 @@ type PlaylistTrack struct {
|
||||
AlbumTitle string
|
||||
DurationSec int32
|
||||
AddedAt pgtype.Timestamptz
|
||||
PickKind *string
|
||||
}
|
||||
|
||||
type RegistrationSetting struct {
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
)
|
||||
|
||||
const appendPlaylistTrack = `-- name: AppendPlaylistTrack :one
|
||||
INSERT INTO playlist_tracks (playlist_id, position, track_id, title, artist_name, album_title, duration_sec)
|
||||
INSERT INTO playlist_tracks (playlist_id, position, track_id, title, artist_name, album_title, duration_sec, pick_kind)
|
||||
SELECT
|
||||
$1::uuid,
|
||||
COALESCE((SELECT MAX(position) + 1 FROM playlist_tracks WHERE playlist_id = $1::uuid), 0),
|
||||
@@ -20,24 +20,27 @@ SELECT
|
||||
t.title,
|
||||
artists.name,
|
||||
albums.title,
|
||||
(t.duration_ms / 1000)::integer
|
||||
(t.duration_ms / 1000)::integer,
|
||||
$2::text
|
||||
FROM tracks t
|
||||
JOIN albums ON albums.id = t.album_id
|
||||
JOIN artists ON artists.id = t.artist_id
|
||||
WHERE t.id = $2::uuid
|
||||
RETURNING playlist_id, position, track_id, title, artist_name, album_title, duration_sec, added_at
|
||||
WHERE t.id = $3::uuid
|
||||
RETURNING playlist_id, position, track_id, title, artist_name, album_title, duration_sec, added_at, pick_kind
|
||||
`
|
||||
|
||||
type AppendPlaylistTrackParams struct {
|
||||
PlaylistID pgtype.UUID
|
||||
PickKind *string
|
||||
TrackID pgtype.UUID
|
||||
}
|
||||
|
||||
// Inserts at the next available position. Snapshot fields are copied
|
||||
// from the tracks/albums/artists join at insert time. tracks.duration_ms
|
||||
// is converted to seconds for the snapshot.
|
||||
// is converted to seconds for the snapshot. pick_kind is NULL for manual
|
||||
// playlists; the For You builder stamps 'taste'/'fresh' (#1249).
|
||||
func (q *Queries) AppendPlaylistTrack(ctx context.Context, arg AppendPlaylistTrackParams) (PlaylistTrack, error) {
|
||||
row := q.db.QueryRow(ctx, appendPlaylistTrack, arg.PlaylistID, arg.TrackID)
|
||||
row := q.db.QueryRow(ctx, appendPlaylistTrack, arg.PlaylistID, arg.PickKind, arg.TrackID)
|
||||
var i PlaylistTrack
|
||||
err := row.Scan(
|
||||
&i.PlaylistID,
|
||||
@@ -48,6 +51,7 @@ func (q *Queries) AppendPlaylistTrack(ctx context.Context, arg AppendPlaylistTra
|
||||
&i.AlbumTitle,
|
||||
&i.DurationSec,
|
||||
&i.AddedAt,
|
||||
&i.PickKind,
|
||||
)
|
||||
return i, err
|
||||
}
|
||||
@@ -257,7 +261,7 @@ func (q *Queries) ListAllPlaylistTracksForCollage(ctx context.Context, arg ListA
|
||||
}
|
||||
|
||||
const listPlaylistTracks = `-- name: ListPlaylistTracks :many
|
||||
SELECT pt.playlist_id, pt.position, pt.track_id, pt.title, pt.artist_name, pt.album_title, pt.duration_sec, pt.added_at,
|
||||
SELECT pt.playlist_id, pt.position, pt.track_id, pt.title, pt.artist_name, pt.album_title, pt.duration_sec, pt.added_at, pt.pick_kind,
|
||||
t.id AS live_track_id,
|
||||
albums.id AS album_id,
|
||||
artists.id AS artist_id
|
||||
@@ -278,6 +282,7 @@ type ListPlaylistTracksRow struct {
|
||||
AlbumTitle string
|
||||
DurationSec int32
|
||||
AddedAt pgtype.Timestamptz
|
||||
PickKind *string
|
||||
LiveTrackID pgtype.UUID
|
||||
AlbumID pgtype.UUID
|
||||
ArtistID pgtype.UUID
|
||||
@@ -305,6 +310,7 @@ func (q *Queries) ListPlaylistTracks(ctx context.Context, playlistID pgtype.UUID
|
||||
&i.AlbumTitle,
|
||||
&i.DurationSec,
|
||||
&i.AddedAt,
|
||||
&i.PickKind,
|
||||
&i.LiveTrackID,
|
||||
&i.AlbumID,
|
||||
&i.ArtistID,
|
||||
|
||||
@@ -15,6 +15,7 @@ const recommendationSourceMetricsForUser = `-- name: RecommendationSourceMetrics
|
||||
|
||||
SELECT
|
||||
pe.source,
|
||||
pe.pick_kind,
|
||||
count(*)::bigint AS plays,
|
||||
count(*) FILTER (WHERE pe.was_skipped)::bigint AS skips,
|
||||
count(pe.completion_ratio)::bigint AS completion_n,
|
||||
@@ -22,7 +23,7 @@ SELECT
|
||||
FROM play_events pe
|
||||
WHERE pe.user_id = $1
|
||||
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
|
||||
GROUP BY pe.source
|
||||
GROUP BY pe.source, pe.pick_kind
|
||||
ORDER BY plays DESC
|
||||
`
|
||||
|
||||
@@ -33,6 +34,7 @@ type RecommendationSourceMetricsForUserParams struct {
|
||||
|
||||
type RecommendationSourceMetricsForUserRow struct {
|
||||
Source *string
|
||||
PickKind *string
|
||||
Plays int64
|
||||
Skips int64
|
||||
CompletionN int64
|
||||
@@ -50,6 +52,8 @@ type RecommendationSourceMetricsForUserRow struct {
|
||||
// avg_completion correctly.
|
||||
// $1 user_id, $2 window_days. plays/skips are counts; avg_completion is the
|
||||
// mean completion ratio over the completion_n plays that recorded one.
|
||||
// pick_kind splits For You plays into taste/fresh/unattributed (#1249);
|
||||
// it is NULL for every other source, so those still group to one row.
|
||||
func (q *Queries) RecommendationSourceMetricsForUser(ctx context.Context, arg RecommendationSourceMetricsForUserParams) ([]RecommendationSourceMetricsForUserRow, error) {
|
||||
rows, err := q.db.Query(ctx, recommendationSourceMetricsForUser, arg.UserID, arg.Column2)
|
||||
if err != nil {
|
||||
@@ -61,6 +65,7 @@ func (q *Queries) RecommendationSourceMetricsForUser(ctx context.Context, arg Re
|
||||
var i RecommendationSourceMetricsForUserRow
|
||||
if err := rows.Scan(
|
||||
&i.Source,
|
||||
&i.PickKind,
|
||||
&i.Plays,
|
||||
&i.Skips,
|
||||
&i.CompletionN,
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE play_events DROP COLUMN pick_kind;
|
||||
ALTER TABLE playlist_tracks DROP COLUMN pick_kind;
|
||||
@@ -0,0 +1,34 @@
|
||||
-- For You exploration attribution (milestone #127 step 2, Scribe #1249).
|
||||
--
|
||||
-- For You is deliberately composed of two populations: a head of
|
||||
-- top-scored similarity matches ("taste" picks) and a tail sampled from
|
||||
-- deeper in the candidate ranking ("fresh" picks — the freshness
|
||||
-- injection). Until now the metrics could only judge For You as one
|
||||
-- blob, so a high skip rate couldn't be read as "the taste engine is
|
||||
-- missing" vs "the freshness tax is too high".
|
||||
--
|
||||
-- Two denormalized columns, both nullable:
|
||||
--
|
||||
-- 1. playlist_tracks.pick_kind — stamped by the system-playlist builder
|
||||
-- at snapshot build time. NULL for manual playlists and for system
|
||||
-- variants that don't split (Discover, Songs like X, discovery
|
||||
-- mixes). System snapshots are atomically replaced daily, so the
|
||||
-- column never goes stale within a snapshot.
|
||||
--
|
||||
-- 2. play_events.pick_kind — stamped at play-ingestion time by looking
|
||||
-- the track up in the user's CURRENT For You snapshot. Attribution
|
||||
-- must be frozen at play time: For You rebuilds daily, so a 30-day
|
||||
-- metrics window spans ~30 snapshots and a join-at-read against the
|
||||
-- live snapshot would misattribute nearly everything. Plays that
|
||||
-- predate this feature (or whose track already rotated out, e.g. a
|
||||
-- late offline replay) stay NULL → reported as unattributed.
|
||||
|
||||
ALTER TABLE playlist_tracks
|
||||
ADD COLUMN pick_kind text
|
||||
CONSTRAINT playlist_tracks_pick_kind_check
|
||||
CHECK (pick_kind IN ('taste', 'fresh'));
|
||||
|
||||
ALTER TABLE play_events
|
||||
ADD COLUMN pick_kind text
|
||||
CONSTRAINT play_events_pick_kind_check
|
||||
CHECK (pick_kind IN ('taste', 'fresh'));
|
||||
@@ -24,11 +24,27 @@ ORDER BY started_at DESC
|
||||
LIMIT 1;
|
||||
|
||||
-- name: InsertPlayEvent :one
|
||||
-- pick_kind is non-NULL only for source='for_you' plays whose track was
|
||||
-- found in the user's live For You snapshot at ingestion time (#1249).
|
||||
INSERT INTO play_events (
|
||||
user_id, track_id, session_id, started_at, client_id, source
|
||||
) VALUES ($1, $2, $3, $4, $5, $6)
|
||||
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)
|
||||
RETURNING *;
|
||||
|
||||
-- name: GetForYouPickKindForTrack :one
|
||||
-- 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).
|
||||
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;
|
||||
|
||||
-- name: UpdatePlayEventEnded :one
|
||||
-- Closes a play_event by id with the given ended_at, duration, and skip flag.
|
||||
-- completion_ratio is computed from duration_played_ms and the track duration
|
||||
|
||||
@@ -68,8 +68,9 @@ ORDER BY pt.position;
|
||||
-- name: AppendPlaylistTrack :one
|
||||
-- Inserts at the next available position. Snapshot fields are copied
|
||||
-- from the tracks/albums/artists join at insert time. tracks.duration_ms
|
||||
-- is converted to seconds for the snapshot.
|
||||
INSERT INTO playlist_tracks (playlist_id, position, track_id, title, artist_name, album_title, duration_sec)
|
||||
-- is converted to seconds for the snapshot. pick_kind is NULL for manual
|
||||
-- playlists; the For You builder stamps 'taste'/'fresh' (#1249).
|
||||
INSERT INTO playlist_tracks (playlist_id, position, track_id, title, artist_name, album_title, duration_sec, pick_kind)
|
||||
SELECT
|
||||
sqlc.arg(playlist_id)::uuid,
|
||||
COALESCE((SELECT MAX(position) + 1 FROM playlist_tracks WHERE playlist_id = sqlc.arg(playlist_id)::uuid), 0),
|
||||
@@ -77,7 +78,8 @@ SELECT
|
||||
t.title,
|
||||
artists.name,
|
||||
albums.title,
|
||||
(t.duration_ms / 1000)::integer
|
||||
(t.duration_ms / 1000)::integer,
|
||||
sqlc.narg(pick_kind)::text
|
||||
FROM tracks t
|
||||
JOIN albums ON albums.id = t.album_id
|
||||
JOIN artists ON artists.id = t.artist_id
|
||||
|
||||
@@ -11,8 +11,11 @@
|
||||
-- name: RecommendationSourceMetricsForUser :many
|
||||
-- $1 user_id, $2 window_days. plays/skips are counts; avg_completion is the
|
||||
-- mean completion ratio over the completion_n plays that recorded one.
|
||||
-- pick_kind splits For You plays into taste/fresh/unattributed (#1249);
|
||||
-- it is NULL for every other source, so those still group to one row.
|
||||
SELECT
|
||||
pe.source,
|
||||
pe.pick_kind,
|
||||
count(*)::bigint AS plays,
|
||||
count(*) FILTER (WHERE pe.was_skipped)::bigint AS skips,
|
||||
count(pe.completion_ratio)::bigint AS completion_n,
|
||||
@@ -20,5 +23,5 @@ SELECT
|
||||
FROM play_events pe
|
||||
WHERE pe.user_id = $1
|
||||
AND pe.started_at > now() - ($2::float8 * INTERVAL '1 day')
|
||||
GROUP BY pe.source
|
||||
GROUP BY pe.source, pe.pick_kind
|
||||
ORDER BY plays DESC;
|
||||
|
||||
@@ -97,6 +97,35 @@ var systemPlaylistSources = map[string]bool{
|
||||
"songs_like_artist": true,
|
||||
}
|
||||
|
||||
// sourceForYou is the one source whose plays carry exploration
|
||||
// attribution (taste vs fresh pick, #1249).
|
||||
const sourceForYou = "for_you"
|
||||
|
||||
// lookupForYouPickKind resolves a For-You play's pick_kind ('taste' or
|
||||
// 'fresh') from the user's live snapshot at ingestion time. Attribution
|
||||
// must be frozen now — the snapshot rebuilds daily, so it can't be
|
||||
// reconstructed at metrics-read time. Returns nil (unattributed) when
|
||||
// the track isn't in the current snapshot: plays predating the feature,
|
||||
// or an offline replay arriving after the track rotated out. Real DB
|
||||
// errors propagate and fail the caller's transaction.
|
||||
func lookupForYouPickKind(
|
||||
ctx context.Context,
|
||||
q *dbq.Queries,
|
||||
userID, trackID pgtype.UUID,
|
||||
) (*string, error) {
|
||||
kind, err := q.GetForYouPickKindForTrack(ctx, dbq.GetForYouPickKindForTrackParams{
|
||||
UserID: userID,
|
||||
TrackID: trackID,
|
||||
})
|
||||
if errors.Is(err, pgx.ErrNoRows) {
|
||||
return nil, nil
|
||||
}
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return kind, nil
|
||||
}
|
||||
|
||||
// RecordPlayStartedWithSource is RecordPlayStarted plus a `source`
|
||||
// tag identifying which surface the play came from. When source is a
|
||||
// known system-playlist kind the track is appended to that user's
|
||||
@@ -127,6 +156,13 @@ func (w *Writer) RecordPlayStartedWithSource(
|
||||
if source != "" {
|
||||
sourcePtr = &source
|
||||
}
|
||||
var pickKind *string
|
||||
if source == sourceForYou {
|
||||
pickKind, err = lookupForYouPickKind(ctx, q, userID, trackID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
ev, err := q.InsertPlayEvent(ctx, dbq.InsertPlayEventParams{
|
||||
UserID: userID,
|
||||
TrackID: trackID,
|
||||
@@ -134,6 +170,7 @@ func (w *Writer) RecordPlayStartedWithSource(
|
||||
StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
|
||||
ClientID: clientIDPtr,
|
||||
Source: sourcePtr,
|
||||
PickKind: pickKind,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
@@ -395,6 +432,13 @@ func (w *Writer) RecordOfflinePlay(
|
||||
if source != "" {
|
||||
sourcePtr = &source
|
||||
}
|
||||
var pickKind *string
|
||||
if source == sourceForYou {
|
||||
pickKind, err = lookupForYouPickKind(ctx, q, userID, trackID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
ev, err := q.InsertPlayEvent(ctx, dbq.InsertPlayEventParams{
|
||||
UserID: userID,
|
||||
TrackID: trackID,
|
||||
@@ -402,6 +446,7 @@ func (w *Writer) RecordOfflinePlay(
|
||||
StartedAt: pgtype.Timestamptz{Time: at, Valid: true},
|
||||
ClientID: clientIDPtr,
|
||||
Source: sourcePtr,
|
||||
PickKind: pickKind,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
|
||||
@@ -488,3 +488,102 @@ func TestRecordPlayStarted_NoRotationWithoutSource(t *testing.T) {
|
||||
t.Errorf("expected no rotation row for a source-less play")
|
||||
}
|
||||
}
|
||||
|
||||
// seedForYouSnapshot creates a system For You playlist containing the
|
||||
// fixture track with the given pick_kind, mimicking what the builder
|
||||
// persists (#1249).
|
||||
func seedForYouSnapshot(t *testing.T, f fixture, pickKind string) {
|
||||
t.Helper()
|
||||
variant := "for_you"
|
||||
pl, err := f.q.CreateSystemPlaylist(context.Background(), dbq.CreateSystemPlaylistParams{
|
||||
UserID: f.user, Name: "For You", SystemVariant: &variant,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CreateSystemPlaylist: %v", err)
|
||||
}
|
||||
if _, err := f.q.AppendPlaylistTrack(context.Background(), dbq.AppendPlaylistTrackParams{
|
||||
PlaylistID: pl.ID, TrackID: f.track, PickKind: &pickKind,
|
||||
}); err != nil {
|
||||
t.Fatalf("AppendPlaylistTrack: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordPlayStartedWithSource_StampsForYouPickKind(t *testing.T) {
|
||||
f := newFixture(t, 200_000)
|
||||
seedForYouSnapshot(t, f, "fresh")
|
||||
res, err := f.w.RecordPlayStartedWithSource(
|
||||
context.Background(), f.user, f.track, "c", "for_you", 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 != "fresh" {
|
||||
t.Errorf("pick_kind = %v, want fresh (frozen from live snapshot)", got.PickKind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordPlayStartedWithSource_NoSnapshotMatch_PickKindNull(t *testing.T) {
|
||||
// A for_you play whose track isn't in the current snapshot (rotated
|
||||
// out, or no snapshot exists) stays unattributed rather than guessing.
|
||||
f := newFixture(t, 200_000)
|
||||
res, err := f.w.RecordPlayStartedWithSource(
|
||||
context.Background(), f.user, f.track, "c", "for_you", 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 {
|
||||
t.Errorf("pick_kind = %q, want NULL (track not in snapshot)", *got.PickKind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordPlayStartedWithSource_NonForYouSource_PickKindNull(t *testing.T) {
|
||||
// Even with a For You snapshot containing the track, a play from a
|
||||
// different surface must not pick up For You attribution.
|
||||
f := newFixture(t, 200_000)
|
||||
seedForYouSnapshot(t, f, "taste")
|
||||
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 {
|
||||
t.Errorf("pick_kind = %q, want NULL for non-for_you source", *got.PickKind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecordOfflinePlay_StampsForYouPickKind(t *testing.T) {
|
||||
// The offline replay path threads source the same way; a replayed
|
||||
// for_you play whose track is still in the snapshot gets attributed.
|
||||
f := newFixture(t, 200_000)
|
||||
seedForYouSnapshot(t, f, "taste")
|
||||
at := time.Now().UTC().Add(-time.Hour)
|
||||
if err := f.w.RecordOfflinePlay(
|
||||
context.Background(), f.user, f.track, "c", "for_you", at, 180_000,
|
||||
); err != nil {
|
||||
t.Fatalf("RecordOfflinePlay: %v", err)
|
||||
}
|
||||
var pickKind *string
|
||||
if err := f.pool.QueryRow(context.Background(),
|
||||
`SELECT pick_kind FROM play_events WHERE user_id = $1 AND track_id = $2`,
|
||||
f.user, f.track,
|
||||
).Scan(&pickKind); err != nil {
|
||||
t.Fatalf("select: %v", err)
|
||||
}
|
||||
if pickKind == nil || *pickKind != "taste" {
|
||||
t.Errorf("pick_kind = %v, want taste", pickKind)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -12,6 +12,9 @@ export type SurfaceMetric = {
|
||||
skip_rate: number;
|
||||
avg_completion: number;
|
||||
low_confidence: boolean;
|
||||
// Only For You carries a breakdown (#1249): taste picks vs fresh picks
|
||||
// (the deliberate freshness injection), plus pre-attribution plays.
|
||||
breakdown?: SurfaceMetric[];
|
||||
};
|
||||
|
||||
export type SurfaceIntent = 'go_to' | 'discovery' | 'direct';
|
||||
|
||||
@@ -372,6 +372,40 @@
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
<!-- For You splits into taste picks vs the deliberate
|
||||
freshness injection (#1249) — the number that says
|
||||
whether skips come from the taste engine missing or
|
||||
from the exploration tax. -->
|
||||
{#each m.breakdown ?? [] as b (b.key)}
|
||||
<tr
|
||||
class="border-t border-border/50 text-text-secondary"
|
||||
class:opacity-60={b.low_confidence}
|
||||
title={b.low_confidence
|
||||
? 'Fewer than 20 plays — treat these rates as anecdote, not signal.'
|
||||
: undefined}
|
||||
>
|
||||
<td class="py-1 pl-4 text-xs">
|
||||
↳ {b.label}{#if b.low_confidence}<span class="ml-1">· low data</span>{/if}
|
||||
</td>
|
||||
<td class="py-1 text-right text-xs tabular-nums">{b.plays}</td>
|
||||
<td class="py-1 text-right text-xs tabular-nums">
|
||||
{pct(b.skip_rate)}
|
||||
{#if baseline}
|
||||
<span class="ml-1 {skipDeltaClass(b, baseline)}">
|
||||
{deltaPts(b.skip_rate, baseline.skip_rate)}
|
||||
</span>
|
||||
{/if}
|
||||
</td>
|
||||
<td class="py-1 text-right text-xs tabular-nums">
|
||||
{pct(b.avg_completion)}
|
||||
{#if baseline}
|
||||
<span class="ml-1 {completionDeltaClass(b, baseline)}">
|
||||
{deltaPts(b.avg_completion, baseline.avg_completion)}
|
||||
</span>
|
||||
{/if}
|
||||
</td>
|
||||
</tr>
|
||||
{/each}
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -20,10 +20,16 @@ vi.mock('$lib/api/me', () => ({
|
||||
regenerateAPIToken: vi.fn()
|
||||
}));
|
||||
|
||||
// Mutable holder so individual tests can inject populated metrics;
|
||||
// vi.mock is hoisted, hence vi.hoisted for the shared reference.
|
||||
const metricsMock = vi.hoisted(() => ({
|
||||
data: { window_days: 30, baseline: null, groups: [] } as unknown
|
||||
}));
|
||||
|
||||
vi.mock('$lib/api/metrics', () => ({
|
||||
createRecommendationMetricsQuery: () => ({
|
||||
subscribe: (run: (v: unknown) => void) => {
|
||||
run({ isPending: false, isError: false, data: { window_days: 30, baseline: null, groups: [] } });
|
||||
run({ isPending: false, isError: false, data: metricsMock.data });
|
||||
return () => {};
|
||||
}
|
||||
})
|
||||
@@ -52,7 +58,10 @@ function mockMutationStore(mutateFn: (vars: unknown) => void = vi.fn()) {
|
||||
return readable({ isPending: false, mutate: mutateFn, mutateAsync: vi.fn() });
|
||||
}
|
||||
|
||||
afterEach(() => vi.clearAllMocks());
|
||||
afterEach(() => {
|
||||
vi.clearAllMocks();
|
||||
metricsMock.data = { window_days: 30, baseline: null, groups: [] };
|
||||
});
|
||||
|
||||
describe('Settings page — ListenBrainz', () => {
|
||||
test('token-not-set state renders input + Save button', async () => {
|
||||
@@ -177,6 +186,70 @@ describe('Settings page — Password card', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('Settings page — Recommendation metrics card', () => {
|
||||
const metric = (key: string, label: string, over: Record<string, unknown> = {}) => ({
|
||||
key,
|
||||
label,
|
||||
plays: 30,
|
||||
skips: 3,
|
||||
skip_rate: 0.1,
|
||||
avg_completion: 0.9,
|
||||
low_confidence: false,
|
||||
...over
|
||||
});
|
||||
|
||||
test('renders the For You taste/fresh breakdown rows (#1249)', async () => {
|
||||
setupPage();
|
||||
metricsMock.data = {
|
||||
window_days: 30,
|
||||
baseline: metric('manual', 'Manual library plays', { plays: 100 }),
|
||||
groups: [
|
||||
{
|
||||
intent: 'go_to',
|
||||
label: 'Go-to surfaces',
|
||||
surfaces: [
|
||||
metric('for_you', 'For You', {
|
||||
plays: 45,
|
||||
breakdown: [
|
||||
metric('for_you_taste', 'Taste picks'),
|
||||
metric('for_you_fresh', 'Fresh picks', {
|
||||
plays: 10,
|
||||
skip_rate: 0.4,
|
||||
low_confidence: true
|
||||
}),
|
||||
metric('for_you_unattributed', 'Earlier plays', { plays: 5 })
|
||||
]
|
||||
})
|
||||
]
|
||||
}
|
||||
]
|
||||
};
|
||||
render(SettingsPage);
|
||||
await waitFor(() => expect(screen.getByText('For You')).toBeInTheDocument());
|
||||
expect(screen.getByText(/Taste picks/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Fresh picks/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/Earlier plays/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('surfaces without a breakdown render no sub-rows', async () => {
|
||||
setupPage();
|
||||
metricsMock.data = {
|
||||
window_days: 30,
|
||||
baseline: null,
|
||||
groups: [
|
||||
{
|
||||
intent: 'go_to',
|
||||
label: 'Go-to surfaces',
|
||||
surfaces: [metric('radio', 'Radio')]
|
||||
}
|
||||
]
|
||||
};
|
||||
render(SettingsPage);
|
||||
await waitFor(() => expect(screen.getByText('Radio')).toBeInTheDocument());
|
||||
expect(screen.queryByText(/Taste picks/)).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Settings page — API Token card', () => {
|
||||
test('first click on Regenerate shows "Click again to confirm"', async () => {
|
||||
setupPage();
|
||||
|
||||
Reference in New Issue
Block a user