# 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.