release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m23s
release / go (push) Successful in 1m36s
release / integration (push) Successful in 5m4s
release / android (push) Successful in 5m5s
release / Build signed APK (releases and dev) (push) Successful in 5m18s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 30s
release / Verify release artifacts (tag releases only) (push) Skipped
LB's similarity scores are counts (16 to 1445 for recordings, to 10,875 for artists), and they reached a scorer that reads similarity as 0 to 1. One edge added 64 to 5,800 points under Songs-like, so likes, skips and recency stopped mattering for any candidate with an edge. - ResolveListenBrainzTrackEdges divides each edge by the seed's best score in its whole cached answer. The cache keeps the raw answer. - The worker stores matched and unmatched artist rows over the seed's top artist. - Migration 0096 rescales the rows already stored. - The candidate arms hold sim_score bands so tier 1 stays above tier 2: lb_similar 0.5 + 0.5 x edge, tag_overlap halved, likes_overlap 0.3. Otherwise a seed's weaker LB edges fell below a shared genre tag. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
334 lines
12 KiB
Go
334 lines
12 KiB
Go
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 (#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
|
||
// UserCoplay (#1533): 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}"
|
||
// (#3881). 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", and it shared the default
|
||
// pool with For-You, which answers the much broader "what will they enjoy
|
||
// today". Under the default, 50 of ~170 candidates carried sim_score = 0 by
|
||
// construction — `taste_overlap` (tracks by the user's top taste artists) and
|
||
// `random_fill` (literally any track not already in the pool), both of which
|
||
// are seed-INDEPENDENT. Nearly a third of the pool had no relationship to the
|
||
// seed at all, and the operator saw it: "I was getting a seeming wide variety
|
||
// of music from each one when I was hoping to stay in a certain neighborhood."
|
||
//
|
||
// TIERED, per rule 131 — 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, #6177), and tier-3 arms carry sim_score 0, so under 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. That is the rule's
|
||
// "fill from tier 1 first, reach down only when a tier cannot fill".
|
||
//
|
||
// Which is exactly why tier 3 is REDUCED rather than removed. Zeroing those
|
||
// two arms was the first instinct and it is the vanish-or-nothing shape rule
|
||
// 131 exists to forbid: a seed whose artist has thin ListenBrainz coverage
|
||
// would produce 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 seed-independent arms are trimmed hardest, because on this surface they
|
||
// are noise: `taste_overlap` (tracks by the user's top taste artists) and
|
||
// `random_fill` (any track not already in the pool) both carry
|
||
// `0.0::float8 AS sim_score`, so nearly a third of the default pool had no
|
||
// relationship to the seed at all.
|
||
//
|
||
// THESE TRIMS WERE BLOCKED UNTIL #3889. `likes_overlap` and `random_fill`
|
||
// used to end in a bare `ORDER BY random()`, which made their output a stable
|
||
// SET only while the limit exceeded the eligible rows — so SHRINKING them
|
||
// changed pool membership between same-day rebuilds and broke daily
|
||
// determinism. Those arms now order by md5(id || seed), so a smaller limit
|
||
// takes a smaller but REPRODUCIBLE slice, and the trim is safe.
|
||
//
|
||
// Reduced, never removed. Rule 131: the two seed-independent arms are the
|
||
// tier-3 floor, and zeroing them would leave a seed with thin ListenBrainz
|
||
// coverage producing a short mix or none at all. The weights (SimilarityWeight
|
||
// 4.0, everything seed-independent demoted) keep them ranked last, so they
|
||
// surface only when the closer tiers cannot fill the mix.
|
||
//
|
||
// likes_overlap is cut hardest of the tier-2 arms for a specific reason: 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 (#6177); halved pending the fill-rate measurement in
|
||
// #3879, and the honest fix is still to stop it claiming a similarity score
|
||
// it never computed.
|
||
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 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.
|
||
//
|
||
// orderSeed decides whether the randomised arms repeat their draw — see
|
||
// Column12 below and #3889. 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),
|
||
// #3889. Four arms used to end in a bare ORDER BY random(), which made
|
||
// their output a stable SET only while the limit exceeded the eligible
|
||
// rows. They now order by md5(id || this), 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, #1534), 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
|
||
}
|