feat(discover): rank suggestions by taste-tag overlap — #2377 (server)
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>
This commit is contained in:
@@ -35,6 +35,11 @@ const (
|
||||
ScopeRadio = "radio"
|
||||
ScopeDailyMix = "daily_mix"
|
||||
ScopeTaste = "taste"
|
||||
// ScopeDiscover is the Discover request surface (#2377). Its own scope
|
||||
// rather than columns on taste: SnoozeDays lives here, 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.
|
||||
ScopeDiscover = "discover"
|
||||
)
|
||||
|
||||
// TasteTuning is the tunable subset of taste.Config: the engagement
|
||||
@@ -87,6 +92,33 @@ func ShippedDailyMixWeights() recommendation.ScoringWeights {
|
||||
}
|
||||
}
|
||||
|
||||
// DiscoverTuning is the tunable set for the Discover request surface (#2377).
|
||||
type DiscoverTuning struct {
|
||||
// TagOverlapWeight scales the taste-tag term: score × (1 + w × overlap).
|
||||
// 0 disables it and restores pure similarity ranking.
|
||||
TagOverlapWeight float64
|
||||
// SnoozeDays is the default "not right now" duration (#2374).
|
||||
SnoozeDays float64
|
||||
}
|
||||
|
||||
// ShippedDiscoverTuning are the shipped Discover defaults.
|
||||
//
|
||||
// TagOverlapWeight 1.0 lets a perfect tag match at most double a candidate's
|
||||
// similarity score — enough to reorder the deck meaningfully, not enough for a
|
||||
// popular-tag coincidence to beat a genuinely strong similarity match. It is a
|
||||
// starting point for the tuning lab, not a tuned value: the honest way to pick
|
||||
// it is the metrics trend view after some real use.
|
||||
//
|
||||
// SnoozeDays 90 matches the operator's approved shape: long enough that a
|
||||
// parked suggestion stops nagging, short enough that a taste shift brings it
|
||||
// back on its own.
|
||||
func ShippedDiscoverTuning() DiscoverTuning {
|
||||
return DiscoverTuning{
|
||||
TagOverlapWeight: 1.0,
|
||||
SnoozeDays: 90,
|
||||
}
|
||||
}
|
||||
|
||||
// ShippedTasteTuning mirrors taste.DefaultConfig's tunable subset.
|
||||
func ShippedTasteTuning() TasteTuning {
|
||||
d := taste.DefaultConfig()
|
||||
@@ -110,6 +142,7 @@ type Service struct {
|
||||
mu sync.RWMutex
|
||||
profiles map[string]recommendation.ScoringWeights
|
||||
taste TasteTuning
|
||||
discover DiscoverTuning
|
||||
}
|
||||
|
||||
// New boots the service: seeds shipped defaults for missing rows,
|
||||
@@ -151,6 +184,13 @@ func (s *Service) reconcile(ctx context.Context) error {
|
||||
}); err != nil {
|
||||
return fmt.Errorf("seed taste tuning: %w", err)
|
||||
}
|
||||
sd := ShippedDiscoverTuning()
|
||||
if err := q.UpsertDiscoverTuningDefaults(ctx, dbq.UpsertDiscoverTuningDefaultsParams{
|
||||
TagOverlapWeight: sd.TagOverlapWeight,
|
||||
SnoozeDays: sd.SnoozeDays,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("seed discover tuning: %w", err)
|
||||
}
|
||||
|
||||
rows, err := q.ListWeightProfiles(ctx)
|
||||
if err != nil {
|
||||
@@ -160,6 +200,10 @@ func (s *Service) reconcile(ctx context.Context) error {
|
||||
if err != nil {
|
||||
return fmt.Errorf("get taste tuning: %w", err)
|
||||
}
|
||||
dt, err := q.GetDiscoverTuning(ctx)
|
||||
if err != nil {
|
||||
return fmt.Errorf("get discover tuning: %w", err)
|
||||
}
|
||||
|
||||
s.mu.Lock()
|
||||
s.profiles = map[string]recommendation.ScoringWeights{}
|
||||
@@ -175,6 +219,10 @@ func (s *Service) reconcile(ctx context.Context) error {
|
||||
EraScale: tt.EraScale,
|
||||
MoodScale: tt.MoodScale,
|
||||
}
|
||||
s.discover = DiscoverTuning{
|
||||
TagOverlapWeight: dt.TagOverlapWeight,
|
||||
SnoozeDays: dt.SnoozeDays,
|
||||
}
|
||||
s.mu.Unlock()
|
||||
|
||||
s.push()
|
||||
@@ -208,6 +256,15 @@ func (s *Service) Taste() TasteTuning {
|
||||
return s.taste
|
||||
}
|
||||
|
||||
// Discover returns the cached Discover-tuning values. Read per request by the
|
||||
// suggestions handler, so an admin change takes effect on the next refresh
|
||||
// with no restart (rule #25).
|
||||
func (s *Service) Discover() DiscoverTuning {
|
||||
s.mu.RLock()
|
||||
defer s.mu.RUnlock()
|
||||
return s.discover
|
||||
}
|
||||
|
||||
// TasteConfig assembles the full taste.Config the profile builder
|
||||
// consumes: shipped non-tunable knobs (like bonuses, floors, caps)
|
||||
// plus the tuned half-life and curve. WindowDays scales with the
|
||||
@@ -267,6 +324,19 @@ func (s *Service) UpdateTaste(ctx context.Context, patch map[string]float64) err
|
||||
return s.persistTaste(ctx, next, "update", changes)
|
||||
}
|
||||
|
||||
// UpdateDiscover applies a partial update to the Discover tuning singleton.
|
||||
func (s *Service) UpdateDiscover(ctx context.Context, patch map[string]float64) error {
|
||||
current := s.Discover()
|
||||
next, changes, err := applyDiscoverPatch(current, patch)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(changes) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.persistDiscover(ctx, next, "update", changes)
|
||||
}
|
||||
|
||||
// Reset restores a scope to its shipped defaults, with one audit row
|
||||
// carrying the full diff. A scope already at defaults is a no-op.
|
||||
func (s *Service) Reset(ctx context.Context, scope string) error {
|
||||
@@ -288,6 +358,13 @@ func (s *Service) Reset(ctx context.Context, scope string) error {
|
||||
return nil
|
||||
}
|
||||
return s.persistTaste(ctx, shipped, "reset", changes)
|
||||
case ScopeDiscover:
|
||||
shipped := ShippedDiscoverTuning()
|
||||
changes := diffDiscover(s.Discover(), shipped)
|
||||
if len(changes) == 0 {
|
||||
return nil
|
||||
}
|
||||
return s.persistDiscover(ctx, shipped, "reset", changes)
|
||||
default:
|
||||
return fmt.Errorf("%w: %q", ErrUnknownScope, scope)
|
||||
}
|
||||
@@ -338,6 +415,28 @@ func (s *Service) persistTaste(
|
||||
return nil
|
||||
}
|
||||
|
||||
// persistDiscover writes the discover row + audit entry and refreshes the
|
||||
// cache. No push(): unlike taste and daily_mix, nothing precomputes from these
|
||||
// — the suggestions handler reads Discover() per request.
|
||||
func (s *Service) persistDiscover(
|
||||
ctx context.Context, d DiscoverTuning, action string, changes []fieldChange,
|
||||
) error {
|
||||
q := dbq.New(s.pool)
|
||||
if _, err := q.UpdateDiscoverTuning(ctx, dbq.UpdateDiscoverTuningParams{
|
||||
TagOverlapWeight: d.TagOverlapWeight,
|
||||
SnoozeDays: d.SnoozeDays,
|
||||
}); err != nil {
|
||||
return fmt.Errorf("update discover tuning: %w", err)
|
||||
}
|
||||
if err := s.audit(ctx, q, ScopeDiscover, action, changes); err != nil {
|
||||
return err
|
||||
}
|
||||
s.mu.Lock()
|
||||
s.discover = d
|
||||
s.mu.Unlock()
|
||||
return nil
|
||||
}
|
||||
|
||||
// audit writes one recommendation_tuning_audit row. Changes are
|
||||
// sorted by field so rows are deterministic and diff-friendly.
|
||||
func (s *Service) audit(
|
||||
|
||||
Reference in New Issue
Block a user