feat(discover): taste-targeted novelty bucket + rebalance toward discovery
test-go / test (push) Successful in 30s
test-go / integration (push) Successful in 4m38s

The 2-week metrics review found Discover beating the manual baseline on
skip rate — which for a discovery surface means it plays it safe. On a
single-user server it's effectively dormant + crude-random, and the
per-user taste profile (taste_profile_tags, #796) went unused (#1254 gap).

Add a fourth Discover candidate bucket, ListTasteUnheardTracksForDiscover:
unheard / non-liked / non-quarantined tracks ranked by summed taste-tag
weight over tracks.genre (split like the radio tag_overlap arm), md5
tiebreak. Its picks are stamped pick_kind = 'taste_unheard' (migration
0041 widens the CHECK on playlist_tracks + play_events, rule #36).

Rebalance the slot allocation 40/30/30 → taste_unheard 35 / dormant 30 /
cross_user 20 / random 15, and lead the interleave with taste_unheard so
a track shared with another bucket keeps the taste stamp and the
targeted-novelty arm stays measurable. Metrics label "Taste-matched" +
order entry added to the single server-side pickKindLabels map, so web
and Android surface the new breakdown row with no client change.

Cold start (empty taste_profile_tags) yields an empty taste bucket that
redistributes to the survivors, so Discover still fills.

Scribe #1488. Companion review outcomes: Songs-like starvation already
fixed (#1255); For You v2 ratified as-is (fresh-injection cost disproven).
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-13 18:16:04 -04:00
parent 772a52b23e
commit 7226dab9ff
8 changed files with 267 additions and 60 deletions
+66
View File
@@ -211,3 +211,69 @@ func (q *Queries) ListRandomUnheardTracksForDiscover(ctx context.Context, arg Li
}
return items, nil
}
const listTasteUnheardTracksForDiscover = `-- name: ListTasteUnheardTracksForDiscover :many
SELECT t.id, t.album_id, t.artist_id
FROM tracks t
JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g_split(g) ON true
JOIN taste_profile_tags nt ON nt.user_id = $1 AND trim(g_split.g) = nt.tag
WHERE nt.weight > 0
AND trim(g_split.g) <> ''
AND NOT EXISTS (
SELECT 1 FROM play_events pe
WHERE pe.user_id = $1
AND pe.track_id = t.id
AND pe.was_skipped = false
)
AND NOT EXISTS (
SELECT 1 FROM general_likes gl
WHERE gl.user_id = $1 AND gl.track_id = t.id
)
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
GROUP BY t.id, t.album_id, t.artist_id
ORDER BY SUM(nt.weight) DESC, md5(t.id::text || $2::text)
LIMIT 120
`
type ListTasteUnheardTracksForDiscoverParams struct {
UserID pgtype.UUID
Column2 string
}
type ListTasteUnheardTracksForDiscoverRow struct {
ID pgtype.UUID
AlbumID pgtype.UUID
ArtistID pgtype.UUID
}
// Taste-targeted novelty: unheard tracks whose genres overlap the user's
// taste-profile tags (taste_profile_tags, #796), ranked by summed tag
// weight — "new to you, but your vibe" rather than the crude random arm.
// Genres live inline on tracks.genre as a delimited string, split the
// same way the radio tag_overlap arm does (regexp_split_to_table on
// [;,]). Same exclusion filters as the other buckets. Returns nothing
// when the user has no taste tags yet (cold start), so the caller
// redistributes its slots to the other buckets. Stamped 'taste_unheard'.
// $1 = user_id, $2 = date string for md5 tiebreak ordering.
func (q *Queries) ListTasteUnheardTracksForDiscover(ctx context.Context, arg ListTasteUnheardTracksForDiscoverParams) ([]ListTasteUnheardTracksForDiscoverRow, error) {
rows, err := q.db.Query(ctx, listTasteUnheardTracksForDiscover, arg.UserID, arg.Column2)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListTasteUnheardTracksForDiscoverRow
for rows.Next() {
var i ListTasteUnheardTracksForDiscoverRow
if err := rows.Scan(&i.ID, &i.AlbumID, &i.ArtistID); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
@@ -0,0 +1,27 @@
-- Reverse 0041_discover_taste_unheard.up.sql.
--
-- Null out any rows stamped with the new value before shrinking the
-- CHECK back to the 0039 vocabulary, or the ADD CONSTRAINT would fail
-- on existing 'taste_unheard' rows (mirrors 0039's down).
UPDATE play_events SET pick_kind = NULL WHERE pick_kind = 'taste_unheard';
UPDATE playlist_tracks SET pick_kind = NULL WHERE pick_kind = 'taste_unheard';
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'
));
@@ -0,0 +1,32 @@
-- Discover taste-targeted novelty bucket (milestone #127, Scribe #1252/#1488).
--
-- The 2-week metrics review found Discover beating the manual baseline on
-- skip rate — which for a discovery surface means it is playing it safe:
-- on a single-user server it is effectively dormant + crude-random, and
-- the per-user taste profile (n_tags, #796) went unused. This adds a
-- fourth Discover candidate bucket that ranks unheard tracks by the
-- user's taste-profile tag weights ("novelty that fits your vibe"), so
-- its picks need a new pick_kind provenance value.
--
-- Postgres CHECK whitelists can't be altered in place: DROP + re-ADD with
-- the expanded list, in the same migration (family rule — new CHECK-enum
-- value needs a same-change 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', 'taste_unheard',
'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', 'taste_unheard',
'tier1', 'tier2', 'tier3'
));
+34
View File
@@ -102,3 +102,37 @@ SELECT t.id, t.album_id, t.artist_id
)
ORDER BY md5(t.id::text || $2::text)
LIMIT 200;
-- name: ListTasteUnheardTracksForDiscover :many
-- Taste-targeted novelty: unheard tracks whose genres overlap the user's
-- taste-profile tags (taste_profile_tags, #796), ranked by summed tag
-- weight — "new to you, but your vibe" rather than the crude random arm.
-- Genres live inline on tracks.genre as a delimited string, split the
-- same way the radio tag_overlap arm does (regexp_split_to_table on
-- [;,]). Same exclusion filters as the other buckets. Returns nothing
-- when the user has no taste tags yet (cold start), so the caller
-- redistributes its slots to the other buckets. Stamped 'taste_unheard'.
-- $1 = user_id, $2 = date string for md5 tiebreak ordering.
SELECT t.id, t.album_id, t.artist_id
FROM tracks t
JOIN LATERAL regexp_split_to_table(coalesce(t.genre, ''), '[;,]') AS g_split(g) ON true
JOIN taste_profile_tags nt ON nt.user_id = $1 AND trim(g_split.g) = nt.tag
WHERE nt.weight > 0
AND trim(g_split.g) <> ''
AND NOT EXISTS (
SELECT 1 FROM play_events pe
WHERE pe.user_id = $1
AND pe.track_id = t.id
AND pe.was_skipped = false
)
AND NOT EXISTS (
SELECT 1 FROM general_likes gl
WHERE gl.user_id = $1 AND gl.track_id = t.id
)
AND NOT EXISTS (
SELECT 1 FROM lidarr_quarantine q
WHERE q.user_id = $1 AND q.track_id = t.id
)
GROUP BY t.id, t.album_id, t.artist_id
ORDER BY SUM(nt.weight) DESC, md5(t.id::text || $2::text)
LIMIT 120;