feat: M3 session similarity + contextual_match_score (closes M3) #23

Merged
bvandeusen merged 262 commits from dev into main 2026-04-27 23:02:29 -04:00
bvandeusen commented 2026-04-27 21:25:59 -04:00 (Migrated from git.fabledsword.com)

Third and final M3 sub-plan (Fable #342). Adds the contextual_match_score term to the recommendation scoring formula, completing the M3 milestone.

What this ships

Builds on PR #21 (weighted shuffle baseline) and PR #22 (session vectors + contextual_likes capture). This slice reads the contextual data accumulated by #22 and folds it into scoring.

New: weighted Jaccard similarity (internal/recommendation/similarity.go)

Pure function Similarity(a, b SessionVector, w SimilarityWeights) float64 returning weighted-Jaccard in [0, 1]:

  • Tags axis (default weight 0.7): classic set Jaccard, |A ∩ B| / |A ∪ B| over the keysets of Tags map[string]int (bag-of-counts collapsed to set for v1; generalized Jaccard remains a one-line upgrade if telemetry justifies it).
  • Artists axis (default weight 0.3): same set Jaccard over deduplicated Artists []string.
  • Returns 0.0 if either input has Seed=true (low-confidence vectors don't contribute).
  • Both-empty cases return 0.0 (not NaN).

Convenience function ContextualMatchScore(current SessionVector, likes []SessionVector, w SimilarityWeights) float64 returns max(Similarity(current, l)) over non-seed entries in likes. Returns 0 when current.Seed is true OR likes is empty after seed-filter.

SimilarityWeights{TagsWeight: 0.7, ArtistsWeight: 0.3} is hardcoded (not YAML-exposed) — the operator-tunable knob is ContextWeight.

Score formula extension (internal/recommendation/score.go)

ScoringInputs gains ContextualMatchScore float64. ScoringWeights gains ContextWeight float64. The formula adds a new term:

score = base
      + (is_general_liked ? LikeBoost : 0)
      + recency_decay * RecencyWeight
      - skip_ratio * SkipPenalty
      + contextual_match_score * ContextWeight   ← NEW
      + jitter

Default ContextWeight = 2.0 — same magnitude as LikeBoost, so a perfect contextual match competes with an explicit general like. Operator-tunable via recommendation.context_weight YAML.

Backward compatible: ScoringInputs{}/ScoringWeights{} zero values produce the v1 score because 0 * anything = 0.

Database access (internal/db/queries/)

Two new sqlc queries:

  • ListActiveContextualLikesForUser :many — bulk fetch of (track_id, session_vector) for the user's non-soft-deleted, non-NULL contextual_likes. Used by LoadCandidates for one-shot grouping.
  • GetCurrentSessionVectorForUser :one — returns session_vector_at_play of the user's most recent play_event in an active session (joined on play_sessions.ended_at IS NULL to exclude stale vectors from closed sessions). NoRows is the no-active-session signal.

LoadCandidates extension (internal/recommendation/candidates.go)

LoadCandidates gains a currentVector SessionVector parameter. Body bulk-fetches the user's contextual_likes once via the new query, groups by track_id into map[pgtype.UUID][]SessionVector, and computes per-candidate ContextualMatchScore(currentVector, group[trackID], DefaultSimilarityWeights). Bad-JSON rows are skipped silently (don't poison scoring over one bad row).

API integration (internal/api/radio.go)

handleRadio calls a new package-level loadCurrentSessionVector helper before LoadCandidates. The helper:

  1. Calls q.GetCurrentSessionVectorForUser(userID).
  2. Returns SessionVector{Seed: true} sentinel on pgx.ErrNoRows (no active session — common path, silent), other errors (warn + sentinel), empty raw, or unmarshal failure (warn + sentinel).
  3. Otherwise unmarshals to SessionVector and returns it.

The sentinel short-circuits ContextualMatchScore to 0, so cold-start cases degrade cleanly to v1 behavior.

ScoringWeights now passes ContextWeight: h.recCfg.ContextWeight from the loaded config.

Cold-start collapse table

Condition Path
User has no play_events NoRows → Seed sentinel → 0
User has plays but no active session NoRows (joined s.ended_at IS NULL filters) → Seed sentinel → 0
Active session but session_vector_at_play is NULL len(raw) == 0 → Seed sentinel → 0
Vector populated with Seed=true ContextualMatchScore short-circuits → 0
Candidate has no contextual_likes absent from map → empty slice → 0
Candidate has only Seed=true likes filtered out → 0
Candidate has only soft-deleted likes excluded by WHERE deleted_at IS NULL SQL → 0

Test plan

  • go test -short -race ./... — all 13 packages pass
  • Full integration suite (MINSTREL_TEST_DATABASE_URL=… go test -p 1 -race ./...) — passes; pre-existing TestScanner_Integration flake unchanged (CI runs -short which skips it)
  • internal/recommendation coverage: 93.9%, internal/playevents coverage: 78.0%, combined 85.7% (target: ≥70%)
  • golangci-lint run ./... — clean
  • cd web && npm run check — 0 errors / 0 warnings
  • cd web && npm test — 174 tests across 29 files pass
  • cd web && npm run build — adapter-static emits web/build/
  • docker build -t minstrel:m3-similarity-smoke . — succeeds
  • End-to-end ranking test (TestHandleRadio_ContextualMatch_BoostsRankingOverControl): seeds a "rock vibe" session, inserts a contextual_like on a target track, verifies target ranks above an unrelated jazz control. Deterministic via fixed RNG (jitter=0).
  • Manual: like a track during a session of one vibe (e.g., 3+ rock tracks), build a similar session later, observe the previously-liked track surfaces above unrelated controls in /api/radio output.

Notes

  • No web changes: /api/radio request and response shapes are unchanged.
  • Backward compat: zero-value ScoringInputs/ScoringWeights continue to produce v1 scores; existing tests pass unchanged.
  • SimilarityWeights hardcoded: 0.7 tags / 0.3 artists. No YAML exposure for v1 — ContextWeight is the only operator-facing tunable for contextual scoring.
  • Per-candidate cost: bulk fetch is O(user's active contextual_likes), not O(candidates). Typical cardinality is tens to low hundreds of contextual_likes vs thousands of tracks — query is cheap and well-indexed via the partial index on (user_id, track_id) WHERE deleted_at IS NULL.

Closes M3

After this slice merges, the M3 milestone is complete:

  • #340 — weighted shuffle v1 baseline ✓ (PR #21)
  • #341 — session vectors written + contextual_likes capture ✓ (PR #22)
  • #342 — contextual_match_score scoring ✓ (this PR)

Unblocks M4 (radio refinements + scrobble polish).

🤖 Generated with Claude Code

Third and final M3 sub-plan (Fable #342). Adds the `contextual_match_score` term to the recommendation scoring formula, completing the M3 milestone. ## What this ships Builds on PR #21 (weighted shuffle baseline) and PR #22 (session vectors + contextual_likes capture). This slice reads the contextual data accumulated by #22 and folds it into scoring. ### New: weighted Jaccard similarity (`internal/recommendation/similarity.go`) Pure function `Similarity(a, b SessionVector, w SimilarityWeights) float64` returning weighted-Jaccard in [0, 1]: - **Tags axis** (default weight 0.7): classic set Jaccard, `|A ∩ B| / |A ∪ B|` over the keysets of `Tags map[string]int` (bag-of-counts collapsed to set for v1; generalized Jaccard remains a one-line upgrade if telemetry justifies it). - **Artists axis** (default weight 0.3): same set Jaccard over deduplicated `Artists []string`. - Returns `0.0` if either input has `Seed=true` (low-confidence vectors don't contribute). - Both-empty cases return `0.0` (not NaN). Convenience function `ContextualMatchScore(current SessionVector, likes []SessionVector, w SimilarityWeights) float64` returns `max(Similarity(current, l))` over non-seed entries in `likes`. Returns 0 when `current.Seed` is true OR likes is empty after seed-filter. `SimilarityWeights{TagsWeight: 0.7, ArtistsWeight: 0.3}` is hardcoded (not YAML-exposed) — the operator-tunable knob is `ContextWeight`. ### Score formula extension (`internal/recommendation/score.go`) `ScoringInputs` gains `ContextualMatchScore float64`. `ScoringWeights` gains `ContextWeight float64`. The formula adds a new term: ``` score = base + (is_general_liked ? LikeBoost : 0) + recency_decay * RecencyWeight - skip_ratio * SkipPenalty + contextual_match_score * ContextWeight ← NEW + jitter ``` Default `ContextWeight = 2.0` — same magnitude as `LikeBoost`, so a perfect contextual match competes with an explicit general like. Operator-tunable via `recommendation.context_weight` YAML. Backward compatible: `ScoringInputs{}`/`ScoringWeights{}` zero values produce the v1 score because `0 * anything = 0`. ### Database access (`internal/db/queries/`) Two new sqlc queries: - **`ListActiveContextualLikesForUser :many`** — bulk fetch of `(track_id, session_vector)` for the user's non-soft-deleted, non-NULL contextual_likes. Used by `LoadCandidates` for one-shot grouping. - **`GetCurrentSessionVectorForUser :one`** — returns `session_vector_at_play` of the user's most recent play_event in an active session (joined on `play_sessions.ended_at IS NULL` to exclude stale vectors from closed sessions). NoRows is the no-active-session signal. ### LoadCandidates extension (`internal/recommendation/candidates.go`) `LoadCandidates` gains a `currentVector SessionVector` parameter. Body bulk-fetches the user's contextual_likes once via the new query, groups by `track_id` into `map[pgtype.UUID][]SessionVector`, and computes per-candidate `ContextualMatchScore(currentVector, group[trackID], DefaultSimilarityWeights)`. Bad-JSON rows are skipped silently (don't poison scoring over one bad row). ### API integration (`internal/api/radio.go`) `handleRadio` calls a new package-level `loadCurrentSessionVector` helper before `LoadCandidates`. The helper: 1. Calls `q.GetCurrentSessionVectorForUser(userID)`. 2. Returns `SessionVector{Seed: true}` sentinel on `pgx.ErrNoRows` (no active session — common path, silent), other errors (warn + sentinel), empty raw, or unmarshal failure (warn + sentinel). 3. Otherwise unmarshals to `SessionVector` and returns it. The sentinel short-circuits `ContextualMatchScore` to 0, so cold-start cases degrade cleanly to v1 behavior. `ScoringWeights` now passes `ContextWeight: h.recCfg.ContextWeight` from the loaded config. ### Cold-start collapse table | Condition | Path | |---|---| | User has no `play_events` | NoRows → Seed sentinel → 0 | | User has plays but no active session | NoRows (joined `s.ended_at IS NULL` filters) → Seed sentinel → 0 | | Active session but `session_vector_at_play` is NULL | `len(raw) == 0` → Seed sentinel → 0 | | Vector populated with `Seed=true` | `ContextualMatchScore` short-circuits → 0 | | Candidate has no `contextual_likes` | absent from map → empty slice → 0 | | Candidate has only `Seed=true` likes | filtered out → 0 | | Candidate has only soft-deleted likes | excluded by `WHERE deleted_at IS NULL` SQL → 0 | ## Test plan - [x] `go test -short -race ./...` — all 13 packages pass - [x] Full integration suite (`MINSTREL_TEST_DATABASE_URL=… go test -p 1 -race ./...`) — passes; pre-existing `TestScanner_Integration` flake unchanged (CI runs `-short` which skips it) - [x] **`internal/recommendation` coverage: 93.9%**, **`internal/playevents` coverage: 78.0%**, **combined 85.7%** (target: ≥70%) - [x] `golangci-lint run ./...` — clean - [x] `cd web && npm run check` — 0 errors / 0 warnings - [x] `cd web && npm test` — 174 tests across 29 files pass - [x] `cd web && npm run build` — adapter-static emits `web/build/` - [x] `docker build -t minstrel:m3-similarity-smoke .` — succeeds - [x] **End-to-end ranking test** (`TestHandleRadio_ContextualMatch_BoostsRankingOverControl`): seeds a "rock vibe" session, inserts a contextual_like on a target track, verifies target ranks above an unrelated jazz control. Deterministic via fixed RNG (jitter=0). - [ ] Manual: like a track during a session of one vibe (e.g., 3+ rock tracks), build a similar session later, observe the previously-liked track surfaces above unrelated controls in `/api/radio` output. ## Notes - **No web changes**: `/api/radio` request and response shapes are unchanged. - **Backward compat**: zero-value `ScoringInputs`/`ScoringWeights` continue to produce v1 scores; existing tests pass unchanged. - **`SimilarityWeights` hardcoded**: 0.7 tags / 0.3 artists. No YAML exposure for v1 — `ContextWeight` is the only operator-facing tunable for contextual scoring. - **Per-candidate cost**: bulk fetch is O(user's active contextual_likes), not O(candidates). Typical cardinality is tens to low hundreds of contextual_likes vs thousands of tracks — query is cheap and well-indexed via the partial index on `(user_id, track_id) WHERE deleted_at IS NULL`. ## Closes M3 After this slice merges, the M3 milestone is complete: - #340 — weighted shuffle v1 baseline ✓ (PR #21) - #341 — session vectors written + contextual_likes capture ✓ (PR #22) - **#342 — contextual_match_score scoring ✓ (this PR)** Unblocks M4 (radio refinements + scrobble polish). 🤖 Generated with [Claude Code](https://claude.com/claude-code)
Sign in to join this conversation.
No Reviewers
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: bvandeusen/minstrel#23