Merge pull request 'feat: M3 session similarity + contextual_match_score (closes M3)' (#23) from dev into main

This commit was merged in pull request #23.
This commit is contained in:
2026-04-28 03:02:27 +00:00
26 changed files with 2480 additions and 29 deletions
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,301 @@
# M3 sub-plan #3 — Session similarity + contextual_match_score in scoring
**Status:** Spec draft, 2026-04-27
**Tracking:** Fable #342
**Closes:** M3 milestone (recommendation engine v1)
**Builds on:**
- #340 — weighted shuffle baseline (`internal/recommendation` package, `Score`/`Shuffle`, `LoadRadioCandidates`)
- #341 — session vectors at play_started + `contextual_likes` capture on like
## 1. Goal
Add the `contextual_match_score` term to the recommendation scoring formula. For each
candidate track, compute the maximum weighted-Jaccard similarity between the user's
*current* session vector and the session vectors stored on that track's active
`contextual_likes` rows. Fold that scalar into `Score()` as
`+ contextual_match_score * ContextWeight`.
When this slice ships, `/api/radio` produces context-aware recommendations: tracks the
user has previously liked while in similar musical contexts get an additive boost on
top of the v1 weighted-shuffle baseline.
## 2. Non-goals
- No new UI surface — `/api/radio` response shape is unchanged.
- No tag enrichment beyond `tracks.genre` (MBID tags / BPM remain post-v1).
- No similarity-axis weight exposure in YAML (hardcoded `0.7 / 0.3` for v1).
- No caching of the current session vector across requests.
- No "why this track?" debug endpoint.
- No ListenBrainz / external-similarity retrieval (M4).
- No GIN index on `play_events.session_vector_at_play` — we read the user's most
recent play by id, not by similarity. Existing `(user_id, started_at)` access
pattern is sufficient.
## 3. Architecture overview
Three additions to `internal/recommendation`, two adjustments to existing files,
one new sqlc query.
### 3.1 New: `internal/recommendation/similarity.go` (pure)
```go
type SimilarityWeights struct {
TagsWeight float64
ArtistsWeight float64
}
// DefaultSimilarityWeights is the v1 axis balance per the M3 design.
// Hardcoded; not exposed via YAML — operators can't tune this for v1.
var DefaultSimilarityWeights = SimilarityWeights{
TagsWeight: 0.7,
ArtistsWeight: 0.3,
}
// Similarity returns weighted-Jaccard similarity in [0, 1]. Returns 0 if
// either input is Seed=true (low-confidence vectors don't contribute).
func Similarity(a, b SessionVector, w SimilarityWeights) float64
// ContextualMatchScore is the per-candidate scalar fed into ScoringInputs.
// Returns 0 if current.Seed is true OR likes (after filtering Seed=true
// entries) is empty. Otherwise: max(Similarity(current, l) for l in likes).
func ContextualMatchScore(current SessionVector, likes []SessionVector, w SimilarityWeights) float64
```
**Per-axis semantics (classic set Jaccard):**
- Tags axis flattens `map[string]int` keysets and computes `|A ∩ B| / |A B|`.
Bag-of-counts data is preserved on disk; we discard counts at similarity time.
Generalized Jaccard remains a one-line upgrade path if telemetry justifies it.
- Artists axis is already a `[]string` (deduplicated artist UUIDs); same Jaccard.
- Both axes empty (zero union) → axis returns 0.0, not NaN.
### 3.2 New: `internal/db/queries/contextual_likes.sql`
```sql
-- name: ListActiveContextualLikesForUser :many
-- Returns all the user's active (non-soft-deleted) contextual_likes with
-- non-null vectors. Cardinality bounded by the user's actual like-while-
-- playing history — typically tens to low hundreds.
SELECT track_id, session_vector
FROM contextual_likes
WHERE user_id = $1 AND deleted_at IS NULL AND session_vector IS NOT NULL;
```
### 3.3 New: `internal/db/queries/events.sql` (or `recommendation.sql`)
```sql
-- name: GetCurrentSessionVectorForUser :one
-- Returns the session_vector_at_play of the user's most recent play_event
-- in a still-active (un-timed-out) session. NoRows means no current vector
-- — caller treats this as Seed=true sentinel.
SELECT pe.session_vector_at_play
FROM play_events pe
JOIN play_sessions s ON s.id = pe.session_id
WHERE pe.user_id = $1
AND s.ended_at IS NULL
ORDER BY pe.started_at DESC
LIMIT 1;
```
(Final placement decided at implementation time — wherever sqlc and existing
query files line up best.)
### 3.4 Modified: `internal/recommendation/score.go`
```go
type ScoringInputs struct {
IsGeneralLiked bool
LastPlayedAt *time.Time
PlayCount int
SkipCount int
ContextualMatchScore float64 // NEW — in [0, 1], 0 when no signal
}
type ScoringWeights struct {
BaseWeight float64
LikeBoost float64
RecencyWeight float64
SkipPenalty float64
JitterMagnitude float64
ContextWeight float64 // NEW
}
// Score gains: + in.ContextualMatchScore * w.ContextWeight
```
Zero-value defaults: a `ScoringInputs{}` with zero `ContextualMatchScore` and a
`ScoringWeights{}` with zero `ContextWeight` produce the v1 score. Existing callers
not passing the new fields see no behavior change.
### 3.5 Modified: `internal/recommendation/candidates.go`
```go
func LoadCandidates(
ctx context.Context,
q *dbq.Queries,
userID, seedID pgtype.UUID,
recentlyPlayedHours int,
currentVector SessionVector, // NEW
) ([]Candidate, error)
```
Body adds:
1. Existing `LoadRadioCandidates` call.
2. New `ListActiveContextualLikesForUser(userID)` call.
3. Group result by `track_id` into `map[pgtype.UUID][]SessionVector`, unmarshaling
each `jsonb` column into `SessionVector`. Unmarshal failures are logged and
skipped (don't poison the entire response over one bad row).
4. For each candidate, set `Inputs.ContextualMatchScore =
ContextualMatchScore(currentVector, group[trackID], DefaultSimilarityWeights)`.
### 3.6 Modified: `internal/api/radio.go`
Before calling `LoadCandidates`, fetch the current session vector:
```go
var currentVec recommendation.SessionVector
if raw, err := q.GetCurrentSessionVectorForUser(ctx, user.ID); err == nil && len(raw) > 0 {
if jerr := json.Unmarshal(raw, &currentVec); jerr != nil {
h.logger.Warn("api: radio: bad session_vector_at_play", "err", jerr)
currentVec = recommendation.SessionVector{Seed: true}
}
} else {
currentVec = recommendation.SessionVector{Seed: true}
}
```
Pass `currentVec` into `LoadCandidates`. Pass `recCfg.ContextWeight` through into the
`ScoringWeights` struct alongside the existing weights.
### 3.7 Modified: `internal/config/config.go`
```go
type RecommendationConfig struct {
BaseWeight float64 `yaml:"base_weight"`
LikeBoost float64 `yaml:"like_boost"`
RecencyWeight float64 `yaml:"recency_weight"`
SkipPenalty float64 `yaml:"skip_penalty"`
JitterMagnitude float64 `yaml:"jitter_magnitude"`
ContextWeight float64 `yaml:"context_weight"` // NEW, default 2.0
RecentlyPlayedHours int `yaml:"recently_played_hours"`
RadioSize int `yaml:"radio_size"`
RadioSizeMax int `yaml:"radio_size_max"`
}
```
`Default()` populates `ContextWeight: 2.0`.
## 4. Request flow
```
GET /api/radio?seed_track=<uuid>&limit=N
handleRadio
1. Auth, parse seed_track, parse limit (unchanged)
2. q.GetCurrentSessionVectorForUser(userID) (NEW)
NoRows / NULL / unmarshal fail → SessionVector{Seed: true}
3. recommendation.LoadCandidates(...) (extended)
a. q.LoadRadioCandidates (unchanged)
b. q.ListActiveContextualLikesForUser (NEW)
c. group by track_id → map[uuid][]SessionVector
d. per candidate: ContextualMatchScore() → ScoringInputs
4. recommendation.Shuffle(candidates, weights, now, rng, limit-1)
Score() now folds in ContextualMatchScore * ContextWeight
5. Resolve album/artist, build response (unchanged)
```
## 5. Cold-start handling
Every cold-start path collapses to `contextual_match_score = 0` for all candidates,
so scoring degrades cleanly to v1 behavior:
| Condition | Path |
|------------------------------------------------------|------------------------------------------------------|
| User has no `play_events` at all | `NoRows` → `Seed=true` sentinel → `ContextualMatchScore` returns 0 |
| User has plays but no active session | `NoRows` (joined `s.ended_at IS NULL` filters) |
| Active session but `session_vector_at_play` is NULL | `len(raw) == 0` → `Seed=true` sentinel |
| Vector populated but `Seed=true` | `ContextualMatchScore` short-circuits to 0 |
| Candidate has no `contextual_likes` | absent from map → empty slice → returns 0 |
| Candidate has only `Seed=true` likes | filtered out → empty → returns 0 |
| Candidate has only soft-deleted likes | excluded by `deleted_at IS NULL` in the SQL |
## 6. Test plan
### 6.1 `similarity_test.go` (pure, table-driven)
- Identical vectors → `1.0`.
- Fully disjoint axes → `0.0`.
- Mixed: shared tags, no shared artists → `0.7 * tagJaccard`.
- Mixed: no shared tags, shared artists → `0.3 * artistJaccard`.
- Either input `Seed=true` → `0.0`.
- Both vectors fully empty → `0.0` (not NaN).
- One side empty on an axis, other side populated → that axis contributes 0.
- Weight balance: shared all tags, default weights → exactly `0.7`.
### 6.2 `score_test.go` extensions
- Perfect contextual match (`ContextualMatchScore=1.0`) at `ContextWeight=2.0` adds
exactly `+2.0` to the base score.
- Half match (`0.5`) adds `+1.0`.
- Zero match (`0.0`) leaves score unchanged from v1 behavior — guards backward compat.
### 6.3 `candidates_test.go` (integration vs test DB)
- Candidate with one matching `contextual_like` → `ContextualMatchScore > 0`.
- Candidate with multiple `contextual_likes` → max similarity wins.
- Candidate whose only `contextual_likes` are `Seed=true` → score 0.
- Candidate whose only `contextual_likes` are soft-deleted → score 0 (SQL filter).
- User with no `contextual_likes` anywhere → every candidate scores 0.
- User with only soft-deleted `contextual_likes` → every candidate scores 0.
### 6.4 `radio_test.go` (integration, end-to-end)
- Seed a current session vibe (3+ tracks of one genre/artist set) by inserting
`play_events` with populated `session_vector_at_play`.
- Insert a `contextual_like` whose `session_vector` matches that vibe, on track T.
- Insert an unrelated control track C with no contextual signal.
- Call `/api/radio` with a deterministic RNG and seed track distinct from T and C.
- Assert: T ranks above C in the response.
### 6.5 Coverage gate
Combined `internal/recommendation` coverage stays ≥ 70% (currently 78.5% combined
with `internal/playevents` post-#341; this slice's pure functions are highly
testable so we expect to land closer to 85%+).
## 7. Backwards compatibility
- `/api/radio` request and response shapes are unchanged — same query params, same
JSON output. Web client requires no edits.
- `ScoringInputs.ContextualMatchScore` and `ScoringWeights.ContextWeight` default to
`0` in zero-value structs. Pre-existing tests that construct these directly continue
passing without modification because the new term contributes nothing when both are
zero.
- `LoadCandidates` gains a `currentVector SessionVector` parameter — this is a
signature change, but the only caller is `internal/api/radio.go`, which we update
in this slice. No external consumers.
- DB schema is unchanged (migrations 0007 already shipped the table + indexes
needed for this slice).
## 8. Out-of-scope (deferred)
- Generalized (bag-of-counts) Jaccard if telemetry shows tag-dominance discrimination
matters.
- YAML exposure of `SimilarityWeights`.
- Per-user override of any recommendation weight.
- Caching `currentVector` across requests within a session.
- ListenBrainz / similar-artist retrieval (M4).
- `/api/radio?explain=true` style debug endpoint.
- Tag enrichment beyond `tracks.genre`.
## 9. Milestone gate (closes M3)
After this slice merges:
- Recommendation engine has all three v1 components: weighted shuffle, session
vectors written, contextual matching folded into scoring.
- Manual end-to-end verification: like a track during a session of one vibe, build a
similar session later, observe the track surfaces above unrelated controls in
`/api/radio` output.
- M4 (radio refinements + scrobble polish) unblocked.
+1 -1
View File
@@ -52,7 +52,7 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) {
w := playevents.NewWriter(pool, logger, 30*time.Minute, 0.5, 30000)
recCfg := config.RecommendationConfig{
BaseWeight: 1.0, LikeBoost: 2.0, RecencyWeight: 1.0,
SkipPenalty: 1.0, JitterMagnitude: 0.1,
SkipPenalty: 1.0, JitterMagnitude: 0.1, ContextWeight: 2.0,
RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200,
}
h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }}
+66
View File
@@ -89,6 +89,72 @@ func seedTrack(t *testing.T, pool *pgxpool.Pool, albumID, artistID pgtype.UUID,
return tr
}
// seedTrackWithGenre is seedTrack but lets the caller set Genre. Used by
// tests that exercise the contextual recommendation path, since session
// vectors collect tags from tracks.genre.
func seedTrackWithGenre(t *testing.T, pool *pgxpool.Pool, albumID, artistID pgtype.UUID, title string, trackNumber int, durationMs int32, genre string) dbq.Track {
t.Helper()
var tn *int32
if trackNumber > 0 {
v := int32(trackNumber)
tn = &v
}
tr, err := dbq.New(pool).UpsertTrack(context.Background(), dbq.UpsertTrackParams{
Title: title,
AlbumID: albumID,
ArtistID: artistID,
TrackNumber: tn,
DiscNumber: nil,
DurationMs: durationMs,
FilePath: "/seed/" + title + ".flac",
FileSize: 1024,
FileFormat: "flac",
Bitrate: nil,
Mbid: nil,
Genre: &genre,
})
if err != nil {
t.Fatalf("UpsertTrack(%s): %v", title, err)
}
return tr
}
// insertOpenSessionWithVector creates a play_session with ended_at NULL and
// inserts a play_event in it whose session_vector_at_play is the given JSON.
// Used to simulate "user is mid-listen" for the radio handler's current-vector
// lookup. The play_event references a placeholder track inserted on the fly
// so the FK is valid; the placeholder is not used for radio scoring.
func insertOpenSessionWithVector(t *testing.T, pool *pgxpool.Pool, userID, anyArtistID pgtype.UUID, vectorJSON []byte) {
t.Helper()
q := dbq.New(pool)
al, err := q.UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{
Title: "PlaceholderAlbum", SortTitle: "PlaceholderAlbum", ArtistID: anyArtistID,
})
if err != nil {
t.Fatalf("placeholder album: %v", err)
}
ph, err := q.UpsertTrack(context.Background(), dbq.UpsertTrackParams{
Title: "Placeholder", AlbumID: al.ID, ArtistID: anyArtistID,
FilePath: "/seed/placeholder.flac", DurationMs: 100_000, FileSize: 1024, FileFormat: "flac",
})
if err != nil {
t.Fatalf("placeholder track: %v", err)
}
var sessionID pgtype.UUID
if err := pool.QueryRow(context.Background(),
`INSERT INTO play_sessions (user_id, started_at, last_event_at, client_id)
VALUES ($1, now() - interval '5 minutes', now(), 'test') RETURNING id`,
userID).Scan(&sessionID); err != nil {
t.Fatalf("insert session: %v", err)
}
if _, err := pool.Exec(context.Background(),
`INSERT INTO play_events (user_id, track_id, session_id, started_at, session_vector_at_play)
VALUES ($1, $2, $3, now() - interval '1 minute', $4)`,
userID, ph.ID, sessionID, vectorJSON); err != nil {
t.Fatalf("insert play_event: %v", err)
}
}
// seedTrackWithFile creates a fresh temp directory, writes fileBody to
// <dir>/<title>.<ext>, and inserts a Track row whose file_path points at it.
// Returns the inserted Track and the directory (callers drop sidecar covers
+35 -2
View File
@@ -1,13 +1,16 @@
package api
import (
"encoding/json"
"errors"
"log/slog"
"net/http"
"strconv"
"strings"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
@@ -22,7 +25,9 @@ type RadioResponse struct {
// handleRadio implements GET /api/radio?seed_track=<uuid>&limit=<int>.
//
// Returns the seed at index 0, followed by up to limit-1 weighted-shuffle
// picks from the user's library, scored by recommendation.Score.
// picks from the user's library, scored by recommendation.Score. The
// scoring formula folds in contextual_match_score using the user's current
// session vector (read from the most recent open play_event).
func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
@@ -74,7 +79,10 @@ func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) {
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
return
}
candidates, err := recommendation.LoadCandidates(r.Context(), q, user.ID, seedID, h.recCfg.RecentlyPlayedHours)
currentVec := loadCurrentSessionVector(r, q, user.ID, h.logger)
candidates, err := recommendation.LoadCandidates(r.Context(), q, user.ID, seedID, h.recCfg.RecentlyPlayedHours, currentVec)
if err != nil {
h.logger.Error("api: radio: load candidates", "err", err)
writeErr(w, http.StatusInternalServerError, "server_error", "candidate load failed")
@@ -86,6 +94,7 @@ func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) {
RecencyWeight: h.recCfg.RecencyWeight,
SkipPenalty: h.recCfg.SkipPenalty,
JitterMagnitude: h.recCfg.JitterMagnitude,
ContextWeight: h.recCfg.ContextWeight,
}
picks := recommendation.Shuffle(candidates, weights, time.Now().UTC(), h.rng, limit-1)
@@ -108,3 +117,27 @@ func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) {
}
writeJSON(w, http.StatusOK, RadioResponse{Tracks: out})
}
// loadCurrentSessionVector returns the user's most recent active session
// vector, or a Seed=true sentinel if none exists / the column is NULL /
// the JSON fails to unmarshal. Sentinel short-circuits ContextualMatchScore
// to 0 so the contextual term contributes nothing in cold-start cases.
func loadCurrentSessionVector(r *http.Request, q *dbq.Queries, userID pgtype.UUID, logger *slog.Logger) recommendation.SessionVector {
raw, err := q.GetCurrentSessionVectorForUser(r.Context(), userID)
if err != nil {
// pgx.ErrNoRows is the common path: no active session yet.
if !errors.Is(err, pgx.ErrNoRows) {
logger.Warn("api: radio: load current session vector", "err", err)
}
return recommendation.SessionVector{Seed: true}
}
if len(raw) == 0 {
return recommendation.SessionVector{Seed: true}
}
var v recommendation.SessionVector
if jerr := json.Unmarshal(raw, &v); jerr != nil {
logger.Warn("api: radio: bad session_vector_at_play json", "err", jerr)
return recommendation.SessionVector{Seed: true}
}
return v
}
+73
View File
@@ -6,6 +6,8 @@ import (
"net/http"
"net/http/httptest"
"testing"
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
)
func callRadio(h *handlers, user interface{}, query string) *httptest.ResponseRecorder {
@@ -128,3 +130,74 @@ func TestHandleRadio_LimitClampedToMax(t *testing.T) {
t.Errorf("len = %d, want 6", len(resp.Tracks))
}
}
func TestHandleRadio_ContextualMatch_BoostsRankingOverControl(t *testing.T) {
h, pool := testHandlers(t)
truncateLibrary(t, pool)
user := seedUser(t, pool, "alice", "x", false)
// Two artists in distinct genres, so we can construct a "rock vibe" session
// and a contextual match.
rockArtist := seedArtist(t, pool, "RockArtist")
rockAlbum := seedAlbum(t, pool, rockArtist.ID, "RockAlbum", 2020)
jazzArtist := seedArtist(t, pool, "JazzArtist")
jazzAlbum := seedAlbum(t, pool, jazzArtist.ID, "JazzAlbum", 2020)
// Seed track is unrelated to both (don't want it to dominate scoring).
popArtist := seedArtist(t, pool, "PopArtist")
popAlbum := seedAlbum(t, pool, popArtist.ID, "PopAlbum", 2020)
seed := seedTrack(t, pool, popAlbum.ID, popArtist.ID, "Seed", 1, 100_000)
// Target: a rock track. Control: a jazz track. Both will be scored.
target := seedTrackWithGenre(t, pool, rockAlbum.ID, rockArtist.ID, "Target", 1, 100_000, "rock")
control := seedTrackWithGenre(t, pool, jazzAlbum.ID, jazzArtist.ID, "Control", 1, 100_000, "jazz")
_ = control // present in DB; we look it up by title in the response
// Build the user's "rock vibe" current context: insert an open play_session
// with a play_event whose session_vector_at_play matches the rock vibe.
rockVec := recommendation.SessionVector{
Artists: []string{rockArtist.ID.String()},
Tags: map[string]int{"rock": 3},
}
rockVecJSON, err := json.Marshal(rockVec)
if err != nil {
t.Fatalf("marshal rockVec: %v", err)
}
insertOpenSessionWithVector(t, pool, user.ID, rockArtist.ID, rockVecJSON)
// Insert a contextual_like on the target track whose stored vector matches
// the rock vibe. Direct DB insert — we want full control over the vector
// for this test.
if _, err := pool.Exec(context.Background(),
`INSERT INTO contextual_likes (user_id, track_id, session_vector) VALUES ($1, $2, $3)`,
user.ID, target.ID, rockVecJSON); err != nil {
t.Fatalf("insert contextual_like: %v", err)
}
// Request radio. The deterministic RNG (rng=0.5 → jitter contribution = 0)
// means rankings are reproducible for this test.
w := callRadio(h, user, "seed_track="+uuidToString(seed.ID))
if w.Code != http.StatusOK {
t.Fatalf("status = %d body=%s", w.Code, w.Body.String())
}
var resp RadioResponse
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
targetIdx, controlIdx := -1, -1
for i, tr := range resp.Tracks {
if tr.Title == "Target" {
targetIdx = i
}
if tr.Title == "Control" {
controlIdx = i
}
}
if targetIdx == -1 || controlIdx == -1 {
t.Fatalf("target=%d control=%d, expected both present (resp.Tracks=%v)", targetIdx, controlIdx, resp.Tracks)
}
if targetIdx >= controlIdx {
t.Errorf("target ranked at %d, control at %d: contextual match should put target above control", targetIdx, controlIdx)
}
}
+2
View File
@@ -72,6 +72,7 @@ type RecommendationConfig struct {
RecencyWeight float64 `yaml:"recency_weight"`
SkipPenalty float64 `yaml:"skip_penalty"`
JitterMagnitude float64 `yaml:"jitter_magnitude"`
ContextWeight float64 `yaml:"context_weight"`
RecentlyPlayedHours int `yaml:"recently_played_hours"`
RadioSize int `yaml:"radio_size"`
RadioSizeMax int `yaml:"radio_size_max"`
@@ -93,6 +94,7 @@ func Default() Config {
RecencyWeight: 1.0,
SkipPenalty: 1.0,
JitterMagnitude: 0.1,
ContextWeight: 2.0,
RecentlyPlayedHours: 1,
RadioSize: 50,
RadioSizeMax: 200,
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// sqlc v1.27.0
// source: albums.sql
package dbq
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// sqlc v1.27.0
// source: artists.sql
package dbq
+38 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// sqlc v1.27.0
// source: contextual_likes.sql
package dbq
@@ -33,6 +33,43 @@ func (q *Queries) InsertContextualLike(ctx context.Context, arg InsertContextual
return err
}
const listActiveContextualLikesForUser = `-- name: ListActiveContextualLikesForUser :many
SELECT track_id, session_vector
FROM contextual_likes
WHERE user_id = $1
AND deleted_at IS NULL
AND session_vector IS NOT NULL
`
type ListActiveContextualLikesForUserRow struct {
TrackID pgtype.UUID
SessionVector []byte
}
// Returns all the user's active (non-soft-deleted) contextual_likes with
// non-null vectors. Cardinality is bounded by the user's actual like-while-
// playing history — typically tens to low hundreds. Used by the engine to
// compute contextual_match_score for the candidate pool.
func (q *Queries) ListActiveContextualLikesForUser(ctx context.Context, userID pgtype.UUID) ([]ListActiveContextualLikesForUserRow, error) {
rows, err := q.db.Query(ctx, listActiveContextualLikesForUser, userID)
if err != nil {
return nil, err
}
defer rows.Close()
var items []ListActiveContextualLikesForUserRow
for rows.Next() {
var i ListActiveContextualLikesForUserRow
if err := rows.Scan(&i.TrackID, &i.SessionVector); err != nil {
return nil, err
}
items = append(items, i)
}
if err := rows.Err(); err != nil {
return nil, err
}
return items, nil
}
const softDeleteContextualLikesForUserTrack = `-- name: SoftDeleteContextualLikesForUserTrack :exec
UPDATE contextual_likes
SET deleted_at = now()
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// sqlc v1.27.0
package dbq
+21 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// sqlc v1.27.0
// source: events.sql
package dbq
@@ -11,6 +11,26 @@ import (
"github.com/jackc/pgx/v5/pgtype"
)
const getCurrentSessionVectorForUser = `-- name: GetCurrentSessionVectorForUser :one
SELECT pe.session_vector_at_play
FROM play_events pe
JOIN play_sessions s ON s.id = pe.session_id
WHERE pe.user_id = $1
AND s.ended_at IS NULL
ORDER BY pe.started_at DESC
LIMIT 1
`
// Returns the session_vector_at_play of the user's most recent play_event
// in a still-active (un-timed-out) session. NoRows means no current vector.
// Joined with play_sessions so closed sessions don't leak stale vectors.
func (q *Queries) GetCurrentSessionVectorForUser(ctx context.Context, userID pgtype.UUID) ([]byte, error) {
row := q.db.QueryRow(ctx, getCurrentSessionVectorForUser, userID)
var session_vector_at_play []byte
err := row.Scan(&session_vector_at_play)
return session_vector_at_play, err
}
const getMostRecentPlaySessionForUser = `-- name: GetMostRecentPlaySessionForUser :one
SELECT id, user_id, started_at, ended_at, last_event_at, track_count, client_id FROM play_sessions
WHERE user_id = $1
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// sqlc v1.27.0
// source: likes.sql
package dbq
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// sqlc v1.27.0
package dbq
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// sqlc v1.27.0
// source: recommendation.sql
package dbq
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// sqlc v1.27.0
// source: sessions.sql
package dbq
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// sqlc v1.27.0
// source: tracks.sql
package dbq
+1 -1
View File
@@ -1,6 +1,6 @@
// Code generated by sqlc. DO NOT EDIT.
// versions:
// sqlc v1.31.1
// sqlc v1.27.0
// source: users.sql
package dbq
+11
View File
@@ -8,3 +8,14 @@ VALUES ($1, $2, $3, $4);
UPDATE contextual_likes
SET deleted_at = now()
WHERE user_id = $1 AND track_id = $2 AND deleted_at IS NULL;
-- name: ListActiveContextualLikesForUser :many
-- Returns all the user's active (non-soft-deleted) contextual_likes with
-- non-null vectors. Cardinality is bounded by the user's actual like-while-
-- playing history — typically tens to low hundreds. Used by the engine to
-- compute contextual_match_score for the candidate pool.
SELECT track_id, session_vector
FROM contextual_likes
WHERE user_id = $1
AND deleted_at IS NULL
AND session_vector IS NOT NULL;
+12
View File
@@ -66,3 +66,15 @@ LIMIT $3;
UPDATE play_events
SET session_vector_at_play = $2
WHERE id = $1;
-- name: GetCurrentSessionVectorForUser :one
-- Returns the session_vector_at_play of the user's most recent play_event
-- in a still-active (un-timed-out) session. NoRows means no current vector.
-- Joined with play_sessions so closed sessions don't leak stale vectors.
SELECT pe.session_vector_at_play
FROM play_events pe
JOIN play_sessions s ON s.id = pe.session_id
WHERE pe.user_id = $1
AND s.ended_at IS NULL
ORDER BY pe.started_at DESC
LIMIT 1;
+43 -4
View File
@@ -2,6 +2,7 @@ package recommendation
import (
"context"
"encoding/json"
"time"
"github.com/jackc/pgx/v5/pgtype"
@@ -9,11 +10,17 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// LoadCandidates fetches the candidate pool for radio scoring. Combines
// the existing track+stats query with a one-shot bulk fetch of the user's
// active contextual_likes, mapping each candidate to its max similarity
// against currentVector. Pass currentVector with Seed=true to short-circuit
// the contextual term to 0 (cold-start path).
func LoadCandidates(
ctx context.Context,
q *dbq.Queries,
userID, seedID pgtype.UUID,
recentlyPlayedHours int,
currentVector SessionVector,
) ([]Candidate, error) {
rows, err := q.LoadRadioCandidates(ctx, dbq.LoadRadioCandidatesParams{
UserID: userID,
@@ -23,6 +30,12 @@ func LoadCandidates(
if err != nil {
return nil, err
}
likes, err := loadContextualLikesByTrack(ctx, q, userID)
if err != nil {
return nil, err
}
out := make([]Candidate, 0, len(rows))
for _, r := range rows {
var lpt *time.Time
@@ -30,15 +43,41 @@ func LoadCandidates(
t := r.LastPlayedAt.Time
lpt = &t
}
ctxScore := ContextualMatchScore(currentVector, likes[r.Track.ID], DefaultSimilarityWeights)
out = append(out, Candidate{
Track: r.Track,
Inputs: ScoringInputs{
IsGeneralLiked: r.IsLiked,
LastPlayedAt: lpt,
PlayCount: int(r.PlayCount),
SkipCount: int(r.SkipCount),
IsGeneralLiked: r.IsLiked,
LastPlayedAt: lpt,
PlayCount: int(r.PlayCount),
SkipCount: int(r.SkipCount),
ContextualMatchScore: ctxScore,
},
})
}
return out, nil
}
// loadContextualLikesByTrack fetches the user's active contextual_likes in
// one query and groups them by track_id. Rows whose session_vector fails
// to unmarshal are skipped with no error (don't poison scoring over one
// bad row); the SQL query already filters NULL vectors.
func loadContextualLikesByTrack(
ctx context.Context,
q *dbq.Queries,
userID pgtype.UUID,
) (map[pgtype.UUID][]SessionVector, error) {
rows, err := q.ListActiveContextualLikesForUser(ctx, userID)
if err != nil {
return nil, err
}
out := make(map[pgtype.UUID][]SessionVector, len(rows))
for _, r := range rows {
var v SessionVector
if err := json.Unmarshal(r.SessionVector, &v); err != nil {
continue
}
out[r.TrackID] = append(out[r.TrackID], v)
}
return out, nil
}
+150 -6
View File
@@ -2,6 +2,7 @@ package recommendation
import (
"context"
"encoding/json"
"io"
"log/slog"
"os"
@@ -33,7 +34,7 @@ func testPool(t *testing.T) *pgxpool.Pool {
}
t.Cleanup(pool.Close)
if _, err := pool.Exec(context.Background(),
"TRUNCATE general_likes, general_likes_albums, general_likes_artists, play_events, skip_events, play_sessions, sessions, users, tracks, albums, artists RESTART IDENTITY CASCADE"); err != nil {
"TRUNCATE contextual_likes, general_likes, general_likes_albums, general_likes_artists, play_events, skip_events, play_sessions, sessions, users, tracks, albums, artists RESTART IDENTITY CASCADE"); err != nil {
t.Fatalf("truncate: %v", err)
}
return pool
@@ -77,7 +78,7 @@ func newFixture(t *testing.T, n int) fixture {
func TestLoadCandidates_ExcludesSeed(t *testing.T) {
f := newFixture(t, 5)
got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1)
got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, SessionVector{Seed: true})
if err != nil {
t.Fatalf("LoadCandidates: %v", err)
}
@@ -110,7 +111,7 @@ func TestLoadCandidates_ExcludesRecentlyPlayed(t *testing.T) {
ClientID: nil,
})
got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1)
got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, SessionVector{Seed: true})
if err != nil {
t.Fatalf("LoadCandidates: %v", err)
}
@@ -156,7 +157,7 @@ func TestLoadCandidates_StatJoin(t *testing.T) {
DurationPlayedMs: &dur2, CompletionRatio: &ratio2, WasSkipped: true,
})
got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1)
got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, SessionVector{Seed: true})
if err != nil {
t.Fatalf("LoadCandidates: %v", err)
}
@@ -180,7 +181,7 @@ func TestLoadCandidates_StatJoin(t *testing.T) {
func TestLoadCandidates_NeverPlayedHasNilLastPlayed(t *testing.T) {
f := newFixture(t, 2)
got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1)
got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, SessionVector{Seed: true})
if err != nil {
t.Fatalf("LoadCandidates: %v", err)
}
@@ -203,7 +204,7 @@ func TestLoadCandidates_CrossUserIsolation(t *testing.T) {
// Alice likes tracks[1]; Bob shouldn't see it.
_, _ = f.q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: f.user, TrackID: f.tracks[1].ID})
got, err := LoadCandidates(context.Background(), f.q, bob.ID, f.tracks[0].ID, 1)
got, err := LoadCandidates(context.Background(), f.q, bob.ID, f.tracks[0].ID, 1, SessionVector{Seed: true})
if err != nil {
t.Fatalf("LoadCandidates: %v", err)
}
@@ -221,3 +222,146 @@ func trackTitles(cs []Candidate) []string {
}
return out
}
// helperInsertContextualLike inserts a contextual_like row with the given
// session_vector marshaled to JSON. Bypasses playevents.CaptureContextualLikeIfPlaying
// because we want full control over the stored vector for these unit tests.
func helperInsertContextualLike(t *testing.T, f fixture, trackID pgtype.UUID, vec SessionVector) {
t.Helper()
raw, err := json.Marshal(vec)
if err != nil {
t.Fatalf("marshal: %v", err)
}
if _, err := f.pool.Exec(context.Background(),
`INSERT INTO contextual_likes (user_id, track_id, session_vector) VALUES ($1, $2, $3)`,
f.user, trackID, raw); err != nil {
t.Fatalf("insert: %v", err)
}
}
func TestLoadCandidates_NoContextualLikes_AllZero(t *testing.T) {
f := newFixture(t, 5)
current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}
got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, current)
if err != nil {
t.Fatalf("load: %v", err)
}
for _, c := range got {
if c.Inputs.ContextualMatchScore != 0 {
t.Errorf("track %s ContextualMatchScore = %v, want 0", c.Track.Title, c.Inputs.ContextualMatchScore)
}
}
}
func TestLoadCandidates_OneMatchingLike_ScoresPositive(t *testing.T) {
f := newFixture(t, 3)
target := f.tracks[1]
likeVec := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}
helperInsertContextualLike(t, f, target.ID, likeVec)
current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}
got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, current)
if err != nil {
t.Fatalf("load: %v", err)
}
var found bool
for _, c := range got {
if c.Track.ID == target.ID {
found = true
if c.Inputs.ContextualMatchScore < 0.99 {
t.Errorf("target ContextualMatchScore = %v, want ~1.0", c.Inputs.ContextualMatchScore)
}
} else {
if c.Inputs.ContextualMatchScore != 0 {
t.Errorf("non-target track has ContextualMatchScore = %v", c.Inputs.ContextualMatchScore)
}
}
}
if !found {
t.Error("target track missing from candidate list")
}
}
func TestLoadCandidates_MultipleMatchingLikes_TakesMax(t *testing.T) {
f := newFixture(t, 3)
target := f.tracks[1]
helperInsertContextualLike(t, f, target.ID, SessionVector{
Artists: []string{"a99"}, Tags: map[string]int{"jazz": 1},
})
helperInsertContextualLike(t, f, target.ID, SessionVector{
Artists: []string{"a1"}, Tags: map[string]int{"rock": 1},
})
helperInsertContextualLike(t, f, target.ID, SessionVector{
Artists: []string{"a1"}, Tags: map[string]int{"jazz": 1},
})
current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}
got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, current)
if err != nil {
t.Fatalf("load: %v", err)
}
for _, c := range got {
if c.Track.ID == target.ID && c.Inputs.ContextualMatchScore < 0.99 {
t.Errorf("target = %v, want ~1.0 (max)", c.Inputs.ContextualMatchScore)
}
}
}
func TestLoadCandidates_SoftDeletedLikes_Ignored(t *testing.T) {
f := newFixture(t, 3)
target := f.tracks[1]
likeVec := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}
helperInsertContextualLike(t, f, target.ID, likeVec)
if _, err := f.pool.Exec(context.Background(),
`UPDATE contextual_likes SET deleted_at = now() WHERE user_id = $1`, f.user); err != nil {
t.Fatalf("soft-delete: %v", err)
}
current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}
got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, current)
if err != nil {
t.Fatalf("load: %v", err)
}
for _, c := range got {
if c.Inputs.ContextualMatchScore != 0 {
t.Errorf("soft-deleted track %s ContextualMatchScore = %v", c.Track.Title, c.Inputs.ContextualMatchScore)
}
}
}
func TestLoadCandidates_OnlySeedLikes_ScoresZero(t *testing.T) {
f := newFixture(t, 3)
target := f.tracks[1]
helperInsertContextualLike(t, f, target.ID, SessionVector{
Seed: true, Artists: []string{"a1"}, Tags: map[string]int{"rock": 1},
})
current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}
got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, current)
if err != nil {
t.Fatalf("load: %v", err)
}
for _, c := range got {
if c.Inputs.ContextualMatchScore != 0 {
t.Errorf("seed-only track %s ContextualMatchScore = %v", c.Track.Title, c.Inputs.ContextualMatchScore)
}
}
}
func TestLoadCandidates_CurrentSeed_ScoresZero(t *testing.T) {
f := newFixture(t, 3)
target := f.tracks[1]
likeVec := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}
helperInsertContextualLike(t, f, target.ID, likeVec)
currentSeed := SessionVector{Seed: true}
got, err := LoadCandidates(context.Background(), f.q, f.user, f.tracks[0].ID, 1, currentSeed)
if err != nil {
t.Fatalf("load: %v", err)
}
for _, c := range got {
if c.Inputs.ContextualMatchScore != 0 {
t.Errorf("seed-current track %s ContextualMatchScore = %v", c.Track.Title, c.Inputs.ContextualMatchScore)
}
}
}
+11 -5
View File
@@ -8,12 +8,15 @@ import (
)
// ScoringInputs are the per-track facts the score function consumes.
// Sub-plan #3 (contextual scoring) extends this with ContextualMatchScore.
// ContextualMatchScore is in [0, 1] — max similarity between the user's
// current session vector and any non-seed contextual_like row for this
// track. Set by LoadCandidates after a bulk fetch.
type ScoringInputs struct {
IsGeneralLiked bool
LastPlayedAt *time.Time // nil = never played
PlayCount int // total play_events
SkipCount int // play_events with was_skipped=true
IsGeneralLiked bool
LastPlayedAt *time.Time // nil = never played
PlayCount int // total play_events
SkipCount int // play_events with was_skipped=true
ContextualMatchScore float64 // [0, 1]; 0 when no signal
}
// ScoringWeights are the operator-tunable knobs. Defaults live in
@@ -24,6 +27,7 @@ type ScoringWeights struct {
RecencyWeight float64
SkipPenalty float64
JitterMagnitude float64
ContextWeight float64
}
// Score computes the weighted-shuffle score per spec §6:
@@ -32,6 +36,7 @@ type ScoringWeights struct {
// + (is_general_liked ? LikeBoost : 0)
// + recency_decay * RecencyWeight
// - skip_ratio * SkipPenalty
// + contextual_match_score * ContextWeight
// + small_random_jitter
//
// Higher score = more likely to surface. rng is a function returning a
@@ -44,6 +49,7 @@ func Score(in ScoringInputs, w ScoringWeights, now time.Time, rng func() float64
}
s += recencyDecay(in.LastPlayedAt, now) * w.RecencyWeight
s -= skipRatio(in.PlayCount, in.SkipCount) * w.SkipPenalty
s += in.ContextualMatchScore * w.ContextWeight
s += (rng()*2 - 1) * w.JitterMagnitude
return s
}
+34
View File
@@ -148,3 +148,37 @@ func TestSkipRatio_Half(t *testing.T) {
t.Errorf("skipRatio(4,2) = %v, want 0.5", got)
}
}
func TestScore_ContextualMatch_PerfectMatchAtWeight2(t *testing.T) {
w := defaultWeights()
w.ContextWeight = 2.0
in := ScoringInputs{ContextualMatchScore: 1.0}
got := Score(in, w, time.Now(), fixedRNG(0.5))
// base 1.0 + recency 1.0 (never played) + contextual 2.0 = 4.0
want := 4.0
if math.Abs(got-want) > 1e-9 {
t.Errorf("score = %v, want %v", got, want)
}
}
func TestScore_ContextualMatch_HalfMatchAtWeight2(t *testing.T) {
w := defaultWeights()
w.ContextWeight = 2.0
in := ScoringInputs{ContextualMatchScore: 0.5}
got := Score(in, w, time.Now(), fixedRNG(0.5))
// base 1.0 + recency 1.0 + contextual 1.0 = 3.0
want := 3.0
if math.Abs(got-want) > 1e-9 {
t.Errorf("score = %v, want %v", got, want)
}
}
func TestScore_ContextualMatch_ZeroNoEffect(t *testing.T) {
wWithCtx := defaultWeights()
wWithCtx.ContextWeight = 2.0
withCtx := Score(ScoringInputs{ContextualMatchScore: 0}, wWithCtx, time.Now(), fixedRNG(0.5))
withoutCtx := Score(ScoringInputs{}, defaultWeights(), time.Now(), fixedRNG(0.5))
if math.Abs(withCtx-withoutCtx) > 1e-9 {
t.Errorf("score-with-zero-ctx = %v, score-without = %v; should be equal", withCtx, withoutCtx)
}
}
+101
View File
@@ -0,0 +1,101 @@
package recommendation
// SimilarityWeights balances the per-axis contribution to the weighted Jaccard
// score. v1 hardcodes the defaults — operators cannot tune via YAML. If
// telemetry justifies it, expose under recommendation.similarity.* later.
type SimilarityWeights struct {
TagsWeight float64
ArtistsWeight float64
}
// DefaultSimilarityWeights is the v1 axis balance per the M3 design.
// Tags carry more signal than artists because a session's "vibe" tracks
// genre more directly than artist identity (a session can mix artists
// within a genre but rarely mixes genres).
var DefaultSimilarityWeights = SimilarityWeights{
TagsWeight: 0.7,
ArtistsWeight: 0.3,
}
// Similarity returns weighted-Jaccard similarity in [0, 1] between two
// session vectors. Returns 0 if either input is Seed=true (low-confidence
// vectors don't contribute to scoring).
func Similarity(a, b SessionVector, w SimilarityWeights) float64 {
if a.Seed || b.Seed {
return 0.0
}
tagJ := setJaccardKeys(a.Tags, b.Tags)
artistJ := setJaccardSlice(a.Artists, b.Artists)
return tagJ*w.TagsWeight + artistJ*w.ArtistsWeight
}
// setJaccardKeys collapses two map keysets to sets and returns
// |A ∩ B| / |A B|. Both empty → 0 (not NaN).
func setJaccardKeys(a, b map[string]int) float64 {
if len(a) == 0 && len(b) == 0 {
return 0.0
}
intersect := 0
for k := range a {
if _, ok := b[k]; ok {
intersect++
}
}
union := len(a) + len(b) - intersect
if union == 0 {
return 0.0
}
return float64(intersect) / float64(union)
}
// setJaccardSlice deduplicates each input slice into a set and returns
// |A ∩ B| / |A B|. Both empty → 0 (not NaN).
func setJaccardSlice(a, b []string) float64 {
if len(a) == 0 && len(b) == 0 {
return 0.0
}
aset := make(map[string]struct{}, len(a))
for _, x := range a {
aset[x] = struct{}{}
}
bset := make(map[string]struct{}, len(b))
for _, x := range b {
bset[x] = struct{}{}
}
intersect := 0
for k := range aset {
if _, ok := bset[k]; ok {
intersect++
}
}
union := len(aset) + len(bset) - intersect
if union == 0 {
return 0.0
}
return float64(intersect) / float64(union)
}
// ContextualMatchScore returns the maximum Similarity between the current
// session vector and any non-seed entry in likes. Returns 0 when:
// - current.Seed is true (no meaningful current context)
// - likes is empty after filtering out Seed=true entries
//
// The "max" semantics means a single strong contextual match dominates
// over many weak ones — we want to surface the track because it was liked
// in *some* matching context, not because it was vaguely-liked in many.
func ContextualMatchScore(current SessionVector, likes []SessionVector, w SimilarityWeights) float64 {
if current.Seed {
return 0.0
}
best := 0.0
for _, l := range likes {
if l.Seed {
continue
}
s := Similarity(current, l, w)
if s > best {
best = s
}
}
return best
}
+158
View File
@@ -0,0 +1,158 @@
package recommendation
import (
"math"
"testing"
)
func approxEq(a, b float64) bool { return math.Abs(a-b) < 1e-9 }
func TestSimilarity_IdenticalVectors_Returns1(t *testing.T) {
v := SessionVector{
Artists: []string{"a1", "a2"},
Tags: map[string]int{"rock": 2, "indie": 1},
}
got := Similarity(v, v, DefaultSimilarityWeights)
if !approxEq(got, 1.0) {
t.Errorf("Similarity(v,v) = %v, want 1.0", got)
}
}
func TestSimilarity_FullyDisjoint_Returns0(t *testing.T) {
a := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}
b := SessionVector{Artists: []string{"a2"}, Tags: map[string]int{"jazz": 1}}
got := Similarity(a, b, DefaultSimilarityWeights)
if !approxEq(got, 0.0) {
t.Errorf("disjoint = %v, want 0.0", got)
}
}
func TestSimilarity_TagsOnlyShared_AppliesTagsWeight(t *testing.T) {
a := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}
b := SessionVector{Artists: []string{"a2"}, Tags: map[string]int{"rock": 5}}
got := Similarity(a, b, DefaultSimilarityWeights)
if !approxEq(got, 0.7) {
t.Errorf("tags-only = %v, want 0.7", got)
}
}
func TestSimilarity_ArtistsOnlyShared_AppliesArtistsWeight(t *testing.T) {
a := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}
b := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"jazz": 1}}
got := Similarity(a, b, DefaultSimilarityWeights)
if !approxEq(got, 0.3) {
t.Errorf("artists-only = %v, want 0.3", got)
}
}
func TestSimilarity_EitherSeed_Returns0(t *testing.T) {
v := SessionVector{Artists: []string{"a"}, Tags: map[string]int{"rock": 1}}
seed := SessionVector{Seed: true, Artists: []string{"a"}, Tags: map[string]int{"rock": 1}}
if got := Similarity(v, seed, DefaultSimilarityWeights); !approxEq(got, 0.0) {
t.Errorf("v vs seed = %v, want 0.0", got)
}
if got := Similarity(seed, v, DefaultSimilarityWeights); !approxEq(got, 0.0) {
t.Errorf("seed vs v = %v, want 0.0", got)
}
}
func TestSimilarity_BothEmpty_Returns0NotNaN(t *testing.T) {
a := SessionVector{}
b := SessionVector{}
got := Similarity(a, b, DefaultSimilarityWeights)
if math.IsNaN(got) || !approxEq(got, 0.0) {
t.Errorf("empty = %v, want 0.0 (not NaN)", got)
}
}
func TestSimilarity_OneAxisEmptyOneSide_AxisContributesZero(t *testing.T) {
a := SessionVector{Tags: map[string]int{"rock": 1}}
b := SessionVector{Artists: []string{"a1"}}
got := Similarity(a, b, DefaultSimilarityWeights)
if !approxEq(got, 0.0) {
t.Errorf("one-axis-each = %v, want 0.0", got)
}
}
func TestSimilarity_PartialTagsOverlap(t *testing.T) {
a := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1, "indie": 1}}
b := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1, "jazz": 1}}
got := Similarity(a, b, DefaultSimilarityWeights)
want := 0.7*(1.0/3.0) + 0.3*1.0
if !approxEq(got, want) {
t.Errorf("partial = %v, want %v", got, want)
}
}
func TestSimilarity_BagOfCountsCollapsesToSet(t *testing.T) {
a := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 2, "indie": 1}}
b := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 5, "indie": 3}}
got := Similarity(a, b, DefaultSimilarityWeights)
if !approxEq(got, 1.0) {
t.Errorf("set-collapse = %v, want 1.0", got)
}
}
func TestContextualMatchScore_NoLikes_Returns0(t *testing.T) {
current := SessionVector{Artists: []string{"a"}, Tags: map[string]int{"rock": 1}}
got := ContextualMatchScore(current, nil, DefaultSimilarityWeights)
if !approxEq(got, 0.0) {
t.Errorf("no likes = %v, want 0.0", got)
}
got = ContextualMatchScore(current, []SessionVector{}, DefaultSimilarityWeights)
if !approxEq(got, 0.0) {
t.Errorf("empty likes = %v, want 0.0", got)
}
}
func TestContextualMatchScore_CurrentSeed_Returns0(t *testing.T) {
current := SessionVector{Seed: true}
likes := []SessionVector{
{Artists: []string{"a"}, Tags: map[string]int{"rock": 1}},
}
got := ContextualMatchScore(current, likes, DefaultSimilarityWeights)
if !approxEq(got, 0.0) {
t.Errorf("current seed = %v, want 0.0", got)
}
}
func TestContextualMatchScore_AllLikesSeed_Returns0(t *testing.T) {
current := SessionVector{Artists: []string{"a"}, Tags: map[string]int{"rock": 1}}
likes := []SessionVector{
{Seed: true, Artists: []string{"a"}, Tags: map[string]int{"rock": 1}},
{Seed: true, Artists: []string{"a"}, Tags: map[string]int{"rock": 1}},
}
got := ContextualMatchScore(current, likes, DefaultSimilarityWeights)
if !approxEq(got, 0.0) {
t.Errorf("all-seed likes = %v, want 0.0", got)
}
}
func TestContextualMatchScore_TakesMax(t *testing.T) {
// Three likes: full match, partial match, mismatch. Expect full match (1.0).
current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}
// Three likes covering 1.0 (full match), 0.7 (tags-only match), 0.0 (mismatch).
likes := []SessionVector{
{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}},
{Artists: []string{"a2"}, Tags: map[string]int{"rock": 1}},
{Artists: []string{"a99"}, Tags: map[string]int{"jazz": 1}},
}
got := ContextualMatchScore(current, likes, DefaultSimilarityWeights)
if !approxEq(got, 1.0) {
t.Errorf("takes-max = %v, want 1.0", got)
}
}
func TestContextualMatchScore_FiltersSeedThenMaxes(t *testing.T) {
// One Seed=true match (would be 1.0 if not filtered) + one partial match.
current := SessionVector{Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}}
likes := []SessionVector{
{Seed: true, Artists: []string{"a1"}, Tags: map[string]int{"rock": 1}},
{Artists: []string{"a2"}, Tags: map[string]int{"rock": 1}},
}
got := ContextualMatchScore(current, likes, DefaultSimilarityWeights)
// Seed=true filtered out → only partial match counts → 0.7
if !approxEq(got, 0.7) {
t.Errorf("filter-then-max = %v, want 0.7", got)
}
}