From 5b79aa104709c82dba8fde6071df7317dbfd2aa4 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 27 Apr 2026 20:41:55 -0400 Subject: [PATCH] feat(recommendation): LoadCandidates computes per-candidate ContextualMatchScore Adds a 6th argument (currentVector SessionVector) to LoadCandidates, bulk-fetches the user's active contextual_likes in one query, and sets ContextualMatchScore on each candidate's ScoringInputs via max-similarity over that candidate's like vectors. Adds helper + 6 integration tests covering no-likes, single match, multi-like max, soft-delete filtering, seed-like filtering, and seed-current short-circuit. Adds contextual_likes to the TRUNCATE list in testPool to prevent cross-test leakage. internal/api/radio.go is intentionally left broken (Task 6 fixes it). Co-Authored-By: Claude Sonnet 4.6 --- internal/recommendation/candidates.go | 47 ++++++- internal/recommendation/candidates_test.go | 144 ++++++++++++++++++++- 2 files changed, 181 insertions(+), 10 deletions(-) diff --git a/internal/recommendation/candidates.go b/internal/recommendation/candidates.go index 8b825456..ab16352f 100644 --- a/internal/recommendation/candidates.go +++ b/internal/recommendation/candidates.go @@ -2,6 +2,7 @@ package recommendation import ( "context" + "encoding/json" "time" "github.com/jackc/pgx/v5/pgtype" @@ -9,11 +10,17 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) +// LoadCandidates fetches the candidate pool for radio scoring. Combines +// the existing track+stats query with a one-shot bulk fetch of the user's +// active contextual_likes, mapping each candidate to its max similarity +// against currentVector. Pass currentVector with Seed=true to short-circuit +// the contextual term to 0 (cold-start path). func LoadCandidates( ctx context.Context, q *dbq.Queries, userID, seedID pgtype.UUID, recentlyPlayedHours int, + currentVector SessionVector, ) ([]Candidate, error) { rows, err := q.LoadRadioCandidates(ctx, dbq.LoadRadioCandidatesParams{ UserID: userID, @@ -23,6 +30,12 @@ func LoadCandidates( 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 @@ -30,15 +43,41 @@ func LoadCandidates( t := r.LastPlayedAt.Time lpt = &t } + 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), + IsGeneralLiked: r.IsLiked, + LastPlayedAt: lpt, + PlayCount: int(r.PlayCount), + SkipCount: int(r.SkipCount), + ContextualMatchScore: ctxScore, }, }) } 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 +// bad row); the SQL query already filters NULL vectors. +func loadContextualLikesByTrack( + ctx context.Context, + q *dbq.Queries, + userID pgtype.UUID, +) (map[pgtype.UUID][]SessionVector, error) { + rows, err := q.ListActiveContextualLikesForUser(ctx, userID) + if err != nil { + return nil, err + } + out := make(map[pgtype.UUID][]SessionVector, len(rows)) + for _, r := range rows { + var v SessionVector + if err := json.Unmarshal(r.SessionVector, &v); err != nil { + continue + } + out[r.TrackID] = append(out[r.TrackID], v) + } + return out, nil +} diff --git a/internal/recommendation/candidates_test.go b/internal/recommendation/candidates_test.go index 5ed2bdc4..ca32bd98 100644 --- a/internal/recommendation/candidates_test.go +++ b/internal/recommendation/candidates_test.go @@ -2,6 +2,7 @@ package recommendation import ( "context" + "encoding/json" "io" "log/slog" "os" @@ -33,7 +34,7 @@ func testPool(t *testing.T) *pgxpool.Pool { } t.Cleanup(pool.Close) if _, err := pool.Exec(context.Background(), - "TRUNCATE general_likes, general_likes_albums, general_likes_artists, play_events, skip_events, play_sessions, sessions, users, tracks, albums, artists RESTART IDENTITY CASCADE"); err != nil { + "TRUNCATE contextual_likes, general_likes, general_likes_albums, general_likes_artists, play_events, skip_events, play_sessions, sessions, users, tracks, albums, artists RESTART IDENTITY CASCADE"); err != nil { t.Fatalf("truncate: %v", err) } return pool @@ -77,7 +78,7 @@ func newFixture(t *testing.T, n int) fixture { func TestLoadCandidates_ExcludesSeed(t *testing.T) { f := newFixture(t, 5) - got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1) + got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, SessionVector{Seed: true}) if err != nil { t.Fatalf("LoadCandidates: %v", err) } @@ -110,7 +111,7 @@ func TestLoadCandidates_ExcludesRecentlyPlayed(t *testing.T) { ClientID: nil, }) - got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1) + got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, SessionVector{Seed: true}) if err != nil { t.Fatalf("LoadCandidates: %v", err) } @@ -156,7 +157,7 @@ func TestLoadCandidates_StatJoin(t *testing.T) { DurationPlayedMs: &dur2, CompletionRatio: &ratio2, WasSkipped: true, }) - got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1) + got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, SessionVector{Seed: true}) if err != nil { t.Fatalf("LoadCandidates: %v", err) } @@ -180,7 +181,7 @@ func TestLoadCandidates_StatJoin(t *testing.T) { func TestLoadCandidates_NeverPlayedHasNilLastPlayed(t *testing.T) { f := newFixture(t, 2) - got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1) + got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, SessionVector{Seed: true}) if err != nil { t.Fatalf("LoadCandidates: %v", err) } @@ -203,7 +204,7 @@ func TestLoadCandidates_CrossUserIsolation(t *testing.T) { // Alice likes tracks[1]; Bob shouldn't see it. _, _ = f.q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: f.user, TrackID: f.tracks[1].ID}) - got, err := LoadCandidates(context.Background(), f.q, bob.ID, f.tracks[0].ID, 1) + got, err := LoadCandidates(context.Background(), f.q, bob.ID, f.tracks[0].ID, 1, SessionVector{Seed: true}) if err != nil { t.Fatalf("LoadCandidates: %v", err) } @@ -221,3 +222,134 @@ func trackTitles(cs []Candidate) []string { } return out } + +// helperInsertContextualLike inserts a contextual_like row with the given +// session_vector marshaled to JSON. Bypasses playevents.CaptureContextualLikeIfPlaying +// because we want full control over the stored vector for these unit tests. +func helperInsertContextualLike(t *testing.T, f fixture, trackID pgtype.UUID, vec SessionVector) { + t.Helper() + raw, err := json.Marshal(vec) + if err != nil { + t.Fatalf("marshal: %v", err) + } + if _, err := f.pool.Exec(context.Background(), + `INSERT INTO contextual_likes (user_id, track_id, session_vector) VALUES ($1, $2, $3)`, + f.user, trackID, raw); err != nil { + t.Fatalf("insert: %v", err) + } +} + +func TestLoadCandidates_NoContextualLikes_AllZero(t *testing.T) { + f := newFixture(t, 5) + current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} + got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, current) + if err != nil { + t.Fatalf("load: %v", err) + } + for _, c := range got { + if c.Inputs.ContextualMatchScore != 0 { + t.Errorf("track %s ContextualMatchScore = %v, want 0", c.Track.Title, c.Inputs.ContextualMatchScore) + } + } +} + +func TestLoadCandidates_OneMatchingLike_ScoresPositive(t *testing.T) { + f := newFixture(t, 3) + target := f.tracks[1] + likeVec := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} + helperInsertContextualLike(t, f, target.ID, likeVec) + + current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} + got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, current) + if err != nil { + t.Fatalf("load: %v", err) + } + var found bool + for _, c := range got { + if c.Track.ID == target.ID { + found = true + if c.Inputs.ContextualMatchScore < 0.99 { + t.Errorf("target ContextualMatchScore = %v, want ~1.0", c.Inputs.ContextualMatchScore) + } + } else { + if c.Inputs.ContextualMatchScore != 0 { + t.Errorf("non-target track has ContextualMatchScore = %v", c.Inputs.ContextualMatchScore) + } + } + } + if !found { + t.Error("target track missing from candidate list") + } +} + +func TestLoadCandidates_MultipleMatchingLikes_TakesMax(t *testing.T) { + f := newFixture(t, 3) + target := f.tracks[1] + helperInsertContextualLike(t, f, target.ID, SessionVector{ + Artists: []string{"a99"}, Tags: map[string]int{"jazz": 1}, + }) + helperInsertContextualLike(t, f, target.ID, SessionVector{ + Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}, + }) + helperInsertContextualLike(t, f, target.ID, SessionVector{ + Artists: []string{"a1"}, Tags: map[string]int{"jazz": 1}, + }) + + current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} + got, _ := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, current) + for _, c := range got { + if c.Track.ID == target.ID && c.Inputs.ContextualMatchScore < 0.99 { + t.Errorf("target = %v, want ~1.0 (max)", c.Inputs.ContextualMatchScore) + } + } +} + +func TestLoadCandidates_SoftDeletedLikes_Ignored(t *testing.T) { + f := newFixture(t, 3) + target := f.tracks[1] + likeVec := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} + helperInsertContextualLike(t, f, target.ID, likeVec) + if _, err := f.pool.Exec(context.Background(), + `UPDATE contextual_likes SET deleted_at = now() WHERE user_id = $1`, f.user); err != nil { + t.Fatalf("soft-delete: %v", err) + } + + current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} + got, _ := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, current) + for _, c := range got { + if c.Inputs.ContextualMatchScore != 0 { + t.Errorf("soft-deleted track %s ContextualMatchScore = %v", c.Track.Title, c.Inputs.ContextualMatchScore) + } + } +} + +func TestLoadCandidates_OnlySeedLikes_ScoresZero(t *testing.T) { + f := newFixture(t, 3) + target := f.tracks[1] + helperInsertContextualLike(t, f, target.ID, SessionVector{ + Seed: true, Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}, + }) + + current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} + got, _ := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, current) + for _, c := range got { + if c.Inputs.ContextualMatchScore != 0 { + t.Errorf("seed-only track %s ContextualMatchScore = %v", c.Track.Title, c.Inputs.ContextualMatchScore) + } + } +} + +func TestLoadCandidates_CurrentSeed_ScoresZero(t *testing.T) { + f := newFixture(t, 3) + target := f.tracks[1] + likeVec := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} + helperInsertContextualLike(t, f, target.ID, likeVec) + + currentSeed := SessionVector{Seed: true} + got, _ := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, currentSeed) + for _, c := range got { + if c.Inputs.ContextualMatchScore != 0 { + t.Errorf("seed-current track %s ContextualMatchScore = %v", c.Track.Title, c.Inputs.ContextualMatchScore) + } + } +}