feat: M3 session similarity + contextual_match_score (closes M3) #23
Reference in New Issue
Block a user
Delete Branch "dev"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
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