package recommendation import ( "context" "encoding/json" "time" "github.com/jackc/pgx/v5/pgtype" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/mood" ) // 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 } affinity, err := LoadContextAffinity(ctx, q, userID, currentVector.DeviceClass) 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, // Fallback path: mood is scored only in the primary // (similarity) loader — loading per-candidate tags over this // near-whole-library pool isn't worth it (nil moods → 0). TasteMatchScore: profile.Match(r.Track.ArtistID, r.Track.Genre, r.ReleaseDate, nil), ContextAffinityScore: affinity.Affinity(r.Track.ArtistID), }, }) } 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: 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 // UserCoplay: tracks by artists co-played across the instance // with the seed's artist (source='user_cooccurrence'). Empty on // single-user servers, so it contributes nothing there. UserCoplay int } // DefaultCandidateSourceLimits returns the hardcoded per-source limits. func DefaultCandidateSourceLimits() CandidateSourceLimits { return CandidateSourceLimits{ LBSimilar: 30, SimilarArtist: 30, TagOverlap: 20, LikesOverlap: 20, RandomFill: 30, TasteOverlap: 20, UserCoplay: 20, } } // SongsLikeCandidateSourceLimits is the pool shape for "Songs like {X}". // Same total size as the default (~170) — the composition is what changes, // shifted hard toward arms that actually measure distance from the seed. // // The surface answers "what sounds like THIS", where For-You answers the much // broader "what will they enjoy today". In the default pool, 50 of ~170 // candidates carry sim_score = 0 by construction — `taste_overlap` (tracks by // the user's top taste artists) and `random_fill` (any track not already in // the pool), both seed-INDEPENDENT — so nearly a third of it has no // relationship to the seed. // // TIERED — a system mix degrades, it never vanishes: // // tier 1 lb_similar real track-level similarity. The exact promise. // tier 2 similar_artist, tag_overlap, coplay, likes_overlap // seed-RELATED but weaker signal. // tier 3 taste_overlap, random_fill // seed-independent. The floor, and nothing more. // // The tiering is enforced by SCORE rather than by a fallback ladder: each tier // holds its own sim_score band (recommendation.sql; tier 1 above 0.5, tier 2 // at or below it), and tier-3 arms carry sim_score 0, so under the SongsLike // weights (SimilarityWeight 4.0, everything seed-independent demoted) they // rank below any real match and surface only when tiers 1–2 cannot fill the // mix: fill from tier 1 first, reach down only when a tier cannot fill. // // Tier 3 is REDUCED, never removed. Zeroing those two arms would leave a seed // whose artist has thin ListenBrainz coverage with a short mix or none at // all, and "no playlist" is a worse answer than "a few tracks further from // the seed than we would like". The trim is safe because those arms order by // md5(id || seed): a smaller limit takes a smaller but REPRODUCIBLE slice, so // same-day rebuilds still agree. // // likes_overlap is cut hardest of the tier-2 arms: its SQL assigns a FLAT 0.3 // sim_score (recommendation.sql) rather than measuring anything. It is a // collaborative signal wearing similarity's clothes, and a raised // SimilarityWeight amplifies it. The tier bands keep it below every // ListenBrainz match. func SongsLikeCandidateSourceLimits() CandidateSourceLimits { return CandidateSourceLimits{ LBSimilar: 60, // tier 1 — doubled; the only arm that measures the seed SimilarArtist: 40, // tier 2 — raised; growing is always safe TagOverlap: 20, // tier 2 UserCoplay: 20, // tier 2 LikesOverlap: 10, // tier 2, halved — flat 0.3 sim_score, see above TasteOverlap: 10, // tier 3 floor — halved, not removed RandomFill: 10, // tier 3 floor — cut hard, never to zero } } // LoadCandidatesFromSimilarity is the 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. // // orderSeed decides whether the randomised arms repeat their draw — see // Column12 below. Pass a stable per-(user, day) value where the // selection must be reproducible, and a varying one where it should not be. // // 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, orderSeed string, ) ([]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), Limit_7: int32(limits.UserCoplay), // The randomised arms order by md5(id || this) rather than random(), so // the caller decides whether the draw repeats: a per-(user, day) seed // for the system mixes that promise daily determinism, a fresh one per // radio request. Column12: orderSeed, }) 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 } affinity, err := LoadContextAffinity(ctx, q, userID, currentVector.DeviceClass) if err != nil { return nil, err } trackIDs := make([]pgtype.UUID, 0, len(rows)) for _, r := range rows { trackIDs = append(trackIDs, r.Track.ID) } moods, err := loadCandidateMoods(ctx, q, trackIDs) 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, moods[r.Track.ID]), ContextAffinityScore: affinity.Affinity(r.Track.ArtistID), }, }) } return out, nil } // loadCandidateMoods fetches the enriched tags for the given candidate tracks // and reduces each to its canonical mood buckets (internal/mood), so the // scorer can apply the mood facet per candidate. Tracks with no mood-word tags // are absent from the map (→ no mood signal). Empty input short-circuits. func loadCandidateMoods( ctx context.Context, q *dbq.Queries, trackIDs []pgtype.UUID, ) (map[pgtype.UUID][]string, error) { if len(trackIDs) == 0 { return map[pgtype.UUID][]string{}, nil } rows, err := q.ListTrackTagsForTracks(ctx, trackIDs) if err != nil { return nil, err } tagsByTrack := make(map[pgtype.UUID][]string) for _, r := range rows { tagsByTrack[r.TrackID] = append(tagsByTrack[r.TrackID], r.Tag) } out := make(map[pgtype.UUID][]string, len(tagsByTrack)) for id, tags := range tagsByTrack { if m := mood.Of(tags); len(m) > 0 { out[id] = m } } 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 }