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
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:
@@ -131,14 +131,57 @@ skip_counts AS (
|
|||||||
WHERE pe.user_id = $1 AND pe.was_skipped = true
|
WHERE pe.user_id = $1 AND pe.was_skipped = true
|
||||||
GROUP BY pe.track_id
|
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 (
|
eligible AS (
|
||||||
SELECT t.id, t.album_id, t.artist_id,
|
SELECT t.id, t.album_id, t.artist_id,
|
||||||
(COALESCE(pc.c, 0) > 0 OR gl.track_id IS NOT NULL) AS familiar,
|
(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
|
FROM tracks t
|
||||||
LEFT JOIN play_counts pc ON pc.track_id = t.id
|
LEFT JOIN play_counts pc ON pc.track_id = t.id
|
||||||
LEFT JOIN skip_counts sc ON sc.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 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
|
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 NOT (COALESCE(sc.c, 0) >= 2 AND COALESCE(sc.c, 0) > COALESCE(pc.c, 0))
|
||||||
AND EXISTS (
|
AND EXISTS (
|
||||||
@@ -152,13 +195,15 @@ eligible AS (
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
ranked 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
|
FROM eligible e
|
||||||
)
|
)
|
||||||
SELECT r.id, r.album_id, r.artist_id, r.familiar::bool AS familiar
|
SELECT r.id, r.album_id, r.artist_id, r.familiar::bool AS familiar
|
||||||
FROM ranked r
|
FROM ranked r
|
||||||
WHERE r.rn <= 200
|
WHERE r.rn <= 200
|
||||||
ORDER BY r.familiar DESC, r.tiebreak
|
ORDER BY r.familiar DESC, r.draw, r.id
|
||||||
`
|
`
|
||||||
|
|
||||||
type ListGenreMixTracksParams struct {
|
type ListGenreMixTracksParams struct {
|
||||||
@@ -176,8 +221,25 @@ type ListGenreMixTracksRow struct {
|
|||||||
|
|
||||||
// Playable tracks carrying one genre, split into the two halves a genre mix
|
// 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.
|
// 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
|
// Up to 200 of each, drawn by a weighted lottery seeded with the date, so the
|
||||||
// changes day to day and holds still within a day.
|
// 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
|
// 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
|
// often than played through. The second half of that test keeps a favourite
|
||||||
|
|||||||
@@ -73,8 +73,25 @@ SELECT lg.genre_key::text AS genre_key,
|
|||||||
-- name: ListGenreMixTracks :many
|
-- name: ListGenreMixTracks :many
|
||||||
-- Playable tracks carrying one genre, split into the two halves a genre mix
|
-- 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.
|
-- 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
|
-- Up to 200 of each, drawn by a weighted lottery seeded with the date, so the
|
||||||
-- changes day to day and holds still within a day.
|
-- 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
|
-- 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
|
-- often than played through. The second half of that test keeps a favourite
|
||||||
@@ -91,14 +108,57 @@ skip_counts AS (
|
|||||||
WHERE pe.user_id = sqlc.arg(user_id) AND pe.was_skipped = true
|
WHERE pe.user_id = sqlc.arg(user_id) AND pe.was_skipped = true
|
||||||
GROUP BY pe.track_id
|
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 = sqlc.arg(user_id)
|
||||||
|
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 = sqlc.arg(user_id)
|
||||||
|
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 (
|
eligible AS (
|
||||||
SELECT t.id, t.album_id, t.artist_id,
|
SELECT t.id, t.album_id, t.artist_id,
|
||||||
(COALESCE(pc.c, 0) > 0 OR gl.track_id IS NOT NULL) AS familiar,
|
(COALESCE(pc.c, 0) > 0 OR gl.track_id IS NOT NULL) AS familiar,
|
||||||
md5(t.id::text || sqlc.arg(date_seed)::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 || sqlc.arg(date_seed)::text), 1, 8))::bit(32)::bigint)::float8 + 1)
|
||||||
|
/ 4294967297.0 AS u
|
||||||
FROM tracks t
|
FROM tracks t
|
||||||
LEFT JOIN play_counts pc ON pc.track_id = t.id
|
LEFT JOIN play_counts pc ON pc.track_id = t.id
|
||||||
LEFT JOIN skip_counts sc ON sc.track_id = t.id
|
LEFT JOIN skip_counts sc ON sc.track_id = t.id
|
||||||
LEFT JOIN general_likes gl ON gl.user_id = sqlc.arg(user_id) AND gl.track_id = t.id
|
LEFT JOIN general_likes gl ON gl.user_id = sqlc.arg(user_id) 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
|
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 NOT (COALESCE(sc.c, 0) >= 2 AND COALESCE(sc.c, 0) > COALESCE(pc.c, 0))
|
||||||
AND EXISTS (
|
AND EXISTS (
|
||||||
@@ -112,10 +172,12 @@ eligible AS (
|
|||||||
)
|
)
|
||||||
),
|
),
|
||||||
ranked AS (
|
ranked AS (
|
||||||
SELECT e.*, 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
|
FROM eligible e
|
||||||
)
|
)
|
||||||
SELECT r.id, r.album_id, r.artist_id, r.familiar::bool AS familiar
|
SELECT r.id, r.album_id, r.artist_id, r.familiar::bool AS familiar
|
||||||
FROM ranked r
|
FROM ranked r
|
||||||
WHERE r.rn <= 200
|
WHERE r.rn <= 200
|
||||||
ORDER BY r.familiar DESC, r.tiebreak;
|
ORDER BY r.familiar DESC, r.draw, r.id;
|
||||||
|
|||||||
@@ -22,7 +22,10 @@ import (
|
|||||||
// ten channels of music they never play.
|
// ten channels of music they never play.
|
||||||
//
|
//
|
||||||
// The set of genres is stable from day to day, like a list of channels; the
|
// The set of genres is stable from day to day, like a list of channels; the
|
||||||
// tracks inside each mix change daily.
|
// tracks inside each mix change daily. ListGenreMixTracks draws them by a
|
||||||
|
// date-seeded weighted lottery: familiar tracks lean toward the ones played
|
||||||
|
// most and liked, unheard ones toward the user's artists and artists similar
|
||||||
|
// to them, and every track keeps some chance.
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// genreMixCount is the most genre mixes a user gets.
|
// genreMixCount is the most genre mixes a user gets.
|
||||||
|
|||||||
@@ -200,3 +200,105 @@ func TestBuildSystemPlaylists_GenreMixes(t *testing.T) {
|
|||||||
t.Errorf("pick kinds = %v, want both taste and taste_unheard", kinds)
|
t.Errorf("pick kinds = %v, want both taste and taste_unheard", kinds)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// genreDrawPositions runs ListGenreMixTracks for ten different days and
|
||||||
|
// returns, per track, the sum of its positions within its own half
|
||||||
|
// (familiar or unheard). The draw is random per day, so a lean shows up as
|
||||||
|
// an earlier average position across days, not in any single one.
|
||||||
|
func genreDrawPositions(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID, genreKey string) map[pgtype.UUID]int {
|
||||||
|
t.Helper()
|
||||||
|
sum := map[pgtype.UUID]int{}
|
||||||
|
for day := 1; day <= 10; day++ {
|
||||||
|
rows, err := dbq.New(pool).ListGenreMixTracks(context.Background(), dbq.ListGenreMixTracksParams{
|
||||||
|
UserID: userID, DateSeed: fmt.Sprintf("2026-11-%02d", day), GenreKey: genreKey,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("list tracks: %v", err)
|
||||||
|
}
|
||||||
|
pos := map[bool]int{}
|
||||||
|
for _, r := range rows {
|
||||||
|
sum[r.ID] += pos[r.Familiar]
|
||||||
|
pos[r.Familiar]++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return sum
|
||||||
|
}
|
||||||
|
|
||||||
|
func meanPosition(sum map[pgtype.UUID]int, tracks []dbq.Track) float64 {
|
||||||
|
total := 0
|
||||||
|
for _, tr := range tracks {
|
||||||
|
total += sum[tr.ID]
|
||||||
|
}
|
||||||
|
return float64(total) / float64(len(tracks))
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListGenreMixTracks_FamiliarLeansTowardFavourites: among the tracks the
|
||||||
|
// user knows, ones played often and liked come up earlier than ones played
|
||||||
|
// once.
|
||||||
|
func TestListGenreMixTracks_FamiliarLeansTowardFavourites(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
u := seedUser(t, pool, "gmixfav")
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
tracks := seedGenre(t, pool, "Krautrock", 4, 5)
|
||||||
|
favourites, onceOnly := tracks[:10], tracks[10:]
|
||||||
|
for i, tr := range favourites {
|
||||||
|
for k := 0; k < 5; k++ {
|
||||||
|
seedPlayEvent(t, pool, u.ID, tr.ID, now.Add(-time.Duration(i*10+k+1)*time.Hour), false)
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, `INSERT INTO general_likes (user_id, track_id) VALUES ($1, $2)`, u.ID, tr.ID); err != nil {
|
||||||
|
t.Fatalf("like: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for i, tr := range onceOnly {
|
||||||
|
seedPlayEvent(t, pool, u.ID, tr.ID, now.Add(-time.Duration(200+i)*time.Hour), false)
|
||||||
|
}
|
||||||
|
|
||||||
|
sum := genreDrawPositions(t, pool, u.ID, "krautrock")
|
||||||
|
fav, once := meanPosition(sum, favourites), meanPosition(sum, onceOnly)
|
||||||
|
if fav >= once {
|
||||||
|
t.Errorf("favourites' summed position %.1f, played-once %.1f: want favourites earlier", fav, once)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestListGenreMixTracks_UnheardLeansTowardTheUsersArtists: among unheard
|
||||||
|
// tracks, an artist the user plays and likes comes up earlier than one they
|
||||||
|
// have never met, and so does an artist similar to theirs. The stranger
|
||||||
|
// still appears: the lean tilts the draw, it does not exclude.
|
||||||
|
func TestListGenreMixTracks_UnheardLeansTowardTheUsersArtists(t *testing.T) {
|
||||||
|
pool := newPool(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
u := seedUser(t, pool, "gmixartist")
|
||||||
|
now := time.Now().UTC()
|
||||||
|
|
||||||
|
tracks := seedGenre(t, pool, "Shoegaze", 3, 12)
|
||||||
|
loved, similar, stranger := tracks[:12], tracks[12:24], tracks[24:]
|
||||||
|
for k := 0; k < 15; k++ {
|
||||||
|
seedPlayEvent(t, pool, u.ID, loved[0].ID, now.Add(-time.Duration(k+1)*time.Hour), false)
|
||||||
|
}
|
||||||
|
for _, tr := range loved[1:3] {
|
||||||
|
if _, err := pool.Exec(ctx, `INSERT INTO general_likes (user_id, track_id) VALUES ($1, $2)`, u.ID, tr.ID); err != nil {
|
||||||
|
t.Fatalf("like: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if _, err := pool.Exec(ctx, `
|
||||||
|
INSERT INTO artist_similarity (artist_a_id, artist_b_id, score, source)
|
||||||
|
VALUES ($1, $2, 0.9, 'listenbrainz')`, loved[0].ArtistID, similar[0].ArtistID); err != nil {
|
||||||
|
t.Fatalf("seed similarity: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sum := genreDrawPositions(t, pool, u.ID, "shoegaze")
|
||||||
|
l, s, x := meanPosition(sum, loved[3:]), meanPosition(sum, similar), meanPosition(sum, stranger)
|
||||||
|
if l >= x {
|
||||||
|
t.Errorf("loved artist's unheard tracks at %.1f, stranger's at %.1f: want the loved artist earlier", l, x)
|
||||||
|
}
|
||||||
|
if s >= x {
|
||||||
|
t.Errorf("similar artist's tracks at %.1f, stranger's at %.1f: want the similar artist earlier", s, x)
|
||||||
|
}
|
||||||
|
for _, tr := range stranger {
|
||||||
|
if _, in := sum[tr.ID]; !in {
|
||||||
|
t.Fatalf("a stranger's track was left out of the pool; the lean must not exclude")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user