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.
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:
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 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:
Calls q.GetCurrentSessionVectorForUser(userID).
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).
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.
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)
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:
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)
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.
Third and final M3 sub-plan (Fable #342). Adds the
contextual_match_scoreterm 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) float64returning weighted-Jaccard in [0, 1]:|A ∩ B| / |A ∪ B|over the keysets ofTags map[string]int(bag-of-counts collapsed to set for v1; generalized Jaccard remains a one-line upgrade if telemetry justifies it).Artists []string.0.0if either input hasSeed=true(low-confidence vectors don't contribute).0.0(not NaN).Convenience function
ContextualMatchScore(current SessionVector, likes []SessionVector, w SimilarityWeights) float64returnsmax(Similarity(current, l))over non-seed entries inlikes. Returns 0 whencurrent.Seedis true OR likes is empty after seed-filter.SimilarityWeights{TagsWeight: 0.7, ArtistsWeight: 0.3}is hardcoded (not YAML-exposed) — the operator-tunable knob isContextWeight.Score formula extension (
internal/recommendation/score.go)ScoringInputsgainsContextualMatchScore float64.ScoringWeightsgainsContextWeight float64. The formula adds a new term:Default
ContextWeight = 2.0— same magnitude asLikeBoost, so a perfect contextual match competes with an explicit general like. Operator-tunable viarecommendation.context_weightYAML.Backward compatible:
ScoringInputs{}/ScoringWeights{}zero values produce the v1 score because0 * 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 byLoadCandidatesfor one-shot grouping.GetCurrentSessionVectorForUser :one— returnssession_vector_at_playof the user's most recent play_event in an active session (joined onplay_sessions.ended_at IS NULLto exclude stale vectors from closed sessions). NoRows is the no-active-session signal.LoadCandidates extension (
internal/recommendation/candidates.go)LoadCandidatesgains acurrentVector SessionVectorparameter. Body bulk-fetches the user's contextual_likes once via the new query, groups bytrack_idintomap[pgtype.UUID][]SessionVector, and computes per-candidateContextualMatchScore(currentVector, group[trackID], DefaultSimilarityWeights). Bad-JSON rows are skipped silently (don't poison scoring over one bad row).API integration (
internal/api/radio.go)handleRadiocalls a new package-levelloadCurrentSessionVectorhelper beforeLoadCandidates. The helper:q.GetCurrentSessionVectorForUser(userID).SessionVector{Seed: true}sentinel onpgx.ErrNoRows(no active session — common path, silent), other errors (warn + sentinel), empty raw, or unmarshal failure (warn + sentinel).SessionVectorand returns it.The sentinel short-circuits
ContextualMatchScoreto 0, so cold-start cases degrade cleanly to v1 behavior.ScoringWeightsnow passesContextWeight: h.recCfg.ContextWeightfrom the loaded config.Cold-start collapse table
play_eventss.ended_at IS NULLfilters) → Seed sentinel → 0session_vector_at_playis NULLlen(raw) == 0→ Seed sentinel → 0Seed=trueContextualMatchScoreshort-circuits → 0contextual_likesSeed=truelikesWHERE deleted_at IS NULLSQL → 0Test plan
go test -short -race ./...— all 13 packages passMINSTREL_TEST_DATABASE_URL=… go test -p 1 -race ./...) — passes; pre-existingTestScanner_Integrationflake unchanged (CI runs-shortwhich skips it)internal/recommendationcoverage: 93.9%,internal/playeventscoverage: 78.0%, combined 85.7% (target: ≥70%)golangci-lint run ./...— cleancd web && npm run check— 0 errors / 0 warningscd web && npm test— 174 tests across 29 files passcd web && npm run build— adapter-static emitsweb/build/docker build -t minstrel:m3-similarity-smoke .— succeedsTestHandleRadio_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)./api/radiooutput.Notes
/api/radiorequest and response shapes are unchanged.ScoringInputs/ScoringWeightscontinue to produce v1 scores; existing tests pass unchanged.SimilarityWeightshardcoded: 0.7 tags / 0.3 artists. No YAML exposure for v1 —ContextWeightis the only operator-facing tunable for contextual scoring.(user_id, track_id) WHERE deleted_at IS NULL.Closes M3
After this slice merges, the M3 milestone is complete:
Unblocks M4 (radio refinements + scrobble polish).
🤖 Generated with Claude Code