From 5bcbc80555b92e13a6f719cce79675c02827b95c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 21 Oct 2026 12:12:58 -0400 Subject: [PATCH] fix: the taste arm round-robins across taste artists, and tag ties follow the order seed (#3880) Measured on the operator's library (26,005 tracks, pool scale 2.28): the arms the library scale grows all have far more rows than it asks for, and two arms collapsed whatever their limits were. - taste_overlap ordered by weight then track id, so the strongest taste artist's catalogue filled it: 20 tracks of saib. (197 tracks) out of 174 taste artists, the same lowest-id tracks every time. It now takes one track per taste artist, strongest first, md5-seeded, as similar_artists does since #5297. - tag_overlap broke ties in no order. A one-tag seed ties 7,804 tracks, and the LIMIT took whichever Postgres reached first, which the order seed did not control. Ties now follow md5(id || seed). - library_scale.go records the per-arm measurement beside the reference size it leaves as is. Co-Authored-By: Claude Opus 5.5 --- internal/db/dbq/recommendation.sql.go | 34 +++++-- internal/db/queries/recommendation.sql | 34 +++++-- internal/recommendation/candidates_v2_test.go | 88 +++++++++++++++++++ internal/recommendation/library_scale.go | 16 +++- 4 files changed, 152 insertions(+), 20 deletions(-) diff --git a/internal/db/dbq/recommendation.sql.go b/internal/db/dbq/recommendation.sql.go index 9c88b1c5..c148db96 100644 --- a/internal/db/dbq/recommendation.sql.go +++ b/internal/db/dbq/recommendation.sql.go @@ -873,7 +873,12 @@ tag_overlap AS ( AND t.id <> $2 GROUP BY t.id HAVING count(DISTINCT trim(g_split.g)) > 0 - ORDER BY sim_score DESC + -- md5 breaks the ties (#3880). A seed with one tag gives every track of + -- that genre the same score, 7,804 tracks on the operator's library, and + -- with no tie-break the LIMIT took whichever rows Postgres reached first: + -- the same slice for every seed of the genre, and not one the order seed + -- controls. + ORDER BY sim_score DESC, md5(t.id::text || $12::text) LIMIT $7 ), likes_overlap AS ( @@ -891,14 +896,25 @@ likes_overlap AS ( LIMIT $8 ), taste_overlap AS ( - SELECT t.id AS track_id, 0.0::float8 AS sim_score - FROM taste_profile_artists tpa - JOIN tracks t ON t.artist_id = tpa.artist_id - WHERE tpa.user_id = $1 - AND tpa.weight > 0 - AND t.id NOT IN (SELECT id FROM excluded_ids) - AND t.id <> $2 - ORDER BY tpa.weight DESC, t.id + -- Round-robin across the taste artists (#3880), as similar_artists is + -- (#5297): each artist's first track, strongest artist first, then each + -- one's second. Ordered by weight then track id, the strongest artist's + -- catalogue filled the whole arm with the same lowest-id tracks every + -- time; on the operator's library that was 20 tracks of one artist of + -- 174, whose catalogue is 197 tracks. + SELECT track_id, sim_score + FROM ( + SELECT t.id AS track_id, 0.0::float8 AS sim_score, tpa.weight, + row_number() OVER (PARTITION BY tpa.artist_id + ORDER BY md5(t.id::text || $12::text)) AS artist_turn + FROM taste_profile_artists tpa + JOIN tracks t ON t.artist_id = tpa.artist_id AND t.missing_since IS NULL + WHERE tpa.user_id = $1 + AND tpa.weight > 0 + AND t.id NOT IN (SELECT id FROM excluded_ids) + AND t.id <> $2 + ) ranked + ORDER BY artist_turn, weight DESC, md5(track_id::text || $12::text) LIMIT $10 ), coplay_artists AS ( diff --git a/internal/db/queries/recommendation.sql b/internal/db/queries/recommendation.sql index 18a2089b..740b5dce 100644 --- a/internal/db/queries/recommendation.sql +++ b/internal/db/queries/recommendation.sql @@ -138,7 +138,12 @@ tag_overlap AS ( AND t.id <> $2 GROUP BY t.id HAVING count(DISTINCT trim(g_split.g)) > 0 - ORDER BY sim_score DESC + -- md5 breaks the ties (#3880). A seed with one tag gives every track of + -- that genre the same score, 7,804 tracks on the operator's library, and + -- with no tie-break the LIMIT took whichever rows Postgres reached first: + -- the same slice for every seed of the genre, and not one the order seed + -- controls. + ORDER BY sim_score DESC, md5(t.id::text || $12::text) LIMIT $7 ), likes_overlap AS ( @@ -156,14 +161,25 @@ likes_overlap AS ( LIMIT $8 ), taste_overlap AS ( - SELECT t.id AS track_id, 0.0::float8 AS sim_score - FROM taste_profile_artists tpa - JOIN tracks t ON t.artist_id = tpa.artist_id - WHERE tpa.user_id = $1 - AND tpa.weight > 0 - AND t.id NOT IN (SELECT id FROM excluded_ids) - AND t.id <> $2 - ORDER BY tpa.weight DESC, t.id + -- Round-robin across the taste artists (#3880), as similar_artists is + -- (#5297): each artist's first track, strongest artist first, then each + -- one's second. Ordered by weight then track id, the strongest artist's + -- catalogue filled the whole arm with the same lowest-id tracks every + -- time; on the operator's library that was 20 tracks of one artist of + -- 174, whose catalogue is 197 tracks. + SELECT track_id, sim_score + FROM ( + SELECT t.id AS track_id, 0.0::float8 AS sim_score, tpa.weight, + row_number() OVER (PARTITION BY tpa.artist_id + ORDER BY md5(t.id::text || $12::text)) AS artist_turn + FROM taste_profile_artists tpa + JOIN tracks t ON t.artist_id = tpa.artist_id AND t.missing_since IS NULL + WHERE tpa.user_id = $1 + AND tpa.weight > 0 + AND t.id NOT IN (SELECT id FROM excluded_ids) + AND t.id <> $2 + ) ranked + ORDER BY artist_turn, weight DESC, md5(track_id::text || $12::text) LIMIT $10 ), coplay_artists AS ( diff --git a/internal/recommendation/candidates_v2_test.go b/internal/recommendation/candidates_v2_test.go index 186977e7..49520993 100644 --- a/internal/recommendation/candidates_v2_test.go +++ b/internal/recommendation/candidates_v2_test.go @@ -3,6 +3,7 @@ package recommendation import ( "context" "fmt" + "maps" "reflect" "sort" "strings" @@ -497,3 +498,90 @@ func TestLoadCandidatesFromSimilarity_ArtistArmsRoundRobin(t *testing.T) { }) } } + +// The taste arm takes one track from each taste artist before a second from +// any (#3880). Ordered by weight then track id, the strongest artist's +// catalogue filled the arm: 20 tracks of one artist on the operator's library. +func TestLoadCandidatesFromSimilarity_TasteArmRoundRobinsAcrossArtists(t *testing.T) { + f := newFixture(t, 1) + ctx := context.Background() + seed := f.tracks[0] + artistOf := map[[16]byte]string{} + for i, weight := range []float64{9, 8, 7} { + name := fmt.Sprintf("Taste %d", i) + ar, err := f.q.UpsertArtist(ctx, dbq.UpsertArtistParams{Name: name, SortName: name}) + if err != nil { + t.Fatal(err) + } + al, err := f.q.UpsertAlbum(ctx, dbq.UpsertAlbumParams{Title: name, SortTitle: name, ArtistID: ar.ID}) + if err != nil { + t.Fatal(err) + } + // More tracks per artist than the arm's limit. + for j := 0; j < 5; j++ { + tr, err := f.q.UpsertTrack(ctx, dbq.UpsertTrackParams{ + Title: fmt.Sprintf("%s #%d", name, j), AlbumID: al.ID, ArtistID: ar.ID, + FilePath: fmt.Sprintf("/tmp/taste-%d-%d.flac", i, j), DurationMs: 180_000, + }) + if err != nil { + t.Fatal(err) + } + artistOf[tr.ID.Bytes] = name + } + if _, err := f.pool.Exec(ctx, + `INSERT INTO taste_profile_artists (user_id, artist_id, weight) VALUES ($1, $2, $3)`, + f.user, ar.ID, weight); err != nil { + t.Fatal(err) + } + } + got, err := LoadCandidatesFromSimilarity( + ctx, f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, CandidateSourceLimits{TasteOverlap: 3}, "test-seed", + ) + if err != nil { + t.Fatalf("load: %v", err) + } + artists := map[string]int{} + for _, c := range got { + if name, ok := artistOf[c.Track.ID.Bytes]; ok { + artists[name]++ + } + } + if len(got) != 3 || len(artists) != 3 { + t.Errorf("taste arm of 3 drew %d candidates from %d artists (%v), want one from each of 3", + len(got), len(artists), artists) + } +} + +// Tracks tied on tag overlap are taken in the order seed's order (#3880): the +// same seed takes the same slice, another seed another. With no tie-break a +// one-tag seed's LIMIT took whichever rows Postgres reached first. +func TestLoadCandidatesFromSimilarity_TagTiesFollowTheOrderSeed(t *testing.T) { + f := newFixture(t, 21) + ctx := context.Background() + for _, tr := range f.tracks { + helperSetTrackGenre(t, f, tr.ID, "Rock") + } + pick := func(orderSeed string) map[[16]byte]bool { + got, err := LoadCandidatesFromSimilarity( + ctx, f.q, f.user, f.tracks[0].ID, 1, SessionVector{Seed: true}, nil, CandidateSourceLimits{TagOverlap: 5}, orderSeed, + ) + if err != nil { + t.Fatalf("load: %v", err) + } + out := map[[16]byte]bool{} + for _, c := range got { + out[c.Track.ID.Bytes] = true + } + return out + } + a, again, b := pick("day-a"), pick("day-a"), pick("day-b") + if len(a) != 5 { + t.Fatalf("tag arm of 5 drew %d", len(a)) + } + if !maps.Equal(a, again) { + t.Error("the same order seed took a different slice of tied tracks") + } + if maps.Equal(a, b) { + t.Error("two order seeds took the same slice of 20 tied tracks: the ties are not following the seed") + } +} diff --git a/internal/recommendation/library_scale.go b/internal/recommendation/library_scale.go index a5298244..bd689808 100644 --- a/internal/recommendation/library_scale.go +++ b/internal/recommendation/library_scale.go @@ -34,9 +34,21 @@ const ( // // ASSUMED, NOT MEASURED. The size the v1 constants were actually tuned // against is unrecorded; 5,000 is a plausible mid-size library and a - // deliberately conservative place to start growing. #3879 should replace - // this with the real number, and until it does, this constant is the + // deliberately conservative place to start growing. This constant is the // single thing to change. + // + // Measured on the operator's library, 26,005 tracks (scale 2.28), for the + // 15 most-played seeds and two Moe Shop tracks (#3880, 2026-10-21): + // lb_similar 0–6 edges (3 of 17 seeds have any): never reaches + // even the base limit, so scaling it costs nothing + // similar_artists 860–6,307 tracks over 7–33 artists: always full + // tag_overlap 4,658–19,658 tracks: always full + // likes_overlap 193–497 liked tracks sharing a tag: always full + // taste_overlap 20,045 tracks over 174 artists: always full + // coplay 0 rows: a single-user server + // So the arms the scale grows all have far more rows than it asks for, and + // the tuned-against size cannot be recovered from data; the number stays + // until a library shows a scaled arm running dry or a pool too slow. libraryScaleReference = 5000 // maxLibraryScale bounds growth so a very large library does not turn