The payoff slice. Until now a candidate's only claim on a slot was "some
artist you play is adjacent to it in a similarity graph" — a fact that says
nothing about whether the music sounds like anything you like. Now the
candidate's own folksonomy tags (cached by slice 5) are compared against
the user's taste-profile tags, so the deck ranks on taste and can say WHY.
The blend is MULTIPLICATIVE — score × (1 + weight × overlap) — and that
choice carries the whole safety argument:
- An untagged candidate has overlap 0, so its score is EXACTLY unchanged.
Tag coverage is permanently partial (#2376); it must cost a candidate
nothing, not sink it (rule #131).
- Nothing can leapfrog on tags alone. An additive term with a large
weight would let a near-zero-similarity artist outrank a strong match
for sharing one popular tag, which reads as noise.
- Weight 0 restores pure similarity order bit-for-bit, so the operator's
knob has a real off position.
overlap = Σ(shared) candWeight × normalizedTasteWeight ÷ Σ(all) candWeight.
Normalizing the taste side by the user's strongest tag makes the score
comparable across users (taste weights accumulate with listening, so a
heavy listener's raw numbers dwarf a new user's while meaning the same
thing). Dividing by the candidate's own mass makes it comparable across
candidates, so a densely-tagged artist can't win on tag count alone.
Applied to the whole over-fetched pool BEFORE selectSuggestions, so the
rotation and diversity rules operate on blended scores — boosting only the
twelve already chosen by similarity would leave the re-ranking undone.
A query failure is returned, NOT degraded past. Graceful degradation is
for expected absence (no taste profile, no cached tags) and both are
handled explicitly as empty inputs; swallowing a real error would hide a
broken DB behind a subtly worse ranking that nothing reports.
Migration 0051 adds a FOURTH tuning scope rather than columns on
taste_tuning, because snooze_days lives here too and a snooze must never
be read as taste signal (#2374) — filing it under 'taste' would put it one
careless join from the leak that design forbids. Expanding
recommendation_tuning_audit's CHECK is in the same migration per rule #36,
and a test asserts the audit row lands, which is what would catch its
absence.
snooze_days moves out of a Go constant onto the tuning card (rule #25),
closing the deferral from #2374.
Tag-overlap tests use deliberately SKEWED fixtures: an evenly-matching pool
cannot exercise a re-ranking, since every candidate gets the same
multiplier and the order is unchanged whether the blend works or not.
Admin UI + client attribution follow in this batch — rule #27.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
324 lines
11 KiB
Go
324 lines
11 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
|
|
// 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[:])
|
|
}
|