feat: genre mixes lean toward what the listener likes (#6921)
release / web (push) Successful in 1m40s
release / go (push) Successful in 2m5s
release / govulncheck (push) Successful in 19s
release / integration (push) Successful in 5m2s
release / android (push) Successful in 4m40s
release / Build signed APK (push) Successful in 5m7s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 32s
release / Verify release artifacts (tag releases only) (push) Skipped

Within a genre both halves of the mix were a uniform daily draw: a song
played forty times was no likelier than one played once, and the unheard
40% ignored who the artist was. ListGenreMixTracks now draws each half by a
date-seeded weighted lottery (Efraimidis-Spirakis: smallest -ln(u)/weight
wins), so the mix still changes daily and holds within a day.

- Familiar: 1 + ln(1 + plays) + 1 if liked, times the share of plays that
  weren't skips.
- Unheard: 1 + ln(1 + the artist's plays + 2 x its liked tracks)
  + 2 x the artist's best similarity to the user's own artists, times the
  artist's non-skip share. An unknown artist weighs 1, so new artists still
  come up.

Which genres are offered, the exclusions and the familiar/unheard blend
are unchanged. DB tests check the lean averaged over ten days, and that a
stranger's tracks stay in the pool.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-11-06 14:21:04 -05:00
co-authored by Claude Opus 5.5
parent adbb3a3cd8
commit 14debe2ea9
4 changed files with 240 additions and 11 deletions
+67 -5
View File
@@ -131,14 +131,57 @@ skip_counts AS (
WHERE pe.user_id = $1 AND pe.was_skipped = true
GROUP BY pe.track_id
),
artist_plays AS (
SELECT t.artist_id,
COUNT(*) FILTER (WHERE NOT pe.was_skipped) AS plays,
COUNT(*) FILTER (WHERE pe.was_skipped) AS skips
FROM play_events pe
JOIN tracks t ON t.id = pe.track_id
WHERE pe.user_id = $1
GROUP BY t.artist_id
),
artist_likes AS (
SELECT t.artist_id, COUNT(*) AS likes
FROM general_likes gl
JOIN tracks t ON t.id = gl.track_id
WHERE gl.user_id = $1
GROUP BY t.artist_id
),
own_artists AS (
SELECT ap.artist_id FROM artist_plays ap WHERE ap.plays > 0
UNION
SELECT al.artist_id FROM artist_likes al
),
artist_sim AS (
SELECT asim.artist_b_id AS artist_id,
MAX(LEAST(GREATEST(asim.score, 0), 1)) AS sim
FROM artist_similarity asim
JOIN own_artists oa ON oa.artist_id = asim.artist_a_id
GROUP BY asim.artist_b_id
),
eligible AS (
SELECT t.id, t.album_id, t.artist_id,
(COALESCE(pc.c, 0) > 0 OR gl.track_id IS NOT NULL) AS familiar,
md5(t.id::text || $2::text) AS tiebreak
CASE
WHEN COALESCE(pc.c, 0) > 0 OR gl.track_id IS NOT NULL THEN
(1 + ln(1 + COALESCE(pc.c, 0)::float8)
+ CASE WHEN gl.track_id IS NOT NULL THEN 1 ELSE 0 END)
* ((COALESCE(pc.c, 0) + 1)::float8 / (COALESCE(pc.c, 0) + COALESCE(sc.c, 0) + 1))
ELSE
(1 + ln(1 + COALESCE(ap.plays, 0)::float8 + 2 * COALESCE(al.likes, 0)::float8)
+ 2 * COALESCE(asm.sim, 0))
* ((COALESCE(ap.plays, 0) + 1)::float8 / (COALESCE(ap.plays, 0) + COALESCE(ap.skips, 0) + 1))
END::float8 AS weight,
-- u in (0, 1): the first 32 bits of md5(track, date), shifted off 0.
((('x' || substr(md5(t.id::text || $2::text), 1, 8))::bit(32)::bigint)::float8 + 1)
/ 4294967297.0 AS u
FROM tracks t
LEFT JOIN play_counts pc ON pc.track_id = t.id
LEFT JOIN skip_counts sc ON sc.track_id = t.id
LEFT JOIN general_likes gl ON gl.user_id = $1 AND gl.track_id = t.id
LEFT JOIN artist_plays ap ON ap.artist_id = t.artist_id
LEFT JOIN artist_likes al ON al.artist_id = t.artist_id
LEFT JOIN artist_sim asm ON asm.artist_id = t.artist_id
WHERE t.missing_since IS NULL -- never offer a file that is gone
AND NOT (COALESCE(sc.c, 0) >= 2 AND COALESCE(sc.c, 0) > COALESCE(pc.c, 0))
AND EXISTS (
@@ -152,13 +195,15 @@ eligible AS (
)
),
ranked AS (
SELECT e.id, e.album_id, e.artist_id, e.familiar, e.tiebreak, row_number() OVER (PARTITION BY e.familiar ORDER BY e.tiebreak) AS rn
SELECT e.id, e.album_id, e.artist_id, e.familiar,
-ln(e.u) / e.weight AS draw,
row_number() OVER (PARTITION BY e.familiar ORDER BY -ln(e.u) / e.weight, e.id) AS rn
FROM eligible e
)
SELECT r.id, r.album_id, r.artist_id, r.familiar::bool AS familiar
FROM ranked r
WHERE r.rn <= 200
ORDER BY r.familiar DESC, r.tiebreak
ORDER BY r.familiar DESC, r.draw, r.id
`
type ListGenreMixTracksParams struct {
@@ -176,8 +221,25 @@ type ListGenreMixTracksRow struct {
// Playable tracks carrying one genre, split into the two halves a genre mix
// blends: familiar (the user has played it through or liked it) and unheard.
// Up to 200 of each, each half in a daily-deterministic order so the mix
// changes day to day and holds still within a day.
// Up to 200 of each, drawn by a weighted lottery seeded with the date, so the
// mix changes day to day, holds still within a day, and leans toward what the
// user likes without becoming the same 50 songs every day.
//
// The lottery is the Efraimidis-Spirakis draw: each track gets
// -ln(u) / weight with u uniform in (0, 1) from md5(track, date), and the
// smallest keys win. A track of weight 3 is three times as likely as one of
// weight 1 to come before it, and every track keeps some chance.
//
// Familiar weight: 1 + ln(1 + plays through) + 1 if liked, times the share of
// its plays that were not skips. A song played 40 times weighs about 4.7, one
// played once about 1.7.
//
// Unheard weight: 1 + ln(1 + the artist's plays through + 2 × its liked
// tracks) + 2 × how similar the artist is to the user's own artists (the best
// artist_similarity score from any artist they have played through or liked,
// 0 to 1), times the share of the artist's plays that were not skips. An
// artist the user has never met weighs 1, so new artists still come up; an
// artist they keep skipping weighs well under 1.
//
// A track the user keeps skipping stays out: twice-skipped and skipped more
// often than played through. The second half of that test keeps a favourite