Files
minstrel/internal/recommendation/candidates.go
T
bvandeusen 40384cc05e
test-go / test (push) Successful in 34s
test-web / test (push) Successful in 41s
test-go / integration (push) Successful in 4m40s
feat(taste): era/decade taste facet — #1530
Milestone #160 Opt 2 (era half). A third taste facet alongside artists
+ genre tags: signed weights over decade buckets ("1990s") derived from
albums.release_date, rebuilt daily and scored into the taste match.

- Migration 0045: taste_profile_eras table (mirrors taste_profile_tags)
  + taste_tuning.era_scale column (DEFAULT 0.5).
- Build side (internal/taste): Config.EraScale ([0,1] damper, mirrors
  EnrichedTagScale), accumulate folds each play/like's decade at
  base*EraScale, persist atomic-replaces the era rows.
- Scorer (internal/recommendation): TasteProfile gains an era term (own
  tanh scale + additive 0.15 share so it never weakens the existing
  artist/tag signal when a track is undated); candidate queries return
  album release_date; decadeOf mirrors the builder helper.
- Tuning lab: era_scale threaded through recsettings + admin API + web
  card (auto-renders the new row) + Go/web tests.

Mood facet deferred to #1534 (partial enrichment coverage + needs
candidate-side enriched-tag loading).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-14 09:01:00 -04:00

191 lines
5.4 KiB
Go

package recommendation
import (
"context"
"encoding/json"
"time"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// LoadCandidates fetches the candidate pool for radio scoring. Combines
// the existing track+stats query with a one-shot bulk fetch of the user's
// active contextual_likes, mapping each candidate to its max similarity
// against currentVector. Pass currentVector with Seed=true to short-circuit
// the contextual term to 0 (cold-start path).
func LoadCandidates(
ctx context.Context,
q *dbq.Queries,
userID, seedID pgtype.UUID,
recentlyPlayedHours int,
currentVector SessionVector,
) ([]Candidate, error) {
rows, err := q.LoadRadioCandidates(ctx, dbq.LoadRadioCandidatesParams{
UserID: userID,
ID: seedID,
Column3: float64(recentlyPlayedHours),
})
if err != nil {
return nil, err
}
likes, err := loadContextualLikesByTrack(ctx, q, userID)
if err != nil {
return nil, err
}
profile, err := LoadTasteProfile(ctx, q, userID)
if err != nil {
return nil, err
}
out := make([]Candidate, 0, len(rows))
for _, r := range rows {
var lpt *time.Time
if r.LastPlayedAt.Valid {
t := r.LastPlayedAt.Time
lpt = &t
}
ctxScore := ContextualMatchScore(currentVector, likes[r.Track.ID], DefaultSimilarityWeights)
out = append(out, Candidate{
Track: r.Track,
Inputs: ScoringInputs{
IsGeneralLiked: r.IsLiked,
LastPlayedAt: lpt,
PlayCount: int(r.PlayCount),
SkipCount: int(r.SkipCount),
ContextualMatchScore: ctxScore,
TasteMatchScore: profile.Match(r.Track.ArtistID, r.Track.Genre, r.ReleaseDate),
},
})
}
return out, nil
}
// CandidateSourceLimits controls per-source K values for the M4c
// similarity-driven pool. Defaults via DefaultCandidateSourceLimits().
type CandidateSourceLimits struct {
LBSimilar int
SimilarArtist int
TagOverlap int
LikesOverlap int
RandomFill int
// TasteOverlap (#796 phase 2b): tracks by the user's top positively-
// weighted taste-profile artists. 0 disables the arm (e.g. cold-start
// users have an empty profile, so it contributes nothing anyway).
TasteOverlap int
}
// DefaultCandidateSourceLimits returns the v1 hardcoded constants per spec.
func DefaultCandidateSourceLimits() CandidateSourceLimits {
return CandidateSourceLimits{
LBSimilar: 30,
SimilarArtist: 30,
TagOverlap: 20,
LikesOverlap: 20,
RandomFill: 30,
TasteOverlap: 20,
}
}
// LoadCandidatesFromSimilarity is M4c's primary candidate-pool loader.
// 5-way SQL UNION (LB-similar / similar-artist tracks / MB-tag overlap /
// likes-overlap / random fill) + dedup-by-max sim_score. Returns
// []Candidate (same shape as LoadCandidates) so Shuffle is unchanged.
//
// Caller (radio handler) falls back to LoadCandidates on error.
func LoadCandidatesFromSimilarity(
ctx context.Context,
q *dbq.Queries,
userID, seedID pgtype.UUID,
recentlyPlayedHours int,
currentVector SessionVector,
exclude []pgtype.UUID,
limits CandidateSourceLimits,
) ([]Candidate, error) {
if exclude == nil {
exclude = []pgtype.UUID{}
}
rows, err := q.LoadRadioCandidatesV2(ctx, dbq.LoadRadioCandidatesV2Params{
UserID: userID,
ID: seedID,
Column3: int64(recentlyPlayedHours),
Column4: exclude,
Limit: int32(limits.LBSimilar),
Limit_2: int32(limits.SimilarArtist),
Limit_3: int32(limits.TagOverlap),
Limit_4: int32(limits.LikesOverlap),
Limit_5: int32(limits.RandomFill),
Limit_6: int32(limits.TasteOverlap),
})
if err != nil {
return nil, err
}
likes, err := loadContextualLikesByTrack(ctx, q, userID)
if err != nil {
return nil, err
}
profile, err := LoadTasteProfile(ctx, q, userID)
if err != nil {
return nil, err
}
out := make([]Candidate, 0, len(rows))
for _, r := range rows {
var lpt *time.Time
if r.LastPlayedAt.Valid {
t := r.LastPlayedAt.Time
lpt = &t
}
// sqlc returns SimilarityScore as interface{} (couldn't infer the
// type through max(...) over a UNION). Type-assert; default to 0
// on the (impossible-but-defensive) case where it's nil/wrong type.
var simScore float64
if v, ok := r.SimilarityScore.(float64); ok {
simScore = v
}
ctxScore := ContextualMatchScore(currentVector, likes[r.Track.ID], DefaultSimilarityWeights)
out = append(out, Candidate{
Track: r.Track,
Inputs: ScoringInputs{
IsGeneralLiked: r.IsLiked,
LastPlayedAt: lpt,
PlayCount: int(r.PlayCount),
SkipCount: int(r.SkipCount),
ContextualMatchScore: ctxScore,
SimilarityScore: simScore,
TasteMatchScore: profile.Match(r.Track.ArtistID, r.Track.Genre, r.ReleaseDate),
},
})
}
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
}