Files
minstrel/docs/superpowers/plans/2026-04-27-m3-similarity.md
T
bvandeusen 00483539ad docs(plan): add M3 session similarity implementation plan
Eight-task TDD-shaped plan covering pure Similarity + ContextualMatchScore
functions, Score extension with ContextWeight=2.0, two new sqlc queries
(ListActiveContextualLikesForUser, GetCurrentSessionVectorForUser),
LoadCandidates signature change to accept currentVector, radio handler
wiring, end-to-end contextual ranking test, and final verification.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-04-27 19:57:18 -04:00

1415 lines
50 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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=<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. 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=<dsn> 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=<dsn> 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=<dsn> 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.