feat(discover): seed request suggestions from the taste profile — #2372
test-go / test (push) Successful in 29s
test-go / integration (push) Successful in 4m55s

The Discover request surface was the one recommendation surface still on its
M5c implementation from early May. #796's taste profile, #1488's taste_unheard
bucket and #1490's folksonomy enrichment all modernized in-library surfaces;
this one was never in scope for any of them, so it still projected raw likes +
plays through artist_similarity_unmatched.

Two defects fall out of that signal, `5*liked + Σexp(-age/halflife)` summed
over every play of the artist.

It is unbounded, and contribution is signal × similarity — so a handful of
heavily-played artists monopolize all twelve slots, and their share GROWS the
more the user listens. The surface entrenched harder the better it knew you,
which is exactly backwards and matches the reported "goes stale once it has a
strong signal of your taste".

It also counted every play_event with no was_skipped filter, so skipping an
artist repeatedly INCREASED its signal and pushed more of its neighbours at the
user. ListMostPlayedTracksForUser and the taste engine both filter skips; this
query was the odd one out.

Seeds now come from taste_profile_artists.weight, which the taste engine has
already engagement-graded, time-decayed and signed — an artist the user drifted
away from stops contributing instead of accumulating forever, and can even
contribute negatively. Tiered per rule #131 rather than hard-switched: tier 1 is
the profile, tier 2 is likes + completed plays for a user who has no profile
rows yet (new account, or before the first daily recompute), so the surface
never empties. The old unfiltered-play signal is gone, not kept behind a toggle.

The signal is also log-damped, so one artist cannot take every slot even when
its weight dwarfs the rest.

$2 stays wired to the tier-2 decay: it is genuinely still used there, and
dropping the parameter would have changed the generated signature.

sqlc's image is not on this workstation and the change preserves the query
signature exactly — same three params, same seven columns — so only the
embedded SQL const moves. Both copies are edited and verified byte-identical
rather than pulling a container onto the operator's machine; a malformed query
fails the integration lane loudly, which is the real check either way.

