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:
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, ¤tVec); jerr != nil {
|
||||
h.logger.Warn("api: radio: bad session_vector_at_play", "err", jerr)
|
||||
currentVec = recommendation.SessionVector{Seed: true}
|
||||
}
|
||||
} else {
|
||||
currentVec = recommendation.SessionVector{Seed: true}
|
||||
}
|
||||
```
|
||||
|
||||
Pass `currentVec` into `LoadCandidates`. Pass `recCfg.ContextWeight` through into the
|
||||
`ScoringWeights` struct alongside the existing weights.
|
||||
|
||||
### 3.7 Modified: `internal/config/config.go`
|
||||
|
||||
```go
|
||||
type RecommendationConfig struct {
|
||||
BaseWeight float64 `yaml:"base_weight"`
|
||||
LikeBoost float64 `yaml:"like_boost"`
|
||||
RecencyWeight float64 `yaml:"recency_weight"`
|
||||
SkipPenalty float64 `yaml:"skip_penalty"`
|
||||
JitterMagnitude float64 `yaml:"jitter_magnitude"`
|
||||
ContextWeight float64 `yaml:"context_weight"` // NEW, default 2.0
|
||||
RecentlyPlayedHours int `yaml:"recently_played_hours"`
|
||||
RadioSize int `yaml:"radio_size"`
|
||||
RadioSizeMax int `yaml:"radio_size_max"`
|
||||
}
|
||||
```
|
||||
|
||||
`Default()` populates `ContextWeight: 2.0`.
|
||||
|
||||
## 4. Request flow
|
||||
|
||||
```
|
||||
GET /api/radio?seed_track=<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.
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// sqlc v1.27.0
|
||||
// source: artists.sql
|
||||
|
||||
package dbq
|
||||
|
||||
@@ -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,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// sqlc v1.27.0
|
||||
|
||||
package dbq
|
||||
|
||||
|
||||
@@ -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,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,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// sqlc v1.27.0
|
||||
|
||||
package dbq
|
||||
|
||||
|
||||
@@ -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,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,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,6 +1,6 @@
|
||||
// Code generated by sqlc. DO NOT EDIT.
|
||||
// versions:
|
||||
// sqlc v1.31.1
|
||||
// sqlc v1.27.0
|
||||
// source: users.sql
|
||||
|
||||
package dbq
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user