diff --git a/docs/superpowers/plans/2026-04-27-m3-similarity.md b/docs/superpowers/plans/2026-04-27-m3-similarity.md new file mode 100644 index 00000000..b892be85 --- /dev/null +++ b/docs/superpowers/plans/2026-04-27-m3-similarity.md @@ -0,0 +1,1414 @@ +# M3 Session Similarity + contextual_match_score Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add the `contextual_match_score` term to the recommendation scoring formula by computing weighted-Jaccard similarity between the user's current session vector and each candidate track's stored `contextual_likes` session vectors. Closes M3. + +**Architecture:** New pure `Similarity` + `ContextualMatchScore` functions in `internal/recommendation/similarity.go` — set Jaccard on tags + artists, weighted 0.7/0.3, hardcoded for v1. `Score()` gains `ContextualMatchScore` input + `ContextWeight` weight. `LoadCandidates` accepts a `currentVector` and bulk-fetches the user's active `contextual_likes` once, mapping by `track_id` and computing per-candidate max similarity. `internal/api/radio.go` reads the user's most recent open session's `session_vector_at_play` via a new `GetCurrentSessionVectorForUser` query and threads it through. + +**Tech Stack:** Go 1.23 + sqlc + pgx/v5. No web changes. + +**Reference:** design spec at `docs/superpowers/specs/2026-04-27-m3-similarity-design.md`. + +--- + +## File Structure + +**New server files:** + +| File | Responsibility | +|---|---| +| `internal/recommendation/similarity.go` | Pure: `SimilarityWeights` struct, `DefaultSimilarityWeights`, `Similarity(a, b SessionVector, w SimilarityWeights) float64`, `ContextualMatchScore(current SessionVector, likes []SessionVector, w SimilarityWeights) float64`. | +| `internal/recommendation/similarity_test.go` | Pure unit tests (table-driven). | + +**Modified server files:** + +| File | Change | +|---|---| +| `internal/recommendation/score.go` | `ScoringInputs` gains `ContextualMatchScore float64`. `ScoringWeights` gains `ContextWeight float64`. `Score()` adds `+ in.ContextualMatchScore * w.ContextWeight`. | +| `internal/recommendation/score_test.go` | Three new tests for the new term. | +| `internal/recommendation/candidates.go` | `LoadCandidates` signature gains `currentVector SessionVector` parameter. Body bulk-fetches user's contextual_likes, groups by track_id, populates per-candidate `ContextualMatchScore`. | +| `internal/recommendation/candidates_test.go` | Six new tests for contextual scoring. | +| `internal/db/queries/contextual_likes.sql` | Add `ListActiveContextualLikesForUser :many`. | +| `internal/db/queries/events.sql` | Add `GetCurrentSessionVectorForUser :one`. | +| `internal/db/dbq/contextual_likes.sql.go` | Generated bindings. | +| `internal/db/dbq/events.sql.go` | Generated bindings. | +| `internal/config/config.go` | `RecommendationConfig` gains `ContextWeight float64` (yaml `context_weight`, default `2.0`). | +| `internal/api/radio.go` | Fetch current session vector before `LoadCandidates`, build `ContextWeight` into `ScoringWeights`, thread `currentVector` into `LoadCandidates`. | +| `internal/api/auth_test.go` | `recCfg` test-helper builds `ContextWeight: 2.0`. | +| `internal/api/radio_test.go` | One new end-to-end test: contextual ranking. | + +**No web changes.** + +--- + +## Task 1: Pure `Similarity` function (Jaccard + axis weights) + +**Files:** +- Create: `internal/recommendation/similarity.go` +- Create: `internal/recommendation/similarity_test.go` + +- [ ] **Step 1: Write the failing tests for `Similarity`** + +Create `internal/recommendation/similarity_test.go`: + +```go +package recommendation + +import ( + "math" + "testing" +) + +func approxEq(a, b float64) bool { return math.Abs(a-b) < 1e-9 } + +func TestSimilarity_IdenticalVectors_Returns1(t *testing.T) { + v := SessionVector{ + Artists: []string{"a1", "a2"}, + Tags: map[string]int{"rock": 2, "indie": 1}, + } + got := Similarity(v, v, DefaultSimilarityWeights) + if !approxEq(got, 1.0) { + t.Errorf("Similarity(v,v) = %v, want 1.0", got) + } +} + +func TestSimilarity_FullyDisjoint_Returns0(t *testing.T) { + a := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} + b := SessionVector{Artists: []string{"a2"}, Tags: map[string]int{"jazz": 1}} + got := Similarity(a, b, DefaultSimilarityWeights) + if !approxEq(got, 0.0) { + t.Errorf("disjoint = %v, want 0.0", got) + } +} + +func TestSimilarity_TagsOnlyShared_AppliesTagsWeight(t *testing.T) { + // Shared tags fully (Jaccard=1), no shared artists (Jaccard=0). + a := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} + b := SessionVector{Artists: []string{"a2"}, Tags: map[string]int{"rock": 5}} + got := Similarity(a, b, DefaultSimilarityWeights) + // Expected: 0.7 * 1.0 + 0.3 * 0.0 = 0.7 + if !approxEq(got, 0.7) { + t.Errorf("tags-only = %v, want 0.7", got) + } +} + +func TestSimilarity_ArtistsOnlyShared_AppliesArtistsWeight(t *testing.T) { + a := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} + b := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"jazz": 1}} + got := Similarity(a, b, DefaultSimilarityWeights) + // Expected: 0.7 * 0.0 + 0.3 * 1.0 = 0.3 + if !approxEq(got, 0.3) { + t.Errorf("artists-only = %v, want 0.3", got) + } +} + +func TestSimilarity_EitherSeed_Returns0(t *testing.T) { + v := SessionVector{Artists: []string{"a"}, Tags: map[string]int{"rock": 1}} + seed := SessionVector{Seed: true, Artists: []string{"a"}, Tags: map[string]int{"rock": 1}} + if got := Similarity(v, seed, DefaultSimilarityWeights); !approxEq(got, 0.0) { + t.Errorf("v vs seed = %v, want 0.0", got) + } + if got := Similarity(seed, v, DefaultSimilarityWeights); !approxEq(got, 0.0) { + t.Errorf("seed vs v = %v, want 0.0", got) + } +} + +func TestSimilarity_BothEmpty_Returns0NotNaN(t *testing.T) { + a := SessionVector{} + b := SessionVector{} + got := Similarity(a, b, DefaultSimilarityWeights) + if math.IsNaN(got) || !approxEq(got, 0.0) { + t.Errorf("empty = %v, want 0.0 (not NaN)", got) + } +} + +func TestSimilarity_OneAxisEmptyOneSide_AxisContributesZero(t *testing.T) { + // a has tags but no artists; b has artists but no tags. + a := SessionVector{Tags: map[string]int{"rock": 1}} + b := SessionVector{Artists: []string{"a1"}} + got := Similarity(a, b, DefaultSimilarityWeights) + // tags axis: A={rock}, B={} → union={rock}, intersect={} → 0 + // artists axis: A={}, B={a1} → union={a1}, intersect={} → 0 + if !approxEq(got, 0.0) { + t.Errorf("one-axis-each = %v, want 0.0", got) + } +} + +func TestSimilarity_PartialTagsOverlap(t *testing.T) { + // Tags A={rock,indie}, B={rock,jazz}: intersect=1, union=3, J=1/3 + // Artists fully shared: J=1 + a := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1, "indie": 1}} + b := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1, "jazz": 1}} + got := Similarity(a, b, DefaultSimilarityWeights) + want := 0.7*(1.0/3.0) + 0.3*1.0 + if !approxEq(got, want) { + t.Errorf("partial = %v, want %v", got, want) + } +} + +func TestSimilarity_BagOfCountsCollapsesToSet(t *testing.T) { + // Same tag keysets, different counts → set-Jaccard collapses to 1.0. + a := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 2, "indie": 1}} + b := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 5, "indie": 3}} + got := Similarity(a, b, DefaultSimilarityWeights) + if !approxEq(got, 1.0) { + t.Errorf("set-collapse = %v, want 1.0", got) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/recommendation/ -run Similarity -v` + +Expected: FAIL with "undefined: Similarity" / "undefined: DefaultSimilarityWeights". + +- [ ] **Step 3: Write minimal `Similarity` implementation** + +Create `internal/recommendation/similarity.go`: + +```go +package recommendation + +// SimilarityWeights balances the per-axis contribution to the weighted Jaccard +// score. v1 hardcodes the defaults — operators cannot tune via YAML. If +// telemetry justifies it, expose under recommendation.similarity.* later. +type SimilarityWeights struct { + TagsWeight float64 + ArtistsWeight float64 +} + +// DefaultSimilarityWeights is the v1 axis balance per the M3 design. +// Tags carry more signal than artists because a session's "vibe" tracks +// genre more directly than artist identity (a session can mix artists +// within a genre but rarely mixes genres). +var DefaultSimilarityWeights = SimilarityWeights{ + TagsWeight: 0.7, + ArtistsWeight: 0.3, +} + +// Similarity returns weighted-Jaccard similarity in [0, 1] between two +// session vectors. Returns 0 if either input is Seed=true (low-confidence +// vectors don't contribute to scoring). +func Similarity(a, b SessionVector, w SimilarityWeights) float64 { + if a.Seed || b.Seed { + return 0.0 + } + tagJ := setJaccardKeys(a.Tags, b.Tags) + artistJ := setJaccardSlice(a.Artists, b.Artists) + return tagJ*w.TagsWeight + artistJ*w.ArtistsWeight +} + +// setJaccardKeys collapses two map keysets to sets and returns +// |A ∩ B| / |A ∪ B|. Both empty → 0 (not NaN). +func setJaccardKeys(a, b map[string]int) float64 { + if len(a) == 0 && len(b) == 0 { + return 0.0 + } + intersect := 0 + for k := range a { + if _, ok := b[k]; ok { + intersect++ + } + } + union := len(a) + len(b) - intersect + if union == 0 { + return 0.0 + } + return float64(intersect) / float64(union) +} + +// setJaccardSlice deduplicates each input slice into a set and returns +// |A ∩ B| / |A ∪ B|. Both empty → 0 (not NaN). +func setJaccardSlice(a, b []string) float64 { + if len(a) == 0 && len(b) == 0 { + return 0.0 + } + aset := make(map[string]struct{}, len(a)) + for _, x := range a { + aset[x] = struct{}{} + } + bset := make(map[string]struct{}, len(b)) + for _, x := range b { + bset[x] = struct{}{} + } + intersect := 0 + for k := range aset { + if _, ok := bset[k]; ok { + intersect++ + } + } + union := len(aset) + len(bset) - intersect + if union == 0 { + return 0.0 + } + return float64(intersect) / float64(union) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/recommendation/ -run Similarity -v` + +Expected: PASS for all 9 tests. + +- [ ] **Step 5: Commit** + +```bash +git add internal/recommendation/similarity.go internal/recommendation/similarity_test.go +git commit -m "feat(recommendation): add pure Similarity function with weighted Jaccard" +``` + +--- + +## Task 2: `ContextualMatchScore` convenience function + +**Files:** +- Modify: `internal/recommendation/similarity.go` (append) +- Modify: `internal/recommendation/similarity_test.go` (append) + +- [ ] **Step 1: Write the failing tests** + +Append to `internal/recommendation/similarity_test.go`: + +```go +func TestContextualMatchScore_NoLikes_Returns0(t *testing.T) { + current := SessionVector{Artists: []string{"a"}, Tags: map[string]int{"rock": 1}} + got := ContextualMatchScore(current, nil, DefaultSimilarityWeights) + if !approxEq(got, 0.0) { + t.Errorf("no likes = %v, want 0.0", got) + } + got = ContextualMatchScore(current, []SessionVector{}, DefaultSimilarityWeights) + if !approxEq(got, 0.0) { + t.Errorf("empty likes = %v, want 0.0", got) + } +} + +func TestContextualMatchScore_CurrentSeed_Returns0(t *testing.T) { + current := SessionVector{Seed: true} + likes := []SessionVector{ + {Artists: []string{"a"}, Tags: map[string]int{"rock": 1}}, + } + got := ContextualMatchScore(current, likes, DefaultSimilarityWeights) + if !approxEq(got, 0.0) { + t.Errorf("current seed = %v, want 0.0", got) + } +} + +func TestContextualMatchScore_AllLikesSeed_Returns0(t *testing.T) { + current := SessionVector{Artists: []string{"a"}, Tags: map[string]int{"rock": 1}} + likes := []SessionVector{ + {Seed: true, Artists: []string{"a"}, Tags: map[string]int{"rock": 1}}, + {Seed: true, Artists: []string{"a"}, Tags: map[string]int{"rock": 1}}, + } + got := ContextualMatchScore(current, likes, DefaultSimilarityWeights) + if !approxEq(got, 0.0) { + t.Errorf("all-seed likes = %v, want 0.0", got) + } +} + +func TestContextualMatchScore_TakesMax(t *testing.T) { + // Three likes: full match, partial match, mismatch. Expect full match (1.0). + current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} + likes := []SessionVector{ + {Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}, // 1.0 + {Artists: []string{"a2"}, Tags: map[string]int{"rock": 1}}, // 0.7 + {Artists: []string{"a99"}, Tags: map[string]int{"jazz": 1}}, // 0.0 + } + got := ContextualMatchScore(current, likes, DefaultSimilarityWeights) + if !approxEq(got, 1.0) { + t.Errorf("takes-max = %v, want 1.0", got) + } +} + +func TestContextualMatchScore_FiltersSeedThenMaxes(t *testing.T) { + // One Seed=true match (would be 1.0 if not filtered) + one partial match. + current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} + likes := []SessionVector{ + {Seed: true, Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}, + {Artists: []string{"a2"}, Tags: map[string]int{"rock": 1}}, + } + got := ContextualMatchScore(current, likes, DefaultSimilarityWeights) + // Seed=true filtered out → only partial match counts → 0.7 + if !approxEq(got, 0.7) { + t.Errorf("filter-then-max = %v, want 0.7", got) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/recommendation/ -run ContextualMatchScore -v` + +Expected: FAIL with "undefined: ContextualMatchScore". + +- [ ] **Step 3: Append `ContextualMatchScore` to `similarity.go`** + +Append to `internal/recommendation/similarity.go`: + +```go +// ContextualMatchScore returns the maximum Similarity between the current +// session vector and any non-seed entry in likes. Returns 0 when: +// - current.Seed is true (no meaningful current context) +// - likes is empty after filtering out Seed=true entries +// +// The "max" semantics means a single strong contextual match dominates +// over many weak ones — we want to surface the track because it was liked +// in *some* matching context, not because it was vaguely-liked in many. +func ContextualMatchScore(current SessionVector, likes []SessionVector, w SimilarityWeights) float64 { + if current.Seed { + return 0.0 + } + best := 0.0 + for _, l := range likes { + if l.Seed { + continue + } + s := Similarity(current, l, w) + if s > best { + best = s + } + } + return best +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/recommendation/ -run ContextualMatchScore -v` + +Expected: PASS for all 5 tests. + +- [ ] **Step 5: Commit** + +```bash +git add internal/recommendation/similarity.go internal/recommendation/similarity_test.go +git commit -m "feat(recommendation): add ContextualMatchScore (max over non-seed likes)" +``` + +--- + +## Task 3: Extend `Score` with `ContextualMatchScore` + `ContextWeight` + +**Files:** +- Modify: `internal/recommendation/score.go` +- Modify: `internal/recommendation/score_test.go` + +- [ ] **Step 1: Write the failing tests** + +Append to `internal/recommendation/score_test.go`: + +```go +func TestScore_ContextualMatch_PerfectMatchAtWeight2(t *testing.T) { + w := defaultWeights() + w.ContextWeight = 2.0 + in := ScoringInputs{ContextualMatchScore: 1.0} + got := Score(in, w, time.Now(), fixedRNG(0.5)) + // base 1.0 + recency 1.0 (never played) + contextual 2.0 = 4.0 + want := 4.0 + if math.Abs(got-want) > 1e-9 { + t.Errorf("score = %v, want %v", got, want) + } +} + +func TestScore_ContextualMatch_HalfMatchAtWeight2(t *testing.T) { + w := defaultWeights() + w.ContextWeight = 2.0 + in := ScoringInputs{ContextualMatchScore: 0.5} + got := Score(in, w, time.Now(), fixedRNG(0.5)) + // base 1.0 + recency 1.0 + contextual 1.0 = 3.0 + want := 3.0 + if math.Abs(got-want) > 1e-9 { + t.Errorf("score = %v, want %v", got, want) + } +} + +func TestScore_ContextualMatch_ZeroNoEffect(t *testing.T) { + wWithCtx := defaultWeights() + wWithCtx.ContextWeight = 2.0 + withCtx := Score(ScoringInputs{ContextualMatchScore: 0}, wWithCtx, time.Now(), fixedRNG(0.5)) + withoutCtx := Score(ScoringInputs{}, defaultWeights(), time.Now(), fixedRNG(0.5)) + if math.Abs(withCtx-withoutCtx) > 1e-9 { + t.Errorf("score-with-zero-ctx = %v, score-without = %v; should be equal", withCtx, withoutCtx) + } +} +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `go test ./internal/recommendation/ -run Score -v` + +Expected: FAIL — `ScoringInputs` has no `ContextualMatchScore`, `ScoringWeights` has no `ContextWeight`. + +- [ ] **Step 3: Extend `score.go`** + +Modify `internal/recommendation/score.go`. Replace the file contents with: + +```go +// Package recommendation implements the weighted-shuffle scoring engine +// from spec §6. The Score function is pure and takes an injectable RNG so +// tests can pin jitter to deterministic values. +package recommendation + +import ( + "time" +) + +// ScoringInputs are the per-track facts the score function consumes. +// ContextualMatchScore is in [0, 1] — max similarity between the user's +// current session vector and any non-seed contextual_like row for this +// track. Set by LoadCandidates after a bulk fetch. +type ScoringInputs struct { + IsGeneralLiked bool + LastPlayedAt *time.Time // nil = never played + PlayCount int // total play_events + SkipCount int // play_events with was_skipped=true + ContextualMatchScore float64 // [0, 1]; 0 when no signal +} + +// ScoringWeights are the operator-tunable knobs. Defaults live in +// config.RecommendationConfig and are propagated here per request. +type ScoringWeights struct { + BaseWeight float64 + LikeBoost float64 + RecencyWeight float64 + SkipPenalty float64 + JitterMagnitude float64 + ContextWeight float64 +} + +// Score computes the weighted-shuffle score per spec §6: +// +// score = base +// + (is_general_liked ? LikeBoost : 0) +// + recency_decay * RecencyWeight +// - skip_ratio * SkipPenalty +// + contextual_match_score * ContextWeight +// + small_random_jitter +// +// Higher score = more likely to surface. rng is a function returning a +// uniform sample in [0,1) — pass math/rand.Float64 in production, a fixed +// value in tests. +func Score(in ScoringInputs, w ScoringWeights, now time.Time, rng func() float64) float64 { + s := w.BaseWeight + if in.IsGeneralLiked { + s += w.LikeBoost + } + s += recencyDecay(in.LastPlayedAt, now) * w.RecencyWeight + s -= skipRatio(in.PlayCount, in.SkipCount) * w.SkipPenalty + s += in.ContextualMatchScore * w.ContextWeight + s += (rng()*2 - 1) * w.JitterMagnitude + return s +} + +// recencyDecay returns a value in [0, 1]: +// - never played → 1.0 (cold-start tracks compete favorably with stale ones). +// - age < 30 days → linear ramp age_days / 30. +// - age ≥ 30 days → 1.0 (capped). +// +// Negative ages (clock skew) clamp to 0 to avoid math weirdness. +func recencyDecay(lastPlayed *time.Time, now time.Time) float64 { + if lastPlayed == nil { + return 1.0 + } + age := now.Sub(*lastPlayed) + days := age.Hours() / 24 + if days < 0 { + return 0.0 + } + if days >= 30 { + return 1.0 + } + return days / 30.0 +} + +// skipRatio returns skips/plays in [0, 1]; never-played tracks return 0 +// rather than dividing by zero, so they aren't penalized. +func skipRatio(plays, skips int) float64 { + if plays == 0 { + return 0.0 + } + return float64(skips) / float64(plays) +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `go test ./internal/recommendation/ -run Score -v` + +Expected: PASS for all existing Score tests + 3 new contextual tests. + +- [ ] **Step 5: Commit** + +```bash +git add internal/recommendation/score.go internal/recommendation/score_test.go +git commit -m "feat(recommendation): extend Score with ContextualMatchScore + ContextWeight" +``` + +--- + +## Task 4: New sqlc queries for current vector + contextual likes lookup + +**Files:** +- Modify: `internal/db/queries/contextual_likes.sql` +- Modify: `internal/db/queries/events.sql` +- Generated: `internal/db/dbq/contextual_likes.sql.go` +- Generated: `internal/db/dbq/events.sql.go` + +- [ ] **Step 1: Add `ListActiveContextualLikesForUser` query** + +Append to `internal/db/queries/contextual_likes.sql`: + +```sql +-- name: ListActiveContextualLikesForUser :many +-- Returns all the user's active (non-soft-deleted) contextual_likes with +-- non-null vectors. Cardinality is bounded by the user's actual like-while- +-- playing history — typically tens to low hundreds. Used by the engine to +-- compute contextual_match_score for the candidate pool. +SELECT track_id, session_vector +FROM contextual_likes +WHERE user_id = $1 + AND deleted_at IS NULL + AND session_vector IS NOT NULL; +``` + +- [ ] **Step 2: Add `GetCurrentSessionVectorForUser` query** + +Append to `internal/db/queries/events.sql`: + +```sql +-- name: GetCurrentSessionVectorForUser :one +-- Returns the session_vector_at_play of the user's most recent play_event +-- in a still-active (un-timed-out) session. NoRows means no current vector. +-- Joined with play_sessions so closed sessions don't leak stale vectors. +SELECT pe.session_vector_at_play +FROM play_events pe +JOIN play_sessions s ON s.id = pe.session_id +WHERE pe.user_id = $1 + AND s.ended_at IS NULL +ORDER BY pe.started_at DESC +LIMIT 1; +``` + +- [ ] **Step 3: Run sqlc generate** + +Run: `make generate` (or `cd internal/db && sqlc generate` if the Makefile target differs). + +Expected: `internal/db/dbq/contextual_likes.sql.go` gains `ListActiveContextualLikesForUser` method; `internal/db/dbq/events.sql.go` gains `GetCurrentSessionVectorForUser`. + +- [ ] **Step 4: Verify compile** + +Run: `go build ./...` + +Expected: clean compile (no test-side changes yet, just generated bindings). + +- [ ] **Step 5: Commit** + +```bash +git add internal/db/queries/contextual_likes.sql internal/db/queries/events.sql internal/db/dbq/ +git commit -m "feat(db): add similarity lookup queries (ListActiveContextualLikesForUser, GetCurrentSessionVectorForUser)" +``` + +--- + +## Task 5: Extend `LoadCandidates` to compute per-candidate contextual scores + +**Files:** +- Modify: `internal/recommendation/candidates.go` +- Modify: `internal/recommendation/candidates_test.go` + +- [ ] **Step 1: Write failing tests for `LoadCandidates` contextual behavior** + +Append to `internal/recommendation/candidates_test.go`: + +```go +import ( + "context" + "encoding/json" + "testing" + "time" +) + +// helperInsertContextualLike inserts a contextual_like row with the given +// session_vector marshaled to JSON. Returns nothing — the test asserts via +// LoadCandidates output. 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] + // Three likes on the same track: weak, strong, medium. Expect strong. + 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) + // Soft-delete the row. + 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] + // Only a Seed=true contextual_like exists. + 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) + } + } +} +``` + +Then update existing test call sites (`TestLoadCandidates_ExcludesSeed` and any other extant calls in this file): each `LoadCandidates(...)` call gets a sixth argument. For tests that don't care about contextual scoring, pass `SessionVector{Seed: true}` — that short-circuits the term to 0 and matches v1 behavior. + +Replace existing call patterns: + +```go +got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1) +``` + +with: + +```go +got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, SessionVector{Seed: true}) +``` + +(There are 5 such call sites in the existing `candidates_test.go`. Update them all.) + +Update the `import` block at the top to add `"encoding/json"` if not present. + +- [ ] **Step 2: Run tests to verify the new ones fail and existing ones still compile** + +Run: `go test ./internal/recommendation/ -run LoadCandidates -v` + +Expected: existing tests fail to compile because `LoadCandidates` only takes 5 args. After updating call sites, they pass; new tests fail with "too few arguments" or "ContextualMatchScore not set". + +- [ ] **Step 3: Update `LoadCandidates` signature + body** + +Replace `internal/recommendation/candidates.go` contents: + +```go +package recommendation + +import ( + "context" + "encoding/json" + "time" + + "github.com/jackc/pgx/v5/pgtype" + + "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, + ID: seedID, + Column3: float64(recentlyPlayedHours), + }) + 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 + } + 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, + }, + }) + } + 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 +} +``` + +- [ ] **Step 4: Run tests to verify all pass** + +Run: `go test ./internal/recommendation/ -v` + +Expected: PASS for all existing tests + 6 new contextual tests. + +- [ ] **Step 5: Verify other callers compile** + +Run: `go build ./...` + +Expected: `internal/api/radio.go` fails — it still calls `LoadCandidates` with 5 args. We fix that in Task 6. + +- [ ] **Step 6: Commit (allow temporary build break — fixed in Task 6)** + +```bash +git add internal/recommendation/candidates.go internal/recommendation/candidates_test.go +git commit -m "feat(recommendation): LoadCandidates computes per-candidate ContextualMatchScore" +``` + +Note: this commit leaves `internal/api/radio.go` non-compiling. Task 6 restores green. Don't push the branch in this state — finish Task 6 first. + +--- + +## Task 6: Config + radio handler wiring + +**Files:** +- Modify: `internal/config/config.go` +- Modify: `internal/api/radio.go` +- Modify: `internal/api/auth_test.go` + +- [ ] **Step 1: Add `ContextWeight` to `RecommendationConfig`** + +In `internal/config/config.go`, modify the `RecommendationConfig` struct: + +```go +type RecommendationConfig struct { + BaseWeight float64 `yaml:"base_weight"` + LikeBoost float64 `yaml:"like_boost"` + RecencyWeight float64 `yaml:"recency_weight"` + SkipPenalty float64 `yaml:"skip_penalty"` + JitterMagnitude float64 `yaml:"jitter_magnitude"` + ContextWeight float64 `yaml:"context_weight"` + RecentlyPlayedHours int `yaml:"recently_played_hours"` + RadioSize int `yaml:"radio_size"` + RadioSizeMax int `yaml:"radio_size_max"` +} +``` + +In the same file, modify `Default()`'s `Recommendation` block to include the new field: + +```go +Recommendation: RecommendationConfig{ + BaseWeight: 1.0, + LikeBoost: 2.0, + RecencyWeight: 1.0, + SkipPenalty: 1.0, + JitterMagnitude: 0.1, + ContextWeight: 2.0, + RecentlyPlayedHours: 1, + RadioSize: 50, + RadioSizeMax: 200, +}, +``` + +- [ ] **Step 2: Update `radio.go` to fetch current vector and pass it through** + +Replace `internal/api/radio.go` contents: + +```go +package api + +import ( + "encoding/json" + "errors" + "net/http" + "strconv" + "strings" + "time" + + "github.com/jackc/pgx/v5" + + "git.fabledsword.com/bvandeusen/minstrel/internal/auth" + "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" + "git.fabledsword.com/bvandeusen/minstrel/internal/recommendation" +) + +// RadioResponse is the body of GET /api/radio. +type RadioResponse struct { + Tracks []TrackRef `json:"tracks"` +} + +// handleRadio implements GET /api/radio?seed_track=&limit=. +// +// Returns the seed at index 0, followed by up to limit-1 weighted-shuffle +// picks from the user's library, scored by recommendation.Score. The +// scoring formula folds in contextual_match_score using the user's current +// session vector (read from the most recent open play_event). +func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) { + user, ok := auth.UserFromContext(r.Context()) + if !ok { + writeErr(w, http.StatusUnauthorized, "unauthorized", "authentication required") + return + } + raw := strings.TrimSpace(r.URL.Query().Get("seed_track")) + if raw == "" { + writeErr(w, http.StatusBadRequest, "bad_request", "seed_track is required") + return + } + seedID, ok := parseUUID(raw) + if !ok { + writeErr(w, http.StatusBadRequest, "bad_request", "invalid seed_track id") + return + } + limit := h.recCfg.RadioSize + if v := r.URL.Query().Get("limit"); v != "" { + n, err := strconv.Atoi(v) + if err != nil || n < 1 { + writeErr(w, http.StatusBadRequest, "bad_request", "invalid limit") + return + } + limit = n + } + if limit > h.recCfg.RadioSizeMax { + limit = h.recCfg.RadioSizeMax + } + q := dbq.New(h.pool) + track, err := q.GetTrackByID(r.Context(), seedID) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + writeErr(w, http.StatusNotFound, "not_found", "seed_track not found") + return + } + h.logger.Error("api: get radio seed track failed", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") + return + } + album, err := q.GetAlbumByID(r.Context(), track.AlbumID) + if err != nil { + h.logger.Error("api: get radio seed album failed", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") + return + } + artist, err := q.GetArtistByID(r.Context(), track.ArtistID) + if err != nil { + h.logger.Error("api: get radio seed artist failed", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") + return + } + + currentVec := loadCurrentSessionVector(r, q, user.ID, h.logger) + + candidates, err := recommendation.LoadCandidates(r.Context(), q, user.ID, seedID, h.recCfg.RecentlyPlayedHours, currentVec) + if err != nil { + h.logger.Error("api: radio: load candidates", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "candidate load failed") + return + } + weights := recommendation.ScoringWeights{ + BaseWeight: h.recCfg.BaseWeight, + LikeBoost: h.recCfg.LikeBoost, + RecencyWeight: h.recCfg.RecencyWeight, + SkipPenalty: h.recCfg.SkipPenalty, + JitterMagnitude: h.recCfg.JitterMagnitude, + ContextWeight: h.recCfg.ContextWeight, + } + picks := recommendation.Shuffle(candidates, weights, time.Now().UTC(), h.rng, limit-1) + + out := make([]TrackRef, 0, len(picks)+1) + out = append(out, trackRefFrom(track, album.Title, artist.Name)) + for _, p := range picks { + al, err := q.GetAlbumByID(r.Context(), p.Track.AlbumID) + if err != nil { + h.logger.Error("api: radio: resolve album", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "resolve failed") + return + } + ar, err := q.GetArtistByID(r.Context(), p.Track.ArtistID) + if err != nil { + h.logger.Error("api: radio: resolve artist", "err", err) + writeErr(w, http.StatusInternalServerError, "server_error", "resolve failed") + return + } + out = append(out, trackRefFrom(p.Track, al.Title, ar.Name)) + } + writeJSON(w, http.StatusOK, RadioResponse{Tracks: out}) +} + +// loadCurrentSessionVector returns the user's most recent active session +// vector, or a Seed=true sentinel if none exists / the column is NULL / +// the JSON fails to unmarshal. Sentinel short-circuits ContextualMatchScore +// to 0 so the contextual term contributes nothing in cold-start cases. +func loadCurrentSessionVector(r *http.Request, q *dbq.Queries, userID pgtype.UUID, logger *slog.Logger) recommendation.SessionVector { + raw, err := q.GetCurrentSessionVectorForUser(r.Context(), userID) + if err != nil { + // pgx.ErrNoRows is the common path: no active session yet. + if !errors.Is(err, pgx.ErrNoRows) { + logger.Warn("api: radio: load current session vector", "err", err) + } + return recommendation.SessionVector{Seed: true} + } + if len(raw) == 0 { + return recommendation.SessionVector{Seed: true} + } + var v recommendation.SessionVector + if jerr := json.Unmarshal(raw, &v); jerr != nil { + logger.Warn("api: radio: bad session_vector_at_play json", "err", jerr) + return recommendation.SessionVector{Seed: true} + } + return v +} +``` + +Add the missing imports at the top: `"log/slog"` and `"github.com/jackc/pgx/v5/pgtype"`. + +- [ ] **Step 3: Update `auth_test.go` test helper to include `ContextWeight`** + +In `internal/api/auth_test.go`, modify the `recCfg` definition inside `testHandlers`: + +```go +recCfg := config.RecommendationConfig{ + BaseWeight: 1.0, LikeBoost: 2.0, RecencyWeight: 1.0, + SkipPenalty: 1.0, JitterMagnitude: 0.1, ContextWeight: 2.0, + RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200, +} +``` + +- [ ] **Step 4: Run full build + existing tests** + +Run: `go build ./...` + +Expected: clean compile. + +Run: `go test ./internal/api/ ./internal/recommendation/ ./internal/config/ -v -short` + +Expected: PASS. The `-short` flag lets unit tests run; integration tests need `MINSTREL_TEST_DATABASE_URL`. + +If `MINSTREL_TEST_DATABASE_URL` is set, drop `-short` and verify integration tests pass: + +Run: `go test ./internal/api/ ./internal/recommendation/ -v` + +Expected: PASS. All existing radio tests still pass — `currentVec` defaults to `Seed=true` for users with no plays, so behavior is identical to v1. + +- [ ] **Step 5: Commit** + +```bash +git add internal/config/config.go internal/api/radio.go internal/api/auth_test.go +git commit -m "feat(api): radio handler reads current session vector + threads ContextWeight" +``` + +--- + +## Task 7: End-to-end test — contextual match boosts ranking + +**Files:** +- Modify: `internal/api/radio_test.go` + +- [ ] **Step 1: Write the failing end-to-end test** + +Append to `internal/api/radio_test.go`: + +```go +func TestHandleRadio_ContextualMatch_BoostsRankingOverControl(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + user := seedUser(t, pool, "alice", "x", false) + + // Two artists in distinct genres, so we can construct a "rock vibe" session + // and a contextual match. + rockArtist := seedArtist(t, pool, "RockArtist") + rockAlbum := seedAlbum(t, pool, rockArtist.ID, "RockAlbum", 2020) + jazzArtist := seedArtist(t, pool, "JazzArtist") + jazzAlbum := seedAlbum(t, pool, jazzArtist.ID, "JazzAlbum", 2020) + + // Seed track is unrelated to both (don't want it to dominate scoring). + popArtist := seedArtist(t, pool, "PopArtist") + popAlbum := seedAlbum(t, pool, popArtist.ID, "PopAlbum", 2020) + seed := seedTrack(t, pool, popAlbum.ID, popArtist.ID, "Seed", 1, 100_000) + + // Target: a rock track. Control: a jazz track. Both will be scored. + target := seedTrackWithGenre(t, pool, rockAlbum.ID, rockArtist.ID, "Target", 1, 100_000, "rock") + control := seedTrackWithGenre(t, pool, jazzAlbum.ID, jazzArtist.ID, "Control", 1, 100_000, "jazz") + + // Build the user's "rock vibe" current context: insert an open play_session + // with a play_event whose session_vector_at_play matches the rock vibe. + rockVec := recommendation.SessionVector{ + Artists: []string{toUUIDString(rockArtist.ID)}, + Tags: map[string]int{"rock": 3}, + } + rockVecJSON, _ := json.Marshal(rockVec) + insertOpenSessionWithVector(t, pool, user.ID, rockArtist.ID, rockVecJSON) + + // Insert a contextual_like on the target track whose stored vector matches + // the rock vibe. (Direct DB insert — we want full control over the vector + // for this test, not whatever playevents.CaptureContextualLikeIfPlaying + // would produce.) + if _, err := pool.Exec(context.Background(), + `INSERT INTO contextual_likes (user_id, track_id, session_vector) VALUES ($1, $2, $3)`, + user.ID, target.ID, rockVecJSON); err != nil { + t.Fatalf("insert contextual_like: %v", err) + } + + // Request radio. The deterministic RNG (rng=0.5 → jitter contribution = 0) + // means rankings are reproducible for this test. + w := callRadio(h, user, "seed_track="+uuidToString(seed.ID)) + if w.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", w.Code, w.Body.String()) + } + var resp RadioResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + + // Find the indexes of target and control in the response. + targetIdx, controlIdx := -1, -1 + for i, tr := range resp.Tracks { + if tr.Title == "Target" { + targetIdx = i + } + if tr.Title == "Control" { + controlIdx = i + } + } + if targetIdx == -1 || controlIdx == -1 { + t.Fatalf("target=%d control=%d, expected both present (resp.Tracks=%v)", targetIdx, controlIdx, resp.Tracks) + } + if targetIdx >= controlIdx { + t.Errorf("target ranked at %d, control at %d: contextual match should put target above control", targetIdx, controlIdx) + } +} +``` + +- [ ] **Step 2: Add the helper `seedTrackWithGenre` and `insertOpenSessionWithVector`** + +These don't exist yet. Add them to `internal/api/radio_test.go` (or to whichever helpers file the existing `seedTrack` lives in — keep them adjacent): + +```go +func seedTrackWithGenre(t *testing.T, pool *pgxpool.Pool, albumID, artistID pgtype.UUID, title string, trackNo, durationMs int, genre string) dbq.Track { + t.Helper() + q := dbq.New(pool) + tr, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ + Title: title, + AlbumID: albumID, + ArtistID: artistID, + FilePath: "/tmp/" + title + ".flac", + DurationMs: int32(durationMs), + Genre: &genre, + }) + if err != nil { + t.Fatalf("UpsertTrack: %v", err) + } + return tr +} + +// insertOpenSessionWithVector creates a play_session with ended_at NULL and +// inserts a play_event in it whose session_vector_at_play is the given JSON. +// Used to simulate "user is mid-listen" for the radio handler's current-vector +// lookup. The play_event itself doesn't reference any of the test tracks. +func insertOpenSessionWithVector(t *testing.T, pool *pgxpool.Pool, userID, anyArtistID pgtype.UUID, vectorJSON []byte) { + t.Helper() + // Create a placeholder track so the play_event has a valid track_id. + q := dbq.New(pool) + al, _ := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ + Title: "PlaceholderAlbum", SortTitle: "PlaceholderAlbum", ArtistID: anyArtistID, + }) + ph, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ + Title: "Placeholder", AlbumID: al.ID, ArtistID: anyArtistID, + FilePath: "/tmp/placeholder.flac", DurationMs: 100_000, + }) + if err != nil { + t.Fatalf("placeholder track: %v", err) + } + // Insert open session. + var sessionID pgtype.UUID + if err := 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`, + userID).Scan(&sessionID); err != nil { + t.Fatalf("insert session: %v", err) + } + // Insert play_event with the given session_vector_at_play. + if _, err := pool.Exec(context.Background(), + `INSERT INTO play_events (user_id, track_id, session_id, started_at, session_vector_at_play) + VALUES ($1, $2, $3, now() - interval '1 minute', $4)`, + userID, ph.ID, sessionID, vectorJSON); err != nil { + t.Fatalf("insert play_event: %v", err) + } +} + +// toUUIDString converts a pgtype.UUID to its canonical hex string. Mirrors +// what BuildSessionVector emits, so the test's session_vector matches what +// the engine would produce in production. +func toUUIDString(u pgtype.UUID) string { + if !u.Valid { + return "" + } + return u.String() +} +``` + +Add the missing imports at the top of `radio_test.go`: `"github.com/jackc/pgx/v5/pgtype"`, `"github.com/jackc/pgx/v5/pgxpool"`, `"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"`, `"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"`. (Some may already exist.) + +Also: the `truncateLibrary` helper used by other tests probably truncates `play_events, play_sessions, sessions, users, tracks, albums, artists, general_likes`. We need it to also include `contextual_likes`. Find the helper (probably in `auth_test.go` or `radio_test.go`) and add `contextual_likes` to its TRUNCATE list: + +```go +func truncateLibrary(t *testing.T, pool *pgxpool.Pool) { + t.Helper() + if _, err := pool.Exec(context.Background(), + "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) + } +} +``` + +(If `truncateLibrary` already exists, add `contextual_likes` to its TRUNCATE list. If it doesn't exist as a named helper, find the inline TRUNCATE in `testHandlers` and update that.) + +- [ ] **Step 3: Run the new test** + +Run: `MINSTREL_TEST_DATABASE_URL= go test ./internal/api/ -run TestHandleRadio_ContextualMatch_BoostsRankingOverControl -v` + +Expected: PASS. Target track appears at a lower index than Control in the response. + +- [ ] **Step 4: Run the full integration suite** + +Run: `MINSTREL_TEST_DATABASE_URL= go test ./internal/api/ ./internal/recommendation/ -v` + +Expected: PASS. Existing tests unaffected; new contextual test passes. + +- [ ] **Step 5: Commit** + +```bash +git add internal/api/radio_test.go internal/api/auth_test.go +git commit -m "test(api): end-to-end contextual ranking test for /api/radio" +``` + +(Only commit `auth_test.go` here if you didn't already in Task 6 — if `truncateLibrary` lives there and got modified, include it.) + +--- + +## Task 8: Final verification + branch finish + +**Files:** none (verification only) + +- [ ] **Step 1: Full Go test suite** + +Run: `go test -short -race ./...` + +Expected: PASS. All packages, no race conditions. + +- [ ] **Step 2: Full integration suite (with DB)** + +Run: `MINSTREL_TEST_DATABASE_URL= go test -race ./...` + +Expected: PASS. + +- [ ] **Step 3: Lint** + +Run: `golangci-lint run ./...` + +Expected: clean. + +- [ ] **Step 4: Coverage check** + +Run: `go test -short -coverprofile=cover.out ./internal/recommendation/ ./internal/playevents/ && go tool cover -func=cover.out | tail -1` + +Expected: combined coverage ≥ 70% (target). With pure functions in similarity.go heavily tested, we expect to land at 80%+. + +- [ ] **Step 5: Web verification (sanity)** + +Run: `cd web && npm run check && npm test -- --run && npm run build` + +Expected: svelte-check 0/0, all vitest tests pass, build succeeds. (No web changes in this slice, so this should be clean.) + +- [ ] **Step 6: Docker smoke (if present locally)** + +Run: `make docker-build && make docker-smoke` (or whatever the project's smoke-test target is). + +Expected: container builds; smoke check (`/healthz`, `/api/auth/login`) returns expected codes. + +- [ ] **Step 7: Manual end-to-end verification** + +Start the server. As an authenticated user: + +1. Play a few tracks of one genre (e.g., 3+ rock tracks) via the web UI to populate a session vector. +2. Like one of those tracks (creates a contextual_like with the rock-vibe vector). +3. Wait for the recently-played-hours window to clear, OR pick tracks not yet recently played. +4. Click "Play radio" from a different track. +5. Inspect: the previously-liked rock track should appear in the radio queue with a noticeably-likely-to-rank-high position. Compare against a jazz track that the user has NEVER liked — it should be ranked lower. + +Verification is qualitative for v1; the integration test in Task 7 is the deterministic guarantee. + +- [ ] **Step 8: Update Fable task #342** + +Set status to `in_progress` (after PR opens). After PR merges, mark `done` with the closing summary in the body. The task tracking should mention: + +- Closes the M3 milestone +- All three v1 components shipped: weighted shuffle (#340), session vectors (#341), contextual matching (this slice) +- Coverage targets met +- Backwards-compat: `/api/radio` API shape unchanged + +- [ ] **Step 9: Finishing the branch** + +**REQUIRED SUB-SKILL:** Use `superpowers:finishing-a-development-branch` to verify tests, present completion options (merge locally / push + PR / keep / discard), and execute the user's choice. + +Per established cadence: this slice will land as a single-purpose PR (no bundling with future M3.5 / M4 work). Once merged, M3 is closed. + +--- + +## Self-Review + +**Spec coverage:** + +- §3.1 (similarity.go) → Tasks 1, 2 ✓ +- §3.2 (ListActiveContextualLikesForUser) → Task 4 ✓ +- §3.3 (GetCurrentSessionVectorForUser) → Task 4 ✓ +- §3.4 (Score extension) → Task 3 ✓ +- §3.5 (LoadCandidates extension) → Task 5 ✓ +- §3.6 (radio.go wiring) → Task 6 ✓ +- §3.7 (config) → Task 6 ✓ +- §4 (request flow) → Tasks 5+6 (composition) ✓ +- §5 (cold-start) → Task 5 covers Seed/empty paths; Task 6 covers no-session/NULL paths ✓ +- §6.1 (similarity_test) → Task 1 ✓ +- §6.2 (score_test) → Task 3 ✓ +- §6.3 (candidates_test) → Task 5 ✓ +- §6.4 (radio_test) → Task 7 ✓ +- §6.5 (coverage gate) → Task 8 ✓ +- §7 (backwards compat) → preserved by zero-value semantics in Task 3 + cold-start handling in Tasks 5-6 ✓ + +No gaps. + +**Placeholder scan:** No "TBD"/"TODO" content. All steps have explicit code or commands. + +**Type consistency:** Verified — `SessionVector`, `Similarity`, `ContextualMatchScore`, `ScoringInputs.ContextualMatchScore`, `ScoringWeights.ContextWeight`, `RecommendationConfig.ContextWeight`, `LoadCandidates`'s sixth parameter `currentVector SessionVector`, `loadContextualLikesByTrack` return type `map[pgtype.UUID][]SessionVector`, `loadCurrentSessionVector` return type `recommendation.SessionVector` — all consistent across tasks. diff --git a/docs/superpowers/specs/2026-04-27-m3-similarity-design.md b/docs/superpowers/specs/2026-04-27-m3-similarity-design.md new file mode 100644 index 00000000..44392aca --- /dev/null +++ b/docs/superpowers/specs/2026-04-27-m3-similarity-design.md @@ -0,0 +1,301 @@ +# M3 sub-plan #3 — Session similarity + contextual_match_score in scoring + +**Status:** Spec draft, 2026-04-27 +**Tracking:** Fable #342 +**Closes:** M3 milestone (recommendation engine v1) +**Builds on:** +- #340 — weighted shuffle baseline (`internal/recommendation` package, `Score`/`Shuffle`, `LoadRadioCandidates`) +- #341 — session vectors at play_started + `contextual_likes` capture on like + +## 1. Goal + +Add the `contextual_match_score` term to the recommendation scoring formula. For each +candidate track, compute the maximum weighted-Jaccard similarity between the user's +*current* session vector and the session vectors stored on that track's active +`contextual_likes` rows. Fold that scalar into `Score()` as +`+ contextual_match_score * ContextWeight`. + +When this slice ships, `/api/radio` produces context-aware recommendations: tracks the +user has previously liked while in similar musical contexts get an additive boost on +top of the v1 weighted-shuffle baseline. + +## 2. Non-goals + +- No new UI surface — `/api/radio` response shape is unchanged. +- No tag enrichment beyond `tracks.genre` (MBID tags / BPM remain post-v1). +- No similarity-axis weight exposure in YAML (hardcoded `0.7 / 0.3` for v1). +- No caching of the current session vector across requests. +- No "why this track?" debug endpoint. +- No ListenBrainz / external-similarity retrieval (M4). +- No GIN index on `play_events.session_vector_at_play` — we read the user's most + recent play by id, not by similarity. Existing `(user_id, started_at)` access + pattern is sufficient. + +## 3. Architecture overview + +Three additions to `internal/recommendation`, two adjustments to existing files, +one new sqlc query. + +### 3.1 New: `internal/recommendation/similarity.go` (pure) + +```go +type SimilarityWeights struct { + TagsWeight float64 + ArtistsWeight float64 +} + +// DefaultSimilarityWeights is the v1 axis balance per the M3 design. +// Hardcoded; not exposed via YAML — operators can't tune this for v1. +var DefaultSimilarityWeights = SimilarityWeights{ + TagsWeight: 0.7, + ArtistsWeight: 0.3, +} + +// Similarity returns weighted-Jaccard similarity in [0, 1]. Returns 0 if +// either input is Seed=true (low-confidence vectors don't contribute). +func Similarity(a, b SessionVector, w SimilarityWeights) float64 + +// ContextualMatchScore is the per-candidate scalar fed into ScoringInputs. +// Returns 0 if current.Seed is true OR likes (after filtering Seed=true +// entries) is empty. Otherwise: max(Similarity(current, l) for l in likes). +func ContextualMatchScore(current SessionVector, likes []SessionVector, w SimilarityWeights) float64 +``` + +**Per-axis semantics (classic set Jaccard):** + +- Tags axis flattens `map[string]int` keysets and computes `|A ∩ B| / |A ∪ B|`. + Bag-of-counts data is preserved on disk; we discard counts at similarity time. + Generalized Jaccard remains a one-line upgrade path if telemetry justifies it. +- Artists axis is already a `[]string` (deduplicated artist UUIDs); same Jaccard. +- Both axes empty (zero union) → axis returns 0.0, not NaN. + +### 3.2 New: `internal/db/queries/contextual_likes.sql` + +```sql +-- name: ListActiveContextualLikesForUser :many +-- Returns all the user's active (non-soft-deleted) contextual_likes with +-- non-null vectors. Cardinality bounded by the user's actual like-while- +-- playing history — typically tens to low hundreds. +SELECT track_id, session_vector +FROM contextual_likes +WHERE user_id = $1 AND deleted_at IS NULL AND session_vector IS NOT NULL; +``` + +### 3.3 New: `internal/db/queries/events.sql` (or `recommendation.sql`) + +```sql +-- name: GetCurrentSessionVectorForUser :one +-- Returns the session_vector_at_play of the user's most recent play_event +-- in a still-active (un-timed-out) session. NoRows means no current vector +-- — caller treats this as Seed=true sentinel. +SELECT pe.session_vector_at_play +FROM play_events pe +JOIN play_sessions s ON s.id = pe.session_id +WHERE pe.user_id = $1 + AND s.ended_at IS NULL +ORDER BY pe.started_at DESC +LIMIT 1; +``` + +(Final placement decided at implementation time — wherever sqlc and existing +query files line up best.) + +### 3.4 Modified: `internal/recommendation/score.go` + +```go +type ScoringInputs struct { + IsGeneralLiked bool + LastPlayedAt *time.Time + PlayCount int + SkipCount int + ContextualMatchScore float64 // NEW — in [0, 1], 0 when no signal +} + +type ScoringWeights struct { + BaseWeight float64 + LikeBoost float64 + RecencyWeight float64 + SkipPenalty float64 + JitterMagnitude float64 + ContextWeight float64 // NEW +} + +// Score gains: + in.ContextualMatchScore * w.ContextWeight +``` + +Zero-value defaults: a `ScoringInputs{}` with zero `ContextualMatchScore` and a +`ScoringWeights{}` with zero `ContextWeight` produce the v1 score. Existing callers +not passing the new fields see no behavior change. + +### 3.5 Modified: `internal/recommendation/candidates.go` + +```go +func LoadCandidates( + ctx context.Context, + q *dbq.Queries, + userID, seedID pgtype.UUID, + recentlyPlayedHours int, + currentVector SessionVector, // NEW +) ([]Candidate, error) +``` + +Body adds: + +1. Existing `LoadRadioCandidates` call. +2. New `ListActiveContextualLikesForUser(userID)` call. +3. Group result by `track_id` into `map[pgtype.UUID][]SessionVector`, unmarshaling + each `jsonb` column into `SessionVector`. Unmarshal failures are logged and + skipped (don't poison the entire response over one bad row). +4. For each candidate, set `Inputs.ContextualMatchScore = + ContextualMatchScore(currentVector, group[trackID], DefaultSimilarityWeights)`. + +### 3.6 Modified: `internal/api/radio.go` + +Before calling `LoadCandidates`, fetch the current session vector: + +```go +var currentVec recommendation.SessionVector +if raw, err := q.GetCurrentSessionVectorForUser(ctx, user.ID); err == nil && len(raw) > 0 { + if jerr := json.Unmarshal(raw, ¤tVec); jerr != nil { + h.logger.Warn("api: radio: bad session_vector_at_play", "err", jerr) + currentVec = recommendation.SessionVector{Seed: true} + } +} else { + currentVec = recommendation.SessionVector{Seed: true} +} +``` + +Pass `currentVec` into `LoadCandidates`. Pass `recCfg.ContextWeight` through into the +`ScoringWeights` struct alongside the existing weights. + +### 3.7 Modified: `internal/config/config.go` + +```go +type RecommendationConfig struct { + BaseWeight float64 `yaml:"base_weight"` + LikeBoost float64 `yaml:"like_boost"` + RecencyWeight float64 `yaml:"recency_weight"` + SkipPenalty float64 `yaml:"skip_penalty"` + JitterMagnitude float64 `yaml:"jitter_magnitude"` + ContextWeight float64 `yaml:"context_weight"` // NEW, default 2.0 + RecentlyPlayedHours int `yaml:"recently_played_hours"` + RadioSize int `yaml:"radio_size"` + RadioSizeMax int `yaml:"radio_size_max"` +} +``` + +`Default()` populates `ContextWeight: 2.0`. + +## 4. Request flow + +``` +GET /api/radio?seed_track=&limit=N + ↓ +handleRadio + 1. Auth, parse seed_track, parse limit (unchanged) + 2. q.GetCurrentSessionVectorForUser(userID) (NEW) + NoRows / NULL / unmarshal fail → SessionVector{Seed: true} + 3. recommendation.LoadCandidates(...) (extended) + a. q.LoadRadioCandidates (unchanged) + b. q.ListActiveContextualLikesForUser (NEW) + c. group by track_id → map[uuid][]SessionVector + d. per candidate: ContextualMatchScore() → ScoringInputs + 4. recommendation.Shuffle(candidates, weights, now, rng, limit-1) + Score() now folds in ContextualMatchScore * ContextWeight + 5. Resolve album/artist, build response (unchanged) +``` + +## 5. Cold-start handling + +Every cold-start path collapses to `contextual_match_score = 0` for all candidates, +so scoring degrades cleanly to v1 behavior: + +| Condition | Path | +|------------------------------------------------------|------------------------------------------------------| +| User has no `play_events` at all | `NoRows` → `Seed=true` sentinel → `ContextualMatchScore` returns 0 | +| User has plays but no active session | `NoRows` (joined `s.ended_at IS NULL` filters) | +| Active session but `session_vector_at_play` is NULL | `len(raw) == 0` → `Seed=true` sentinel | +| Vector populated but `Seed=true` | `ContextualMatchScore` short-circuits to 0 | +| Candidate has no `contextual_likes` | absent from map → empty slice → returns 0 | +| Candidate has only `Seed=true` likes | filtered out → empty → returns 0 | +| Candidate has only soft-deleted likes | excluded by `deleted_at IS NULL` in the SQL | + +## 6. Test plan + +### 6.1 `similarity_test.go` (pure, table-driven) + +- Identical vectors → `1.0`. +- Fully disjoint axes → `0.0`. +- Mixed: shared tags, no shared artists → `0.7 * tagJaccard`. +- Mixed: no shared tags, shared artists → `0.3 * artistJaccard`. +- Either input `Seed=true` → `0.0`. +- Both vectors fully empty → `0.0` (not NaN). +- One side empty on an axis, other side populated → that axis contributes 0. +- Weight balance: shared all tags, default weights → exactly `0.7`. + +### 6.2 `score_test.go` extensions + +- Perfect contextual match (`ContextualMatchScore=1.0`) at `ContextWeight=2.0` adds + exactly `+2.0` to the base score. +- Half match (`0.5`) adds `+1.0`. +- Zero match (`0.0`) leaves score unchanged from v1 behavior — guards backward compat. + +### 6.3 `candidates_test.go` (integration vs test DB) + +- Candidate with one matching `contextual_like` → `ContextualMatchScore > 0`. +- Candidate with multiple `contextual_likes` → max similarity wins. +- Candidate whose only `contextual_likes` are `Seed=true` → score 0. +- Candidate whose only `contextual_likes` are soft-deleted → score 0 (SQL filter). +- User with no `contextual_likes` anywhere → every candidate scores 0. +- User with only soft-deleted `contextual_likes` → every candidate scores 0. + +### 6.4 `radio_test.go` (integration, end-to-end) + +- Seed a current session vibe (3+ tracks of one genre/artist set) by inserting + `play_events` with populated `session_vector_at_play`. +- Insert a `contextual_like` whose `session_vector` matches that vibe, on track T. +- Insert an unrelated control track C with no contextual signal. +- Call `/api/radio` with a deterministic RNG and seed track distinct from T and C. +- Assert: T ranks above C in the response. + +### 6.5 Coverage gate + +Combined `internal/recommendation` coverage stays ≥ 70% (currently 78.5% combined +with `internal/playevents` post-#341; this slice's pure functions are highly +testable so we expect to land closer to 85%+). + +## 7. Backwards compatibility + +- `/api/radio` request and response shapes are unchanged — same query params, same + JSON output. Web client requires no edits. +- `ScoringInputs.ContextualMatchScore` and `ScoringWeights.ContextWeight` default to + `0` in zero-value structs. Pre-existing tests that construct these directly continue + passing without modification because the new term contributes nothing when both are + zero. +- `LoadCandidates` gains a `currentVector SessionVector` parameter — this is a + signature change, but the only caller is `internal/api/radio.go`, which we update + in this slice. No external consumers. +- DB schema is unchanged (migrations 0007 already shipped the table + indexes + needed for this slice). + +## 8. Out-of-scope (deferred) + +- Generalized (bag-of-counts) Jaccard if telemetry shows tag-dominance discrimination + matters. +- YAML exposure of `SimilarityWeights`. +- Per-user override of any recommendation weight. +- Caching `currentVector` across requests within a session. +- ListenBrainz / similar-artist retrieval (M4). +- `/api/radio?explain=true` style debug endpoint. +- Tag enrichment beyond `tracks.genre`. + +## 9. Milestone gate (closes M3) + +After this slice merges: + +- Recommendation engine has all three v1 components: weighted shuffle, session + vectors written, contextual matching folded into scoring. +- Manual end-to-end verification: like a track during a session of one vibe, build a + similar session later, observe the track surfaces above unrelated controls in + `/api/radio` output. +- M4 (radio refinements + scrobble polish) unblocked. diff --git a/internal/api/auth_test.go b/internal/api/auth_test.go index b6f2d5e2..851e52b7 100644 --- a/internal/api/auth_test.go +++ b/internal/api/auth_test.go @@ -52,7 +52,7 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) { w := playevents.NewWriter(pool, logger, 30*time.Minute, 0.5, 30000) recCfg := config.RecommendationConfig{ BaseWeight: 1.0, LikeBoost: 2.0, RecencyWeight: 1.0, - SkipPenalty: 1.0, JitterMagnitude: 0.1, + SkipPenalty: 1.0, JitterMagnitude: 0.1, ContextWeight: 2.0, RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200, } h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }} diff --git a/internal/api/library_fixtures_test.go b/internal/api/library_fixtures_test.go index dd685fb2..6c6a7d2f 100644 --- a/internal/api/library_fixtures_test.go +++ b/internal/api/library_fixtures_test.go @@ -89,6 +89,72 @@ func seedTrack(t *testing.T, pool *pgxpool.Pool, albumID, artistID pgtype.UUID, return tr } +// seedTrackWithGenre is seedTrack but lets the caller set Genre. Used by +// tests that exercise the contextual recommendation path, since session +// vectors collect tags from tracks.genre. +func seedTrackWithGenre(t *testing.T, pool *pgxpool.Pool, albumID, artistID pgtype.UUID, title string, trackNumber int, durationMs int32, genre string) dbq.Track { + t.Helper() + var tn *int32 + if trackNumber > 0 { + v := int32(trackNumber) + tn = &v + } + tr, err := dbq.New(pool).UpsertTrack(context.Background(), dbq.UpsertTrackParams{ + Title: title, + AlbumID: albumID, + ArtistID: artistID, + TrackNumber: tn, + DiscNumber: nil, + DurationMs: durationMs, + FilePath: "/seed/" + title + ".flac", + FileSize: 1024, + FileFormat: "flac", + Bitrate: nil, + Mbid: nil, + Genre: &genre, + }) + if err != nil { + t.Fatalf("UpsertTrack(%s): %v", title, err) + } + return tr +} + +// insertOpenSessionWithVector creates a play_session with ended_at NULL and +// inserts a play_event in it whose session_vector_at_play is the given JSON. +// Used to simulate "user is mid-listen" for the radio handler's current-vector +// lookup. The play_event references a placeholder track inserted on the fly +// so the FK is valid; the placeholder is not used for radio scoring. +func insertOpenSessionWithVector(t *testing.T, pool *pgxpool.Pool, userID, anyArtistID pgtype.UUID, vectorJSON []byte) { + t.Helper() + q := dbq.New(pool) + al, err := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{ + Title: "PlaceholderAlbum", SortTitle: "PlaceholderAlbum", ArtistID: anyArtistID, + }) + if err != nil { + t.Fatalf("placeholder album: %v", err) + } + ph, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{ + Title: "Placeholder", AlbumID: al.ID, ArtistID: anyArtistID, + FilePath: "/seed/placeholder.flac", DurationMs: 100_000, FileSize: 1024, FileFormat: "flac", + }) + if err != nil { + t.Fatalf("placeholder track: %v", err) + } + var sessionID pgtype.UUID + if err := 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`, + userID).Scan(&sessionID); err != nil { + t.Fatalf("insert session: %v", err) + } + if _, err := pool.Exec(context.Background(), + `INSERT INTO play_events (user_id, track_id, session_id, started_at, session_vector_at_play) + VALUES ($1, $2, $3, now() - interval '1 minute', $4)`, + userID, ph.ID, sessionID, vectorJSON); err != nil { + t.Fatalf("insert play_event: %v", err) + } +} + // seedTrackWithFile creates a fresh temp directory, writes fileBody to // /.<ext>, and inserts a Track row whose file_path points at it. // Returns the inserted Track and the directory (callers drop sidecar covers diff --git a/internal/api/radio.go b/internal/api/radio.go index 6d84994f..dd02b4c1 100644 --- a/internal/api/radio.go +++ b/internal/api/radio.go @@ -1,13 +1,16 @@ package api import ( + "encoding/json" "errors" + "log/slog" "net/http" "strconv" "strings" "time" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgtype" "git.fabledsword.com/bvandeusen/minstrel/internal/auth" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" @@ -22,7 +25,9 @@ type RadioResponse struct { // handleRadio implements GET /api/radio?seed_track=<uuid>&limit=<int>. // // Returns the seed at index 0, followed by up to limit-1 weighted-shuffle -// picks from the user's library, scored by recommendation.Score. +// picks from the user's library, scored by recommendation.Score. The +// scoring formula folds in contextual_match_score using the user's current +// session vector (read from the most recent open play_event). func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) { user, ok := auth.UserFromContext(r.Context()) if !ok { @@ -74,7 +79,10 @@ func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) { writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed") return } - candidates, err := recommendation.LoadCandidates(r.Context(), q, user.ID, seedID, h.recCfg.RecentlyPlayedHours) + + currentVec := loadCurrentSessionVector(r, q, user.ID, h.logger) + + candidates, err := recommendation.LoadCandidates(r.Context(), q, user.ID, seedID, h.recCfg.RecentlyPlayedHours, currentVec) if err != nil { h.logger.Error("api: radio: load candidates", "err", err) writeErr(w, http.StatusInternalServerError, "server_error", "candidate load failed") @@ -86,6 +94,7 @@ func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) { RecencyWeight: h.recCfg.RecencyWeight, SkipPenalty: h.recCfg.SkipPenalty, JitterMagnitude: h.recCfg.JitterMagnitude, + ContextWeight: h.recCfg.ContextWeight, } picks := recommendation.Shuffle(candidates, weights, time.Now().UTC(), h.rng, limit-1) @@ -108,3 +117,27 @@ func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) { } writeJSON(w, http.StatusOK, RadioResponse{Tracks: out}) } + +// loadCurrentSessionVector returns the user's most recent active session +// vector, or a Seed=true sentinel if none exists / the column is NULL / +// the JSON fails to unmarshal. Sentinel short-circuits ContextualMatchScore +// to 0 so the contextual term contributes nothing in cold-start cases. +func loadCurrentSessionVector(r *http.Request, q *dbq.Queries, userID pgtype.UUID, logger *slog.Logger) recommendation.SessionVector { + raw, err := q.GetCurrentSessionVectorForUser(r.Context(), userID) + if err != nil { + // pgx.ErrNoRows is the common path: no active session yet. + if !errors.Is(err, pgx.ErrNoRows) { + logger.Warn("api: radio: load current session vector", "err", err) + } + return recommendation.SessionVector{Seed: true} + } + if len(raw) == 0 { + return recommendation.SessionVector{Seed: true} + } + var v recommendation.SessionVector + if jerr := json.Unmarshal(raw, &v); jerr != nil { + logger.Warn("api: radio: bad session_vector_at_play json", "err", jerr) + return recommendation.SessionVector{Seed: true} + } + return v +} diff --git a/internal/api/radio_test.go b/internal/api/radio_test.go index 732ec5bd..9002efeb 100644 --- a/internal/api/radio_test.go +++ b/internal/api/radio_test.go @@ -6,6 +6,8 @@ import ( "net/http" "net/http/httptest" "testing" + + "git.fabledsword.com/bvandeusen/minstrel/internal/recommendation" ) func callRadio(h *handlers, user interface{}, query string) *httptest.ResponseRecorder { @@ -128,3 +130,74 @@ func TestHandleRadio_LimitClampedToMax(t *testing.T) { t.Errorf("len = %d, want 6", len(resp.Tracks)) } } + +func TestHandleRadio_ContextualMatch_BoostsRankingOverControl(t *testing.T) { + h, pool := testHandlers(t) + truncateLibrary(t, pool) + user := seedUser(t, pool, "alice", "x", false) + + // Two artists in distinct genres, so we can construct a "rock vibe" session + // and a contextual match. + rockArtist := seedArtist(t, pool, "RockArtist") + rockAlbum := seedAlbum(t, pool, rockArtist.ID, "RockAlbum", 2020) + jazzArtist := seedArtist(t, pool, "JazzArtist") + jazzAlbum := seedAlbum(t, pool, jazzArtist.ID, "JazzAlbum", 2020) + + // Seed track is unrelated to both (don't want it to dominate scoring). + popArtist := seedArtist(t, pool, "PopArtist") + popAlbum := seedAlbum(t, pool, popArtist.ID, "PopAlbum", 2020) + seed := seedTrack(t, pool, popAlbum.ID, popArtist.ID, "Seed", 1, 100_000) + + // Target: a rock track. Control: a jazz track. Both will be scored. + target := seedTrackWithGenre(t, pool, rockAlbum.ID, rockArtist.ID, "Target", 1, 100_000, "rock") + control := seedTrackWithGenre(t, pool, jazzAlbum.ID, jazzArtist.ID, "Control", 1, 100_000, "jazz") + _ = control // present in DB; we look it up by title in the response + + // Build the user's "rock vibe" current context: insert an open play_session + // with a play_event whose session_vector_at_play matches the rock vibe. + rockVec := recommendation.SessionVector{ + Artists: []string{rockArtist.ID.String()}, + Tags: map[string]int{"rock": 3}, + } + rockVecJSON, err := json.Marshal(rockVec) + if err != nil { + t.Fatalf("marshal rockVec: %v", err) + } + insertOpenSessionWithVector(t, pool, user.ID, rockArtist.ID, rockVecJSON) + + // Insert a contextual_like on the target track whose stored vector matches + // the rock vibe. Direct DB insert — we want full control over the vector + // for this test. + if _, err := pool.Exec(context.Background(), + `INSERT INTO contextual_likes (user_id, track_id, session_vector) VALUES ($1, $2, $3)`, + user.ID, target.ID, rockVecJSON); err != nil { + t.Fatalf("insert contextual_like: %v", err) + } + + // Request radio. The deterministic RNG (rng=0.5 → jitter contribution = 0) + // means rankings are reproducible for this test. + w := callRadio(h, user, "seed_track="+uuidToString(seed.ID)) + if w.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", w.Code, w.Body.String()) + } + var resp RadioResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + + targetIdx, controlIdx := -1, -1 + for i, tr := range resp.Tracks { + if tr.Title == "Target" { + targetIdx = i + } + if tr.Title == "Control" { + controlIdx = i + } + } + if targetIdx == -1 || controlIdx == -1 { + t.Fatalf("target=%d control=%d, expected both present (resp.Tracks=%v)", targetIdx, controlIdx, resp.Tracks) + } + if targetIdx >= controlIdx { + t.Errorf("target ranked at %d, control at %d: contextual match should put target above control", targetIdx, controlIdx) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 2a6a2a39..059aa0a8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -72,6 +72,7 @@ type RecommendationConfig struct { RecencyWeight float64 `yaml:"recency_weight"` SkipPenalty float64 `yaml:"skip_penalty"` JitterMagnitude float64 `yaml:"jitter_magnitude"` + ContextWeight float64 `yaml:"context_weight"` RecentlyPlayedHours int `yaml:"recently_played_hours"` RadioSize int `yaml:"radio_size"` RadioSizeMax int `yaml:"radio_size_max"` @@ -93,6 +94,7 @@ func Default() Config { RecencyWeight: 1.0, SkipPenalty: 1.0, JitterMagnitude: 0.1, + ContextWeight: 2.0, RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200, diff --git a/internal/db/dbq/albums.sql.go b/internal/db/dbq/albums.sql.go index acbdeb5b..f94956b6 100644 --- a/internal/db/dbq/albums.sql.go +++ b/internal/db/dbq/albums.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.1 +// sqlc v1.27.0 // source: albums.sql package dbq diff --git a/internal/db/dbq/artists.sql.go b/internal/db/dbq/artists.sql.go index cafeb6b5..259efbc4 100644 --- a/internal/db/dbq/artists.sql.go +++ b/internal/db/dbq/artists.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.1 +// sqlc v1.27.0 // source: artists.sql package dbq diff --git a/internal/db/dbq/contextual_likes.sql.go b/internal/db/dbq/contextual_likes.sql.go index 7543cc6e..6b932e5e 100644 --- a/internal/db/dbq/contextual_likes.sql.go +++ b/internal/db/dbq/contextual_likes.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.1 +// sqlc v1.27.0 // source: contextual_likes.sql package dbq @@ -33,6 +33,43 @@ func (q *Queries) InsertContextualLike(ctx context.Context, arg InsertContextual return err } +const listActiveContextualLikesForUser = `-- name: ListActiveContextualLikesForUser :many +SELECT track_id, session_vector +FROM contextual_likes +WHERE user_id = $1 + AND deleted_at IS NULL + AND session_vector IS NOT NULL +` + +type ListActiveContextualLikesForUserRow struct { + TrackID pgtype.UUID + SessionVector []byte +} + +// Returns all the user's active (non-soft-deleted) contextual_likes with +// non-null vectors. Cardinality is bounded by the user's actual like-while- +// playing history — typically tens to low hundreds. Used by the engine to +// compute contextual_match_score for the candidate pool. +func (q *Queries) ListActiveContextualLikesForUser(ctx context.Context, userID pgtype.UUID) ([]ListActiveContextualLikesForUserRow, error) { + rows, err := q.db.Query(ctx, listActiveContextualLikesForUser, userID) + if err != nil { + return nil, err + } + defer rows.Close() + var items []ListActiveContextualLikesForUserRow + for rows.Next() { + var i ListActiveContextualLikesForUserRow + if err := rows.Scan(&i.TrackID, &i.SessionVector); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + const softDeleteContextualLikesForUserTrack = `-- name: SoftDeleteContextualLikesForUserTrack :exec UPDATE contextual_likes SET deleted_at = now() diff --git a/internal/db/dbq/db.go b/internal/db/dbq/db.go index 1890abd0..8650b40f 100644 --- a/internal/db/dbq/db.go +++ b/internal/db/dbq/db.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.1 +// sqlc v1.27.0 package dbq diff --git a/internal/db/dbq/events.sql.go b/internal/db/dbq/events.sql.go index 44183e7d..7d3624b9 100644 --- a/internal/db/dbq/events.sql.go +++ b/internal/db/dbq/events.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.1 +// sqlc v1.27.0 // source: events.sql package dbq @@ -11,6 +11,26 @@ import ( "github.com/jackc/pgx/v5/pgtype" ) +const getCurrentSessionVectorForUser = `-- name: GetCurrentSessionVectorForUser :one +SELECT pe.session_vector_at_play +FROM play_events pe +JOIN play_sessions s ON s.id = pe.session_id +WHERE pe.user_id = $1 + AND s.ended_at IS NULL +ORDER BY pe.started_at DESC +LIMIT 1 +` + +// Returns the session_vector_at_play of the user's most recent play_event +// in a still-active (un-timed-out) session. NoRows means no current vector. +// Joined with play_sessions so closed sessions don't leak stale vectors. +func (q *Queries) GetCurrentSessionVectorForUser(ctx context.Context, userID pgtype.UUID) ([]byte, error) { + row := q.db.QueryRow(ctx, getCurrentSessionVectorForUser, userID) + var session_vector_at_play []byte + err := row.Scan(&session_vector_at_play) + return session_vector_at_play, err +} + const getMostRecentPlaySessionForUser = `-- name: GetMostRecentPlaySessionForUser :one SELECT id, user_id, started_at, ended_at, last_event_at, track_count, client_id FROM play_sessions WHERE user_id = $1 diff --git a/internal/db/dbq/likes.sql.go b/internal/db/dbq/likes.sql.go index 5510829b..53533cc2 100644 --- a/internal/db/dbq/likes.sql.go +++ b/internal/db/dbq/likes.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.1 +// sqlc v1.27.0 // source: likes.sql package dbq diff --git a/internal/db/dbq/models.go b/internal/db/dbq/models.go index 4de19781..04475635 100644 --- a/internal/db/dbq/models.go +++ b/internal/db/dbq/models.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.1 +// sqlc v1.27.0 package dbq diff --git a/internal/db/dbq/recommendation.sql.go b/internal/db/dbq/recommendation.sql.go index 535b4bea..8bfbcf62 100644 --- a/internal/db/dbq/recommendation.sql.go +++ b/internal/db/dbq/recommendation.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.1 +// sqlc v1.27.0 // source: recommendation.sql package dbq diff --git a/internal/db/dbq/sessions.sql.go b/internal/db/dbq/sessions.sql.go index bc34671b..3ac9741c 100644 --- a/internal/db/dbq/sessions.sql.go +++ b/internal/db/dbq/sessions.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.1 +// sqlc v1.27.0 // source: sessions.sql package dbq diff --git a/internal/db/dbq/tracks.sql.go b/internal/db/dbq/tracks.sql.go index d17447c9..5254bb50 100644 --- a/internal/db/dbq/tracks.sql.go +++ b/internal/db/dbq/tracks.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.1 +// sqlc v1.27.0 // source: tracks.sql package dbq diff --git a/internal/db/dbq/users.sql.go b/internal/db/dbq/users.sql.go index c3b5f3d9..2e7dccbf 100644 --- a/internal/db/dbq/users.sql.go +++ b/internal/db/dbq/users.sql.go @@ -1,6 +1,6 @@ // Code generated by sqlc. DO NOT EDIT. // versions: -// sqlc v1.31.1 +// sqlc v1.27.0 // source: users.sql package dbq diff --git a/internal/db/queries/contextual_likes.sql b/internal/db/queries/contextual_likes.sql index 2a99793a..c84fdb5f 100644 --- a/internal/db/queries/contextual_likes.sql +++ b/internal/db/queries/contextual_likes.sql @@ -8,3 +8,14 @@ VALUES ($1, $2, $3, $4); UPDATE contextual_likes SET deleted_at = now() WHERE user_id = $1 AND track_id = $2 AND deleted_at IS NULL; + +-- name: ListActiveContextualLikesForUser :many +-- Returns all the user's active (non-soft-deleted) contextual_likes with +-- non-null vectors. Cardinality is bounded by the user's actual like-while- +-- playing history — typically tens to low hundreds. Used by the engine to +-- compute contextual_match_score for the candidate pool. +SELECT track_id, session_vector +FROM contextual_likes +WHERE user_id = $1 + AND deleted_at IS NULL + AND session_vector IS NOT NULL; diff --git a/internal/db/queries/events.sql b/internal/db/queries/events.sql index 3a7ec1b7..60e42f4d 100644 --- a/internal/db/queries/events.sql +++ b/internal/db/queries/events.sql @@ -66,3 +66,15 @@ LIMIT $3; UPDATE play_events SET session_vector_at_play = $2 WHERE id = $1; + +-- name: GetCurrentSessionVectorForUser :one +-- Returns the session_vector_at_play of the user's most recent play_event +-- in a still-active (un-timed-out) session. NoRows means no current vector. +-- Joined with play_sessions so closed sessions don't leak stale vectors. +SELECT pe.session_vector_at_play +FROM play_events pe +JOIN play_sessions s ON s.id = pe.session_id +WHERE pe.user_id = $1 + AND s.ended_at IS NULL +ORDER BY pe.started_at DESC +LIMIT 1; 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..0446ba3d 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,146 @@ 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, 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.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, 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("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, 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("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, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, currentSeed) + if err != nil { + t.Fatalf("load: %v", err) + } + for _, c := range got { + if c.Inputs.ContextualMatchScore != 0 { + t.Errorf("seed-current track %s ContextualMatchScore = %v", c.Track.Title, c.Inputs.ContextualMatchScore) + } + } +} diff --git a/internal/recommendation/score.go b/internal/recommendation/score.go index f71e193a..82f4e940 100644 --- a/internal/recommendation/score.go +++ b/internal/recommendation/score.go @@ -8,12 +8,15 @@ import ( ) // ScoringInputs are the per-track facts the score function consumes. -// Sub-plan #3 (contextual scoring) extends this with ContextualMatchScore. +// ContextualMatchScore is in [0, 1] — max similarity between the user's +// current session vector and any non-seed contextual_like row for this +// track. Set by LoadCandidates after a bulk fetch. type ScoringInputs struct { - IsGeneralLiked bool - LastPlayedAt *time.Time // nil = never played - PlayCount int // total play_events - SkipCount int // play_events with was_skipped=true + IsGeneralLiked bool + LastPlayedAt *time.Time // nil = never played + PlayCount int // total play_events + SkipCount int // play_events with was_skipped=true + ContextualMatchScore float64 // [0, 1]; 0 when no signal } // ScoringWeights are the operator-tunable knobs. Defaults live in @@ -24,6 +27,7 @@ type ScoringWeights struct { RecencyWeight float64 SkipPenalty float64 JitterMagnitude float64 + ContextWeight float64 } // Score computes the weighted-shuffle score per spec §6: @@ -32,6 +36,7 @@ type ScoringWeights struct { // + (is_general_liked ? LikeBoost : 0) // + recency_decay * RecencyWeight // - skip_ratio * SkipPenalty +// + contextual_match_score * ContextWeight // + small_random_jitter // // Higher score = more likely to surface. rng is a function returning a @@ -44,6 +49,7 @@ func Score(in ScoringInputs, w ScoringWeights, now time.Time, rng func() float64 } s += recencyDecay(in.LastPlayedAt, now) * w.RecencyWeight s -= skipRatio(in.PlayCount, in.SkipCount) * w.SkipPenalty + s += in.ContextualMatchScore * w.ContextWeight s += (rng()*2 - 1) * w.JitterMagnitude return s } diff --git a/internal/recommendation/score_test.go b/internal/recommendation/score_test.go index bbee4f2c..f4521910 100644 --- a/internal/recommendation/score_test.go +++ b/internal/recommendation/score_test.go @@ -148,3 +148,37 @@ func TestSkipRatio_Half(t *testing.T) { t.Errorf("skipRatio(4,2) = %v, want 0.5", got) } } + +func TestScore_ContextualMatch_PerfectMatchAtWeight2(t *testing.T) { + w := defaultWeights() + w.ContextWeight = 2.0 + in := ScoringInputs{ContextualMatchScore: 1.0} + got := Score(in, w, time.Now(), fixedRNG(0.5)) + // base 1.0 + recency 1.0 (never played) + contextual 2.0 = 4.0 + want := 4.0 + if math.Abs(got-want) > 1e-9 { + t.Errorf("score = %v, want %v", got, want) + } +} + +func TestScore_ContextualMatch_HalfMatchAtWeight2(t *testing.T) { + w := defaultWeights() + w.ContextWeight = 2.0 + in := ScoringInputs{ContextualMatchScore: 0.5} + got := Score(in, w, time.Now(), fixedRNG(0.5)) + // base 1.0 + recency 1.0 + contextual 1.0 = 3.0 + want := 3.0 + if math.Abs(got-want) > 1e-9 { + t.Errorf("score = %v, want %v", got, want) + } +} + +func TestScore_ContextualMatch_ZeroNoEffect(t *testing.T) { + wWithCtx := defaultWeights() + wWithCtx.ContextWeight = 2.0 + withCtx := Score(ScoringInputs{ContextualMatchScore: 0}, wWithCtx, time.Now(), fixedRNG(0.5)) + withoutCtx := Score(ScoringInputs{}, defaultWeights(), time.Now(), fixedRNG(0.5)) + if math.Abs(withCtx-withoutCtx) > 1e-9 { + t.Errorf("score-with-zero-ctx = %v, score-without = %v; should be equal", withCtx, withoutCtx) + } +} diff --git a/internal/recommendation/similarity.go b/internal/recommendation/similarity.go new file mode 100644 index 00000000..3062a7f3 --- /dev/null +++ b/internal/recommendation/similarity.go @@ -0,0 +1,101 @@ +package recommendation + +// SimilarityWeights balances the per-axis contribution to the weighted Jaccard +// score. v1 hardcodes the defaults — operators cannot tune via YAML. If +// telemetry justifies it, expose under recommendation.similarity.* later. +type SimilarityWeights struct { + TagsWeight float64 + ArtistsWeight float64 +} + +// DefaultSimilarityWeights is the v1 axis balance per the M3 design. +// Tags carry more signal than artists because a session's "vibe" tracks +// genre more directly than artist identity (a session can mix artists +// within a genre but rarely mixes genres). +var DefaultSimilarityWeights = SimilarityWeights{ + TagsWeight: 0.7, + ArtistsWeight: 0.3, +} + +// Similarity returns weighted-Jaccard similarity in [0, 1] between two +// session vectors. Returns 0 if either input is Seed=true (low-confidence +// vectors don't contribute to scoring). +func Similarity(a, b SessionVector, w SimilarityWeights) float64 { + if a.Seed || b.Seed { + return 0.0 + } + tagJ := setJaccardKeys(a.Tags, b.Tags) + artistJ := setJaccardSlice(a.Artists, b.Artists) + return tagJ*w.TagsWeight + artistJ*w.ArtistsWeight +} + +// setJaccardKeys collapses two map keysets to sets and returns +// |A ∩ B| / |A ∪ B|. Both empty → 0 (not NaN). +func setJaccardKeys(a, b map[string]int) float64 { + if len(a) == 0 && len(b) == 0 { + return 0.0 + } + intersect := 0 + for k := range a { + if _, ok := b[k]; ok { + intersect++ + } + } + union := len(a) + len(b) - intersect + if union == 0 { + return 0.0 + } + return float64(intersect) / float64(union) +} + +// setJaccardSlice deduplicates each input slice into a set and returns +// |A ∩ B| / |A ∪ B|. Both empty → 0 (not NaN). +func setJaccardSlice(a, b []string) float64 { + if len(a) == 0 && len(b) == 0 { + return 0.0 + } + aset := make(map[string]struct{}, len(a)) + for _, x := range a { + aset[x] = struct{}{} + } + bset := make(map[string]struct{}, len(b)) + for _, x := range b { + bset[x] = struct{}{} + } + intersect := 0 + for k := range aset { + if _, ok := bset[k]; ok { + intersect++ + } + } + union := len(aset) + len(bset) - intersect + if union == 0 { + return 0.0 + } + return float64(intersect) / float64(union) +} + +// ContextualMatchScore returns the maximum Similarity between the current +// session vector and any non-seed entry in likes. Returns 0 when: +// - current.Seed is true (no meaningful current context) +// - likes is empty after filtering out Seed=true entries +// +// The "max" semantics means a single strong contextual match dominates +// over many weak ones — we want to surface the track because it was liked +// in *some* matching context, not because it was vaguely-liked in many. +func ContextualMatchScore(current SessionVector, likes []SessionVector, w SimilarityWeights) float64 { + if current.Seed { + return 0.0 + } + best := 0.0 + for _, l := range likes { + if l.Seed { + continue + } + s := Similarity(current, l, w) + if s > best { + best = s + } + } + return best +} diff --git a/internal/recommendation/similarity_test.go b/internal/recommendation/similarity_test.go new file mode 100644 index 00000000..ee4708b2 --- /dev/null +++ b/internal/recommendation/similarity_test.go @@ -0,0 +1,158 @@ +package recommendation + +import ( + "math" + "testing" +) + +func approxEq(a, b float64) bool { return math.Abs(a-b) < 1e-9 } + +func TestSimilarity_IdenticalVectors_Returns1(t *testing.T) { + v := SessionVector{ + Artists: []string{"a1", "a2"}, + Tags: map[string]int{"rock": 2, "indie": 1}, + } + got := Similarity(v, v, DefaultSimilarityWeights) + if !approxEq(got, 1.0) { + t.Errorf("Similarity(v,v) = %v, want 1.0", got) + } +} + +func TestSimilarity_FullyDisjoint_Returns0(t *testing.T) { + a := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} + b := SessionVector{Artists: []string{"a2"}, Tags: map[string]int{"jazz": 1}} + got := Similarity(a, b, DefaultSimilarityWeights) + if !approxEq(got, 0.0) { + t.Errorf("disjoint = %v, want 0.0", got) + } +} + +func TestSimilarity_TagsOnlyShared_AppliesTagsWeight(t *testing.T) { + a := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} + b := SessionVector{Artists: []string{"a2"}, Tags: map[string]int{"rock": 5}} + got := Similarity(a, b, DefaultSimilarityWeights) + if !approxEq(got, 0.7) { + t.Errorf("tags-only = %v, want 0.7", got) + } +} + +func TestSimilarity_ArtistsOnlyShared_AppliesArtistsWeight(t *testing.T) { + a := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} + b := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"jazz": 1}} + got := Similarity(a, b, DefaultSimilarityWeights) + if !approxEq(got, 0.3) { + t.Errorf("artists-only = %v, want 0.3", got) + } +} + +func TestSimilarity_EitherSeed_Returns0(t *testing.T) { + v := SessionVector{Artists: []string{"a"}, Tags: map[string]int{"rock": 1}} + seed := SessionVector{Seed: true, Artists: []string{"a"}, Tags: map[string]int{"rock": 1}} + if got := Similarity(v, seed, DefaultSimilarityWeights); !approxEq(got, 0.0) { + t.Errorf("v vs seed = %v, want 0.0", got) + } + if got := Similarity(seed, v, DefaultSimilarityWeights); !approxEq(got, 0.0) { + t.Errorf("seed vs v = %v, want 0.0", got) + } +} + +func TestSimilarity_BothEmpty_Returns0NotNaN(t *testing.T) { + a := SessionVector{} + b := SessionVector{} + got := Similarity(a, b, DefaultSimilarityWeights) + if math.IsNaN(got) || !approxEq(got, 0.0) { + t.Errorf("empty = %v, want 0.0 (not NaN)", got) + } +} + +func TestSimilarity_OneAxisEmptyOneSide_AxisContributesZero(t *testing.T) { + a := SessionVector{Tags: map[string]int{"rock": 1}} + b := SessionVector{Artists: []string{"a1"}} + got := Similarity(a, b, DefaultSimilarityWeights) + if !approxEq(got, 0.0) { + t.Errorf("one-axis-each = %v, want 0.0", got) + } +} + +func TestSimilarity_PartialTagsOverlap(t *testing.T) { + a := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1, "indie": 1}} + b := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1, "jazz": 1}} + got := Similarity(a, b, DefaultSimilarityWeights) + want := 0.7*(1.0/3.0) + 0.3*1.0 + if !approxEq(got, want) { + t.Errorf("partial = %v, want %v", got, want) + } +} + +func TestSimilarity_BagOfCountsCollapsesToSet(t *testing.T) { + a := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 2, "indie": 1}} + b := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 5, "indie": 3}} + got := Similarity(a, b, DefaultSimilarityWeights) + if !approxEq(got, 1.0) { + t.Errorf("set-collapse = %v, want 1.0", got) + } +} + +func TestContextualMatchScore_NoLikes_Returns0(t *testing.T) { + current := SessionVector{Artists: []string{"a"}, Tags: map[string]int{"rock": 1}} + got := ContextualMatchScore(current, nil, DefaultSimilarityWeights) + if !approxEq(got, 0.0) { + t.Errorf("no likes = %v, want 0.0", got) + } + got = ContextualMatchScore(current, []SessionVector{}, DefaultSimilarityWeights) + if !approxEq(got, 0.0) { + t.Errorf("empty likes = %v, want 0.0", got) + } +} + +func TestContextualMatchScore_CurrentSeed_Returns0(t *testing.T) { + current := SessionVector{Seed: true} + likes := []SessionVector{ + {Artists: []string{"a"}, Tags: map[string]int{"rock": 1}}, + } + got := ContextualMatchScore(current, likes, DefaultSimilarityWeights) + if !approxEq(got, 0.0) { + t.Errorf("current seed = %v, want 0.0", got) + } +} + +func TestContextualMatchScore_AllLikesSeed_Returns0(t *testing.T) { + current := SessionVector{Artists: []string{"a"}, Tags: map[string]int{"rock": 1}} + likes := []SessionVector{ + {Seed: true, Artists: []string{"a"}, Tags: map[string]int{"rock": 1}}, + {Seed: true, Artists: []string{"a"}, Tags: map[string]int{"rock": 1}}, + } + got := ContextualMatchScore(current, likes, DefaultSimilarityWeights) + if !approxEq(got, 0.0) { + t.Errorf("all-seed likes = %v, want 0.0", got) + } +} + +func TestContextualMatchScore_TakesMax(t *testing.T) { + // Three likes: full match, partial match, mismatch. Expect full match (1.0). + current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} + // Three likes covering 1.0 (full match), 0.7 (tags-only match), 0.0 (mismatch). + likes := []SessionVector{ + {Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}, + {Artists: []string{"a2"}, Tags: map[string]int{"rock": 1}}, + {Artists: []string{"a99"}, Tags: map[string]int{"jazz": 1}}, + } + got := ContextualMatchScore(current, likes, DefaultSimilarityWeights) + if !approxEq(got, 1.0) { + t.Errorf("takes-max = %v, want 1.0", got) + } +} + +func TestContextualMatchScore_FiltersSeedThenMaxes(t *testing.T) { + // One Seed=true match (would be 1.0 if not filtered) + one partial match. + current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}} + likes := []SessionVector{ + {Seed: true, Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}, + {Artists: []string{"a2"}, Tags: map[string]int{"rock": 1}}, + } + got := ContextualMatchScore(current, likes, DefaultSimilarityWeights) + // Seed=true filtered out → only partial match counts → 0.7 + if !approxEq(got, 0.7) { + t.Errorf("filter-then-max = %v, want 0.7", got) + } +}