Files
minstrel/internal/recommendation/candidates.go
T
bvandeusenandClaude Opus 5.5 fcf5b7e5a8 docs: wanted, dbtest, recommendation and playlists comments say what the code does (#6277)
The same rewrite as d191a282, for four packages whose comments carried the
most history: tuning stories, quotes from the operator, single-install
measurements in library_scale.go, and 'used to' accounts of bugs. The
constraint each story protected stays as a present-tense reason (why tier 3
is reduced not removed, why the randomised arms take an order seed, why the
seed artist is eligible in Songs-like). Comments naming system_cron.go and
config.RecommendationConfig, neither of which holds what they claimed, now
name the scheduler and internal/recsettings.

Checked: non-comment tokens identical to HEAD; gofmt clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:19:15 -04:00

309 lines
10 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 v1 hardcoded constants per spec.
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
}