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

50 KiB
Raw Blame History

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:

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:

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
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:

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:

// 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
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:

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:

// 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
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:

-- 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:

-- 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
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:

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:

got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1)

with:

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:

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)
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:

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:

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:

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:

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
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:

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):

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:

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