// suggestions.go is the per-user artist-suggestion service behind the // Discover request surface. Seeds from the taste profile (falling back to // likes + completed plays for a user who has none yet), projects those seeds // through artist_similarity_unmatched via a single CTE, and returns top-N // out-of-library candidates with top-3 attribution seeds resolved to names. // // Originally M5c, which seeded from raw likes + plays. That signal grew // without bound and counted skips as engagement, so a few heavily-played // artists monopolized every slot and the surface entrenched harder the more // the user listened. Reworked to seed from the taste profile in issue #2367; // see internal/db/queries/recommendation.sql for the tiering. package recommendation import ( "context" "crypto/md5" "encoding/hex" "fmt" "sort" "time" "github.com/jackc/pgx/v5/pgtype" "github.com/jackc/pgx/v5/pgxpool" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" ) // ArtistSuggestion is one ranked candidate with its top-3 attribution seeds. type ArtistSuggestion struct { MBID string Name string Score float64 Attribution []SeedContribution // MatchedTags are the candidate's own tags that overlap the user's taste // profile, strongest first (max 3) — the "matches: shoegaze, melancholic" // explanation (#2377). Empty when the candidate has no cached tags, which // is common and not an error: coverage is permanently partial (#2376). MatchedTags []string // TagOverlap is the [0,1] share of the candidate's tag mass the user likes. // Exposed for the admin tuning lab — seeing the term's actual distribution // is how the operator picks a weight rather than guessing at one. TagOverlap float64 } // SeedContribution is one of the top-3 contributing seeds for a candidate. type SeedContribution struct { ArtistID pgtype.UUID Name string Contribution float64 IsLiked bool PlayCount int64 } // SuggestArtists returns top-N artist suggestions for the user. limit is // capped at 50 (default 12 when out of range). // // halfLifeDays (default 30) is the recency-decay half-life for the TIER-2 // seed path only — the likes + completed-plays fallback used while the user // has no taste-profile rows yet. Once the profile is populated it seeds // instead, carrying its own decay, so this knob stops applying. func SuggestArtists( ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID, halfLifeDays float64, limit int, tagOverlapWeight float64, ) ([]ArtistSuggestion, error) { if limit <= 0 || limit > 50 { limit = 12 } if halfLifeDays <= 0 { halfLifeDays = 30 } q := dbq.New(pool) // Over-fetch so there is something to rotate through. A deterministic // top-N over a score ordering shows the same faces every day until one is // requested away — the tail of the ranking was unreachable (#2367 // mechanism 1). Selection from this pool happens in selectSuggestions. rows, err := q.SuggestArtistsForUser(ctx, dbq.SuggestArtistsForUserParams{ UserID: userID, Column2: halfLifeDays, Limit: int32(poolSizeFor(limit)), }) if err != nil { return nil, fmt.Errorf("suggest: query: %w", err) } if len(rows) == 0 { return []ArtistSuggestion{}, nil } // Collect the union of top-3 seed IDs across all rows for one batched // name lookup. pgtype.UUID is a comparable struct so it works as a map // key directly. seedSet := make(map[pgtype.UUID]struct{}, len(rows)*3) for _, r := range rows { for _, sid := range r.TopSeedIds { seedSet[sid] = struct{}{} } } seedIDs := make([]pgtype.UUID, 0, len(seedSet)) for id := range seedSet { seedIDs = append(seedIDs, id) } artists, err := q.GetArtistsByIDs(ctx, seedIDs) if err != nil { return nil, fmt.Errorf("suggest: resolve seeds: %w", err) } nameByID := make(map[pgtype.UUID]string, len(artists)) for _, a := range artists { nameByID[a.ID] = a.Name } out := make([]ArtistSuggestion, 0, len(rows)) for _, r := range rows { attribution := make([]SeedContribution, 0, len(r.TopSeedIds)) for i, sid := range r.TopSeedIds { if i >= len(r.TopContributions) || i >= len(r.TopIsLiked) || i >= len(r.TopPlayCounts) { break } attribution = append(attribution, SeedContribution{ ArtistID: sid, Name: nameByID[sid], Contribution: r.TopContributions[i], IsLiked: r.TopIsLiked[i], PlayCount: r.TopPlayCounts[i], }) } out = append(out, ArtistSuggestion{ MBID: r.CandidateMbid, Name: r.CandidateName, Score: r.TotalScore, Attribution: attribution, }) } // Taste-tag term (#2377). Applied to the whole over-fetched pool BEFORE // selection, so the rotation and diversity rules in selectSuggestions // operate on taste-blended scores — boosting only the twelve already // chosen by similarity would leave the actual re-ranking undone. // // A query error here is returned, NOT degraded past. Graceful degradation // is for expected absence — no taste profile yet, no cached tags for a // candidate — and both of those are handled explicitly as empty inputs // below. A failing query is neither: swallowing it would hide a broken DB // behind a subtly worse ranking that nothing reports. tasteTags, candTags, err := loadTagInputs(ctx, q, userID, out) if err != nil { return nil, err } out = applyTagOverlap(out, candTags, tasteTags, tagOverlapWeight) return selectSuggestions(out, limit, rotationDay(time.Now())), nil } // tasteTagLimit caps how many of the user's taste tags participate. The // profile's long tail is near-zero weight and contributes nothing after // normalization, so this bounds the query rather than the meaning. const tasteTagLimit = 50 // loadTagInputs fetches both sides of the overlap comparison: the user's taste // tags and the cached tags for exactly the candidates in this pool. func loadTagInputs( ctx context.Context, q *dbq.Queries, userID pgtype.UUID, pool []ArtistSuggestion, ) (TagWeights, map[string]TagWeights, error) { tasteRows, err := q.ListTasteProfileTagsForUser(ctx, dbq.ListTasteProfileTagsForUserParams{ UserID: userID, Limit: tasteTagLimit, }) if err != nil { return nil, nil, fmt.Errorf("suggest: taste tags: %w", err) } // No taste tags is a cold start, not a failure — return early and skip the // candidate-tag fetch entirely, since nothing could match. if len(tasteRows) == 0 { return nil, nil, nil } taste := make(TagWeights, len(tasteRows)) for _, r := range tasteRows { taste[r.Tag] = r.Weight } mbids := make([]string, 0, len(pool)) for _, s := range pool { mbids = append(mbids, s.MBID) } tagRows, err := q.ListCandidateArtistTagsForMbids(ctx, mbids) if err != nil { return nil, nil, fmt.Errorf("suggest: candidate tags: %w", err) } byCandidate := make(map[string]TagWeights, len(pool)) for _, r := range tagRows { if byCandidate[r.CandidateMbid] == nil { byCandidate[r.CandidateMbid] = TagWeights{} } byCandidate[r.CandidateMbid][r.Tag] = r.Weight } return taste, byCandidate, nil } // Pool multiplier: how many scored candidates to fetch per slot shown, so the // rotation has somewhere to rotate. 4x keeps a day's deck genuinely different // from yesterday's without pulling the whole long tail (whose scores are noise) // into every request. const suggestionPoolFactor = 4 // Hard ceiling on the fetched pool. The scored tail past this is low-signal, so // paying for it would buy churn rather than better suggestions. const suggestionPoolMax = 60 func poolSizeFor(limit int) int { n := limit * suggestionPoolFactor if n > suggestionPoolMax { return suggestionPoolMax } return n } // rotationDay is the bucket the daily rotation hashes against. Server-local // date, matching the `current_date` the Home rows already rotate on, so the // whole product turns over at the same moment. func rotationDay(now time.Time) string { return now.Format("2006-01-02") } // selectSuggestions turns the scored pool into the response. // // Pure by design — no DB, no clock — so the rotation and diversity rules are // unit-testable without Postgres. Callers pass the day bucket in. // // Three rules, in order: // // 1. Diversity cap. Walking in score order, a candidate is dropped once its // dominant seed already owns maxPerSeed slots. Nothing capped this before, // so all twelve slots could be neighbours of one artist and the surface // read as a single narrow cluster (#2367 mechanism 4). // 2. Head. The best few by score always lead, so the strongest matches are // never rotated out of sight. Mirrors For You's head/tail shape. // 3. Tail. The rest of the slots are drawn from the remaining pool ordered by // md5(mbid + day) — the same daily-stable idiom the Home rows use. Stable // within a day, different tomorrow, and it needs no stored state. func selectSuggestions(pool []ArtistSuggestion, limit int, day string) []ArtistSuggestion { if len(pool) <= limit { return pool } preferred, overflow := partitionPerSeed(pool, maxPerSeedFor(limit)) headCount := limit / 3 if headCount > len(preferred) { headCount = len(preferred) } out := make([]ArtistSuggestion, 0, limit) out = append(out, preferred[:headCount]...) // Hash once per candidate, not once per comparison. rest := preferred[headCount:] tail := make([]rotatable, 0, len(rest)) for _, s := range rest { tail = append(tail, rotatable{key: rotationKey(s.MBID, day), suggestion: s}) } sort.SliceStable(tail, func(i, j int) bool { return tail[i].key < tail[j].key }) for _, r := range tail { if len(out) >= limit { break } out = append(out, r.suggestion) } // Diversity is a PREFERENCE, not a quota that may starve the deck. A user // whose whole pool hangs off one or two seeds would otherwise get a // three-card surface, which is worse than the monoculture we were avoiding // (and is the vanish-or-nothing shape rule #131 exists to prevent). Top up // in score order from what the cap set aside. for _, s := range overflow { if len(out) >= limit { break } out = append(out, s) } return out } // rotatable pairs a candidate with its precomputed daily rotation key. type rotatable struct { key string suggestion ArtistSuggestion } // maxPerSeedFor keeps roughly a quarter of the deck attributable to any single // seed artist — enough for a strong affinity to be well represented, not enough // for it to BE the deck. Floored at 1 so a small limit still returns something. func maxPerSeedFor(limit int) int { n := limit / 4 if n < 1 { return 1 } return n } // partitionPerSeed splits the pool by whether each candidate's dominant // (highest-contributing) seed still has allowance left. Input must be in score // order; both outputs preserve it. // // Candidates with no attribution are always preferred — there is no seed to // attribute them to, so they cannot be the cause of a monoculture. func partitionPerSeed(pool []ArtistSuggestion, maxPerSeed int) (preferred, overflow []ArtistSuggestion) { perSeed := make(map[pgtype.UUID]int, len(pool)) preferred = make([]ArtistSuggestion, 0, len(pool)) for _, s := range pool { if len(s.Attribution) == 0 { preferred = append(preferred, s) continue } dominant := s.Attribution[0].ArtistID if perSeed[dominant] >= maxPerSeed { overflow = append(overflow, s) continue } perSeed[dominant]++ preferred = append(preferred, s) } return preferred, overflow } func rotationKey(mbid, day string) string { sum := md5.Sum([]byte(mbid + day)) return hex.EncodeToString(sum[:]) }