fix: the taste arm round-robins across taste artists, and tag ties follow the order seed (#3880)
release / govulncheck (push) Successful in 31s
release / web (push) Successful in 1m37s
release / go (push) Successful in 1m46s
release / integration (push) Successful in 5m20s
release / android (push) Successful in 5m22s
release / Build signed APK (releases and dev) (push) Successful in 5m39s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m29s
release / Verify release artifacts (tag releases only) (push) Skipped
release / govulncheck (push) Successful in 31s
release / web (push) Successful in 1m37s
release / go (push) Successful in 1m46s
release / integration (push) Successful in 5m20s
release / android (push) Successful in 5m22s
release / Build signed APK (releases and dev) (push) Successful in 5m39s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m29s
release / Verify release artifacts (tag releases only) (push) Skipped
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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 (
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user