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:
@@ -161,6 +161,70 @@ func applyTastePatch(current TasteTuning, patch map[string]float64) (TasteTuning
|
||||
return next, changes, nil
|
||||
}
|
||||
|
||||
// Discover tuning bounds.
|
||||
const (
|
||||
// A tag-overlap weight above this stops being a boost and becomes the
|
||||
// ranking — at 10, a perfect match multiplies similarity by 11, which lets
|
||||
// tag agreement swamp the similarity signal entirely. The bound is for
|
||||
// typos, not to constrain exploration; the multiplicative blend keeps even
|
||||
// the maximum from reordering an untagged candidate.
|
||||
tagOverlapWeightMax = 10.0
|
||||
// Snooze duration: at least a day (anything less isn't a snooze, it's a
|
||||
// flicker), at most a year — past that it's a permanent dismissal wearing a
|
||||
// snooze's clothes, which is exactly the shape rule #101 rules out.
|
||||
snoozeDaysMin = 1.0
|
||||
snoozeDaysMax = 365.0
|
||||
)
|
||||
|
||||
// applyDiscoverPatch validates and applies a partial Discover update.
|
||||
func applyDiscoverPatch(
|
||||
current DiscoverTuning, patch map[string]float64,
|
||||
) (DiscoverTuning, []fieldChange, error) {
|
||||
next := current
|
||||
var changes []fieldChange
|
||||
for field, v := range patch {
|
||||
var target *float64
|
||||
switch field {
|
||||
case "tag_overlap_weight":
|
||||
if v < 0 || v > tagOverlapWeightMax {
|
||||
return current, nil, fmt.Errorf("%w: %s = %v (must be in [0, %v])",
|
||||
ErrOutOfRange, field, v, tagOverlapWeightMax)
|
||||
}
|
||||
target = &next.TagOverlapWeight
|
||||
case "snooze_days":
|
||||
if v < snoozeDaysMin || v > snoozeDaysMax {
|
||||
return current, nil, fmt.Errorf("%w: %s = %v (must be in [%v, %v])",
|
||||
ErrOutOfRange, field, v, snoozeDaysMin, snoozeDaysMax)
|
||||
}
|
||||
target = &next.SnoozeDays
|
||||
default:
|
||||
return current, nil, fmt.Errorf("%w: %q", ErrUnknownField, field)
|
||||
}
|
||||
if *target == v {
|
||||
continue
|
||||
}
|
||||
changes = append(changes, fieldChange{Field: field, Old: *target, New: v})
|
||||
*target = v
|
||||
}
|
||||
return next, changes, nil
|
||||
}
|
||||
|
||||
// diffDiscover returns per-field changes from a to b (empty when equal).
|
||||
func diffDiscover(a, b DiscoverTuning) []fieldChange {
|
||||
var out []fieldChange
|
||||
if a.TagOverlapWeight != b.TagOverlapWeight {
|
||||
out = append(out, fieldChange{
|
||||
Field: "tag_overlap_weight", Old: a.TagOverlapWeight, New: b.TagOverlapWeight,
|
||||
})
|
||||
}
|
||||
if a.SnoozeDays != b.SnoozeDays {
|
||||
out = append(out, fieldChange{
|
||||
Field: "snooze_days", Old: a.SnoozeDays, New: b.SnoozeDays,
|
||||
})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// diffWeights returns per-field changes from a to b (empty when equal).
|
||||
func diffWeights(a, b recommendation.ScoringWeights) []fieldChange {
|
||||
var out []fieldChange
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -281,3 +281,118 @@ func TestUpdate_NoOpWritesNoAudit(t *testing.T) {
|
||||
t.Errorf("no-op update wrote %d audit rows, want 0", len(rows))
|
||||
}
|
||||
}
|
||||
|
||||
// --- Discover scope (#2377, milestone #268 slice 6) ---
|
||||
|
||||
func TestNew_SeedsDiscoverDefaults(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
s := newService(t, pool)
|
||||
got := s.Discover()
|
||||
want := ShippedDiscoverTuning()
|
||||
if got != want {
|
||||
t.Errorf("Discover() = %+v, want shipped %+v", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDiscover_PersistsAndAudits(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
s := newService(t, pool)
|
||||
if err := s.UpdateDiscover(context.Background(), map[string]float64{
|
||||
"tag_overlap_weight": 2.5,
|
||||
"snooze_days": 30,
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdateDiscover: %v", err)
|
||||
}
|
||||
if got := s.Discover().TagOverlapWeight; got != 2.5 {
|
||||
t.Errorf("TagOverlapWeight = %v, want 2.5", got)
|
||||
}
|
||||
if got := s.Discover().SnoozeDays; got != 30 {
|
||||
t.Errorf("SnoozeDays = %v, want 30", got)
|
||||
}
|
||||
|
||||
// The audit row must land under the new scope. This is the assertion that
|
||||
// would have caught a missing rule-#36 CHECK migration: without expanding
|
||||
// recommendation_tuning_audit's whitelist, this INSERT fails at runtime.
|
||||
rows := auditRows(t, pool)
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("audit rows = %d, want 1", len(rows))
|
||||
}
|
||||
if rows[0].Scope != ScopeDiscover {
|
||||
t.Errorf("audit scope = %q, want %q", rows[0].Scope, ScopeDiscover)
|
||||
}
|
||||
if len(rows[0].Changes) != 2 {
|
||||
t.Errorf("audit changes = %+v, want both fields", rows[0].Changes)
|
||||
}
|
||||
|
||||
// Reload from the DB to prove it persisted rather than only caching.
|
||||
s2 := newService(t, pool)
|
||||
if got := s2.Discover().TagOverlapWeight; got != 2.5 {
|
||||
t.Errorf("after reload TagOverlapWeight = %v, want 2.5 (not re-seeded to shipped)", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateDiscover_Validation(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
s := newService(t, pool)
|
||||
cases := []struct {
|
||||
name string
|
||||
patch map[string]float64
|
||||
}{
|
||||
{"unknown field", map[string]float64{"nope": 1}},
|
||||
{"negative weight", map[string]float64{"tag_overlap_weight": -1}},
|
||||
{"weight past the typo bound", map[string]float64{"tag_overlap_weight": 100}},
|
||||
// A sub-day snooze isn't a snooze, it's a flicker.
|
||||
{"snooze under a day", map[string]float64{"snooze_days": 0.5}},
|
||||
// Past a year it's a permanent dismissal wearing a snooze's clothes —
|
||||
// the shape rule #101 rules out.
|
||||
{"snooze past a year", map[string]float64{"snooze_days": 400}},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if err := s.UpdateDiscover(context.Background(), c.patch); err == nil {
|
||||
t.Errorf("%s: expected rejection, got nil", c.name)
|
||||
}
|
||||
}
|
||||
if got := s.Discover(); got != ShippedDiscoverTuning() {
|
||||
t.Errorf("a rejected patch mutated state: %+v", got)
|
||||
}
|
||||
if rows := auditRows(t, pool); len(rows) != 0 {
|
||||
t.Errorf("rejected patches wrote %d audit rows, want 0", len(rows))
|
||||
}
|
||||
}
|
||||
|
||||
// Weight 0 must be accepted — it's the operator's off switch for the whole
|
||||
// tag term, so a "must be positive" bound would remove their ability to
|
||||
// disable the feature.
|
||||
func TestUpdateDiscover_ZeroWeightIsAllowed(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
s := newService(t, pool)
|
||||
if err := s.UpdateDiscover(context.Background(),
|
||||
map[string]float64{"tag_overlap_weight": 0}); err != nil {
|
||||
t.Fatalf("UpdateDiscover(0): %v", err)
|
||||
}
|
||||
if got := s.Discover().TagOverlapWeight; got != 0 {
|
||||
t.Errorf("TagOverlapWeight = %v, want 0", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetDiscover_RestoresShippedDefaults(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
s := newService(t, pool)
|
||||
if err := s.UpdateDiscover(context.Background(),
|
||||
map[string]float64{"tag_overlap_weight": 4}); err != nil {
|
||||
t.Fatalf("UpdateDiscover: %v", err)
|
||||
}
|
||||
if err := s.Reset(context.Background(), ScopeDiscover); err != nil {
|
||||
t.Fatalf("Reset: %v", err)
|
||||
}
|
||||
if got := s.Discover(); got != ShippedDiscoverTuning() {
|
||||
t.Errorf("after reset = %+v, want shipped %+v", got, ShippedDiscoverTuning())
|
||||
}
|
||||
// Already-at-defaults is a no-op: update + reset = 2 rows, not 3.
|
||||
if err := s.Reset(context.Background(), ScopeDiscover); err != nil {
|
||||
t.Fatalf("second Reset: %v", err)
|
||||
}
|
||||
if rows := auditRows(t, pool); len(rows) != 2 {
|
||||
t.Errorf("audit rows = %d, want 2 (the no-op reset must not audit)", len(rows))
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user