From 51811c9d35c67cbc9a7e005e3c27797ecf717d94 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Wed, 29 Apr 2026 08:30:53 -0400 Subject: [PATCH] feat(recommendation): add LoadCandidatesFromSimilarity (5-source candidate pool) Implements M4c's similarity-driven candidate pool: CandidateSourceLimits, DefaultCandidateSourceLimits, and LoadCandidatesFromSimilarity consuming LoadRadioCandidatesV2 (5-way UNION + max-score dedup). Adds 10 integration tests covering all sources, exclusions, dedup, and edge cases. Co-Authored-By: Claude Sonnet 4.6 --- internal/recommendation/candidates.go | 89 ++++++ internal/recommendation/candidates_v2_test.go | 283 ++++++++++++++++++ 2 files changed, 372 insertions(+) create mode 100644 internal/recommendation/candidates_v2_test.go diff --git a/internal/recommendation/candidates.go b/internal/recommendation/candidates.go index ab16352f..6c30cb21 100644 --- a/internal/recommendation/candidates.go +++ b/internal/recommendation/candidates.go @@ -58,6 +58,95 @@ func LoadCandidates( return out, nil } +// CandidateSourceLimits controls per-source K values for the M4c +// similarity-driven pool. Defaults via DefaultCandidateSourceLimits(). +type CandidateSourceLimits struct { + LBSimilar int + SimilarArtist int + TagOverlap int + LikesOverlap int + RandomFill int +} + +// DefaultCandidateSourceLimits returns the v1 hardcoded constants per spec. +func DefaultCandidateSourceLimits() CandidateSourceLimits { + return CandidateSourceLimits{ + LBSimilar: 30, + SimilarArtist: 30, + TagOverlap: 20, + LikesOverlap: 20, + RandomFill: 30, + } +} + +// LoadCandidatesFromSimilarity is M4c's primary candidate-pool loader. +// 5-way SQL UNION (LB-similar / similar-artist tracks / MB-tag overlap / +// likes-overlap / random fill) + dedup-by-max sim_score. Returns +// []Candidate (same shape as LoadCandidates) so Shuffle is unchanged. +// +// Caller (radio handler) falls back to LoadCandidates on error. +func LoadCandidatesFromSimilarity( + ctx context.Context, + q *dbq.Queries, + userID, seedID pgtype.UUID, + recentlyPlayedHours int, + currentVector SessionVector, + exclude []pgtype.UUID, + limits CandidateSourceLimits, +) ([]Candidate, error) { + if exclude == nil { + exclude = []pgtype.UUID{} + } + rows, err := q.LoadRadioCandidatesV2(ctx, dbq.LoadRadioCandidatesV2Params{ + UserID: userID, + ID: seedID, + Column3: int64(recentlyPlayedHours), + Column4: exclude, + Limit: int32(limits.LBSimilar), + Limit_2: int32(limits.SimilarArtist), + Limit_3: int32(limits.TagOverlap), + Limit_4: int32(limits.LikesOverlap), + Limit_5: int32(limits.RandomFill), + }) + if err != nil { + return nil, err + } + + likes, err := loadContextualLikesByTrack(ctx, q, userID) + if err != nil { + return nil, err + } + + out := make([]Candidate, 0, len(rows)) + for _, r := range rows { + var lpt *time.Time + if r.LastPlayedAt.Valid { + t := r.LastPlayedAt.Time + lpt = &t + } + // sqlc returns SimilarityScore as interface{} (couldn't infer the + // type through max(...) over a UNION). Type-assert; default to 0 + // on the (impossible-but-defensive) case where it's nil/wrong type. + var simScore float64 + if v, ok := r.SimilarityScore.(float64); ok { + simScore = v + } + ctxScore := ContextualMatchScore(currentVector, likes[r.Track.ID], DefaultSimilarityWeights) + out = append(out, Candidate{ + Track: r.Track, + Inputs: ScoringInputs{ + IsGeneralLiked: r.IsLiked, + LastPlayedAt: lpt, + PlayCount: int(r.PlayCount), + SkipCount: int(r.SkipCount), + ContextualMatchScore: ctxScore, + SimilarityScore: simScore, + }, + }) + } + return out, nil +} + // loadContextualLikesByTrack fetches the user's active contextual_likes in // one query and groups them by track_id. Rows whose session_vector fails // to unmarshal are skipped with no error (don't poison scoring over one diff --git a/internal/recommendation/candidates_v2_test.go b/internal/recommendation/candidates_v2_test.go new file mode 100644 index 00000000..c04b1b2e --- /dev/null +++ b/internal/recommendation/candidates_v2_test.go @@ -0,0 +1,283 @@ +package recommendation + +import ( + "context" + "testing" + + "github.com/jackc/pgx/v5/pgtype" + + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" +) + +// helperLBSimilarity inserts a track_similarity row. +func helperLBSimilarity(t *testing.T, f fixture, a, b pgtype.UUID, score float64) { + t.Helper() + if _, err := f.pool.Exec(context.Background(), + `INSERT INTO track_similarity (track_a_id, track_b_id, score, source) VALUES ($1, $2, $3, 'listenbrainz')`, + a, b, score); err != nil { + t.Fatalf("insert track_similarity: %v", err) + } +} + +// helperArtistSimilarity inserts an artist_similarity row. +func helperArtistSimilarity(t *testing.T, f fixture, a, b pgtype.UUID, score float64) { + t.Helper() + if _, err := f.pool.Exec(context.Background(), + `INSERT INTO artist_similarity (artist_a_id, artist_b_id, score, source) VALUES ($1, $2, $3, 'listenbrainz')`, + a, b, score); err != nil { + t.Fatalf("insert artist_similarity: %v", err) + } +} + +// helperSetTrackGenre updates a track's genre column. Used to retrofit +// genres onto the fixture's auto-created tracks (fixture creates tracks +// with NULL genre). +func helperSetTrackGenre(t *testing.T, f fixture, trackID pgtype.UUID, genre string) { + t.Helper() + if _, err := f.pool.Exec(context.Background(), + `UPDATE tracks SET genre = $1 WHERE id = $2`, genre, trackID); err != nil { + t.Fatalf("set genre: %v", err) + } +} + +func defaultLimits() CandidateSourceLimits { + return DefaultCandidateSourceLimits() +} + +func TestLoadCandidatesFromSimilarity_LBSimilarSourceContributes(t *testing.T) { + f := newFixture(t, 5) + seed := f.tracks[0] + target := f.tracks[1] + helperLBSimilarity(t, f, seed.ID, target.ID, 0.85) + got, err := LoadCandidatesFromSimilarity( + context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), + ) + if err != nil { + t.Fatalf("load: %v", err) + } + var found *Candidate + for i := range got { + if got[i].Track.ID == target.ID { + found = &got[i] + break + } + } + if found == nil { + t.Fatal("LB-similar target missing from candidates") + } + if found.Inputs.SimilarityScore < 0.84 || found.Inputs.SimilarityScore > 0.86 { + t.Errorf("LB-similar SimilarityScore = %v, want ~0.85", found.Inputs.SimilarityScore) + } +} + +func TestLoadCandidatesFromSimilarity_SimilarArtistTracksContribute(t *testing.T) { + f := newFixture(t, 1) // creates 1 artist + 1 album + 1 track (the seed) + seed := f.tracks[0] + // seed_info CTE only emits artist_id rows when genre is non-NULL (it + // uses a LATERAL regexp_split_to_table join filtered by trim(g) <> ''). + // Give the seed a genre so that seed_info is populated and the + // similar_artists JOIN can find artist_a_id = seed.ArtistID. + helperSetTrackGenre(t, f, seed.ID, "Rock") + // Add a SECOND artist + track in that artist; relate the two artists via artist_similarity. + otherArtist, _ := f.q.UpsertArtist(context.Background(), dbq.UpsertArtistParams{Name: "OtherArtist", SortName: "OtherArtist"}) + otherAlbum, _ := f.q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{Title: "OtherAlbum", SortTitle: "OtherAlbum", ArtistID: otherArtist.ID}) + otherTrack, _ := f.q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ + Title: "OtherTrack", AlbumID: otherAlbum.ID, ArtistID: otherArtist.ID, + FilePath: "/tmp/other.flac", DurationMs: 180_000, + }) + helperArtistSimilarity(t, f, seed.ArtistID, otherArtist.ID, 0.8) + got, err := LoadCandidatesFromSimilarity( + context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), + ) + if err != nil { + t.Fatalf("load: %v", err) + } + for _, c := range got { + if c.Track.ID == otherTrack.ID { + // 0.8 × 0.5 = 0.4 + if c.Inputs.SimilarityScore < 0.39 || c.Inputs.SimilarityScore > 0.41 { + t.Errorf("similar-artist SimilarityScore = %v, want ~0.4 (0.8 × 0.5)", c.Inputs.SimilarityScore) + } + return + } + } + t.Error("similar-artist track missing from candidates") +} + +func TestLoadCandidatesFromSimilarity_TagOverlapContributes(t *testing.T) { + f := newFixture(t, 2) + seed := f.tracks[0] + target := f.tracks[1] + helperSetTrackGenre(t, f, seed.ID, "Rock; Pop") + helperSetTrackGenre(t, f, target.ID, "Rock") + got, err := LoadCandidatesFromSimilarity( + context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), + ) + if err != nil { + t.Fatalf("load: %v", err) + } + for _, c := range got { + if c.Track.ID == target.ID { + // Seed has 2 tags; target shares 1 → jaccard 1/2 = 0.5. + if c.Inputs.SimilarityScore < 0.49 || c.Inputs.SimilarityScore > 0.51 { + t.Errorf("tag-overlap SimilarityScore = %v, want ~0.5", c.Inputs.SimilarityScore) + } + return + } + } + t.Error("tag-overlap target missing from candidates") +} + +func TestLoadCandidatesFromSimilarity_LikesOverlapContributes(t *testing.T) { + f := newFixture(t, 2) + seed := f.tracks[0] + liked := f.tracks[1] + helperSetTrackGenre(t, f, seed.ID, "Rock") + helperSetTrackGenre(t, f, liked.ID, "Rock") + if _, err := f.q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: f.user, TrackID: liked.ID}); err != nil { + t.Fatalf("like: %v", err) + } + got, err := LoadCandidatesFromSimilarity( + context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), + ) + if err != nil { + t.Fatalf("load: %v", err) + } + for _, c := range got { + if c.Track.ID == liked.ID { + // Both tracks tagged "Rock" → jaccard 1/1 = 1.0 from tag-overlap. + // likes-overlap = 0.6. Max wins = 1.0. + if c.Inputs.SimilarityScore < 0.59 { + t.Errorf("likes-overlap candidate SimilarityScore = %v, want ≥ 0.6", c.Inputs.SimilarityScore) + } + return + } + } + t.Error("liked track with shared tag missing from candidates") +} + +func TestLoadCandidatesFromSimilarity_RandomFillReturnsTracks(t *testing.T) { + f := newFixture(t, 10) // 10 tracks; no similarity data + seed := f.tracks[0] + got, err := LoadCandidatesFromSimilarity( + context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), + ) + if err != nil { + t.Fatalf("load: %v", err) + } + if len(got) == 0 { + t.Error("random fill returned 0 candidates; expected at least some") + } + for _, c := range got { + if c.Inputs.SimilarityScore != 0 { + t.Errorf("random-fill track %s has SimilarityScore = %v, want 0", c.Track.Title, c.Inputs.SimilarityScore) + } + } +} + +func TestLoadCandidatesFromSimilarity_ExcludeListRespected(t *testing.T) { + f := newFixture(t, 5) + seed := f.tracks[0] + excluded := f.tracks[1].ID + got, err := LoadCandidatesFromSimilarity( + context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, + []pgtype.UUID{excluded}, defaultLimits(), + ) + if err != nil { + t.Fatalf("load: %v", err) + } + for _, c := range got { + if c.Track.ID == excluded { + t.Error("excluded track appeared in candidates") + } + } +} + +func TestLoadCandidatesFromSimilarity_SeedAlwaysExcluded(t *testing.T) { + f := newFixture(t, 5) + seed := f.tracks[0] + got, err := LoadCandidatesFromSimilarity( + context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), + ) + if err != nil { + t.Fatalf("load: %v", err) + } + for _, c := range got { + if c.Track.ID == seed.ID { + t.Error("seed track appeared in candidates") + } + } +} + +func TestLoadCandidatesFromSimilarity_RecentlyPlayedExcluded(t *testing.T) { + f := newFixture(t, 5) + seed := f.tracks[0] + recent := f.tracks[1].ID + var sessionID pgtype.UUID + if err := f.pool.QueryRow(context.Background(), + `INSERT INTO play_sessions (user_id, started_at, last_event_at, client_id) + VALUES ($1, now() - interval '5 minutes', now(), 'test') RETURNING id`, + f.user).Scan(&sessionID); err != nil { + t.Fatalf("session: %v", err) + } + if _, err := f.pool.Exec(context.Background(), + `INSERT INTO play_events (user_id, track_id, session_id, started_at, ended_at, duration_played_ms, completion_ratio, was_skipped) + VALUES ($1, $2, $3, now() - interval '30 minutes', now() - interval '20 minutes', 200000, 0.9, false)`, + f.user, recent, sessionID); err != nil { + t.Fatalf("play_event: %v", err) + } + got, err := LoadCandidatesFromSimilarity( + context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), + ) + if err != nil { + t.Fatalf("load: %v", err) + } + for _, c := range got { + if c.Track.ID == recent { + t.Error("recently-played track appeared in candidates") + } + } +} + +func TestLoadCandidatesFromSimilarity_DedupTakesMaxScore(t *testing.T) { + f := newFixture(t, 2) + seed := f.tracks[0] + target := f.tracks[1] + helperSetTrackGenre(t, f, seed.ID, "Rock") + helperSetTrackGenre(t, f, target.ID, "Rock") // jaccard 1/1 = 1.0 from tag-overlap + helperLBSimilarity(t, f, seed.ID, target.ID, 0.5) // weaker LB signal + got, err := LoadCandidatesFromSimilarity( + context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), + ) + if err != nil { + t.Fatalf("load: %v", err) + } + count := 0 + for _, c := range got { + if c.Track.ID == target.ID { + count++ + // tag-overlap (1.0) wins over LB (0.5) per max() — expect ≥ 0.99 + if c.Inputs.SimilarityScore < 0.99 { + t.Errorf("dedup max SimilarityScore = %v, want ≥ 0.99 (tag-overlap should win)", c.Inputs.SimilarityScore) + } + } + } + if count != 1 { + t.Errorf("target appeared %d times, want 1 (dedup failed)", count) + } +} + +func TestLoadCandidatesFromSimilarity_EmptyLibrary_NoError(t *testing.T) { + f := newFixture(t, 1) // just the seed + seed := f.tracks[0] + got, err := LoadCandidatesFromSimilarity( + context.Background(), f.q, f.user, seed.ID, 1, SessionVector{Seed: true}, nil, defaultLimits(), + ) + if err != nil { + t.Fatalf("load: %v", err) + } + // Only the seed exists; it's excluded → 0 candidates. + if len(got) != 0 { + t.Errorf("got %d candidates from seed-only library, want 0", len(got)) + } +}