docs: wanted, dbtest, recommendation and playlists comments say what the code does (#6277)
The same rewrite as d191a282, for four packages whose comments carried the
most history: tuning stories, quotes from the operator, single-install
measurements in library_scale.go, and 'used to' accounts of bugs. The
constraint each story protected stays as a present-tense reason (why tier 3
is reduced not removed, why the randomised arms take an order seed, why the
seed artist is eligible in Songs-like). Comments naming system_cron.go and
config.RecommendationConfig, neither of which holds what they claimed, now
name the scheduler and internal/recsettings.
Checked: non-comment tokens identical to HEAD; gofmt clean.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -10,11 +10,11 @@ import (
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
// Slice 5 (#2376): the candidate-artist tag cache for out-of-library Discover
|
||||
// candidates. These cover the SQL rather than the provider chain — the
|
||||
// eligibility query is the piece with real risk in it (a GROUP BY over a
|
||||
// many-rows-per-candidate table, a LEFT JOIN to bookkeeping, and two exclusion
|
||||
// branches), and it's consumed by this package's slice-6 ranking.
|
||||
// The candidate-artist tag cache for out-of-library Discover candidates. These
|
||||
// cover the SQL rather than the provider chain — the eligibility query is the
|
||||
// piece with real risk in it (a GROUP BY over a many-rows-per-candidate table,
|
||||
// a LEFT JOIN to bookkeeping, and two exclusion branches), and it feeds this
|
||||
// package's taste-tag ranking.
|
||||
//
|
||||
// Fixtures live here because the harness and seedUnmatched do.
|
||||
|
||||
@@ -262,7 +262,7 @@ func TestCandidateTags_GcDropsOrphansAndKeepsLiveOnes(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Coverage is the operator's window onto the honest ceiling: processed vs
|
||||
// Coverage is the admin's window onto the honest ceiling: processed vs
|
||||
// actually-tagged. A big gap means thin upstream data, not a broken worker.
|
||||
func TestCandidateTags_CoverageCountsProcessedAndTagged(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
@@ -282,7 +282,7 @@ func TestCandidateTags_CoverageCountsProcessedAndTagged(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Slice 6 (#2377): the taste-tag blend, end to end ---
|
||||
// --- 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
|
||||
@@ -348,7 +348,7 @@ func TestSuggestArtists_TasteTagMatchOvertakesStrongerSimilarity(t *testing.T) {
|
||||
}
|
||||
|
||||
// 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.
|
||||
// off switch, verified against a real DB rather than assumed.
|
||||
func TestSuggestArtists_ZeroTagWeightKeepsSimilarityOrder(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
user := seedUser(t, pool, "alice")
|
||||
@@ -367,7 +367,7 @@ func TestSuggestArtists_ZeroTagWeightKeepsSimilarityOrder(t *testing.T) {
|
||||
}
|
||||
|
||||
// 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).
|
||||
// the cold-start path, which is every user's first days.
|
||||
func TestSuggestArtists_NoTasteTagsStillReturnsSimilarityOrder(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
user := seedUser(t, pool, "alice")
|
||||
@@ -388,7 +388,7 @@ func TestSuggestArtists_NoTasteTagsStillReturnsSimilarityOrder(t *testing.T) {
|
||||
}
|
||||
|
||||
// An untagged candidate must never be dropped or sunk just because another
|
||||
// candidate has tags — permanently-partial coverage (#2376) must not become a
|
||||
// candidate has tags — permanently-partial coverage must not become a
|
||||
// permanent ranking penalty.
|
||||
func TestSuggestArtists_UntaggedCandidateSurvivesAlongsideTagged(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
|
||||
@@ -82,11 +82,11 @@ type CandidateSourceLimits struct {
|
||||
TagOverlap int
|
||||
LikesOverlap int
|
||||
RandomFill int
|
||||
// TasteOverlap (#796 phase 2b): tracks by the user's top positively-
|
||||
// TasteOverlap: tracks by the user's top positively-
|
||||
// weighted taste-profile artists. 0 disables the arm (e.g. cold-start
|
||||
// users have an empty profile, so it contributes nothing anyway).
|
||||
TasteOverlap int
|
||||
// UserCoplay (#1533): tracks by artists co-played across the instance
|
||||
// UserCoplay: tracks by artists co-played across the instance
|
||||
// with the seed's artist (source='user_cooccurrence'). Empty on
|
||||
// single-user servers, so it contributes nothing there.
|
||||
UserCoplay int
|
||||
@@ -105,21 +105,18 @@ func DefaultCandidateSourceLimits() CandidateSourceLimits {
|
||||
}
|
||||
}
|
||||
|
||||
// SongsLikeCandidateSourceLimits is the pool shape for "Songs like {X}"
|
||||
// (#3881). Same total size as the default (~170) — the composition is what
|
||||
// changes, shifted hard toward arms that actually measure distance from the
|
||||
// seed.
|
||||
// SongsLikeCandidateSourceLimits is the pool shape for "Songs like {X}".
|
||||
// Same total size as the default (~170) — the composition is what changes,
|
||||
// shifted hard toward arms that actually measure distance from the seed.
|
||||
//
|
||||
// The surface answers "what sounds like THIS", and it shared the default
|
||||
// pool with For-You, which answers the much broader "what will they enjoy
|
||||
// today". Under the default, 50 of ~170 candidates carried sim_score = 0 by
|
||||
// construction — `taste_overlap` (tracks by the user's top taste artists) and
|
||||
// `random_fill` (literally any track not already in the pool), both of which
|
||||
// are seed-INDEPENDENT. Nearly a third of the pool had no relationship to the
|
||||
// seed at all, and the operator saw it: "I was getting a seeming wide variety
|
||||
// of music from each one when I was hoping to stay in a certain neighborhood."
|
||||
// The surface answers "what sounds like THIS", where For-You answers the much
|
||||
// broader "what will they enjoy today". In the default pool, 50 of ~170
|
||||
// candidates carry sim_score = 0 by construction — `taste_overlap` (tracks by
|
||||
// the user's top taste artists) and `random_fill` (any track not already in
|
||||
// the pool), both seed-INDEPENDENT — so nearly a third of it has no
|
||||
// relationship to the seed.
|
||||
//
|
||||
// TIERED, per rule 131 — a system mix degrades, it never vanishes:
|
||||
// TIERED — a system mix degrades, it never vanishes:
|
||||
//
|
||||
// tier 1 lb_similar real track-level similarity. The exact promise.
|
||||
// tier 2 similar_artist, tag_overlap, coplay, likes_overlap
|
||||
@@ -129,43 +126,23 @@ func DefaultCandidateSourceLimits() CandidateSourceLimits {
|
||||
//
|
||||
// The tiering is enforced by SCORE rather than by a fallback ladder: each tier
|
||||
// holds its own sim_score band (recommendation.sql; tier 1 above 0.5, tier 2
|
||||
// at or below it, #6177), and tier-3 arms carry sim_score 0, so under SongsLike weights (SimilarityWeight 4.0,
|
||||
// everything seed-independent demoted) they rank below any real match and
|
||||
// surface only when tiers 1–2 cannot fill the mix. That is the rule's
|
||||
// "fill from tier 1 first, reach down only when a tier cannot fill".
|
||||
// at or below it), and tier-3 arms carry sim_score 0, so under the SongsLike
|
||||
// weights (SimilarityWeight 4.0, everything seed-independent demoted) they
|
||||
// rank below any real match and surface only when tiers 1–2 cannot fill the
|
||||
// mix: fill from tier 1 first, reach down only when a tier cannot fill.
|
||||
//
|
||||
// Which is exactly why tier 3 is REDUCED rather than removed. Zeroing those
|
||||
// two arms was the first instinct and it is the vanish-or-nothing shape rule
|
||||
// 131 exists to forbid: a seed whose artist has thin ListenBrainz coverage
|
||||
// would produce a short mix or none at all, and "no playlist" is a worse
|
||||
// answer than "a few tracks further from the seed than we would like".
|
||||
// Tier 3 is REDUCED, never removed. Zeroing those two arms would leave a seed
|
||||
// whose artist has thin ListenBrainz coverage with a short mix or none at
|
||||
// all, and "no playlist" is a worse answer than "a few tracks further from
|
||||
// the seed than we would like". The trim is safe because those arms order by
|
||||
// md5(id || seed): a smaller limit takes a smaller but REPRODUCIBLE slice, so
|
||||
// same-day rebuilds still agree.
|
||||
//
|
||||
// The seed-independent arms are trimmed hardest, because on this surface they
|
||||
// are noise: `taste_overlap` (tracks by the user's top taste artists) and
|
||||
// `random_fill` (any track not already in the pool) both carry
|
||||
// `0.0::float8 AS sim_score`, so nearly a third of the default pool had no
|
||||
// relationship to the seed at all.
|
||||
//
|
||||
// THESE TRIMS WERE BLOCKED UNTIL #3889. `likes_overlap` and `random_fill`
|
||||
// used to end in a bare `ORDER BY random()`, which made their output a stable
|
||||
// SET only while the limit exceeded the eligible rows — so SHRINKING them
|
||||
// changed pool membership between same-day rebuilds and broke daily
|
||||
// determinism. Those arms now order by md5(id || seed), so a smaller limit
|
||||
// takes a smaller but REPRODUCIBLE slice, and the trim is safe.
|
||||
//
|
||||
// Reduced, never removed. Rule 131: the two seed-independent arms are the
|
||||
// tier-3 floor, and zeroing them would leave a seed with thin ListenBrainz
|
||||
// coverage producing a short mix or none at all. The weights (SimilarityWeight
|
||||
// 4.0, everything seed-independent demoted) keep them ranked last, so they
|
||||
// surface only when the closer tiers cannot fill the mix.
|
||||
//
|
||||
// likes_overlap is cut hardest of the tier-2 arms for a specific reason: its
|
||||
// SQL assigns a FLAT 0.3 sim_score (recommendation.sql) rather than measuring
|
||||
// anything. It is a collaborative signal wearing similarity's clothes, and a
|
||||
// raised SimilarityWeight amplifies it. The tier bands keep it below every
|
||||
// ListenBrainz match (#6177); halved pending the fill-rate measurement in
|
||||
// #3879, and the honest fix is still to stop it claiming a similarity score
|
||||
// it never computed.
|
||||
// likes_overlap is cut hardest of the tier-2 arms: its SQL assigns a FLAT 0.3
|
||||
// sim_score (recommendation.sql) rather than measuring anything. It is a
|
||||
// collaborative signal wearing similarity's clothes, and a raised
|
||||
// SimilarityWeight amplifies it. The tier bands keep it below every
|
||||
// ListenBrainz match.
|
||||
func SongsLikeCandidateSourceLimits() CandidateSourceLimits {
|
||||
return CandidateSourceLimits{
|
||||
LBSimilar: 60, // tier 1 — doubled; the only arm that measures the seed
|
||||
@@ -178,13 +155,13 @@ func SongsLikeCandidateSourceLimits() CandidateSourceLimits {
|
||||
}
|
||||
}
|
||||
|
||||
// LoadCandidatesFromSimilarity is M4c's primary candidate-pool loader.
|
||||
// LoadCandidatesFromSimilarity is the primary candidate-pool loader.
|
||||
// 5-way SQL UNION (LB-similar / similar-artist tracks / MB-tag overlap /
|
||||
// likes-overlap / random fill) + dedup-by-max sim_score. Returns
|
||||
// []Candidate (same shape as LoadCandidates) so Shuffle is unchanged.
|
||||
//
|
||||
// orderSeed decides whether the randomised arms repeat their draw — see
|
||||
// Column12 below and #3889. Pass a stable per-(user, day) value where the
|
||||
// Column12 below. Pass a stable per-(user, day) value where the
|
||||
// selection must be reproducible, and a varying one where it should not be.
|
||||
//
|
||||
// Caller (radio handler) falls back to LoadCandidates on error.
|
||||
@@ -213,10 +190,8 @@ func LoadCandidatesFromSimilarity(
|
||||
Limit_5: int32(limits.RandomFill),
|
||||
Limit_6: int32(limits.TasteOverlap),
|
||||
Limit_7: int32(limits.UserCoplay),
|
||||
// #3889. Four arms used to end in a bare ORDER BY random(), which made
|
||||
// their output a stable SET only while the limit exceeded the eligible
|
||||
// rows. They now order by md5(id || this), so the caller decides
|
||||
// whether the draw repeats: a per-(user, day) seed for the system
|
||||
// The randomised arms order by md5(id || this) rather than random(),
|
||||
// so the caller decides whether the draw repeats: a per-(user, day) seed for the system
|
||||
// mixes that promise daily determinism, a fresh one per radio request.
|
||||
Column12: orderSeed,
|
||||
})
|
||||
@@ -282,7 +257,7 @@ func LoadCandidatesFromSimilarity(
|
||||
}
|
||||
|
||||
// loadCandidateMoods fetches the enriched tags for the given candidate tracks
|
||||
// and reduces each to its canonical mood buckets (internal/mood, #1534), so the
|
||||
// and reduces each to its canonical mood buckets (internal/mood), so the
|
||||
// scorer can apply the mood facet per candidate. Tracks with no mood-word tags
|
||||
// are absent from the map (→ no mood signal). Empty input short-circuits.
|
||||
func loadCandidateMoods(
|
||||
|
||||
@@ -70,7 +70,7 @@ func TestLoadCandidatesFromSimilarity_LBSimilarSourceContributes(t *testing.T) {
|
||||
if found == nil {
|
||||
t.Fatal("LB-similar target missing from candidates")
|
||||
}
|
||||
// Tier 1's band (#6177): 0.5 + 0.5 × 0.85.
|
||||
// Tier 1's band: 0.5 + 0.5 × 0.85.
|
||||
if found.Inputs.SimilarityScore < 0.92 || found.Inputs.SimilarityScore > 0.93 {
|
||||
t.Errorf("LB-similar SimilarityScore = %v, want ~0.925 (0.5 + 0.5 × 0.85)", found.Inputs.SimilarityScore)
|
||||
}
|
||||
@@ -258,7 +258,7 @@ func TestLoadCandidatesFromSimilarity_DedupTakesMaxScore(t *testing.T) {
|
||||
if c.Track.ID == target.ID {
|
||||
count++
|
||||
// max() keeps LB's 0.55. Even a weak ListenBrainz edge is tier 1 and
|
||||
// outranks a full tag match (#6177).
|
||||
// outranks a full tag match.
|
||||
if c.Inputs.SimilarityScore < 0.54 || c.Inputs.SimilarityScore > 0.56 {
|
||||
t.Errorf("dedup max SimilarityScore = %v, want ~0.55 (the LB edge's band should win)", c.Inputs.SimilarityScore)
|
||||
}
|
||||
@@ -269,7 +269,7 @@ func TestLoadCandidatesFromSimilarity_DedupTakesMaxScore(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadCandidatesFromSimilarity_TasteOverlapArm (#796 phase 2b): a track by
|
||||
// TestLoadCandidatesFromSimilarity_TasteOverlapArm: a track by
|
||||
// a positively-weighted taste-profile artist enters the pool via taste_overlap
|
||||
// even with every other arm disabled; a negatively-weighted artist's track does
|
||||
// not (the WHERE weight > 0 filter).
|
||||
@@ -339,22 +339,16 @@ func TestLoadCandidatesFromSimilarity_EmptyLibrary_NoError(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The randomised arms must draw REPRODUCIBLY for a given seed (#3889).
|
||||
// The randomised arms must draw REPRODUCIBLY for a given seed.
|
||||
//
|
||||
// Four arms used to end in a bare `ORDER BY random()`. That returned a stable
|
||||
// set only while the arm's LIMIT exceeded the rows eligible for it — at that
|
||||
// point it returned all of them and the order stopped mattering, because the
|
||||
// caller sorts by track id before scoring. Below that threshold it returned a
|
||||
// random SUBSET, so two calls drew different candidates.
|
||||
// An arm ordered by bare `random()` returns a stable set only while its LIMIT
|
||||
// exceeds the rows eligible for it (it returns all of them, and the caller
|
||||
// sorts by track id). Below that it returns a random SUBSET, so two calls
|
||||
// draw different candidates — invisible, because a mix that changes after a
|
||||
// refresh looks like a feature.
|
||||
//
|
||||
// It therefore held by ACCIDENT, and only for libraries smaller than the
|
||||
// limits. Any real library is larger, so same-day rebuilds had been drawing
|
||||
// different mixes since the arm was written — invisible, because a mix that
|
||||
// changes after a refresh looks like a feature.
|
||||
//
|
||||
// Limits deliberately smaller than the fixture, because that is the only
|
||||
// regime where the bug existed at all: with limits above the eligible count
|
||||
// the old code passes this too.
|
||||
// The limits are deliberately smaller than the fixture: with limits above the
|
||||
// eligible count, an unseeded arm passes this too.
|
||||
func TestLoadCandidatesFromSimilarity_SameSeedDrawsTheSameSet(t *testing.T) {
|
||||
f := newFixture(t, 12)
|
||||
seed := f.tracks[0]
|
||||
@@ -433,10 +427,9 @@ func TestLoadCandidatesFromSimilarity_DifferentSeedsCanDrawDifferently(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
// #5297: an artist-level arm takes a track from each related artist before a
|
||||
// second from any. Ordered by artist score alone, the closest artist's
|
||||
// catalogue filled the whole LIMIT; on the operator's library the
|
||||
// similar_artists arm held one artist for every seed measured (#3879).
|
||||
// An artist-level arm takes a track from each related artist before a second
|
||||
// from any. Ordered by artist score alone, the closest artist's catalogue
|
||||
// would fill the whole LIMIT.
|
||||
func TestLoadCandidatesFromSimilarity_ArtistArmsRoundRobin(t *testing.T) {
|
||||
for _, source := range []string{"listenbrainz", "user_cooccurrence"} {
|
||||
t.Run(source, func(t *testing.T) {
|
||||
@@ -500,8 +493,8 @@ func TestLoadCandidatesFromSimilarity_ArtistArmsRoundRobin(t *testing.T) {
|
||||
}
|
||||
|
||||
// The taste arm takes one track from each taste artist before a second from
|
||||
// any (#3880). Ordered by weight then track id, the strongest artist's
|
||||
// catalogue filled the arm: 20 tracks of one artist on the operator's library.
|
||||
// any. Ordered by weight then track id, the strongest artist's catalogue would
|
||||
// fill the arm.
|
||||
func TestLoadCandidatesFromSimilarity_TasteArmRoundRobinsAcrossArtists(t *testing.T) {
|
||||
f := newFixture(t, 1)
|
||||
ctx := context.Background()
|
||||
@@ -552,9 +545,9 @@ func TestLoadCandidatesFromSimilarity_TasteArmRoundRobinsAcrossArtists(t *testin
|
||||
}
|
||||
}
|
||||
|
||||
// Tracks tied on tag overlap are taken in the order seed's order (#3880): the
|
||||
// same seed takes the same slice, another seed another. With no tie-break a
|
||||
// one-tag seed's LIMIT took whichever rows Postgres reached first.
|
||||
// Tracks tied on tag overlap are taken in the order seed's order: the same
|
||||
// seed takes the same slice, another seed another. With no tie-break a
|
||||
// one-tag seed's LIMIT would take whichever rows Postgres reached first.
|
||||
func TestLoadCandidatesFromSimilarity_TagTiesFollowTheOrderSeed(t *testing.T) {
|
||||
f := newFixture(t, 21)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -15,7 +15,7 @@ import (
|
||||
const contextAffinityShrinkage = 5.0
|
||||
|
||||
// ContextAffinity is the read-side map of per-artist time-of-day/weekday
|
||||
// affinity for the CURRENT context (#1531): artist_id → score in [-1, +1].
|
||||
// affinity for the CURRENT context: artist_id → score in [-1, +1].
|
||||
// Absent artists (no play history) score 0, so cold-start candidates stay
|
||||
// neutral. The zero value is a valid all-neutral affinity.
|
||||
type ContextAffinity struct {
|
||||
@@ -29,7 +29,7 @@ func (c ContextAffinity) Affinity(artistID pgtype.UUID) float64 {
|
||||
|
||||
// LoadContextAffinity computes each artist's affinity for the user's CURRENT
|
||||
// context cell — daypart × weekday, narrowed by deviceClass when non-empty
|
||||
// (#1551; radio passes the current device, the daily mixes pass "" for a
|
||||
// (radio passes the current device, the daily mixes pass "" for a
|
||||
// device-agnostic cell). For every artist with completed plays in the window it
|
||||
// compares the share of that artist's plays that fall in the current cell
|
||||
// against the user's overall baseline share, shrinking sparse artists toward
|
||||
|
||||
@@ -33,7 +33,7 @@ func insertPlayEventDevice(
|
||||
|
||||
// TestLoadContextAffinity_DeviceNarrowing seeds one artist played only on
|
||||
// mobile and another only on web (all in the current time cell), and verifies
|
||||
// the current-device parameter narrows the affinity cell (#1551): on 'mobile'
|
||||
// the current-device parameter narrows the affinity cell: on 'mobile'
|
||||
// the mobile artist out-scores the web artist and vice-versa, while the
|
||||
// device-agnostic (”) pass treats them equally.
|
||||
func TestLoadContextAffinity_DeviceNarrowing(t *testing.T) {
|
||||
|
||||
@@ -11,44 +11,26 @@ import (
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
)
|
||||
|
||||
// Scaling the candidate pool to the library (#3880).
|
||||
// Scaling the candidate pool to the library.
|
||||
//
|
||||
// DefaultCandidateSourceLimits returns what its own comment calls "the v1
|
||||
// hardcoded constants per spec" — ~170 candidates, identical for a 500-track
|
||||
// library and a 100,000-track one. Operator, 2026-09-10: "is the pool that we
|
||||
// draw from somehow scaled to the amount of music in the library... my earlier
|
||||
// understanding of the tuning and work may have been skewed by what was in my
|
||||
// library."
|
||||
//
|
||||
// It was not, and the consequence compounds. The pool samples a shrinking
|
||||
// FRACTION of the library as it grows — 17% of 1,000 tracks, 1.7% of 10,000,
|
||||
// 0.17% of 100,000 — so the ceiling on how much of a collection can ever
|
||||
// surface stays flat while the collection does not. RandomFill, whose entire
|
||||
// job is exploration, becomes a thinner and noisier slice of a more diverse
|
||||
// corpus at exactly the moment diversity rises.
|
||||
// DefaultCandidateSourceLimits is ~170 candidates, whatever the library's
|
||||
// size. Unscaled, the pool samples a shrinking FRACTION of the library as it
|
||||
// grows — 17% of 1,000 tracks, 1.7% of 10,000, 0.17% of 100,000 — so the
|
||||
// ceiling on how much of a collection can ever surface stays flat while the
|
||||
// collection does not, and RandomFill, whose job is exploration, becomes a
|
||||
// thinner slice of a more diverse corpus.
|
||||
|
||||
const (
|
||||
// libraryScaleReference is the library size at which the base limits
|
||||
// apply unchanged. Below it nothing scales, so small libraries keep
|
||||
// today's behaviour exactly.
|
||||
//
|
||||
// ASSUMED, NOT MEASURED. The size the v1 constants were actually tuned
|
||||
// against is unrecorded; 5,000 is a plausible mid-size library and a
|
||||
// deliberately conservative place to start growing. This constant is the
|
||||
// single thing to change.
|
||||
//
|
||||
// Measured on the operator's library, 26,005 tracks (scale 2.28), for the
|
||||
// 15 most-played seeds and two Moe Shop tracks (#3880, 2026-10-21):
|
||||
// lb_similar 0–6 edges (3 of 17 seeds have any): never reaches
|
||||
// even the base limit, so scaling it costs nothing
|
||||
// similar_artists 860–6,307 tracks over 7–33 artists: always full
|
||||
// tag_overlap 4,658–19,658 tracks: always full
|
||||
// likes_overlap 193–497 liked tracks sharing a tag: always full
|
||||
// taste_overlap 20,045 tracks over 174 artists: always full
|
||||
// coplay 0 rows: a single-user server
|
||||
// So the arms the scale grows all have far more rows than it asks for, and
|
||||
// the tuned-against size cannot be recovered from data; the number stays
|
||||
// until a library shows a scaled arm running dry or a pool too slow.
|
||||
// An assumption, not a measurement: 5,000 is a plausible mid-size library
|
||||
// and a deliberately conservative place to start growing. On a large
|
||||
// library the arms the scale grows (similar_artists, tag_overlap,
|
||||
// likes_overlap, taste_overlap) hold far more rows than it asks for, while
|
||||
// lb_similar rarely reaches even its base limit. Change this if a library
|
||||
// shows a scaled arm running dry or a pool too slow.
|
||||
libraryScaleReference = 5000
|
||||
|
||||
// maxLibraryScale bounds growth so a very large library does not turn
|
||||
@@ -63,9 +45,9 @@ const (
|
||||
// Square-root growth keeps the pool meaningfully proportional while staying
|
||||
// bounded: 1x at the reference, 2x at four times it, 4x at sixteen times.
|
||||
//
|
||||
// Never returns below 1: the base limits are a floor, not a midpoint. That
|
||||
// also keeps this safe against #3889 — growing an arm ordered by unseeded
|
||||
// random() is fine, shrinking one is what breaks same-day determinism.
|
||||
// Never returns below 1: the base limits are a floor, not a midpoint. Growing
|
||||
// an arm ordered by unseeded random() is harmless; shrinking one would break
|
||||
// same-day determinism.
|
||||
func libraryScale(libraryTracks int64) float64 {
|
||||
if libraryTracks <= libraryScaleReference {
|
||||
return 1.0
|
||||
@@ -113,8 +95,7 @@ func ScaleForLibrary(base CandidateSourceLimits, libraryTracks int64) CandidateS
|
||||
// CountLibraryTracks returns the whole library's track count.
|
||||
//
|
||||
// Reuses CountTracksMatching with an empty pattern — `title ILIKE '%%'`
|
||||
// matches every row — rather than adding a query, because a new one would
|
||||
// need sqlc regeneration (rule 28, and see #3889 for where that blocks).
|
||||
// matches every row — rather than adding a query.
|
||||
// The user id is left invalid so the quarantine anti-join is skipped: this
|
||||
// number sizes a pool, and a per-user view of it would be false precision.
|
||||
func CountLibraryTracks(ctx context.Context, q *dbq.Queries) (int64, error) {
|
||||
@@ -155,11 +136,11 @@ func NewLibrarySize(now func() time.Time) *LibrarySize {
|
||||
// Get returns the cached size, refreshing through load when stale.
|
||||
//
|
||||
// A NIL RECEIVER IS VALID and means "no cache": the count still runs, it is
|
||||
// just not memoised. That is deliberate rather than defensive habit. The api
|
||||
// handlers struct is built directly by a dozen tests that cannot know about
|
||||
// every field, and a nil here previously panicked inside a radio request —
|
||||
// turning a missing pool-sizing HINT into a 500. Uncached-but-correct is the
|
||||
// right failure for something whose whole contract is that it degrades.
|
||||
// just not memoised. The api handlers struct is built directly by a dozen
|
||||
// tests that cannot know about every field, and a panic here would turn a
|
||||
// missing pool-sizing HINT into a 500 inside a radio request.
|
||||
// Uncached-but-correct is the right failure for something whose whole
|
||||
// contract is that it degrades.
|
||||
func (l *LibrarySize) Get(ctx context.Context, load func(context.Context) (int64, error)) int64 {
|
||||
if l == nil {
|
||||
n, err := loadWithDeadline(ctx, load)
|
||||
@@ -191,8 +172,8 @@ func (l *LibrarySize) Get(ctx context.Context, load func(context.Context) (int64
|
||||
return l.size
|
||||
}
|
||||
|
||||
// loadWithDeadline bounds the count. Rule 156: the caller is a user waiting
|
||||
// on a radio, and a pool-sizing hint is never worth hanging for.
|
||||
// loadWithDeadline bounds the count: the caller is a user waiting on a radio,
|
||||
// and a pool-sizing hint is never worth hanging for.
|
||||
func loadWithDeadline(ctx context.Context, load func(context.Context) (int64, error)) (int64, error) {
|
||||
cctx, cancel := context.WithTimeout(ctx, librarySizeTimeout)
|
||||
defer cancel()
|
||||
|
||||
@@ -18,9 +18,7 @@ func TestScaleForLibrary_SmallLibrariesAreUntouched(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// The complaint, restated as a property: a bigger library must reach further
|
||||
// into itself. Operator, 2026-09-10 — "is the pool that we draw from somehow
|
||||
// scaled to the amount of music in the library".
|
||||
// A bigger library must reach further into itself.
|
||||
func TestScaleForLibrary_BigLibrariesGetABiggerPool(t *testing.T) {
|
||||
base := DefaultCandidateSourceLimits()
|
||||
small := ScaleForLibrary(base, libraryScaleReference)
|
||||
@@ -86,9 +84,9 @@ func TestScaleForLibrary_GrowthIsBounded(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Never below the base. The base limits are a floor, not a midpoint — and
|
||||
// #3889 makes this load-bearing rather than tidy: shrinking an arm ordered by
|
||||
// unseeded random() changes pool membership between same-day rebuilds.
|
||||
// Never below the base. The base limits are a floor, not a midpoint, and
|
||||
// shrinking an arm ordered by unseeded random() would change pool membership
|
||||
// between same-day rebuilds.
|
||||
func TestScaleForLibrary_NeverShrinksAnArm(t *testing.T) {
|
||||
base := DefaultCandidateSourceLimits()
|
||||
for _, size := range []int64{0, 1, 100, 4999, 5001, 1_000_000} {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
// Package recommendation implements the weighted-shuffle scoring engine
|
||||
// from spec §6. The Score function is pure and takes an injectable RNG so
|
||||
// Package recommendation implements the weighted-shuffle scoring engine.
|
||||
// The Score function is pure and takes an injectable RNG so
|
||||
// tests can pin jitter to deterministic values.
|
||||
package recommendation
|
||||
|
||||
@@ -20,20 +20,20 @@ type ScoringInputs struct {
|
||||
ContextualMatchScore float64 // [0, 1]; 0 when no signal
|
||||
SimilarityScore float64 // [0, 1]; 0 when no signal (random fill)
|
||||
// TasteMatchScore is the candidate's fit to the user's learned taste
|
||||
// profile (#796 phase 2), in [-1, +1]: positive draws a track toward the
|
||||
// profile, in [-1, +1]: positive draws a track toward the
|
||||
// user's taste, negative reflects passive avoidance, 0 when there's no
|
||||
// profile signal (cold start / artist+tags absent from the profile).
|
||||
TasteMatchScore float64
|
||||
// ContextAffinityScore is the candidate artist's time-of-day/weekday
|
||||
// affinity for the CURRENT context (#1531), in [-1, +1]: positive when the
|
||||
// affinity for the CURRENT context, in [-1, +1]: positive when the
|
||||
// artist's plays concentrate in the current daypart × weekday-type cell
|
||||
// more than the user's baseline, negative when under-represented, 0 when
|
||||
// there's no history (cold-start neutral).
|
||||
ContextAffinityScore float64
|
||||
}
|
||||
|
||||
// ScoringWeights are the operator-tunable knobs. Defaults live in
|
||||
// config.RecommendationConfig and are propagated here per request.
|
||||
// ScoringWeights are the tunable knobs. The shipped values and any tuned ones
|
||||
// live in internal/recsettings, which hands them in per request.
|
||||
type ScoringWeights struct {
|
||||
BaseWeight float64
|
||||
LikeBoost float64
|
||||
@@ -46,7 +46,7 @@ type ScoringWeights struct {
|
||||
ContextTimeWeight float64
|
||||
}
|
||||
|
||||
// Score computes the weighted-shuffle score per spec §6:
|
||||
// Score computes the weighted-shuffle score:
|
||||
//
|
||||
// score = base
|
||||
// + (is_general_liked ? LikeBoost : 0)
|
||||
|
||||
@@ -13,7 +13,7 @@ type SessionVector struct {
|
||||
Artists []string `json:"artists"`
|
||||
Tags map[string]int `json:"tags"`
|
||||
RecentTrackIDs []string `json:"recent_track_ids"`
|
||||
// DeviceClass is the current request's device (#1551), set by the radio
|
||||
// DeviceClass is the current request's device, set by the radio
|
||||
// handler from the user's latest play; drives the device dimension of the
|
||||
// context-affinity term. Empty (omitted) for the daily mixes and for the
|
||||
// snapshot stored on play_events, so it never narrows those.
|
||||
@@ -55,12 +55,6 @@ func uuidString(u pgtype.UUID) string {
|
||||
// multi-genre delimiters (`;`, `,`) used by various tag editors. Trims
|
||||
// whitespace; drops empty fragments. Strings with no delimiter come back
|
||||
// as a single-element slice.
|
||||
//
|
||||
// This comment used to blame concatenated inputs like
|
||||
// "ElectronicComplextroGlitch Hop" on broken tag editors. They were ours: the
|
||||
// scanner stored dhowden/tag's welded multi-value frames verbatim. Fixed in
|
||||
// #2499 — the scanner now writes ";"-delimited values, so such tokens only
|
||||
// survive on rows not yet re-scanned.
|
||||
func splitGenres(s string) []string {
|
||||
parts := strings.FieldsFunc(s, func(r rune) bool {
|
||||
return r == ';' || r == ','
|
||||
|
||||
@@ -16,8 +16,7 @@ type Candidate struct {
|
||||
}
|
||||
|
||||
// DiversityCaps bounds how much of one selection a single artist or album
|
||||
// may occupy. A zero value means "no cap", which is what Shuffle did
|
||||
// unconditionally before #3882.
|
||||
// may occupy. A zero value means "no cap".
|
||||
type DiversityCaps struct {
|
||||
MaxPerArtist int
|
||||
MaxPerAlbum int
|
||||
@@ -53,7 +52,7 @@ func RadioDiversityCaps(limit int) DiversityCaps {
|
||||
}
|
||||
|
||||
// heldBack says whether the candidate is a video rip held back from radio
|
||||
// (M498 #5439): it plays when chosen, but radio does not choose it.
|
||||
// it plays when chosen, but radio does not choose it.
|
||||
func heldBack(c Candidate) bool {
|
||||
return c.Track.SourceVerdict != nil && *c.Track.SourceVerdict == "suspect"
|
||||
}
|
||||
@@ -69,19 +68,13 @@ func heldBack(c Candidate) bool {
|
||||
// So the result holds min(limit, len(candidates)) either way — the caps
|
||||
// change WHICH tracks are chosen, never HOW MANY. (What is not a candidate at
|
||||
// all is dropped first: a held-back video rip, and a second copy of a song
|
||||
// already chosen from another release, M498.)
|
||||
// already chosen from another release.)
|
||||
//
|
||||
// That two-pass shape is the whole design, and a hard cap would have been
|
||||
// the easy mistake. Radio asks for 50 tracks by default and 200 at most; a
|
||||
// pool concentrated on a few artists would return six tracks and call it a
|
||||
// radio. Rule 131's principle — degrade, never vanish — applies past the
|
||||
// system mixes it was written for.
|
||||
//
|
||||
// Before #3882 there was no cap here at all, while discover.go,
|
||||
// you_might_like.go and home.go all had one. That asymmetry is what let a
|
||||
// radio session come back entirely from a single artist: nothing between
|
||||
// the pool and the output bounded any artist's share, so a pool dominated
|
||||
// by one artist produced an output dominated by it too.
|
||||
// That two-pass shape is the whole design. A hard cap would be the easy
|
||||
// mistake: radio asks for 50 tracks by default and 200 at most, and a pool
|
||||
// concentrated on a few artists would return six tracks and call it a radio.
|
||||
// Degrade, never vanish. Without any cap, a pool dominated by one artist
|
||||
// would produce a radio entirely of that artist.
|
||||
func Shuffle(
|
||||
candidates []Candidate,
|
||||
weights ScoringWeights,
|
||||
@@ -112,7 +105,7 @@ func Shuffle(
|
||||
deferred := make([]Candidate, 0, len(scored)-limit)
|
||||
artistCount := map[pgtype.UUID]int{}
|
||||
albumCount := map[pgtype.UUID]int{}
|
||||
// One song on several releases is played once (M498 #5438): after its
|
||||
// One song on several releases is played once: after its
|
||||
// best-scoring copy, the others are dropped, not held back.
|
||||
songs := map[pgtype.UUID]bool{}
|
||||
repeat := func(c Candidate) bool { return c.Track.SongKey.Valid && songs[c.Track.SongKey] }
|
||||
|
||||
@@ -50,9 +50,6 @@ func artistsOf(picks []Candidate) map[string]int {
|
||||
return out
|
||||
}
|
||||
|
||||
// THE BUG. Operator, 2026-09-10: started radio from a song and "literally
|
||||
// all of the songs in the playlist after that were from a single artist".
|
||||
//
|
||||
// One artist's tracks all outscore everything else, and there are more of
|
||||
// them than the radio has room for. Without a cap the output is entirely
|
||||
// that artist — nothing between the pool and the result bounded its share.
|
||||
|
||||
@@ -112,7 +112,7 @@ func TestShuffle_OneCopyPerSong(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// A video rip held back from radio (M498 #5439) is never chosen, even to fill
|
||||
// A video rip held back from radio is never chosen, even to fill
|
||||
// the request.
|
||||
func TestShuffle_SkipsHeldBackRips(t *testing.T) {
|
||||
suspect := "suspect"
|
||||
|
||||
@@ -8,7 +8,7 @@ type SimilarityWeights struct {
|
||||
ArtistsWeight float64
|
||||
}
|
||||
|
||||
// DefaultSimilarityWeights is the v1 axis balance per the M3 design.
|
||||
// DefaultSimilarityWeights is the shipped axis balance.
|
||||
// Tags carry more signal than artists because a session's "vibe" tracks
|
||||
// genre more directly than artist identity (a session can mix artists
|
||||
// within a genre but rarely mixes genres).
|
||||
|
||||
@@ -4,11 +4,11 @@
|
||||
// through artist_similarity_unmatched via a single CTE, and returns top-N
|
||||
// out-of-library candidates with top-3 attribution seeds resolved to names.
|
||||
//
|
||||
// Originally M5c, which seeded from raw likes + plays. That signal grew
|
||||
// without bound and counted skips as engagement, so a few heavily-played
|
||||
// artists monopolized every slot and the surface entrenched harder the more
|
||||
// the user listened. Reworked to seed from the taste profile in issue #2367;
|
||||
// see internal/db/queries/recommendation.sql for the tiering.
|
||||
// Seeding from the taste profile rather than raw likes + plays matters: raw
|
||||
// counts grow without bound and count skips as engagement, so a few
|
||||
// heavily-played artists would monopolize every slot and entrench harder the
|
||||
// more the user listened. See internal/db/queries/recommendation.sql for the
|
||||
// tiering.
|
||||
package recommendation
|
||||
|
||||
import (
|
||||
@@ -33,12 +33,12 @@ type ArtistSuggestion struct {
|
||||
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).
|
||||
// explanation. Empty when the candidate has no cached tags, which is
|
||||
// common and not an error: coverage is permanently partial.
|
||||
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.
|
||||
// Exposed for admin tuning — seeing the term's actual distribution is how
|
||||
// a weight gets picked rather than guessed.
|
||||
TagOverlap float64
|
||||
}
|
||||
|
||||
@@ -71,8 +71,8 @@ func SuggestArtists(
|
||||
q := dbq.New(pool)
|
||||
// Over-fetch so there is something to rotate through. A deterministic
|
||||
// top-N over a score ordering shows the same faces every day until one is
|
||||
// requested away — the tail of the ranking was unreachable (#2367
|
||||
// mechanism 1). Selection from this pool happens in selectSuggestions.
|
||||
// requested away, leaving the tail of the ranking unreachable. Selection
|
||||
// from this pool happens in selectSuggestions.
|
||||
rows, err := q.SuggestArtistsForUser(ctx, dbq.SuggestArtistsForUserParams{
|
||||
UserID: userID,
|
||||
Column2: halfLifeDays,
|
||||
@@ -130,7 +130,7 @@ func SuggestArtists(
|
||||
})
|
||||
}
|
||||
|
||||
// Taste-tag term (#2377). Applied to the whole over-fetched pool BEFORE
|
||||
// Taste-tag term. 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.
|
||||
@@ -227,9 +227,8 @@ func rotationDay(now time.Time) string {
|
||||
// Three rules, in order:
|
||||
//
|
||||
// 1. Diversity cap. Walking in score order, a candidate is dropped once its
|
||||
// dominant seed already owns maxPerSeed slots. Nothing capped this before,
|
||||
// so all twelve slots could be neighbours of one artist and the surface
|
||||
// read as a single narrow cluster (#2367 mechanism 4).
|
||||
// dominant seed already owns maxPerSeed slots, so twelve slots cannot all
|
||||
// be neighbours of one artist and read as a single narrow cluster.
|
||||
// 2. Head. The best few by score always lead, so the strongest matches are
|
||||
// never rotated out of sight. Mirrors For You's head/tail shape.
|
||||
// 3. Tail. The rest of the slots are drawn from the remaining pool ordered by
|
||||
@@ -263,8 +262,8 @@ func selectSuggestions(pool []ArtistSuggestion, limit int, day string) []ArtistS
|
||||
}
|
||||
// Diversity is a PREFERENCE, not a quota that may starve the deck. A user
|
||||
// whose whole pool hangs off one or two seeds would otherwise get a
|
||||
// three-card surface, which is worse than the monoculture we were avoiding
|
||||
// (and is the vanish-or-nothing shape rule #131 exists to prevent). Top up
|
||||
// three-card surface, which is worse than the monoculture it avoids.
|
||||
// Top up
|
||||
// in score order from what the cap set aside.
|
||||
for _, s := range overflow {
|
||||
if len(out) >= limit {
|
||||
|
||||
@@ -331,7 +331,7 @@ func TestSuggestArtists_EmptyForNewUser(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Slice 1 (#2372): taste-profile seeding, tiering, and the skip fix ---
|
||||
// --- Taste-profile seeding, tiering, and skipped plays ---
|
||||
|
||||
func setTasteWeight(t *testing.T, pool *pgxpool.Pool, userID, artistID pgtype.UUID, weight float64) {
|
||||
t.Helper()
|
||||
@@ -365,7 +365,7 @@ func insertSkippedPlayEvent(t *testing.T, pool *pgxpool.Pool, userID, trackID pg
|
||||
}
|
||||
|
||||
// Tier 1: a taste-profile weight alone seeds a suggestion, with no like and no
|
||||
// play on the seed artist. Before #2367 the profile was ignored entirely here.
|
||||
// play on the seed artist.
|
||||
func TestSuggestArtists_TasteProfileWeightSeedsTier1(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
user := seedUser(t, pool, "alice")
|
||||
@@ -449,9 +449,9 @@ func TestSuggestArtists_NonPositiveTasteWeightDoesNotSeed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Tier 2 counts COMPLETED plays only. Previously every play_event counted, so
|
||||
// skipping an artist repeatedly increased its signal and pushed more of its
|
||||
// neighbours at the user (#2367 mechanism 3).
|
||||
// Tier 2 counts COMPLETED plays only. Counting every play_event would make
|
||||
// skipping an artist repeatedly increase its signal and push more of its
|
||||
// neighbours at the user.
|
||||
func TestSuggestArtists_SkippedPlaysDoNotSeedTier2(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
user := seedUser(t, pool, "alice")
|
||||
@@ -473,12 +473,11 @@ func TestSuggestArtists_SkippedPlaysDoNotSeedTier2(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// --- Slice 3 (#2374): time-boxed suggestion snooze ---
|
||||
// --- Time-boxed suggestion snooze ---
|
||||
//
|
||||
// The defining property under test is that a snooze EXPIRES. A permanent
|
||||
// dismissal would pass most of these; only TestSuggestArtists_ExpiredSnooze
|
||||
// distinguishes the two, and it is the reason this shape was approved over an
|
||||
// exclusion UI (rule #101).
|
||||
// The defining property under test is that a snooze EXPIRES: suggestions have
|
||||
// no permanent "never show" control. A permanent dismissal would pass most of
|
||||
// these; only TestSuggestArtists_ExpiredSnooze distinguishes the two.
|
||||
|
||||
// snoozeUntil inserts a snooze row with an explicit absolute expiry, so a
|
||||
// test can place it in the past without depending on the duration arithmetic
|
||||
@@ -546,7 +545,7 @@ func TestSuggestArtists_ExpiredSnoozeShowsCandidateAgain(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Rule #47: one household member parking a suggestion must not remove it
|
||||
// One household member parking a suggestion must not remove it
|
||||
// from anyone else's deck.
|
||||
func TestSuggestArtists_SnoozeIsPerUser(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
|
||||
@@ -8,7 +8,7 @@ import (
|
||||
)
|
||||
|
||||
// selectSuggestions is deliberately pure (no DB, no clock), so the rotation and
|
||||
// diversity rules from slice #2373 are covered here in the fast lane rather
|
||||
// diversity rules are covered here in the fast lane rather
|
||||
// than behind the integration gate.
|
||||
|
||||
func seedID(n byte) pgtype.UUID {
|
||||
@@ -95,13 +95,12 @@ func TestSelectSuggestions_HeadIsStableTopScorers(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// #2367 mechanism 4: all twelve slots could be neighbours of one artist.
|
||||
// Twelve slots must not all be neighbours of one artist.
|
||||
//
|
||||
// The pool is deliberately SKEWED — one seed owns the entire top of the score
|
||||
// ranking — because that is the only shape where a cap can bite. With scores
|
||||
// spread evenly across seeds the top-N is already diverse and the cap is
|
||||
// untestable (an earlier version of this test asserted against an even pool and
|
||||
// could not fail).
|
||||
// untestable: an even pool cannot fail this test.
|
||||
func TestSelectSuggestions_CapsOneDominantSeed(t *testing.T) {
|
||||
const dominantRun = 20
|
||||
pool := make([]ArtistSuggestion, 0, 48)
|
||||
@@ -131,7 +130,7 @@ func TestSelectSuggestions_CapsOneDominantSeed(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Diversity must not starve the deck (rule #131): a pool hanging off a single
|
||||
// Diversity must not starve the deck: a pool hanging off a single
|
||||
// seed should still fill, not collapse to maxPerSeed entries.
|
||||
func TestSelectSuggestions_SingleSeedPoolStillFills(t *testing.T) {
|
||||
got := selectSuggestions(poolOf(30, 1), 12, "2026-08-01")
|
||||
|
||||
@@ -1,13 +1,10 @@
|
||||
// tagoverlap.go — the taste-tag term for the Discover request surface
|
||||
// (#2377, milestone #268 slice 6).
|
||||
// tagoverlap.go — the taste-tag term for the Discover request surface.
|
||||
//
|
||||
// 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.
|
||||
// Similarity alone says "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 (from
|
||||
// the candidate tag cache) 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.
|
||||
@@ -42,9 +39,8 @@ type TagWeights map[string]float64
|
||||
//
|
||||
// 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.
|
||||
// partial, and a cold-start user has no taste tags at all. Both must leave
|
||||
// the candidate's similarity score untouched rather than sink it.
|
||||
func tagOverlap(candidate, taste TagWeights) (float64, []string) {
|
||||
if len(candidate) == 0 || len(taste) == 0 {
|
||||
return 0, nil
|
||||
@@ -123,7 +119,7 @@ func tagOverlap(candidate, taste TagWeights) (float64, []string) {
|
||||
// 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.
|
||||
// order, so the 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
|
||||
@@ -132,8 +128,8 @@ func tagOverlap(candidate, taste TagWeights) (float64, []string) {
|
||||
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.
|
||||
// A zero weight turns the feature off. Skip the work AND the re-sort so
|
||||
// the ordering is bit-for-bit pure similarity order.
|
||||
if weight == 0 || len(taste) == 0 {
|
||||
return pool
|
||||
}
|
||||
@@ -149,7 +145,7 @@ func applyTagOverlap(
|
||||
}
|
||||
// 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.
|
||||
// rotation exists to prevent.
|
||||
return pool[i].MBID < pool[j].MBID
|
||||
})
|
||||
return pool
|
||||
|
||||
@@ -5,14 +5,13 @@ import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
// tagOverlap / applyTagOverlap are pure, so slice 6's ranking rules are covered
|
||||
// in the fast lane rather than behind the integration gate.
|
||||
// tagOverlap / applyTagOverlap are pure, so the taste-tag 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.
|
||||
// unchanged whether the blend works or not.
|
||||
|
||||
func TestTagOverlap_FullMatchScoresOne(t *testing.T) {
|
||||
// Every unit of the candidate's tag mass sits on the user's single
|
||||
@@ -68,8 +67,8 @@ func TestTagOverlap_WeakTasteTagScoresLowerThanStrong(t *testing.T) {
|
||||
}
|
||||
|
||||
// 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).
|
||||
// permanently partial and cold-start users have no taste tags, so both must be
|
||||
// scored 0 — never negative, never dropped.
|
||||
func TestTagOverlap_EmptyEitherSideIsZeroNotNegative(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -186,8 +185,8 @@ func TestApplyTagOverlap_UntaggedCandidateScoreIsUnchanged(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Weight 0 is the operator's off switch: the ordering must be bit-for-bit the
|
||||
// pre-slice-6 result, not merely similar.
|
||||
// Weight 0 is the off switch: the ordering must be bit-for-bit pure similarity
|
||||
// order, 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}}
|
||||
@@ -232,7 +231,7 @@ func TestApplyTagOverlap_BoostIsBoundedByOnePlusWeight(t *testing.T) {
|
||||
|
||||
// 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.
|
||||
// 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)
|
||||
|
||||
@@ -16,11 +16,11 @@ import (
|
||||
// rest toward zero (as a per-user max-normalisation would). A weight at the
|
||||
// scale value maps to tanh(1) ≈ 0.76 — "clearly a preference."
|
||||
//
|
||||
// The era (#1530) and mood (#1534) terms are added on TOP of artist+tag
|
||||
// The era and mood terms are added on TOP of artist+tag
|
||||
// (shares don't sum to 1) so they're pure nudges that never weaken the existing
|
||||
// artist/tag signal when a track lacks a date or mood tags; clampUnit bounds
|
||||
// the combined result. Their shares are small — both are coarse, partial-
|
||||
// coverage signals — and re-bakeable if the lab shows they should carry more.
|
||||
// coverage signals — and re-bakeable if tuning shows they should carry more.
|
||||
const (
|
||||
tasteArtistScale = 4.0
|
||||
tasteTagScale = 3.0
|
||||
|
||||
Reference in New Issue
Block a user