Second half of the reported symptom: suggestions "show the same artists until you request one". The ranking was `ORDER BY total_score DESC` with no randomization and no seen-state, so the only things that could ever change the deck were a candidate entering the library or the user filing a request. The tail of the ranking was unreachable — requesting was literally the only lever. No SQL change was needed. The query already takes a limit, so it over-fetches a pool (4x the slots, capped at 60) and the selection moves to Go, where it is a pure function of (pool, limit, day) — no DB, no clock — and therefore unit testable in the fast lane instead of behind the integration gate. Three rules. The best few by score always lead, so the strongest matches never rotate out of sight (For You's head/tail shape). The remaining slots are drawn by md5(mbid + day), the same daily-stable idiom the Home rows already use: stable within a day so pull-to-refresh doesn't reshuffle, different tomorrow, and no stored state. And a per-seed cap keeps roughly a quarter of the deck attributable to any one seed artist, so twelve neighbours of a single artist can't be the whole surface. The cap is a preference, not a quota. A user whose pool hangs off one or two seeds would otherwise get a three-card surface — worse than the monoculture being avoided, and exactly the vanish-or-nothing shape rule #131 exists to prevent — so a short deck tops up in score order from what the cap set aside. This is also what keeps the existing Top12Cap integration test honest: its 30 candidates share one seed, and without the top-up it would return 3. Eight unit tests, including one that had to be rewritten mid-change: the first version asserted the cap against an evenly-spread pool, where the top-N is already diverse and the assertion could not fail. It now uses a skewed pool where one seed owns the entire top of the ranking, which is the only shape that actually exercises a cap. Dropped two //nolint:gosec directives added in passing — gosec isn't in .golangci.yml, so they suppressed nothing and only implied a check that runs. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
250 lines
8.4 KiB
Go
250 lines
8.4 KiB
Go
// 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
|
|
}
|
|
|
|
// 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) ([]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,
|
|
})
|
|
}
|
|
return selectSuggestions(out, limit, rotationDay(time.Now())), 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[:])
|
|
}
|