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)
|
w := playevents.NewWriter(pool, logger, 30*time.Minute, 0.5, 30000)
|
||||||
recCfg := config.RecommendationConfig{
|
recCfg := config.RecommendationConfig{
|
||||||
BaseWeight: 1.0, LikeBoost: 2.0, RecencyWeight: 1.0,
|
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,
|
RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200,
|
||||||
}
|
}
|
||||||
h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }}
|
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
|
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
|
// seedTrackWithFile creates a fresh temp directory, writes fileBody to
|
||||||
// <dir>/<title>.<ext>, and inserts a Track row whose file_path points at it.
|
// <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
|
// Returns the inserted Track and the directory (callers drop sidecar covers
|
||||||
|
|||||||
+35
-2
@@ -1,13 +1,16 @@
|
|||||||
package api
|
package api
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"errors"
|
"errors"
|
||||||
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
|
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
"git.fabledsword.com/bvandeusen/minstrel/internal/auth"
|
||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"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>.
|
// handleRadio implements GET /api/radio?seed_track=<uuid>&limit=<int>.
|
||||||
//
|
//
|
||||||
// Returns the seed at index 0, followed by up to limit-1 weighted-shuffle
|
// 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) {
|
func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) {
|
||||||
user, ok := auth.UserFromContext(r.Context())
|
user, ok := auth.UserFromContext(r.Context())
|
||||||
if !ok {
|
if !ok {
|
||||||
@@ -74,7 +79,10 @@ func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) {
|
|||||||
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
|
writeErr(w, http.StatusInternalServerError, "server_error", "lookup failed")
|
||||||
return
|
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 {
|
if err != nil {
|
||||||
h.logger.Error("api: radio: load candidates", "err", err)
|
h.logger.Error("api: radio: load candidates", "err", err)
|
||||||
writeErr(w, http.StatusInternalServerError, "server_error", "candidate load failed")
|
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,
|
RecencyWeight: h.recCfg.RecencyWeight,
|
||||||
SkipPenalty: h.recCfg.SkipPenalty,
|
SkipPenalty: h.recCfg.SkipPenalty,
|
||||||
JitterMagnitude: h.recCfg.JitterMagnitude,
|
JitterMagnitude: h.recCfg.JitterMagnitude,
|
||||||
|
ContextWeight: h.recCfg.ContextWeight,
|
||||||
}
|
}
|
||||||
picks := recommendation.Shuffle(candidates, weights, time.Now().UTC(), h.rng, limit-1)
|
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})
|
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"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
||||||
)
|
)
|
||||||
|
|
||||||
func callRadio(h *handlers, user interface{}, query string) *httptest.ResponseRecorder {
|
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))
|
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"`
|
RecencyWeight float64 `yaml:"recency_weight"`
|
||||||
SkipPenalty float64 `yaml:"skip_penalty"`
|
SkipPenalty float64 `yaml:"skip_penalty"`
|
||||||
JitterMagnitude float64 `yaml:"jitter_magnitude"`
|
JitterMagnitude float64 `yaml:"jitter_magnitude"`
|
||||||
|
ContextWeight float64 `yaml:"context_weight"`
|
||||||
RecentlyPlayedHours int `yaml:"recently_played_hours"`
|
RecentlyPlayedHours int `yaml:"recently_played_hours"`
|
||||||
RadioSize int `yaml:"radio_size"`
|
RadioSize int `yaml:"radio_size"`
|
||||||
RadioSizeMax int `yaml:"radio_size_max"`
|
RadioSizeMax int `yaml:"radio_size_max"`
|
||||||
@@ -93,6 +94,7 @@ func Default() Config {
|
|||||||
RecencyWeight: 1.0,
|
RecencyWeight: 1.0,
|
||||||
SkipPenalty: 1.0,
|
SkipPenalty: 1.0,
|
||||||
JitterMagnitude: 0.1,
|
JitterMagnitude: 0.1,
|
||||||
|
ContextWeight: 2.0,
|
||||||
RecentlyPlayedHours: 1,
|
RecentlyPlayedHours: 1,
|
||||||
RadioSize: 50,
|
RadioSize: 50,
|
||||||
RadioSizeMax: 200,
|
RadioSizeMax: 200,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.31.1
|
// sqlc v1.27.0
|
||||||
// source: albums.sql
|
// source: albums.sql
|
||||||
|
|
||||||
package dbq
|
package dbq
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.31.1
|
// sqlc v1.27.0
|
||||||
// source: artists.sql
|
// source: artists.sql
|
||||||
|
|
||||||
package dbq
|
package dbq
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.31.1
|
// sqlc v1.27.0
|
||||||
// source: contextual_likes.sql
|
// source: contextual_likes.sql
|
||||||
|
|
||||||
package dbq
|
package dbq
|
||||||
@@ -33,6 +33,43 @@ func (q *Queries) InsertContextualLike(ctx context.Context, arg InsertContextual
|
|||||||
return err
|
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
|
const softDeleteContextualLikesForUserTrack = `-- name: SoftDeleteContextualLikesForUserTrack :exec
|
||||||
UPDATE contextual_likes
|
UPDATE contextual_likes
|
||||||
SET deleted_at = now()
|
SET deleted_at = now()
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.31.1
|
// sqlc v1.27.0
|
||||||
|
|
||||||
package dbq
|
package dbq
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.31.1
|
// sqlc v1.27.0
|
||||||
// source: events.sql
|
// source: events.sql
|
||||||
|
|
||||||
package dbq
|
package dbq
|
||||||
@@ -11,6 +11,26 @@ import (
|
|||||||
"github.com/jackc/pgx/v5/pgtype"
|
"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
|
const getMostRecentPlaySessionForUser = `-- name: GetMostRecentPlaySessionForUser :one
|
||||||
SELECT id, user_id, started_at, ended_at, last_event_at, track_count, client_id FROM play_sessions
|
SELECT id, user_id, started_at, ended_at, last_event_at, track_count, client_id FROM play_sessions
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.31.1
|
// sqlc v1.27.0
|
||||||
// source: likes.sql
|
// source: likes.sql
|
||||||
|
|
||||||
package dbq
|
package dbq
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.31.1
|
// sqlc v1.27.0
|
||||||
|
|
||||||
package dbq
|
package dbq
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.31.1
|
// sqlc v1.27.0
|
||||||
// source: recommendation.sql
|
// source: recommendation.sql
|
||||||
|
|
||||||
package dbq
|
package dbq
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.31.1
|
// sqlc v1.27.0
|
||||||
// source: sessions.sql
|
// source: sessions.sql
|
||||||
|
|
||||||
package dbq
|
package dbq
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.31.1
|
// sqlc v1.27.0
|
||||||
// source: tracks.sql
|
// source: tracks.sql
|
||||||
|
|
||||||
package dbq
|
package dbq
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
// Code generated by sqlc. DO NOT EDIT.
|
// Code generated by sqlc. DO NOT EDIT.
|
||||||
// versions:
|
// versions:
|
||||||
// sqlc v1.31.1
|
// sqlc v1.27.0
|
||||||
// source: users.sql
|
// source: users.sql
|
||||||
|
|
||||||
package dbq
|
package dbq
|
||||||
|
|||||||
@@ -8,3 +8,14 @@ VALUES ($1, $2, $3, $4);
|
|||||||
UPDATE contextual_likes
|
UPDATE contextual_likes
|
||||||
SET deleted_at = now()
|
SET deleted_at = now()
|
||||||
WHERE user_id = $1 AND track_id = $2 AND deleted_at IS NULL;
|
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
|
UPDATE play_events
|
||||||
SET session_vector_at_play = $2
|
SET session_vector_at_play = $2
|
||||||
WHERE id = $1;
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
@@ -9,11 +10,17 @@ import (
|
|||||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
"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(
|
func LoadCandidates(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
q *dbq.Queries,
|
q *dbq.Queries,
|
||||||
userID, seedID pgtype.UUID,
|
userID, seedID pgtype.UUID,
|
||||||
recentlyPlayedHours int,
|
recentlyPlayedHours int,
|
||||||
|
currentVector SessionVector,
|
||||||
) ([]Candidate, error) {
|
) ([]Candidate, error) {
|
||||||
rows, err := q.LoadRadioCandidates(ctx, dbq.LoadRadioCandidatesParams{
|
rows, err := q.LoadRadioCandidates(ctx, dbq.LoadRadioCandidatesParams{
|
||||||
UserID: userID,
|
UserID: userID,
|
||||||
@@ -23,6 +30,12 @@ func LoadCandidates(
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
likes, err := loadContextualLikesByTrack(ctx, q, userID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
out := make([]Candidate, 0, len(rows))
|
out := make([]Candidate, 0, len(rows))
|
||||||
for _, r := range rows {
|
for _, r := range rows {
|
||||||
var lpt *time.Time
|
var lpt *time.Time
|
||||||
@@ -30,15 +43,41 @@ func LoadCandidates(
|
|||||||
t := r.LastPlayedAt.Time
|
t := r.LastPlayedAt.Time
|
||||||
lpt = &t
|
lpt = &t
|
||||||
}
|
}
|
||||||
|
ctxScore := ContextualMatchScore(currentVector, likes[r.Track.ID], DefaultSimilarityWeights)
|
||||||
out = append(out, Candidate{
|
out = append(out, Candidate{
|
||||||
Track: r.Track,
|
Track: r.Track,
|
||||||
Inputs: ScoringInputs{
|
Inputs: ScoringInputs{
|
||||||
IsGeneralLiked: r.IsLiked,
|
IsGeneralLiked: r.IsLiked,
|
||||||
LastPlayedAt: lpt,
|
LastPlayedAt: lpt,
|
||||||
PlayCount: int(r.PlayCount),
|
PlayCount: int(r.PlayCount),
|
||||||
SkipCount: int(r.SkipCount),
|
SkipCount: int(r.SkipCount),
|
||||||
|
ContextualMatchScore: ctxScore,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
return out, nil
|
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 (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"encoding/json"
|
||||||
"io"
|
"io"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"os"
|
"os"
|
||||||
@@ -33,7 +34,7 @@ func testPool(t *testing.T) *pgxpool.Pool {
|
|||||||
}
|
}
|
||||||
t.Cleanup(pool.Close)
|
t.Cleanup(pool.Close)
|
||||||
if _, err := pool.Exec(context.Background(),
|
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)
|
t.Fatalf("truncate: %v", err)
|
||||||
}
|
}
|
||||||
return pool
|
return pool
|
||||||
@@ -77,7 +78,7 @@ func newFixture(t *testing.T, n int) fixture {
|
|||||||
|
|
||||||
func TestLoadCandidates_ExcludesSeed(t *testing.T) {
|
func TestLoadCandidates_ExcludesSeed(t *testing.T) {
|
||||||
f := newFixture(t, 5)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("LoadCandidates: %v", err)
|
t.Fatalf("LoadCandidates: %v", err)
|
||||||
}
|
}
|
||||||
@@ -110,7 +111,7 @@ func TestLoadCandidates_ExcludesRecentlyPlayed(t *testing.T) {
|
|||||||
ClientID: nil,
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("LoadCandidates: %v", err)
|
t.Fatalf("LoadCandidates: %v", err)
|
||||||
}
|
}
|
||||||
@@ -156,7 +157,7 @@ func TestLoadCandidates_StatJoin(t *testing.T) {
|
|||||||
DurationPlayedMs: &dur2, CompletionRatio: &ratio2, WasSkipped: true,
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("LoadCandidates: %v", err)
|
t.Fatalf("LoadCandidates: %v", err)
|
||||||
}
|
}
|
||||||
@@ -180,7 +181,7 @@ func TestLoadCandidates_StatJoin(t *testing.T) {
|
|||||||
|
|
||||||
func TestLoadCandidates_NeverPlayedHasNilLastPlayed(t *testing.T) {
|
func TestLoadCandidates_NeverPlayedHasNilLastPlayed(t *testing.T) {
|
||||||
f := newFixture(t, 2)
|
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 {
|
if err != nil {
|
||||||
t.Fatalf("LoadCandidates: %v", err)
|
t.Fatalf("LoadCandidates: %v", err)
|
||||||
}
|
}
|
||||||
@@ -203,7 +204,7 @@ func TestLoadCandidates_CrossUserIsolation(t *testing.T) {
|
|||||||
// Alice likes tracks[1]; Bob shouldn't see it.
|
// Alice likes tracks[1]; Bob shouldn't see it.
|
||||||
_, _ = f.q.LikeTrack(context.Background(), dbq.LikeTrackParams{UserID: f.user, TrackID: f.tracks[1].ID})
|
_, _ = 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 {
|
if err != nil {
|
||||||
t.Fatalf("LoadCandidates: %v", err)
|
t.Fatalf("LoadCandidates: %v", err)
|
||||||
}
|
}
|
||||||
@@ -221,3 +222,146 @@ func trackTitles(cs []Candidate) []string {
|
|||||||
}
|
}
|
||||||
return out
|
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.
|
// 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 {
|
type ScoringInputs struct {
|
||||||
IsGeneralLiked bool
|
IsGeneralLiked bool
|
||||||
LastPlayedAt *time.Time // nil = never played
|
LastPlayedAt *time.Time // nil = never played
|
||||||
PlayCount int // total play_events
|
PlayCount int // total play_events
|
||||||
SkipCount int // play_events with was_skipped=true
|
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
|
// ScoringWeights are the operator-tunable knobs. Defaults live in
|
||||||
@@ -24,6 +27,7 @@ type ScoringWeights struct {
|
|||||||
RecencyWeight float64
|
RecencyWeight float64
|
||||||
SkipPenalty float64
|
SkipPenalty float64
|
||||||
JitterMagnitude float64
|
JitterMagnitude float64
|
||||||
|
ContextWeight float64
|
||||||
}
|
}
|
||||||
|
|
||||||
// Score computes the weighted-shuffle score per spec §6:
|
// Score computes the weighted-shuffle score per spec §6:
|
||||||
@@ -32,6 +36,7 @@ type ScoringWeights struct {
|
|||||||
// + (is_general_liked ? LikeBoost : 0)
|
// + (is_general_liked ? LikeBoost : 0)
|
||||||
// + recency_decay * RecencyWeight
|
// + recency_decay * RecencyWeight
|
||||||
// - skip_ratio * SkipPenalty
|
// - skip_ratio * SkipPenalty
|
||||||
|
// + contextual_match_score * ContextWeight
|
||||||
// + small_random_jitter
|
// + small_random_jitter
|
||||||
//
|
//
|
||||||
// Higher score = more likely to surface. rng is a function returning a
|
// 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 += recencyDecay(in.LastPlayedAt, now) * w.RecencyWeight
|
||||||
s -= skipRatio(in.PlayCount, in.SkipCount) * w.SkipPenalty
|
s -= skipRatio(in.PlayCount, in.SkipCount) * w.SkipPenalty
|
||||||
|
s += in.ContextualMatchScore * w.ContextWeight
|
||||||
s += (rng()*2 - 1) * w.JitterMagnitude
|
s += (rng()*2 - 1) * w.JitterMagnitude
|
||||||
return s
|
return s
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -148,3 +148,37 @@ func TestSkipRatio_Half(t *testing.T) {
|
|||||||
t.Errorf("skipRatio(4,2) = %v, want 0.5", got)
|
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