test-go / test (push) Successful in 1m18s
test-go / integration (push) Successful in 4m52s
release / Build signed APK (releases and dev) (push) Successful in 6m9s
release / Build + push container image (push) Successful in 2m5s
release / Verify release artifacts (tag releases only) (push) Skipped
Four arms of the candidate query ended in a bare `ORDER BY random()` with no seed: similar_artists, likes_overlap, coplay_artists and random_fill. Such an arm returns a STABLE set only while its LIMIT exceeds the rows eligible for it — at that point it returns all of them and the order stops mattering, because scoreAndSortCandidates sorts by track id before drawing jitter. Below that threshold it returns a random SUBSET, and two builds on the same day draw different ones. So daily determinism held BY ACCIDENT, and only for libraries smaller than the limits. Any real library is larger, which means same-day rebuilds have been producing different mixes since those arms were written — invisible, because a mix that changes after a refresh looks like a feature rather than a broken promise. Found by breaking it: cutting RandomFill to 10 while tuning Songs-like turned TestBuildSystemPlaylists_DailyNonceDeterminism red. That test seeds ~20 tracks against a default RandomFill of 30, so its determinism came from the limit exceeding the library, not from the code being right. It is now a real guard. The arms order by md5(id || $12) instead. The CALLER decides what that means, which is the point: system mixes pass a per-(user, day) seed and get the determinism they promise, radio passes a fresh value per request and keeps varying, which is what a radio should do. Same shape the browse queries in this file already use (`md5(id::text || current_date::text)`) — existing idiom, not a new one. This also unblocks the trim that #3881 wanted and could not have. Shrinking a randomly-ordered arm was what broke membership; a seeded one takes a smaller but REPRODUCIBLE slice. Songs-like's seed-independent share drops from 29% to 12%, which was the original intent before determinism forced it back to 20%. TestSongsLikeLimits_DoNotShrinkTheUnseededRandomArms is DELETED rather than kept passing. It existed to stop anyone trimming those arms while the ordering was broken; the ordering is fixed, so the constraint is gone and a guard enforcing it would now forbid correct code. Was filed as blocked on tooling. It was not: `make generate-go` runs sqlc as a pinned Go tool and is the same path CI takes. One thing worth knowing for next time: three files in internal/db/dbq are owned by root, left by `make generate` running sqlc in Docker. sqlc errored on the first it could not write. They are untouched by this change and the regeneration of recommendation.sql.go completed, but `make generate` will keep failing until they are chowned. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SQ31KQpYbStyK5y58UmPLH
333 lines
12 KiB
Go
333 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: 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.6 sim_score (recommendation.sql) rather than measuring
|
||
// anything. It is a collaborative signal wearing similarity's clothes, and a
|
||
// raised SimilarityWeight amplifies it — if real ListenBrainz scores commonly
|
||
// land below 0.6 it would outrank genuine matches. Halved pending the
|
||
// fill-rate measurement in #3879; the honest fix is 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.6 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
|
||
}
|