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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user