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 <noreply@anthropic.com>
This commit is contained in:
2026-04-27 20:41:55 -04:00
parent cb46b3830f
commit 5b79aa1047
2 changed files with 181 additions and 10 deletions
+138 -6
View File
@@ -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)
}
}
}