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:
2026-10-23 12:19:15 -04:00
co-authored by Claude Opus 5.5
parent d191a28217
commit fcf5b7e5a8
48 changed files with 391 additions and 501 deletions
@@ -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)
+32 -57
View File
@@ -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(
+19 -26
View File
@@ -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()
+2 -2
View File
@@ -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) {
+24 -43
View File
@@ -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} {
+7 -7
View File
@@ -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)
+1 -7
View File
@@ -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 == ','
+9 -16
View File
@@ -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.
+1 -1
View File
@@ -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"
+1 -1
View File
@@ -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).
+16 -17
View File
@@ -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")
+12 -16
View File
@@ -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
+8 -9
View File
@@ -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)
+2 -2
View File
@@ -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