Four integration tests cover what changed: a taste weight alone seeds with no
like or play; tier 2 does not run alongside tier 1; a non-positive weight never
seeds (guarded by a second positive row, so an empty tier 1 can't make it pass
for the wrong reason); and skip-only history seeds nothing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-01 12:45:34 -04:00
co-authored by Claude Opus 5
parent cf7b489fec
commit 14aa22198f
4 changed files with 263 additions and 25 deletions
+52 -6
View File
@@ -1022,20 +1022,66 @@ func (q *Queries) LoadRadioCandidatesV2(ctx context.Context, arg LoadRadioCandid
}
const suggestArtistsForUser = `-- name: SuggestArtistsForUser :many
WITH seeds AS (
-- Per-user artist suggestions ranked by taste signal x similarity, projected
-- through artist_similarity_unmatched (out-of-library candidates only).
--
-- Seeds are TIERED (rule #131) so the surface never empties:
-- tier 1 - taste_profile_artists.weight: engagement-graded, time-decayed and
-- SIGNED by internal/taste, so an artist the user has drifted away
-- from stops contributing instead of accumulating forever.
-- tier 2 - likes + completed plays, used ONLY when the profile has no rows
-- (new account, or before the first daily recompute).
--
-- The signal is log-damped: contribution is signal x similarity, and the old
-- undamped sum let one heavily-played artist's neighbours take every slot --
-- entrenching harder the MORE the user listened (issue #2367 mechanism 2).
--
-- Candidates already in the library, or already requested and not terminal,
-- are excluded. $1=user_id, $2=half_life_days (tier 2 decay), $3=limit.
WITH artist_plays AS (
-- Completed plays only. The previous seed query counted every play_event,
-- so skipping an artist repeatedly INCREASED its signal and pushed more of
-- its neighbours at the user (issue #2367 mechanism 3).
SELECT t.artist_id, count(*)::bigint AS play_count
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
WHERE pe.user_id = $1 AND pe.was_skipped = false
GROUP BY t.artist_id
),
profile_seeds AS (
SELECT tpa.artist_id, tpa.weight AS raw_signal
FROM taste_profile_artists tpa
WHERE tpa.user_id = $1 AND tpa.weight > 0
),
fallback_seeds AS (
SELECT a.id AS artist_id,
5.0 * (CASE WHEN gla.artist_id IS NOT NULL THEN 1 ELSE 0 END)
+ COALESCE(SUM(EXP(- EXTRACT(epoch FROM now() - pe.started_at) / ($2::float8 * 86400.0))), 0)
AS signal,
(gla.artist_id IS NOT NULL) AS is_liked,
COUNT(pe.id)::bigint AS play_count
AS raw_signal
FROM artists a
LEFT JOIN general_likes_artists gla ON gla.artist_id = a.id AND gla.user_id = $1
LEFT JOIN tracks t ON t.artist_id = a.id
LEFT JOIN play_events pe ON pe.track_id = t.id AND pe.user_id = $1
WHERE gla.artist_id IS NOT NULL OR pe.id IS NOT NULL
LEFT JOIN play_events pe
ON pe.track_id = t.id AND pe.user_id = $1 AND pe.was_skipped = false
WHERE (gla.artist_id IS NOT NULL OR pe.id IS NOT NULL)
AND NOT EXISTS (SELECT 1 FROM profile_seeds)
GROUP BY a.id, gla.artist_id
),
seeds AS (
SELECT s.artist_id,
ln(1.0 + s.raw_signal) AS signal,
(gla.artist_id IS NOT NULL) AS is_liked,
COALESCE(ap.play_count, 0)::bigint AS play_count
FROM (
SELECT artist_id, raw_signal FROM profile_seeds
UNION ALL
SELECT artist_id, raw_signal FROM fallback_seeds
) s
LEFT JOIN general_likes_artists gla
ON gla.artist_id = s.artist_id AND gla.user_id = $1
LEFT JOIN artist_plays ap ON ap.artist_id = s.artist_id
WHERE s.raw_signal > 0
),
contributions AS (
SELECT u.candidate_mbid,
u.candidate_name,
+52 -13
View File
@@ -258,27 +258,66 @@ ORDER BY started_at DESC
LIMIT 1;
-- name: SuggestArtistsForUser :many
-- M5c: per-user artist suggestions ranked by signal x similarity. The
-- seeds CTE collects the user's likes (x5) plus recency-decayed plays
-- (exp(-age_days / $2)). The contributions CTE joins those seeds against
-- artist_similarity_unmatched and filters out candidates already in
-- library or already in a non-terminal lidarr_request. The outer SELECT
-- aggregates per candidate, returning the top-3 contributing seeds for
-- attribution. $1=user_id, $2=half_life_days, $3=limit.
WITH seeds AS (
-- Per-user artist suggestions ranked by taste signal x similarity, projected
-- through artist_similarity_unmatched (out-of-library candidates only).
--
-- Seeds are TIERED (rule #131) so the surface never empties:
-- tier 1 - taste_profile_artists.weight: engagement-graded, time-decayed and
-- SIGNED by internal/taste, so an artist the user has drifted away
-- from stops contributing instead of accumulating forever.
-- tier 2 - likes + completed plays, used ONLY when the profile has no rows
-- (new account, or before the first daily recompute).
--
-- The signal is log-damped: contribution is signal x similarity, and the old
-- undamped sum let one heavily-played artist's neighbours take every slot --
-- entrenching harder the MORE the user listened (issue #2367 mechanism 2).
--
-- Candidates already in the library, or already requested and not terminal,
-- are excluded. $1=user_id, $2=half_life_days (tier 2 decay), $3=limit.
WITH artist_plays AS (
-- Completed plays only. The previous seed query counted every play_event,
-- so skipping an artist repeatedly INCREASED its signal and pushed more of
-- its neighbours at the user (issue #2367 mechanism 3).
SELECT t.artist_id, count(*)::bigint AS play_count
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
WHERE pe.user_id = $1 AND pe.was_skipped = false
GROUP BY t.artist_id
),
profile_seeds AS (
SELECT tpa.artist_id, tpa.weight AS raw_signal
FROM taste_profile_artists tpa
WHERE tpa.user_id = $1 AND tpa.weight > 0
),
fallback_seeds AS (
SELECT a.id AS artist_id,
5.0 * (CASE WHEN gla.artist_id IS NOT NULL THEN 1 ELSE 0 END)
+ COALESCE(SUM(EXP(- EXTRACT(epoch FROM now() - pe.started_at) / ($2::float8 * 86400.0))), 0)
AS signal,
(gla.artist_id IS NOT NULL) AS is_liked,
COUNT(pe.id)::bigint AS play_count
AS raw_signal
FROM artists a
LEFT JOIN general_likes_artists gla ON gla.artist_id = a.id AND gla.user_id = $1
LEFT JOIN tracks t ON t.artist_id = a.id
LEFT JOIN play_events pe ON pe.track_id = t.id AND pe.user_id = $1
WHERE gla.artist_id IS NOT NULL OR pe.id IS NOT NULL
LEFT JOIN play_events pe
ON pe.track_id = t.id AND pe.user_id = $1 AND pe.was_skipped = false
WHERE (gla.artist_id IS NOT NULL OR pe.id IS NOT NULL)
AND NOT EXISTS (SELECT 1 FROM profile_seeds)
GROUP BY a.id, gla.artist_id
),
seeds AS (
SELECT s.artist_id,
ln(1.0 + s.raw_signal) AS signal,
(gla.artist_id IS NOT NULL) AS is_liked,
COALESCE(ap.play_count, 0)::bigint AS play_count
FROM (
SELECT artist_id, raw_signal FROM profile_seeds
UNION ALL
SELECT artist_id, raw_signal FROM fallback_seeds
) s
LEFT JOIN general_likes_artists gla
ON gla.artist_id = s.artist_id AND gla.user_id = $1
LEFT JOIN artist_plays ap ON ap.artist_id = s.artist_id
WHERE s.raw_signal > 0
),
contributions AS (
SELECT u.candidate_mbid,
u.candidate_name,