feat(discover): rank suggestions by taste-tag overlap — #2377 (server)
test-go / test (push) Successful in 1m0s
test-go / integration (push) Failing after 4m55s

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:
2026-08-02 20:31:12 -04:00
co-authored by Claude Opus 5
parent 7315e37c15
commit 799dab029a
16 changed files with 1085 additions and 29 deletions
+22 -2
View File
@@ -67,15 +67,30 @@ func tasteRespFrom(t recsettings.TasteTuning) tasteTuningResp {
} }
} }
// discoverTuningResp is the Discover scope on the wire (#2377).
type discoverTuningResp struct {
TagOverlapWeight float64 `json:"tag_overlap_weight"`
SnoozeDays float64 `json:"snooze_days"`
}
func discoverRespFrom(d recsettings.DiscoverTuning) discoverTuningResp {
return discoverTuningResp{
TagOverlapWeight: d.TagOverlapWeight,
SnoozeDays: d.SnoozeDays,
}
}
// tuningSnapshot is both the GET response and the post-mutation echo: // tuningSnapshot is both the GET response and the post-mutation echo:
// current values alongside shipped defaults so the card can mark // current values alongside shipped defaults so the card can mark
// which knobs deviate. // which knobs deviate.
type tuningSnapshot struct { type tuningSnapshot struct {
Profiles map[string]weightsResp `json:"profiles"` Profiles map[string]weightsResp `json:"profiles"`
Taste tasteTuningResp `json:"taste"` Taste tasteTuningResp `json:"taste"`
Discover discoverTuningResp `json:"discover"`
Shipped struct { Shipped struct {
Profiles map[string]weightsResp `json:"profiles"` Profiles map[string]weightsResp `json:"profiles"`
Taste tasteTuningResp `json:"taste"` Taste tasteTuningResp `json:"taste"`
Discover discoverTuningResp `json:"discover"`
} `json:"shipped"` } `json:"shipped"`
} }
@@ -86,11 +101,13 @@ func (h *handlers) tuningSnapshot() tuningSnapshot {
recsettings.ScopeDailyMix: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeDailyMix)), recsettings.ScopeDailyMix: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeDailyMix)),
} }
out.Taste = tasteRespFrom(h.recSettings.Taste()) out.Taste = tasteRespFrom(h.recSettings.Taste())
out.Discover = discoverRespFrom(h.recSettings.Discover())
out.Shipped.Profiles = map[string]weightsResp{ out.Shipped.Profiles = map[string]weightsResp{
recsettings.ScopeRadio: weightsRespFrom(recsettings.ShippedRadioWeights()), recsettings.ScopeRadio: weightsRespFrom(recsettings.ShippedRadioWeights()),
recsettings.ScopeDailyMix: weightsRespFrom(recsettings.ShippedDailyMixWeights()), recsettings.ScopeDailyMix: weightsRespFrom(recsettings.ShippedDailyMixWeights()),
} }
out.Shipped.Taste = tasteRespFrom(recsettings.ShippedTasteTuning()) out.Shipped.Taste = tasteRespFrom(recsettings.ShippedTasteTuning())
out.Shipped.Discover = discoverRespFrom(recsettings.ShippedDiscoverTuning())
return out return out
} }
@@ -120,9 +137,12 @@ func (h *handlers) handlePatchRecommendationTuning(w http.ResponseWriter, r *htt
} }
var err error var err error
if scope == recsettings.ScopeTaste { switch scope {
case recsettings.ScopeTaste:
err = h.recSettings.UpdateTaste(r.Context(), body.Values) err = h.recSettings.UpdateTaste(r.Context(), body.Values)
} else { case recsettings.ScopeDiscover:
err = h.recSettings.UpdateDiscover(r.Context(), body.Values)
default:
err = h.recSettings.UpdateProfile(r.Context(), scope, body.Values) err = h.recSettings.UpdateProfile(r.Context(), scope, body.Values)
} }
if err != nil { if err != nil {
+17 -9
View File
@@ -25,6 +25,12 @@ type suggestionView struct {
Name string `json:"name"` Name string `json:"name"`
Score float64 `json:"score"` Score float64 `json:"score"`
Attribution []seedContributionView `json:"attribution"` Attribution []seedContributionView `json:"attribution"`
// MatchedTags are the candidate's tags that overlap the user's taste
// profile, strongest first (#2377) — the "matches: shoegaze, melancholic"
// line. Omitted when empty, which is common: tag coverage for
// out-of-library artists is permanently partial (#2376), and the card
// falls back to the seed attribution it has always shown.
MatchedTags []string `json:"matched_tags,omitempty"`
// ImageURL is resolved on-demand from Lidarr (out-of-library // ImageURL is resolved on-demand from Lidarr (out-of-library
// artists have no local art row). Omitted when Lidarr is disabled // artists have no local art row). Omitted when Lidarr is disabled
// or has no match — the client falls back to a placeholder. Not // or has no match — the client falls back to a placeholder. Not
@@ -71,7 +77,11 @@ func (h *handlers) handleListSuggestions(w http.ResponseWriter, r *http.Request)
halfLife = f halfLife = f
} }
suggestions, err := recommendation.SuggestArtists(r.Context(), h.pool, user.ID, halfLife, limit) // Read the tuned weight per request so an admin change takes effect on the
// next refresh, no restart (rule #25).
tagWeight := h.recSettings.Discover().TagOverlapWeight
suggestions, err := recommendation.SuggestArtists(
r.Context(), h.pool, user.ID, halfLife, limit, tagWeight)
if err != nil { if err != nil {
h.logger.Error("api: list suggestions", "err", err) h.logger.Error("api: list suggestions", "err", err)
writeErr(w, apierror.InternalMsg("failed to load suggestions", err)) writeErr(w, apierror.InternalMsg("failed to load suggestions", err))
@@ -92,19 +102,17 @@ func (h *handlers) handleListSuggestions(w http.ResponseWriter, r *http.Request)
} }
out = append(out, suggestionView{ out = append(out, suggestionView{
MBID: s.MBID, Name: s.Name, Score: s.Score, Attribution: attr, MBID: s.MBID, Name: s.Name, Score: s.Score, Attribution: attr,
MatchedTags: s.MatchedTags,
}) })
} }
h.resolveSuggestionArt(r.Context(), out) h.resolveSuggestionArt(r.Context(), out)
writeJSON(w, http.StatusOK, out) writeJSON(w, http.StatusOK, out)
} }
// Snooze duration bounds. 90 days is long enough that a parked suggestion // maxSnoozeDays caps a client-supplied duration. The DEFAULT is not here: it's
// stops feeling like it's nagging, short enough that a taste shift brings it // a DB-backed knob on the admin tuning card (rule #25), read per request via
// back on its own — the whole point of a snooze over a dismissal (#2374). // recSettings.Discover().SnoozeDays. See #2377.
const ( const maxSnoozeDays = 365.0
defaultSnoozeDays = 90.0
maxSnoozeDays = 365.0
)
// snoozeRequest is the POST body. Both fields are optional in the JSON sense // snoozeRequest is the POST body. Both fields are optional in the JSON sense
// (an absent body snoozes for the default), but Name is required in practice: // (an absent body snoozes for the default), but Name is required in practice:
@@ -158,7 +166,7 @@ func (h *handlers) handleSnoozeSuggestion(w http.ResponseWriter, r *http.Request
} }
days := body.Days days := body.Days
if days <= 0 { if days <= 0 {
days = defaultSnoozeDays days = h.recSettings.Discover().SnoozeDays
} }
if days > maxSnoozeDays { if days > maxSnoozeDays {
// Clamp rather than reject: a client asking for longer than we allow // Clamp rather than reject: a client asking for longer than we allow
+7
View File
@@ -290,6 +290,13 @@ type DiagnosticEvent struct {
ReceivedAt pgtype.Timestamptz ReceivedAt pgtype.Timestamptz
} }
type DiscoverTuning struct {
Singleton bool
TagOverlapWeight float64
SnoozeDays float64
UpdatedAt pgtype.Timestamptz
}
type GeneralLike struct { type GeneralLike struct {
UserID pgtype.UUID UserID pgtype.UUID
TrackID pgtype.UUID TrackID pgtype.UUID
@@ -9,6 +9,22 @@ import (
"context" "context"
) )
const getDiscoverTuning = `-- name: GetDiscoverTuning :one
SELECT singleton, tag_overlap_weight, snooze_days, updated_at FROM discover_tuning WHERE singleton = true
`
func (q *Queries) GetDiscoverTuning(ctx context.Context) (DiscoverTuning, error) {
row := q.db.QueryRow(ctx, getDiscoverTuning)
var i DiscoverTuning
err := row.Scan(
&i.Singleton,
&i.TagOverlapWeight,
&i.SnoozeDays,
&i.UpdatedAt,
)
return i, err
}
const getTasteTuning = `-- name: GetTasteTuning :one const getTasteTuning = `-- name: GetTasteTuning :one
SELECT singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at, enriched_tag_scale, era_scale, mood_scale FROM taste_tuning WHERE singleton = true SELECT singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at, enriched_tag_scale, era_scale, mood_scale FROM taste_tuning WHERE singleton = true
` `
@@ -118,6 +134,32 @@ func (q *Queries) ListWeightProfiles(ctx context.Context) ([]RecommendationWeigh
return items, nil return items, nil
} }
const updateDiscoverTuning = `-- name: UpdateDiscoverTuning :one
UPDATE discover_tuning
SET tag_overlap_weight = $1,
snooze_days = $2,
updated_at = now()
WHERE singleton = true
RETURNING singleton, tag_overlap_weight, snooze_days, updated_at
`
type UpdateDiscoverTuningParams struct {
TagOverlapWeight float64
SnoozeDays float64
}
func (q *Queries) UpdateDiscoverTuning(ctx context.Context, arg UpdateDiscoverTuningParams) (DiscoverTuning, error) {
row := q.db.QueryRow(ctx, updateDiscoverTuning, arg.TagOverlapWeight, arg.SnoozeDays)
var i DiscoverTuning
err := row.Scan(
&i.Singleton,
&i.TagOverlapWeight,
&i.SnoozeDays,
&i.UpdatedAt,
)
return i, err
}
const updateTasteTuning = `-- name: UpdateTasteTuning :one const updateTasteTuning = `-- name: UpdateTasteTuning :one
UPDATE taste_tuning UPDATE taste_tuning
SET half_life_days = $1, SET half_life_days = $1,
@@ -226,6 +268,24 @@ func (q *Queries) UpdateWeightProfile(ctx context.Context, arg UpdateWeightProfi
return i, err return i, err
} }
const upsertDiscoverTuningDefaults = `-- name: UpsertDiscoverTuningDefaults :exec
INSERT INTO discover_tuning (singleton, tag_overlap_weight, snooze_days)
VALUES (true, $1, $2)
ON CONFLICT (singleton) DO NOTHING
`
type UpsertDiscoverTuningDefaultsParams struct {
TagOverlapWeight float64
SnoozeDays float64
}
// Boot reconcile for the Discover scope (#2377). Never overwrites
// operator-tuned values, same contract as the other two.
func (q *Queries) UpsertDiscoverTuningDefaults(ctx context.Context, arg UpsertDiscoverTuningDefaultsParams) error {
_, err := q.db.Exec(ctx, upsertDiscoverTuningDefaults, arg.TagOverlapWeight, arg.SnoozeDays)
return err
}
const upsertTasteTuningDefaults = `-- name: UpsertTasteTuningDefaults :exec const upsertTasteTuningDefaults = `-- name: UpsertTasteTuningDefaults :exec
INSERT INTO taste_tuning ( INSERT INTO taste_tuning (
singleton, half_life_days, engagement_hard_skip, singleton, half_life_days, engagement_hard_skip,
@@ -0,0 +1,10 @@
-- Drop any audit rows under the scope the constraint is about to forbid,
-- otherwise re-adding the narrower CHECK fails against existing data.
DELETE FROM recommendation_tuning_audit WHERE scope = 'discover';
ALTER TABLE recommendation_tuning_audit
DROP CONSTRAINT recommendation_tuning_audit_scope_check;
ALTER TABLE recommendation_tuning_audit
ADD CONSTRAINT recommendation_tuning_audit_scope_check
CHECK (scope IN ('radio', 'daily_mix', 'taste'));
DROP TABLE IF EXISTS discover_tuning;
@@ -0,0 +1,38 @@
-- 0051_discover_tuning.up.sql — tunable knobs for the Discover request
-- surface (#2377, milestone #268 slice 6).
--
-- A FOURTH tuning scope alongside radio / daily_mix / taste. Its own scope
-- rather than extra columns on taste_tuning, for a reason that matters:
-- snooze_days lives here, and a snooze must never be read as taste signal
-- (#2374). Filing it under 'taste' would put it one careless join away from
-- exactly the leak that design forbids.
--
-- Per rule #25 these are DB-backed and editable in the admin UI with no
-- restart — the shipped values below are defaults, not settings.
CREATE TABLE discover_tuning (
singleton boolean PRIMARY KEY DEFAULT true
CONSTRAINT discover_tuning_singleton_check CHECK (singleton),
-- How strongly taste-tag overlap boosts a candidate's similarity score.
-- The blend is MULTIPLICATIVE: score * (1 + w * overlap), overlap in
-- [0,1]. So 0 disables the feature outright and leaves pure similarity
-- ranking, 1.0 lets a perfectly-matching candidate double its score, and
-- a candidate with no cached tags is unchanged rather than penalised
-- (rule #131 — tag coverage is permanently partial, see #2376).
tag_overlap_weight double precision NOT NULL,
-- Default snooze duration in days. Was a Go constant in
-- internal/api/suggestions.go; moved here per rule #25.
snooze_days double precision NOT NULL,
updated_at timestamptz NOT NULL DEFAULT now()
);
INSERT INTO discover_tuning (singleton, tag_overlap_weight, snooze_days)
VALUES (true, 1.0, 90);
-- Rule #36: a new value for a CHECK-gated column needs the constraint
-- rewritten in the SAME change, or the first audit row written under the new
-- scope fails at runtime rather than at migrate time.
ALTER TABLE recommendation_tuning_audit
DROP CONSTRAINT recommendation_tuning_audit_scope_check;
ALTER TABLE recommendation_tuning_audit
ADD CONSTRAINT recommendation_tuning_audit_scope_check
CHECK (scope IN ('radio', 'daily_mix', 'taste', 'discover'));
@@ -53,6 +53,24 @@ UPDATE taste_tuning
WHERE singleton = true WHERE singleton = true
RETURNING *; RETURNING *;
-- name: UpsertDiscoverTuningDefaults :exec
-- Boot reconcile for the Discover scope (#2377). Never overwrites
-- operator-tuned values, same contract as the other two.
INSERT INTO discover_tuning (singleton, tag_overlap_weight, snooze_days)
VALUES (true, $1, $2)
ON CONFLICT (singleton) DO NOTHING;
-- name: GetDiscoverTuning :one
SELECT * FROM discover_tuning WHERE singleton = true;
-- name: UpdateDiscoverTuning :one
UPDATE discover_tuning
SET tag_overlap_weight = $1,
snooze_days = $2,
updated_at = now()
WHERE singleton = true
RETURNING *;
-- name: InsertTuningAudit :exec -- name: InsertTuningAudit :exec
-- changes is a jsonb array of {field, old, new} objects. -- changes is a jsonb array of {field, old, new} objects.
INSERT INTO recommendation_tuning_audit (scope, action, changes) INSERT INTO recommendation_tuning_audit (scope, action, changes)
+4
View File
@@ -82,6 +82,10 @@ var dataTables = []string{
// (#1250), so truncating gives each test pristine tuning values. // (#1250), so truncating gives each test pristine tuning values.
"recommendation_weight_profiles", "recommendation_weight_profiles",
"taste_tuning", "taste_tuning",
// #2377. Same reasoning as taste_tuning above: recsettings.New re-seeds
// shipped defaults on every construction, so truncating gives each test
// pristine Discover knobs rather than whatever a previous test tuned.
"discover_tuning",
"recommendation_tuning_audit", "recommendation_tuning_audit",
"tracks", "tracks",
"albums", "albums",
@@ -4,6 +4,7 @@ import (
"context" "context"
"testing" "testing"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool" "github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
@@ -280,3 +281,141 @@ func TestCandidateTags_CoverageCountsProcessedAndTagged(t *testing.T) {
t.Errorf("with_tags = %d, want 2 ('none' excluded)", got.WithTags) t.Errorf("with_tags = %d, want 2 ('none' excluded)", got.WithTags)
} }
} }
// --- Slice 6 (#2377): the taste-tag blend, end to end ---
//
// The pure tests in tagoverlap_test.go cover the scoring maths. These cover the
// wiring the pure tests cannot reach: that loadTagInputs actually reads both
// sides from the DB and that the blend reaches the returned deck.
func seedTasteTag(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID, tag string, weight float64) {
t.Helper()
if _, err := pool.Exec(context.Background(),
`INSERT INTO taste_profile_tags (user_id, tag, weight) VALUES ($1, $2, $3)
ON CONFLICT (user_id, tag) DO UPDATE SET weight = EXCLUDED.weight`,
userID, tag, weight,
); err != nil {
t.Fatalf("seedTasteTag: %v", err)
}
}
func seedCandidateTag(t *testing.T, pool *pgxpool.Pool, mbid, tag string, weight float64) {
t.Helper()
if err := dbq.New(pool).InsertCandidateArtistTag(context.Background(),
dbq.InsertCandidateArtistTagParams{CandidateMbid: mbid, Tag: tag, Weight: weight},
); err != nil {
t.Fatalf("seedCandidateTag: %v", err)
}
}
// twoCandidatePool wires a liked seed with two unmatched neighbours: "loud"
// scores higher on similarity, "match" lower. Skewed on purpose — with equal
// similarity the reorder assertion below could not fail.
func twoCandidatePool(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID) {
t.Helper()
seed := seedArtist(t, pool, "Seed", "")
likeArtist(t, pool, userID, seed.ID)
seedUnmatched(t, pool, seed.ID, "loud", "Loud Neighbour", 0.9)
seedUnmatched(t, pool, seed.ID, "match", "Taste Match", 0.6)
}
func TestSuggestArtists_TasteTagMatchOvertakesStrongerSimilarity(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
twoCandidatePool(t, pool, user.ID)
seedTasteTag(t, pool, user.ID, "shoegaze", 10)
seedCandidateTag(t, pool, "match", "shoegaze", 1.0)
seedCandidateTag(t, pool, "loud", "death metal", 1.0)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 1.0)
if err != nil {
t.Fatalf("SuggestArtists: %v", err)
}
if len(out) != 2 {
t.Fatalf("len = %d, want 2", len(out))
}
if out[0].MBID != "match" {
t.Errorf("first = %q, want match (0.6×2 beats 0.9×1)", out[0].MBID)
}
// And it explains itself.
if len(out[0].MatchedTags) != 1 || out[0].MatchedTags[0] != "shoegaze" {
t.Errorf("matched tags = %v, want [shoegaze]", out[0].MatchedTags)
}
if out[1].MatchedTags != nil {
t.Errorf("non-matching candidate got matched tags: %v", out[1].MatchedTags)
}
}
// The same fixture with the knob at 0 must return pure similarity order — the
// operator's off switch, verified against a real DB rather than assumed.
func TestSuggestArtists_ZeroTagWeightKeepsSimilarityOrder(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
twoCandidatePool(t, pool, user.ID)
seedTasteTag(t, pool, user.ID, "shoegaze", 10)
seedCandidateTag(t, pool, "match", "shoegaze", 1.0)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
if err != nil {
t.Fatalf("SuggestArtists: %v", err)
}
if out[0].MBID != "loud" {
t.Errorf("first = %q, want loud (tag term disabled)", out[0].MBID)
}
}
// A user with no taste profile must still get a full deck in similarity order:
// the cold-start path, which is every user's first days (rule #131).
func TestSuggestArtists_NoTasteTagsStillReturnsSimilarityOrder(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
twoCandidatePool(t, pool, user.ID)
// Candidate tags exist, but the user has no taste tags to match them.
seedCandidateTag(t, pool, "match", "shoegaze", 1.0)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 1.0)
if err != nil {
t.Fatalf("SuggestArtists: %v", err)
}
if len(out) != 2 {
t.Fatalf("len = %d, want 2 (nothing dropped)", len(out))
}
if out[0].MBID != "loud" {
t.Errorf("first = %q, want loud", out[0].MBID)
}
}
// An untagged candidate must never be dropped or sunk just because another
// candidate has tags — permanently-partial coverage (#2376) must not become a
// permanent ranking penalty.
func TestSuggestArtists_UntaggedCandidateSurvivesAlongsideTagged(t *testing.T) {
pool := newPool(t)
user := seedUser(t, pool, "alice")
twoCandidatePool(t, pool, user.ID)
seedTasteTag(t, pool, user.ID, "shoegaze", 10)
seedCandidateTag(t, pool, "match", "shoegaze", 1.0)
// "loud" is deliberately left with NO cached tags at all.
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 1.0)
if err != nil {
t.Fatalf("SuggestArtists: %v", err)
}
if len(out) != 2 {
t.Fatalf("len = %d, want 2 — the untagged candidate must still appear", len(out))
}
var loud *ArtistSuggestion
for i := range out {
if out[i].MBID == "loud" {
loud = &out[i]
}
}
if loud == nil {
t.Fatal("untagged candidate vanished from the deck")
}
if loud.Score != 0.9 {
t.Errorf("untagged score = %v, want 0.9 unchanged", loud.Score)
}
}
+75 -1
View File
@@ -31,6 +31,15 @@ type ArtistSuggestion struct {
Name string Name string
Score float64 Score float64
Attribution []SeedContribution 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. // SeedContribution is one of the top-3 contributing seeds for a candidate.
@@ -49,7 +58,10 @@ type SeedContribution struct {
// seed path only — the likes + completed-plays fallback used while the user // 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 // has no taste-profile rows yet. Once the profile is populated it seeds
// instead, carrying its own decay, so this knob stops applying. // 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) { func SuggestArtists(
ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID,
halfLifeDays float64, limit int, tagOverlapWeight float64,
) ([]ArtistSuggestion, error) {
if limit <= 0 || limit > 50 { if limit <= 0 || limit > 50 {
limit = 12 limit = 12
} }
@@ -117,9 +129,71 @@ func SuggestArtists(ctx context.Context, pool *pgxpool.Pool, userID pgtype.UUID,
Attribution: attribution, 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 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 // 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 // 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) // from yesterday's without pulling the whole long tail (whose scores are noise)
@@ -147,7 +147,7 @@ func TestSuggestArtists_LikesAndPlaysContributeToScore(t *testing.T) {
seedUnmatched(t, pool, seedA.ID, "out-mbid", "Outsider", 0.9) seedUnmatched(t, pool, seedA.ID, "out-mbid", "Outsider", 0.9)
seedUnmatched(t, pool, seedB.ID, "out-mbid", "Outsider", 0.5) seedUnmatched(t, pool, seedB.ID, "out-mbid", "Outsider", 0.5)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
if err != nil { if err != nil {
t.Fatalf("SuggestArtists: %v", err) t.Fatalf("SuggestArtists: %v", err)
} }
@@ -174,7 +174,7 @@ func TestSuggestArtists_Top12Cap(t *testing.T) {
for i := 0; i < 30; i++ { for i := 0; i < 30; i++ {
seedUnmatched(t, pool, seed.ID, fmt.Sprintf("mbid-%02d", i), fmt.Sprintf("Artist %02d", i), 0.99-float64(i)*0.01) seedUnmatched(t, pool, seed.ID, fmt.Sprintf("mbid-%02d", i), fmt.Sprintf("Artist %02d", i), 0.99-float64(i)*0.01)
} }
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
if err != nil { if err != nil {
t.Fatalf("SuggestArtists: %v", err) t.Fatalf("SuggestArtists: %v", err)
} }
@@ -195,7 +195,7 @@ func TestSuggestArtists_AttributionTopThree(t *testing.T) {
likeArtist(t, pool, user.ID, seeds[i].ID) likeArtist(t, pool, user.ID, seeds[i].ID)
seedUnmatched(t, pool, seeds[i].ID, "shared-mbid", "Shared", 0.9-float64(i)*0.1) seedUnmatched(t, pool, seeds[i].ID, "shared-mbid", "Shared", 0.9-float64(i)*0.1)
} }
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
if err != nil { if err != nil {
t.Fatalf("SuggestArtists: %v", err) t.Fatalf("SuggestArtists: %v", err)
} }
@@ -227,7 +227,7 @@ func TestSuggestArtists_RecencyDecayDownweightsOldPlays(t *testing.T) {
seedUnmatched(t, pool, recentSeed.ID, "cand", "Cand", 0.5) seedUnmatched(t, pool, recentSeed.ID, "cand", "Cand", 0.5)
seedUnmatched(t, pool, oldSeed.ID, "cand", "Cand", 0.5) seedUnmatched(t, pool, oldSeed.ID, "cand", "Cand", 0.5)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
if err != nil { if err != nil {
t.Fatalf("SuggestArtists: %v", err) t.Fatalf("SuggestArtists: %v", err)
} }
@@ -255,7 +255,7 @@ func TestSuggestArtists_FiltersInLibraryCandidates(t *testing.T) {
seedArtist(t, pool, "InLib", inLibMBID) seedArtist(t, pool, "InLib", inLibMBID)
seedUnmatched(t, pool, seed.ID, inLibMBID, "InLib", 0.9) seedUnmatched(t, pool, seed.ID, inLibMBID, "InLib", 0.9)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
if err != nil { if err != nil {
t.Fatalf("SuggestArtists: %v", err) t.Fatalf("SuggestArtists: %v", err)
} }
@@ -279,7 +279,7 @@ func TestSuggestArtists_FiltersAlreadyRequested(t *testing.T) {
t.Fatalf("CreateLidarrRequest: %v", err) t.Fatalf("CreateLidarrRequest: %v", err)
} }
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
if err != nil { if err != nil {
t.Fatalf("SuggestArtists: %v", err) t.Fatalf("SuggestArtists: %v", err)
} }
@@ -310,7 +310,7 @@ func TestSuggestArtists_RejectedRequestStillShown(t *testing.T) {
t.Fatalf("RejectLidarrRequest: %v", err) t.Fatalf("RejectLidarrRequest: %v", err)
} }
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
if err != nil { if err != nil {
t.Fatalf("SuggestArtists: %v", err) t.Fatalf("SuggestArtists: %v", err)
} }
@@ -322,7 +322,7 @@ func TestSuggestArtists_RejectedRequestStillShown(t *testing.T) {
func TestSuggestArtists_EmptyForNewUser(t *testing.T) { func TestSuggestArtists_EmptyForNewUser(t *testing.T) {
pool := newPool(t) pool := newPool(t)
user := seedUser(t, pool, "newbie") user := seedUser(t, pool, "newbie")
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
if err != nil { if err != nil {
t.Fatalf("SuggestArtists: %v", err) t.Fatalf("SuggestArtists: %v", err)
} }
@@ -374,7 +374,7 @@ func TestSuggestArtists_TasteProfileWeightSeedsTier1(t *testing.T) {
setTasteWeight(t, pool, user.ID, seed.ID, 4.0) setTasteWeight(t, pool, user.ID, seed.ID, 4.0)
seedUnmatched(t, pool, seed.ID, "out-mbid", "Outsider", 0.9) seedUnmatched(t, pool, seed.ID, "out-mbid", "Outsider", 0.9)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
if err != nil { if err != nil {
t.Fatalf("SuggestArtists: %v", err) t.Fatalf("SuggestArtists: %v", err)
} }
@@ -408,7 +408,7 @@ func TestSuggestArtists_TasteProfileSupersedesRawPlays(t *testing.T) {
seedUnmatched(t, pool, kept.ID, "kept-cand", "Kept Candidate", 0.9) seedUnmatched(t, pool, kept.ID, "kept-cand", "Kept Candidate", 0.9)
seedUnmatched(t, pool, dropped.ID, "dropped-cand", "Dropped Candidate", 0.9) seedUnmatched(t, pool, dropped.ID, "dropped-cand", "Dropped Candidate", 0.9)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
if err != nil { if err != nil {
t.Fatalf("SuggestArtists: %v", err) t.Fatalf("SuggestArtists: %v", err)
} }
@@ -435,7 +435,7 @@ func TestSuggestArtists_NonPositiveTasteWeightDoesNotSeed(t *testing.T) {
seedUnmatched(t, pool, positive.ID, "pos-cand", "Positive Candidate", 0.9) seedUnmatched(t, pool, positive.ID, "pos-cand", "Positive Candidate", 0.9)
seedUnmatched(t, pool, abandoned.ID, "neg-cand", "Negative Candidate", 0.9) seedUnmatched(t, pool, abandoned.ID, "neg-cand", "Negative Candidate", 0.9)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
if err != nil { if err != nil {
t.Fatalf("SuggestArtists: %v", err) t.Fatalf("SuggestArtists: %v", err)
} }
@@ -464,7 +464,7 @@ func TestSuggestArtists_SkippedPlaysDoNotSeedTier2(t *testing.T) {
} }
seedUnmatched(t, pool, skipped.ID, "skip-cand", "Skip Candidate", 0.9) seedUnmatched(t, pool, skipped.ID, "skip-cand", "Skip Candidate", 0.9)
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
if err != nil { if err != nil {
t.Fatalf("SuggestArtists: %v", err) t.Fatalf("SuggestArtists: %v", err)
} }
@@ -518,7 +518,7 @@ func TestSuggestArtists_ActiveSnoozeHidesCandidate(t *testing.T) {
t.Fatalf("SnoozeSuggestion: %v", err) t.Fatalf("SnoozeSuggestion: %v", err)
} }
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
if err != nil { if err != nil {
t.Fatalf("SuggestArtists: %v", err) t.Fatalf("SuggestArtists: %v", err)
} }
@@ -534,7 +534,7 @@ func TestSuggestArtists_ExpiredSnoozeShowsCandidateAgain(t *testing.T) {
seedOneCandidate(t, pool, user.ID, "expired-mbid", "Returning Artist") seedOneCandidate(t, pool, user.ID, "expired-mbid", "Returning Artist")
snoozeUntil(t, pool, user.ID, "expired-mbid", "Returning Artist", time.Now().Add(-time.Hour)) snoozeUntil(t, pool, user.ID, "expired-mbid", "Returning Artist", time.Now().Add(-time.Hour))
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
if err != nil { if err != nil {
t.Fatalf("SuggestArtists: %v", err) t.Fatalf("SuggestArtists: %v", err)
} }
@@ -560,14 +560,14 @@ func TestSuggestArtists_SnoozeIsPerUser(t *testing.T) {
snoozeUntil(t, pool, alice.ID, "shared-mbid", "Shared Candidate", time.Now().Add(24*time.Hour)) snoozeUntil(t, pool, alice.ID, "shared-mbid", "Shared Candidate", time.Now().Add(24*time.Hour))
aliceOut, err := SuggestArtists(context.Background(), pool, alice.ID, 30, 12) aliceOut, err := SuggestArtists(context.Background(), pool, alice.ID, 30, 12, 0)
if err != nil { if err != nil {
t.Fatalf("SuggestArtists(alice): %v", err) t.Fatalf("SuggestArtists(alice): %v", err)
} }
if len(aliceOut) != 0 { if len(aliceOut) != 0 {
t.Errorf("alice len = %d, want 0 (she snoozed it)", len(aliceOut)) t.Errorf("alice len = %d, want 0 (she snoozed it)", len(aliceOut))
} }
bobOut, err := SuggestArtists(context.Background(), pool, bob.ID, 30, 12) bobOut, err := SuggestArtists(context.Background(), pool, bob.ID, 30, 12, 0)
if err != nil { if err != nil {
t.Fatalf("SuggestArtists(bob): %v", err) t.Fatalf("SuggestArtists(bob): %v", err)
} }
@@ -604,7 +604,7 @@ func TestUnsnoozeSuggestion_RestoresImmediately(t *testing.T) {
t.Errorf("repeat rows = %d, want 0", rows) t.Errorf("repeat rows = %d, want 0", rows)
} }
out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12) out, err := SuggestArtists(context.Background(), pool, user.ID, 30, 12, 0)
if err != nil { if err != nil {
t.Fatalf("SuggestArtists: %v", err) t.Fatalf("SuggestArtists: %v", err)
} }
+156
View File
@@ -0,0 +1,156 @@
// tagoverlap.go — the taste-tag term for the Discover request surface
// (#2377, milestone #268 slice 6).
//
// Slices 1-2 made the deck stop repeating; this is what makes it *relevant*.
// Before this, a candidate's only claim on a slot was "some artist you play is
// similar to it" — a graph-adjacency fact that says nothing about whether the
// music sounds like anything you actually like. Here the candidate's own
// folksonomy tags (cached by slice 5) are compared against the user's
// taste-profile tags, so the surface can rank on "matches the sound you like"
// and say WHY.
//
// Pure by design — no DB, no clock — so the scoring rules are unit-testable in
// the fast lane rather than behind the integration gate.
package recommendation
import "sort"
// maxMatchedTags caps the "matches: …" explanation. Three is what the existing
// seed attribution shows, and a longer list stops being a reason and becomes a
// tag dump.
const maxMatchedTags = 3
// TagWeights is a tag → weight map. Both sides of the comparison use it:
// candidate tags (normalized [0,1] by the enrichment providers) and the user's
// taste-profile tags (accumulated, unbounded — normalized here).
type TagWeights map[string]float64
// tagOverlap scores how much of a candidate's tag identity the user actually
// likes, in [0,1], and returns the matched tags ordered by contribution.
//
// The measure is: of this candidate's total tag mass, what share sits on tags
// the user likes — each weighted by how strongly they like it?
//
// overlap = Σ(shared) candWeight × normalizedTasteWeight ÷ Σ(all) candWeight
//
// Normalizing the taste side by the user's STRONGEST tag is what makes this
// 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 — "this is my favourite tag". Dividing by the candidate's own total
// mass makes it comparable across candidates, so a densely-tagged artist
// can't out-score a sparsely-tagged one just by having more tags.
//
// Returns (0, nil) when either side is empty. That is the load-bearing
// degradation path: tag coverage for out-of-library candidates is permanently
// partial (#2376), and a cold-start user has no taste tags at all. Both must
// leave the candidate's similarity score untouched rather than sink it —
// rule #131, tiered degradation, never vanish-or-nothing.
func tagOverlap(candidate, taste TagWeights) (float64, []string) {
if len(candidate) == 0 || len(taste) == 0 {
return 0, nil
}
maxTaste := 0.0
for _, w := range taste {
if w > maxTaste {
maxTaste = w
}
}
// Every taste weight <= 0 carries no preference to match against. Guarding
// here also avoids dividing by zero below.
if maxTaste <= 0 {
return 0, nil
}
totalMass := 0.0
for _, w := range candidate {
// Negative or zero candidate weights would let a tag subtract from the
// denominator and inflate the ratio past 1.
if w > 0 {
totalMass += w
}
}
if totalMass <= 0 {
return 0, nil
}
type contribution struct {
tag string
score float64
}
var matched []contribution
sum := 0.0
for tag, candWeight := range candidate {
if candWeight <= 0 {
continue
}
tasteWeight, ok := taste[tag]
if !ok || tasteWeight <= 0 {
continue
}
c := candWeight * (tasteWeight / maxTaste)
sum += c
matched = append(matched, contribution{tag: tag, score: c})
}
if len(matched) == 0 {
return 0, nil
}
// Strongest contribution first; tag name breaks ties so the explanation is
// deterministic for a given input rather than map-iteration order.
sort.Slice(matched, func(i, j int) bool {
if matched[i].score != matched[j].score {
return matched[i].score > matched[j].score
}
return matched[i].tag < matched[j].tag
})
names := make([]string, 0, min(len(matched), maxMatchedTags))
for i := 0; i < len(matched) && i < maxMatchedTags; i++ {
names = append(names, matched[i].tag)
}
return sum / totalMass, names
}
// applyTagOverlap re-scores and re-orders a candidate pool by taste-tag
// overlap, stamping the matched tags onto each suggestion for the UI.
//
// The blend is MULTIPLICATIVE — score × (1 + weight × overlap) — not additive,
// and the difference is the whole safety argument:
//
// - A candidate with no tags has overlap 0, so its score is EXACTLY
// unchanged. Partial tag coverage costs a candidate nothing.
// - Nothing can leapfrog on tags alone. An additive term with a large
// weight would let a near-zero-similarity artist outrank a strong match
// just for sharing a popular tag, which reads as noise to the user.
// - weight 0 disables the feature completely and restores pure similarity
// order, so the operator's knob has a real off position.
//
// Callers must pass the pool in similarity order; it is returned in blended
// order. Mutates the elements in place (they're the caller's own slice built
// per request), and re-sorts, because selectSuggestions downstream relies on
// score order for its head/tail split.
func applyTagOverlap(
pool []ArtistSuggestion, candidateTags map[string]TagWeights, taste TagWeights, weight float64,
) []ArtistSuggestion {
// A zero weight is the operator turning the feature off. Skip the work
// AND the re-sort so the ordering is bit-for-bit the pre-slice-6 result.
if weight == 0 || len(taste) == 0 {
return pool
}
for i := range pool {
overlap, matched := tagOverlap(candidateTags[pool[i].MBID], taste)
pool[i].MatchedTags = matched
pool[i].TagOverlap = overlap
pool[i].Score *= 1 + weight*overlap
}
sort.SliceStable(pool, func(i, j int) bool {
if pool[i].Score != pool[j].Score {
return pool[i].Score > pool[j].Score
}
// Stable tiebreak by MBID. Without it, two candidates on equal scores
// could swap between requests within the same day, which the daily
// rotation (#2373) exists to prevent.
return pool[i].MBID < pool[j].MBID
})
return pool
}
+244
View File
@@ -0,0 +1,244 @@
package recommendation
import (
"fmt"
"testing"
)
// tagOverlap / applyTagOverlap are pure, so slice 6's ranking rules are covered
// in the fast lane rather than behind the integration gate.
//
// Fixtures here are deliberately SKEWED — candidates that match the taste
// profile to clearly different degrees. An evenly-matching pool cannot exercise
// a re-ranking at all: every candidate gets the same multiplier and the order is
// unchanged whether the blend works or not. Slice 2's first diversity test had
// exactly that defect, so it's called out explicitly here.
func TestTagOverlap_FullMatchScoresOne(t *testing.T) {
// Every unit of the candidate's tag mass sits on the user's single
// strongest tag → the whole mass matches at full strength.
got, matched := tagOverlap(
TagWeights{"shoegaze": 1.0},
TagWeights{"shoegaze": 5.0},
)
if got != 1.0 {
t.Errorf("overlap = %v, want 1.0", got)
}
if len(matched) != 1 || matched[0] != "shoegaze" {
t.Errorf("matched = %v, want [shoegaze]", matched)
}
}
func TestTagOverlap_NoSharedTagsScoresZero(t *testing.T) {
got, matched := tagOverlap(
TagWeights{"death metal": 1.0},
TagWeights{"shoegaze": 5.0},
)
if got != 0 {
t.Errorf("overlap = %v, want 0", got)
}
if matched != nil {
t.Errorf("matched = %v, want nil", matched)
}
}
// Half the candidate's mass is on a matching tag, and that tag is the user's
// strongest → 0.5.
func TestTagOverlap_PartialMassMatchIsProportional(t *testing.T) {
got, _ := tagOverlap(
TagWeights{"shoegaze": 1.0, "death metal": 1.0},
TagWeights{"shoegaze": 5.0},
)
if got != 0.5 {
t.Errorf("overlap = %v, want 0.5", got)
}
}
// Matching a tag the user barely likes must score below matching one they love.
func TestTagOverlap_WeakTasteTagScoresLowerThanStrong(t *testing.T) {
taste := TagWeights{"shoegaze": 10.0, "polka": 1.0}
strong, _ := tagOverlap(TagWeights{"shoegaze": 1.0}, taste)
weak, _ := tagOverlap(TagWeights{"polka": 1.0}, taste)
if !(strong > weak) {
t.Errorf("strong=%v weak=%v — a favourite tag must outscore a marginal one", strong, weak)
}
if weak != 0.1 { // 1.0 * (1/10) / 1.0
t.Errorf("weak = %v, want 0.1", weak)
}
}
// THE degradation path. Tag coverage for out-of-library candidates is
// permanently partial (#2376) and cold-start users have no taste tags, so both
// must be scored 0 — never negative, never dropped (rule #131).
func TestTagOverlap_EmptyEitherSideIsZeroNotNegative(t *testing.T) {
cases := []struct {
name string
candidate, taste TagWeights
}{
{"no candidate tags", nil, TagWeights{"shoegaze": 5}},
{"no taste tags", TagWeights{"shoegaze": 1}, nil},
{"both empty", nil, nil},
{"taste weights all zero", TagWeights{"shoegaze": 1}, TagWeights{"shoegaze": 0}},
{"candidate weights all zero", TagWeights{"shoegaze": 0}, TagWeights{"shoegaze": 5}},
}
for _, c := range cases {
got, matched := tagOverlap(c.candidate, c.taste)
if got != 0 {
t.Errorf("%s: overlap = %v, want 0", c.name, got)
}
if matched != nil {
t.Errorf("%s: matched = %v, want nil", c.name, matched)
}
}
}
// A densely-tagged artist must not out-score a focused one merely by having
// more tags — that's what dividing by the candidate's own mass buys.
func TestTagOverlap_IsNotAPopularityContest(t *testing.T) {
taste := TagWeights{"shoegaze": 5.0}
focused, _ := tagOverlap(TagWeights{"shoegaze": 1.0}, taste)
sprawling, _ := tagOverlap(TagWeights{
"shoegaze": 1.0, "rock": 1.0, "alternative": 1.0, "90s": 1.0,
}, taste)
if !(focused > sprawling) {
t.Errorf("focused=%v sprawling=%v — extra unmatched tags must dilute, not add",
focused, sprawling)
}
}
// Taste weights accumulate with listening, so raw magnitudes differ wildly
// between a new user and a heavy one while meaning the same thing. Normalizing
// by the user's own strongest tag is what makes the score comparable.
func TestTagOverlap_IsInvariantToTasteMagnitude(t *testing.T) {
candidate := TagWeights{"shoegaze": 1.0, "dream pop": 1.0}
newUser, _ := tagOverlap(candidate, TagWeights{"shoegaze": 2.0, "polka": 1.0})
heavyUser, _ := tagOverlap(candidate, TagWeights{"shoegaze": 2000.0, "polka": 1000.0})
if newUser != heavyUser {
t.Errorf("newUser=%v heavyUser=%v — scaling all taste weights must not change the result",
newUser, heavyUser)
}
}
func TestTagOverlap_MatchedTagsAreOrderedAndCapped(t *testing.T) {
got, matched := tagOverlap(
TagWeights{"a": 0.2, "b": 0.9, "c": 0.5, "d": 0.7},
TagWeights{"a": 10, "b": 10, "c": 10, "d": 10},
)
if got <= 0 {
t.Fatalf("overlap = %v, want > 0", got)
}
// All taste weights equal, so candidate weight decides: b(.9) d(.7) c(.5).
want := []string{"b", "d", "c"}
if fmt.Sprint(matched) != fmt.Sprint(want) {
t.Errorf("matched = %v, want %v (strongest first, capped at %d)",
matched, want, maxMatchedTags)
}
}
// --- applyTagOverlap ---
func poolFor(specs ...struct {
mbid string
score float64
}) []ArtistSuggestion {
out := make([]ArtistSuggestion, 0, len(specs))
for _, s := range specs {
out = append(out, ArtistSuggestion{MBID: s.mbid, Name: s.mbid, Score: s.score})
}
return out
}
type spec = struct {
mbid string
score float64
}
// The payoff, and the reason the fixture is skewed: a taste-matching candidate
// that started BELOW another must be able to overtake it. With an evenly
// matching pool this assertion could not fail.
func TestApplyTagOverlap_TasteMatchOvertakesAStrongerNonMatch(t *testing.T) {
pool := poolFor(spec{"loud", 1.0}, spec{"match", 0.7})
candTags := map[string]TagWeights{
"loud": {"death metal": 1.0},
"match": {"shoegaze": 1.0},
}
taste := TagWeights{"shoegaze": 5.0}
got := applyTagOverlap(pool, candTags, taste, 1.0)
if got[0].MBID != "match" {
t.Errorf("first = %q, want match (0.7×2 = 1.4 beats 1.0×1)", got[0].MBID)
}
if got[0].MatchedTags == nil {
t.Error("matched tags not stamped onto the winner")
}
}
// An untagged candidate keeps its score EXACTLY. This is the multiplicative
// blend's whole safety argument: partial coverage costs a candidate nothing.
func TestApplyTagOverlap_UntaggedCandidateScoreIsUnchanged(t *testing.T) {
pool := poolFor(spec{"untagged", 0.9})
got := applyTagOverlap(pool, map[string]TagWeights{}, TagWeights{"shoegaze": 5}, 1.0)
if got[0].Score != 0.9 {
t.Errorf("score = %v, want 0.9 exactly (no tags must not penalise)", got[0].Score)
}
if got[0].MatchedTags != nil {
t.Errorf("matched = %v, want nil", got[0].MatchedTags)
}
}
// Weight 0 is the operator's off switch: the ordering must be bit-for-bit the
// pre-slice-6 result, not merely similar.
func TestApplyTagOverlap_ZeroWeightIsAnExactNoOp(t *testing.T) {
pool := poolFor(spec{"a", 1.0}, spec{"b", 0.7})
candTags := map[string]TagWeights{"b": {"shoegaze": 1.0}}
got := applyTagOverlap(pool, candTags, TagWeights{"shoegaze": 5.0}, 0)
if got[0].MBID != "a" || got[0].Score != 1.0 || got[1].Score != 0.7 {
t.Errorf("weight 0 changed the pool: %+v", got)
}
// And it must not stamp matched tags either — the UI would otherwise
// explain a boost that never happened.
if got[1].MatchedTags != nil {
t.Errorf("matched tags stamped while the feature is off: %v", got[1].MatchedTags)
}
}
// A user with no taste profile is the cold-start case: every candidate scores
// the same multiplier of 1, so similarity order must survive intact.
func TestApplyTagOverlap_NoTasteProfileLeavesOrderIntact(t *testing.T) {
pool := poolFor(spec{"a", 1.0}, spec{"b", 0.7}, spec{"c", 0.4})
candTags := map[string]TagWeights{"c": {"shoegaze": 1.0}}
got := applyTagOverlap(pool, candTags, nil, 1.0)
for i, want := range []string{"a", "b", "c"} {
if got[i].MBID != want {
t.Fatalf("order = %v..., want a b c", got[i].MBID)
}
}
}
// Bounded boost: even a perfect match at the maximum weight can only multiply
// by (1 + w), so a candidate cannot be catapulted arbitrarily far.
func TestApplyTagOverlap_BoostIsBoundedByOnePlusWeight(t *testing.T) {
pool := poolFor(spec{"perfect", 1.0})
got := applyTagOverlap(pool,
map[string]TagWeights{"perfect": {"shoegaze": 1.0}},
TagWeights{"shoegaze": 5.0}, 2.0)
if got[0].Score != 3.0 {
t.Errorf("score = %v, want 3.0 (1.0 × (1 + 2×1))", got[0].Score)
}
if got[0].TagOverlap != 1.0 {
t.Errorf("TagOverlap = %v, want 1.0", got[0].TagOverlap)
}
}
// Equal blended scores must resolve deterministically, or two candidates could
// swap between requests inside one day — the exact churn the daily rotation
// (#2373) exists to prevent.
func TestApplyTagOverlap_TiesBreakDeterministically(t *testing.T) {
first := applyTagOverlap(poolFor(spec{"zzz", 1.0}, spec{"aaa", 1.0}),
map[string]TagWeights{}, TagWeights{"x": 1}, 1.0)
second := applyTagOverlap(poolFor(spec{"aaa", 1.0}, spec{"zzz", 1.0}),
map[string]TagWeights{}, TagWeights{"x": 1}, 1.0)
if first[0].MBID != "aaa" || second[0].MBID != "aaa" {
t.Errorf("tie order not deterministic: %q then %q", first[0].MBID, second[0].MBID)
}
}
+64
View File
@@ -161,6 +161,70 @@ func applyTastePatch(current TasteTuning, patch map[string]float64) (TasteTuning
return next, changes, nil 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). // diffWeights returns per-field changes from a to b (empty when equal).
func diffWeights(a, b recommendation.ScoringWeights) []fieldChange { func diffWeights(a, b recommendation.ScoringWeights) []fieldChange {
var out []fieldChange var out []fieldChange
+99
View File
@@ -35,6 +35,11 @@ const (
ScopeRadio = "radio" ScopeRadio = "radio"
ScopeDailyMix = "daily_mix" ScopeDailyMix = "daily_mix"
ScopeTaste = "taste" 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 // 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. // ShippedTasteTuning mirrors taste.DefaultConfig's tunable subset.
func ShippedTasteTuning() TasteTuning { func ShippedTasteTuning() TasteTuning {
d := taste.DefaultConfig() d := taste.DefaultConfig()
@@ -110,6 +142,7 @@ type Service struct {
mu sync.RWMutex mu sync.RWMutex
profiles map[string]recommendation.ScoringWeights profiles map[string]recommendation.ScoringWeights
taste TasteTuning taste TasteTuning
discover DiscoverTuning
} }
// New boots the service: seeds shipped defaults for missing rows, // New boots the service: seeds shipped defaults for missing rows,
@@ -151,6 +184,13 @@ func (s *Service) reconcile(ctx context.Context) error {
}); err != nil { }); err != nil {
return fmt.Errorf("seed taste tuning: %w", err) 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) rows, err := q.ListWeightProfiles(ctx)
if err != nil { if err != nil {
@@ -160,6 +200,10 @@ func (s *Service) reconcile(ctx context.Context) error {
if err != nil { if err != nil {
return fmt.Errorf("get taste tuning: %w", err) 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.mu.Lock()
s.profiles = map[string]recommendation.ScoringWeights{} s.profiles = map[string]recommendation.ScoringWeights{}
@@ -175,6 +219,10 @@ func (s *Service) reconcile(ctx context.Context) error {
EraScale: tt.EraScale, EraScale: tt.EraScale,
MoodScale: tt.MoodScale, MoodScale: tt.MoodScale,
} }
s.discover = DiscoverTuning{
TagOverlapWeight: dt.TagOverlapWeight,
SnoozeDays: dt.SnoozeDays,
}
s.mu.Unlock() s.mu.Unlock()
s.push() s.push()
@@ -208,6 +256,15 @@ func (s *Service) Taste() TasteTuning {
return s.taste 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 // TasteConfig assembles the full taste.Config the profile builder
// consumes: shipped non-tunable knobs (like bonuses, floors, caps) // consumes: shipped non-tunable knobs (like bonuses, floors, caps)
// plus the tuned half-life and curve. WindowDays scales with the // 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) 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 // 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. // carrying the full diff. A scope already at defaults is a no-op.
func (s *Service) Reset(ctx context.Context, scope string) error { 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 nil
} }
return s.persistTaste(ctx, shipped, "reset", changes) 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: default:
return fmt.Errorf("%w: %q", ErrUnknownScope, scope) return fmt.Errorf("%w: %q", ErrUnknownScope, scope)
} }
@@ -338,6 +415,28 @@ func (s *Service) persistTaste(
return nil 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 // audit writes one recommendation_tuning_audit row. Changes are
// sorted by field so rows are deterministic and diff-friendly. // sorted by field so rows are deterministic and diff-friendly.
func (s *Service) audit( func (s *Service) audit(
+115
View File
@@ -281,3 +281,118 @@ func TestUpdate_NoOpWritesNoAudit(t *testing.T) {
t.Errorf("no-op update wrote %d audit rows, want 0", len(rows)) 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))
}
}