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
+43 -44
View File
@@ -1,10 +1,9 @@
// Package dbtest provides shared helpers for integration tests that need
// a clean Postgres state without disturbing the operator's admin user.
// a clean Postgres state without disturbing the developer's admin user.
//
// Background: when integration tests run against the same Postgres
// instance an operator uses for local dev (a common dev-laptop setup),
// a TRUNCATE that includes the users table wipes the operator's admin
// login between every test run. To avoid that, tests should:
// When integration tests run against the same Postgres a developer uses
// for local dev, a TRUNCATE that includes the users table wipes their admin
// login on every test run. To avoid that, tests should:
//
// 1. Call ResetDB instead of issuing TRUNCATE statements directly. It
// truncates every data table EXCEPT users, then deletes only those
@@ -40,8 +39,8 @@ const TestUserPrefix = "test-"
var dataTables = []string{
"artist_similarity",
"track_similarity",
"artist_similarity_unmatched", // M5c
"listenbrainz_similar_recordings", // #5296
"artist_similarity_unmatched",
"listenbrainz_similar_recordings",
"track_similarity_fetches",
"artist_similarity_fetches",
"scrobble_queue",
@@ -55,13 +54,13 @@ var dataTables = []string{
"sessions",
"quarantine_actions",
"quarantine",
// #2374. Keyed by (user_id, candidate_mbid) with no FK to artists —
// Keyed by (user_id, candidate_mbid) with no FK to artists —
// candidates are out-of-library — so the CASCADE from artists/users
// does NOT reach it for a leftover row whose user survived. Truncate
// explicitly or a stale snooze silently hides a candidate from the
// next test's suggestion assertions.
"suggestion_snoozes",
// #2376. Same reasoning: keyed by candidate MBID with no FK anywhere,
// Same reasoning: keyed by candidate MBID with no FK anywhere,
// so nothing cascades to them. A leftover tag row would make a
// candidate look enriched to the next test, and a leftover state row
// would make it look already-settled and thus ineligible.
@@ -69,7 +68,7 @@ var dataTables = []string{
"candidate_artist_tag_state",
"playlist_tracks",
"playlists",
"library_changes", // M7 #357 — must reset to keep cursor isolated per test
"library_changes", // reset so each test's sync cursor starts clean
// SettingsService.reconcile() idempotently re-UpsertProviderSettings
// for every registered provider at boot, so truncating this is the
// correct per-test reset (clears test-modified enabled/api_key rows).
@@ -77,46 +76,46 @@ var dataTables = []string{
// (never recreates the singleton, seeded once by 0018); ResetDB
// resets its counter via UPDATE below instead.
"cover_art_provider_settings",
// Same reasoning for the tag-enrichment settings (#1490): truncate the
// Same reasoning for the tag-enrichment settings: truncate the
// per-provider rows (reconcile re-seeds registered ones), and reset the
// tag_sources_meta counter via UPDATE below rather than truncating it.
"tag_provider_settings",
// recsettings.New reconciles shipped defaults on every construction
// (#1250), so truncating gives each test pristine tuning values.
// recsettings.New reconciles shipped defaults on every construction,
// so truncating gives each test pristine tuning values.
"recommendation_weight_profiles",
"taste_tuning",
// #2377. Same reasoning as taste_tuning above: recsettings.New re-seeds
// Same reasoning as taste_tuning above: recsettings.New re-seeds
// shipped defaults on every construction, so truncating gives each test
// pristine Discover knobs rather than whatever a previous test tuned.
"discover_tuning",
"recommendation_tuning_audit",
"duplicate_group_members", // M400
"duplicate_group_members",
"duplicate_groups",
"duplicate_sweeps",
"track_fingerprints", // M400
"track_loudness", // M464
"album_loudness", // M464
"user_normalization_prefs", // M464
// M489. Both cascade from users, but the operator's own admin row is
"track_fingerprints",
"track_loudness",
"album_loudness",
"user_normalization_prefs",
// Both cascade from users, but the developer's own admin row is
// never deleted, so rows a test wrote for it would otherwise survive.
"user_notifications",
"user_notification_prefs",
"user_notification_email_state", // #5346
"acquisition_services", // M502 #5518
"mb_release_tracks", // M502 #5519
"mb_releases", // M502 #5519
"mb_artist_release_groups", // M502 #5519
"mb_release_groups", // M502 #5519
"mb_artists", // M502 #5519
"requests", // M502 #5520
"download_interventions", // M502 #5523
"library_album_matches", // #5569
"release_blocks", // M502 #5521
"grabs", // M502 #5521
"want_candidates", // M502 #5521
"wants", // M502 #5520
"follow_seen_groups", // M502 #5520
"follows", // M502 #5520
"user_notification_email_state",
"acquisition_services",
"mb_release_tracks",
"mb_releases",
"mb_artist_release_groups",
"mb_release_groups",
"mb_artists",
"requests",
"download_interventions",
"library_album_matches",
"release_blocks",
"grabs",
"want_candidates",
"wants",
"follow_seen_groups",
"follows",
"tracks",
"albums",
"artists",
@@ -124,7 +123,7 @@ var dataTables = []string{
// ResetDB clears all data tables and removes any user whose username
// begins with TestUserPrefix. Users without that prefix (notably the
// operator's admin row) are left alone. Calls t.Fatalf on error.
// developer's admin row) are left alone. Calls t.Fatalf on error.
func ResetDB(t *testing.T, pool *pgxpool.Pool) {
t.Helper()
ctx := context.Background()
@@ -148,7 +147,7 @@ func ResetDB(t *testing.T, pool *pgxpool.Pool) {
); err != nil {
t.Fatalf("dbtest.ResetDB reset cover-art version: %v", err)
}
// Same for the tag-sources counter (#1490) — clears cross-test version
// Same for the tag-sources counter — clears cross-test version
// accumulation that would spuriously report version_bumped on a
// key-only change.
if _, err := pool.Exec(ctx,
@@ -156,7 +155,7 @@ func ResetDB(t *testing.T, pool *pgxpool.Pool) {
); err != nil {
t.Fatalf("dbtest.ResetDB reset tag-sources version: %v", err)
}
// Fingerprinting settings (M400 #3913), a singleton like the counters above.
// Fingerprinting settings, a singleton like the counters above.
// Every column goes back to its migration default rather than to literals
// written here, so a test can pin the Go defaults to the migration's.
if _, err := pool.Exec(ctx, `
@@ -167,14 +166,14 @@ func ResetDB(t *testing.T, pool *pgxpool.Pool) {
); err != nil {
t.Fatalf("dbtest.ResetDB reset fingerprint settings: %v", err)
}
// Loudness analysis settings (M464 #4995), reset the same way.
// Loudness analysis settings, reset the same way.
if _, err := pool.Exec(ctx, `
UPDATE loudness_settings
SET enabled = DEFAULT, backfill_concurrency = DEFAULT, updated_at = DEFAULT`,
); err != nil {
t.Fatalf("dbtest.ResetDB reset loudness settings: %v", err)
}
// Release-type profiles (M502 #5520): migration 0081 seeds "Standard" as
// Release-type profiles: migration 0081 seeds "Standard" as
// the default. Tests add and edit profiles, so put back exactly the
// seeded row rather than truncating it away.
if _, err := pool.Exec(ctx, `
@@ -185,8 +184,8 @@ func ResetDB(t *testing.T, pool *pgxpool.Pool) {
); err != nil {
t.Fatalf("dbtest.ResetDB reset release-type profiles: %v", err)
}
// Search and grab settings (M502 #5521), a singleton reset the same way.
// The library listing (#5567) reads as done, so a test's albums are not
// Search and grab settings, a singleton reset the same way.
// The library listing reads as done, so a test's albums are not
// put on the wanted list behind its back; its own tests clear it.
if _, err := pool.Exec(ctx, `
UPDATE acquisition_settings
@@ -197,7 +196,7 @@ func ResetDB(t *testing.T, pool *pgxpool.Pool) {
); err != nil {
t.Fatalf("dbtest.ResetDB reset acquisition settings: %v", err)
}
// Notification email settings (M489 #5346), reset the same way.
// Notification email settings, reset the same way.
if _, err := pool.Exec(ctx, `
UPDATE notification_email_settings
SET summary_hour = DEFAULT, batch_window_minutes = DEFAULT, updated_at = DEFAULT`,
+9 -11
View File
@@ -11,7 +11,7 @@ import (
const (
discoverTotalSlots = 100
// Bucket allocation, pushed toward *targeted* novelty (Scribe #1488):
// Bucket allocation, pushed toward *targeted* novelty:
// the taste-matched-unheard arm gets the plurality, dormant stays a
// familiar anchor, and the crude random arm is trimmed to a
// serendipity sliver. On a single-user server cross_user is empty and
@@ -28,8 +28,8 @@ const (
// discoverTrack is the common shape used by the bucket allocator. The
// three sqlc-generated row types collapse into this internal struct so
// downstream functions don't need to be generic over them. PickKind is
// the originating bucket (taste_unheard/dormant/cross_user/random,
// #1270/#1488), stamped in the row adapters so it survives the
// the originating bucket (taste_unheard/dormant/cross_user/random),
// stamped in the row adapters so it survives the
// interleave — bucket identity is what makes the slot allocation
// measurable in the metrics.
type discoverTrack struct {
@@ -95,8 +95,8 @@ type discoverPools struct {
}
func loadDiscoverPools(ctx context.Context, q *dbq.Queries, logger *slog.Logger, userID pgtype.UUID, dateStr string) discoverPools {
// The caps go into the query too (#5356): applied only here, after its
// LIMIT, they left a heavily tagged artist or two owning the whole bucket.
// The caps go into the query too: applied only here, after its LIMIT, they
// would leave a heavily tagged artist or two owning the whole bucket.
tasteRows, err := q.ListTasteUnheardTracksForDiscover(ctx, dbq.ListTasteUnheardTracksForDiscoverParams{
UserID: userID, DateSeed: dateStr,
MaxPerAlbum: discoverMaxTracksPerAlbum, MaxPerArtist: discoverMaxTracksPerArtist,
@@ -229,12 +229,10 @@ type bucketRequest struct {
// peer's supply ran out mid-distribution and the residual needs
// to roll to other peers in a later pass.
//
// Bug history (May 2026): an earlier version recomputed
// `deficit = b.want - final[i]` every pass without subtracting
// previously-redistributed amounts, so a bucket with permanently
// 0 final and want=30 kept handing out 30 to peers each pass. The
// `redistributed` array below tracks per-source absorbed deficit so
// subsequent passes only move the residual.
// The `redistributed` array tracks how much of each source's deficit has
// been absorbed, so later passes move only the residual: recomputing
// `b.want - final[i]` each pass would hand out a permanently empty bucket's
// whole want again on every pass.
func redistributeSlots(buckets []bucketRequest) []int {
n := len(buckets)
final := make([]int, n)
+5 -5
View File
@@ -29,11 +29,11 @@ func seedTasteTag(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID, tag stri
}
}
// TestListTasteUnheardTracksForDiscover_CapsBeforeLimit is the deploy's shape
// (#5356): one artist whose every track carries the whole taste profile
// outscores everything on summed weight. Capped only after the LIMIT, that
// artist took all 120 rows and the bucket shrank to its 3. Capped before, the
// artist keeps 3 and the single-tag artists below it fill the rest.
// TestListTasteUnheardTracksForDiscover_CapsBeforeLimit: one artist whose every
// track carries the whole taste profile outscores everything on summed
// weight. Capped only after the LIMIT, that artist would take all 120 rows
// and the bucket shrink to its 3. Capped before, the artist keeps 3 and the
// single-tag artists below it fill the rest.
func TestListTasteUnheardTracksForDiscover_CapsBeforeLimit(t *testing.T) {
pool := newPool(t)
ctx := context.Background()
+1 -1
View File
@@ -163,7 +163,7 @@ func TestInterleaveBuckets_DedupsAcrossBuckets(t *testing.T) {
}
func TestInterleaveBuckets_PreservesBucketPickKind(t *testing.T) {
// Bucket provenance (#1270) is stamped on discoverTrack before the
// Bucket provenance is stamped on discoverTrack before the
// interleave and must survive it — including dedup, where a track in
// two buckets keeps the stamp of the bucket it was taken from.
dormant := []discoverTrack{
+3 -3
View File
@@ -246,7 +246,7 @@ func TestPickTopN_DiversityCap(t *testing.T) {
func TestPickHeadAndTail_MarksPickKinds(t *testing.T) {
// 100-deep pool with headN=20, tailN=5: the 20 head entries are the
// taste picks, the 5 tail entries the freshness injection (#1249).
// taste picks, the 5 tail entries the freshness injection.
in := make([]recommendation.Candidate, 0, 100)
for i := 0; i < 100; i++ {
in = append(in, makeCand(i+1, i+1, i+1, float64(100-i)))
@@ -323,7 +323,7 @@ func TestHeadQuotas(t *testing.T) {
}
func TestPickQuotaHead_BlendsSeeds(t *testing.T) {
// Seed 0's neighborhood out-scores everything (#1269): without
// Seed 0's neighborhood out-scores everything: without
// quotas the head would be all seed-0. With headN=10 and quotas
// [5,3,2], the walk takes the best 5 from seed 0, then seeds 1 and
// 2 get their guaranteed slots from deeper ranks.
@@ -385,7 +385,7 @@ func TestPickQuotaHead_ThinSeedSpillsBestFirst(t *testing.T) {
}
func TestPickWeightedTail_PrefersEarlyRanks(t *testing.T) {
// The fresh tail's sample is rank-weighted (#1269): across many
// The fresh tail's sample is rank-weighted: across many
// days, the mean selected rank must sit well below the uniform
// draw's ~99.5 for a 200-deep pool.
pool := make([]recommendation.Candidate, 0, 200)
+7 -8
View File
@@ -1,5 +1,4 @@
// Per-user system-playlist scheduler (#392 Half B). Replaces the
// 24h-anchored cron loop in system_cron.go (deleted in the same arc).
// Per-user system-playlist scheduler.
//
// Each active user gets one gocron daily job at 03:00 in their stored
// timezone. The scheduler reconciles its in-memory job set with the
@@ -34,7 +33,7 @@ import (
// dailyRebuildHour is the local-time hour at which each user's system
// playlists rebuild. 03:00 is well past most "today" listening and
// before morning use; matches the operator framing in the design doc.
// before morning use.
const dailyRebuildHour = 3
// reconciliationInterval drives the hourly pass that brings the
@@ -48,7 +47,7 @@ type Scheduler struct {
pool *pgxpool.Pool
logger *slog.Logger
dataDir string
bus *eventbus.Bus // #968: announce rebuilds so clients self-refresh
bus *eventbus.Bus // announces rebuilds so clients self-refresh
mu sync.Mutex
jobs map[pgtype.UUID]uuid.UUID // user_id → gocron job id
@@ -245,10 +244,10 @@ func (s *Scheduler) rebuildUserDaily(ctx context.Context, userID pgtype.UUID, no
"user_id", uuidStringPL(userID), "err", err)
return
}
// #968: tell the user's connected clients their home content was
// regenerated, so a tab/app left open across the rebuild refreshes its
// system-playlist + You-might-like views (and a stale active queue can
// re-pull) instead of serving yesterday's snapshot until a manual reload.
// Tell the user's connected clients their home content was regenerated, so
// a tab/app left open across the rebuild refreshes its system-playlist +
// You-might-like views (and a stale active queue can re-pull) instead of
// serving yesterday's snapshot until a manual reload.
s.publishRebuilt(userID)
}
+1 -1
View File
@@ -28,7 +28,7 @@ func markMissing(t *testing.T, pool *pgxpool.Pool, trackID pgtype.UUID) {
}
// TestPickTopPlayedTracksForUser_LikedTierSkipsMissing: with no plays, For
// You seeds from likes. A liked track whose file is gone is not a seed (#2701).
// You seeds from likes. A liked track whose file is gone is not a seed.
func TestPickTopPlayedTracksForUser_LikedTierSkipsMissing(t *testing.T) {
pool := newPool(t)
u := seedUser(t, pool, "seedmissing")
+4 -4
View File
@@ -48,9 +48,9 @@ func TestPickSeedArtistsFromRows_Empty(t *testing.T) {
}
func TestPickKindForSeedTier(t *testing.T) {
// The seed query's fallback tiers map onto the rule-#131 ladder:
// fresh 7-day engagement is the exact desire, everything past the
// 30-day step-back collapses into the far tier (#1255).
// The seed query's fallback tiers map onto the tier ladder: fresh
// 7-day engagement is the exact desire, everything past the 30-day
// step-back collapses into the far tier.
cases := []struct {
tier int32
want string
@@ -99,7 +99,7 @@ func TestUserIDHash_DifferentUserChangesHash(t *testing.T) {
// pickDailySeeds shuffles the candidate pool daily-deterministically
// and takes up to n — For-You uses n=forYouSeedCount for its
// multi-seed blend (#1269), Songs-like uses n=songsLikeSeedCount via
// multi-seed blend, Songs-like uses n=songsLikeSeedCount via
// the pickSeedArtistsForDay wrapper. Verifies determinism within a day,
// variation across days, and graceful degradation on small pools.
+2 -2
View File
@@ -1,6 +1,6 @@
// Package playlists implements the user-facing playlist CRUD service.
//
// Visibility model (from the M7 #352 design spec):
// Visibility model:
// - Playlists are private by default. Only the owner sees private rows
// in List, Get, and is allowed to mutate them via Update / Delete.
// - is_public = true makes a playlist readable by any authenticated
@@ -112,7 +112,7 @@ type PlaylistTrack struct {
AddedAt pgtype.Timestamptz
// Unavailable reports that the track still exists in the library but
// its file is currently missing from disk (tracks.missing_since is
// set, #2527). The entry keeps its place in the playlist — the user
// set). The entry keeps its place in the playlist — the user
// put it there — but nothing should try to play it.
Unavailable bool
}
@@ -51,7 +51,7 @@ func newPool(t *testing.T) *pgxpool.Pool {
}
// seedUser inserts a `test-`-prefixed user (so dbtest.ResetDB can
// clean it without touching the operator's admin row) and returns
// clean it without touching the developer's admin row) and returns
// the persisted user record.
func seedUser(t *testing.T, pool *pgxpool.Pool, name string) dbq.User {
t.Helper()
+3 -3
View File
@@ -10,9 +10,9 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
)
// A mix holds a song once (M498 #5438) and never a held-back video rip
// (#5439): of a single and its album cut, the better-ranked copy stays; a rip
// is left out and the next copy of its song takes the place.
// A mix holds a song once and never a held-back video rip: of a single and its
// album cut, the better-ranked copy stays; a rip is left out and the next copy
// of its song takes the place.
func TestOneCopyPerSong_Integration(t *testing.T) {
pool := newPool(t)
ctx := context.Background()
+66 -82
View File
@@ -1,5 +1,5 @@
// Package playlists' system.go implements the system-generated mix
// builder (M7 #352 slice 2). The cron loop and lazy fallback both call
// builder. The daily scheduler and the on-demand refresh both call
// BuildSystemPlaylists; the helpers in this file (pickSeedArtists,
// pickRepresentativeTrack, tieBreakHash) are the pure parts.
package playlists
@@ -52,15 +52,10 @@ func pickSeedArtistsFromRows(rows []seedArtistRow) []pgtype.UUID {
// daily-deterministic ordering. Same inputs always produce the same
// output; different inputs almost always differ.
//
// Switched from FNV-1a (May 2026) because two date strings differing
// only in the last character (e.g. "2026-05-07" vs "2026-05-08") gave
// hash values whose RELATIVE ORDERING across our small candidate pools
// was identical — the dateStr-driven divergence concentrated in low
// bits that lost out to high-bit ordering during sort. SHA-256
// truncated to 8 bytes has full avalanche, so any single-byte input
// change roughly half-flips the output bits and reorders meaningfully.
// The hash isn't security-load-bearing; we just want strong avalanche
// for tiny input deltas.
// SHA-256 truncated to 8 bytes, not FNV-1a: two date strings differing only
// in the last character must reorder the pool, and FNV's divergence for such
// inputs sits in low bits that lose to high-bit ordering during the sort.
// SHA-256 has full avalanche. The hash isn't security-load-bearing.
func tieBreakHash(trackID pgtype.UUID, dateStr string) uint64 {
h := sha256.New()
if trackID.Valid {
@@ -111,12 +106,9 @@ func pickDailySeeds(pool []pgtype.UUID, userID pgtype.UUID, dateStr string, n in
}
// songsLikeSeedCount is how many "Songs like {artist}" mixes to build
// each day (#1491). Bumped from 3 when Songs-like was promoted to its
// own dedicated Home row — the best-performing surface (low skip / high
// completion) was buried as 3 tiles in the shared Playlists carousel, so
// the wider row now shows a wider spread. Drawn from PickSeedArtists'
// deeper top-12 pool via a daily-deterministic shuffle so the set still
// rotates day to day rather than pinning the same six artists.
// each day, enough to fill Songs-like's own Home row. Drawn from
// PickSeedArtists' deeper top-12 pool via a daily-deterministic shuffle so
// the set still rotates day to day rather than pinning the same six artists.
const songsLikeSeedCount = 6
// pickSeedArtistsForDay picks up to songsLikeSeedCount seed artists for
@@ -127,10 +119,10 @@ func pickSeedArtistsForDay(pool []pgtype.UUID, userID pgtype.UUID, dateStr strin
}
// rankedCandidate is a (track_id, score) pair used during in-memory
// sorting before insert into playlist_tracks. T5 fills these from
// sorting before insert into playlist_tracks, filled from
// recommendation.Candidate scores. PickKind is the track's provenance
// within its mix — For-You's head/tail split (#1249) and Discover's
// buckets (#1270); empty persists as NULL (variant doesn't stamp yet).
// within its mix — For-You's head/tail split and Discover's buckets;
// empty persists as NULL (a variant that doesn't stamp).
type rankedCandidate struct {
TrackID pgtype.UUID
Score float64
@@ -140,8 +132,8 @@ type rankedCandidate struct {
// Pick kinds, persisted on playlist_tracks.pick_kind so plays can
// attribute skips to the population that sourced the track — For You's
// "taste engine missing" vs "freshness tax", Discover's three buckets
// (#1270). Values mirror the CHECK in migration 0041; taste_unheard is
// Discover's taste-targeted-novelty arm (#1488).
// Values mirror the CHECK in migration 0041; taste_unheard is Discover's
// taste-targeted-novelty arm.
const (
pickKindTaste = "taste"
pickKindFresh = "fresh"
@@ -155,7 +147,7 @@ const (
)
// pickKindForSeedTier maps PickSeedArtists' seed-pool fallback tier
// onto the rule-#131 pick-kind ladder: fresh 7-day engagement pins the
// onto the tiered pick-kind ladder: fresh 7-day engagement pins the
// mix's exact desire (tier1), the 30-day window steps back a little
// (tier2), and all-time / liked-only seeds are the far fallback
// (tier3). Stamped mix-wide — seed staleness is a property of the
@@ -172,7 +164,7 @@ func pickKindForSeedTier(tier int32) string {
}
// pickKindForMixTier maps a tiered mix query's 1-based tier column
// (numbered to match rule #131's ladder directly) onto the pick-kind
// (numbered to match the tier ladder directly) onto the pick-kind
// vocabulary. Distinct from pickKindForSeedTier, whose 0-based tiers
// count fallback steps of the seed pool rather than eligibility rungs.
func pickKindForMixTier(tier int32) string {
@@ -194,12 +186,11 @@ const systemMixLength = 25
// scores within a day, but near-tied candidates reshuffle across days
// so the playlist doesn't feel frozen.
//
// DB-tunable since #1250: the recsettings service pushes the current
// daily_mix profile via SetSystemMixWeights at boot and on every admin
// change (coverart Configure() pattern — no signature threading, live
// effect without restart). The literal here is only the pre-push
// value; shipped defaults live in recsettings.ShippedDailyMixWeights,
// which must stay in sync with it.
// DB-tunable: the recsettings service pushes the current daily_mix profile via
// SetSystemMixWeights at boot and on every admin change (coverart Configure()
// pattern — no signature threading, live effect without restart). The literal
// here is only the pre-push value; shipped defaults live in
// recsettings.ShippedDailyMixWeights, which must stay in sync with it.
var (
systemTuningMu sync.RWMutex
systemMixWeights = recommendation.ScoringWeights{
@@ -210,16 +201,16 @@ var (
JitterMagnitude: 0.1,
ContextWeight: 0.5,
SimilarityWeight: 1.5,
// Taste profile (#796 phase 2): the daily mixes are the primary
// Taste profile: the daily mixes are the primary
// taste-driven surface, so they lean on it. TasteMatchScore is in
// [-1,+1], so 1.5 makes a strong taste fit comparable to a like boost
// while passive avoidance (negative) gently demotes.
TasteWeight: 1.5,
// Time-of-day/weekday context affinity (#1531), in [-1,+1]. Starts
// uniform with radio pending trend data.
// Time-of-day/weekday context affinity, in [-1,+1]. The same as
// radio's until trend data says otherwise.
ContextTimeWeight: 1.0,
}
// Songs-like's own profile (#3881). Pre-push literal only; shipped
// Songs-like's own profile. Pre-push literal only; shipped
// defaults live in recsettings.ShippedSongsLikeWeights and must stay in
// sync with it, exactly as systemMixWeights does above.
//
@@ -239,7 +230,7 @@ var (
}
systemTasteConfig = taste.DefaultConfig()
// Sizes the candidate pool to the library (#3880). Cached because the
// Sizes the candidate pool to the library. Cached because the
// count is a full table scan and the daily build runs it once per user;
// within the TTL every user in a build shares one count.
systemLibrarySize = recommendation.NewLibrarySize(nil)
@@ -253,13 +244,13 @@ func SetSystemMixWeights(w recommendation.ScoringWeights) {
systemMixWeights = w
}
// SetSongsLikeWeights installs the songs_like scoring profile (#3881).
// SetSongsLikeWeights installs the songs_like scoring profile.
// Same push model as SetSystemMixWeights — recsettings calls it on boot and
// after every knob turn, so a tuning change takes effect on the next daily
// build with no restart.
//
// Separate from systemMixWeights because Songs-like and For-You want opposite
// things: For-You roams, Songs-like must not. Sharing one profile is what made
// things: For-You roams, Songs-like must not. Sharing one profile makes
// "Songs like X" wander.
func SetSongsLikeWeights(w recommendation.ScoringWeights) {
systemTuningMu.Lock()
@@ -268,7 +259,7 @@ func SetSongsLikeWeights(w recommendation.ScoringWeights) {
}
// SetTasteConfig installs the taste-profile build configuration
// (half-life + engagement curve, #1250). Same push model as
// (half-life + engagement curve). Same push model as
// SetSystemMixWeights.
func SetTasteConfig(c taste.Config) {
systemTuningMu.Lock()
@@ -276,13 +267,11 @@ func SetTasteConfig(c taste.Config) {
systemTasteConfig = c
}
// dailyOrderSeed is the value the randomised candidate arms order by (#3889).
// dailyOrderSeed is the value the randomised candidate arms order by.
//
// Per (user, day) so a same-day rebuild draws the SAME set — which is what
// TestBuildSystemPlaylists_DailyNonceDeterminism asserts and what those arms
// only ever achieved by accident before, when their limits happened to exceed
// the eligible rows. It changes on the day boundary, so the mixes still move
// daily.
// Per (user, day) so a same-day rebuild draws the SAME set, which is what
// TestBuildSystemPlaylists_DailyNonceDeterminism asserts. It changes on the
// day boundary, so the mixes still move daily.
func dailyOrderSeed(userID pgtype.UUID, dateStr string) string {
return uuidStringPL(userID) + ":" + dateStr
}
@@ -326,7 +315,7 @@ const (
)
// forYouSeedCount is how many of the user's top-5 tracks seed each
// day's build (#1269). A single rotating seed made the mix bipolar
// day's build. A single rotating seed would make the mix bipolar
// day-to-day — swinging whole neighborhoods as the rotation moved
// between dissimilar anchors; blending 3 spans neighborhoods within
// one day and smooths the swing across days.
@@ -334,7 +323,7 @@ const forYouSeedCount = 3
// headQuotas fixes each seed's share of the For-You head so one
// neighborhood can't monopolize it even when its candidates out-score
// the others (#1269): 50/30/20 for three seeds, 60/40 for two, all of
// the others: 50/30/20 for three seeds, 60/40 for two, all of
// it for one. Rounding remainder goes to the primary seed; a thin
// seed's unfilled quota spills best-first in pickQuotaHead.
func headQuotas(numSeeds, headN int) []int {
@@ -401,7 +390,7 @@ func pickQuotaHead(capped []recommendation.Candidate, seedOf map[pgtype.UUID]int
return head
}
// forYouTailHalfLifeRanks tunes the fresh tail's rank bias (#1269): a
// forYouTailHalfLifeRanks tunes the fresh tail's rank bias: a
// candidate's sampling weight halves every 50 ranks, so the 101st-best
// candidate is far likelier than the 401st-best instead of equal —
// freshness keeps its "you'll probably enjoy this" half.
@@ -450,8 +439,8 @@ func pickWeightedTail(tailPool []recommendation.Candidate, dateStr string, tailN
// because this sort IS the selection: the caller caps and truncates in the
// order this returns, so whatever profile ranks here decides which tracks
// reach the playlist. Scoring with daily_mix here and re-scoring with
// songs_like afterwards would have let the new profile relabel tracks it had
// no part in choosing — inert where it matters (#3881).
// songs_like afterwards would let the profile relabel tracks it had no part
// in choosing — inert where it matters.
func scoreAndSortCandidates(cands []recommendation.Candidate, userID pgtype.UUID, dateStr string, now time.Time, weights recommendation.ScoringWeights) []recommendation.Candidate {
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
type scored struct {
@@ -603,9 +592,9 @@ func systemForYouSourceLimits() recommendation.CandidateSourceLimits {
RandomFill: 150,
// For-You / You-might-like are the taste-driven surfaces, so pull a
// deep slice of the user's top taste-profile artists into the pool
// (#796 phase 2b). Empty for cold-start users (no profile yet).
// Empty for cold-start users (no profile yet).
TasteOverlap: 80,
// Household co-play (#1533): a deeper collaborative slice for the
// Household co-play: a deeper collaborative slice for the
// taste surfaces. Empty on single-user servers.
UserCoplay: 40,
}
@@ -613,7 +602,7 @@ func systemForYouSourceLimits() recommendation.CandidateSourceLimits {
// produceForYou: blend candidate pools from up to forYouSeedCount of
// the user's top-5 played tracks (rotating daily via pickDailySeeds),
// then head+tail composition with per-seed head quotas (#1269). The
// then head+tail composition with per-seed head quotas. The
// base seed query failing is fatal; a per-seed candidate-load failure
// is logged and that seed just contributes nothing.
func produceForYou(
@@ -667,7 +656,7 @@ func produceForYou(
// produceSeedMixes: up to songsLikeSeedCount "Songs like {artist}"
// mixes. Seed artists rotate daily-deterministically; the seed query falls back
// through widening engagement windows (#1255) and every returned row
// through widening engagement windows and every returned row
// shares the winning tier, stamped onto the built tracks as their
// pick_kind. The base seed-artist query failing is fatal; per-artist
// failures are logged + skipped.
@@ -714,12 +703,12 @@ func produceSeedMixes(
continue
}
zeroVec := recommendation.SessionVector{Seed: true}
// Songs-like's own pool shape, not the default (#3881). Same total
// size; the composition shifts toward arms that actually measure
// distance from the seed. The default gave ~29% of candidates a
// sim_score of literally 0.
// Songs-like's own pool shape, not the default. Same total size; the
// composition shifts toward arms that actually measure distance from
// the seed, where the default gives ~29% of candidates a sim_score of
// 0.
//
// Then scaled to the library (#3880): a bigger collection should put
// Then scaled to the library: a bigger collection should put
// more genuinely-similar candidates in reach, not the same ~170
// regardless. The weights still rank sim_score-0 arms last, so the
// growth reaches coherence rather than working against it.
@@ -737,16 +726,11 @@ func produceSeedMixes(
}
// The seed artist's own songs are ELIGIBLE here, deliberately.
//
// This used to filter them out — "Songs like X excludes X's own
// songs" — which reads as obviously right and is not. The seed is a
// TRACK, and the tracks most likely to sound like it are usually the
// rest of that artist's catalogue; excluding them threw away the
// nearest neighbours of the very thing the mix is built around, and
// then reached further out to replace them. On a surface whose whole
// job is staying in one neighbourhood, that is backwards.
//
// Operator, 2026-09-10: "it should also be able to include music from
// the same artist."
// "Songs like X excludes X's own songs" reads as obviously right and
// is not. The seed is a TRACK, and the tracks most likely to sound
// like it are usually the rest of that artist's catalogue; excluding
// them would throw away the nearest neighbours of the very thing the
// mix is built around, and reach further out to replace them.
//
// Domination is bounded by the cap rather than by exclusion, which is
// the distinction that makes this safe: capCandidatesByAlbumAndArtist
@@ -791,12 +775,12 @@ func produceDiscover(
return []builtPlaylist{{Name: "Discover", Variant: "discover", Tracks: tracks}}, nil
}
// BuildSystemPlaylists builds the user's daily system mixes (one For-You +
// up to songsLikeSeedCount Songs-like-{seed} mixes). Atomic-replace inside one tx;
// concurrency-guarded via system_playlist_runs.in_flight; deterministic
// within a day via tieBreakHash(track_id, now.UTC().Format("2006-01-02")).
// BuildSystemPlaylists builds the user's daily system mixes (one For-You + up
// to songsLikeSeedCount Songs-like-{seed} mixes). Atomic-replace inside one tx;
// concurrency-guarded via system_playlist_runs.in_flight; deterministic within
// a day via tieBreakHash(track_id, now.UTC().Format("2006-01-02")).
//
// Callable from both the cron loop (system_cron.go) and the lazy fallback.
// Called by the daily scheduler and by the on-demand refresh endpoints.
// Returns nil when the user has no library activity to build from. Errors
// are captured in system_playlist_runs.last_error before returning.
func BuildSystemPlaylists(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, userID pgtype.UUID, now time.Time, dataDir string) error {
@@ -929,13 +913,13 @@ func BuildSystemPlaylists(ctx context.Context, pool *pgxpool.Pool, logger *slog.
// truncates to n. Used by Songs-like-X (and as the fallback inside
// pickHeadAndTail for small pools).
func pickTopN(cands []recommendation.Candidate, userID pgtype.UUID, dateStr string, now time.Time, n int) []rankedCandidate {
// songs_like, not daily_mix (#3881). produceSeedMixes is this function's
// only caller, so the switch moves exactly one surface — For-You ranks
// through pickHeadAndTail and keeps the broader daily_mix profile.
// songs_like, not daily_mix. produceSeedMixes is this function's only
// caller, so this moves exactly one surface — For-You ranks through
// pickHeadAndTail and keeps the broader daily_mix profile.
//
// The SAME profile does the selection sort and the final score. Passing
// one and using the other is the subtle version of this bug: the playlist
// would still be chosen by daily_mix and merely wear songs_like numbers.
// one and using the other would leave the playlist chosen by daily_mix
// and merely wearing songs_like numbers.
weights := currentSongsLikeWeights()
sorted := scoreAndSortCandidates(cands, userID, dateStr, now, weights)
capped := capCandidatesByAlbumAndArtist(sorted)
@@ -955,7 +939,7 @@ func pickTopN(cands []recommendation.Candidate, userID pgtype.UUID, dateStr stri
// pickHeadAndTail picks headN taste anchors from the score-sorted pool
// (under per-seed quotas when seedOf/numSeeds describe a multi-seed
// blend, #1269) plus tailN freshness picks sampled rank-weighted from
// blend) plus tailN freshness picks sampled rank-weighted from
// positions 2*headN onward. Caps applied before the head/tail split.
// Used by For-You only.
//
@@ -1022,7 +1006,7 @@ func pickHeadAndTail(
// Combine: head first (score-sorted under quotas), then the fresh
// sample. "First similar, then surprise" reads naturally in
// playback. Head entries are the taste picks; tail entries are the
// freshness injection (#1249) — the split the metrics page
// freshness injection — the split the metrics page
// attributes skips to.
combined := make([]recommendation.Candidate, 0, len(head)+len(tail))
combined = append(combined, head...)
@@ -1044,7 +1028,7 @@ func pickHeadAndTail(
}
// insertSystemPlaylist inserts a kind='system' playlists row plus its
// playlist_tracks via AppendPlaylistTrack (slice 1's existing query, which
// playlist_tracks via AppendPlaylistTrack (which
// does the snapshot lookup from tracks/albums/artists). Then recomputes
// rollups. Returns the new playlist's ID so the caller can drive
// post-commit collage generation.
@@ -1101,8 +1085,8 @@ func insertSystemPlaylist(ctx context.Context, qtx *dbq.Queries, userID pgtype.U
}
// oneCopyPerSong keeps the first of a song's copies on different releases
// (M498 #5438): a mix holds the song once, in its best-ranked place. A video
// rip held back from the mixes (#5439) is left out, and a clean copy of the
// a mix holds the song once, in its best-ranked place. A video rip held back
// from the mixes is left out, and a clean copy of the
// same song can take its place.
func oneCopyPerSong(ctx context.Context, qtx *dbq.Queries, tracks []rankedCandidate) ([]rankedCandidate, error) {
ids := make([]pgtype.UUID, len(tracks))
+8 -9
View File
@@ -11,10 +11,9 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// Discovery mixes (#419-423). One generic producer + per-mix spec.
// Replaces the five near-identical produceXxx functions that all
// fetched ranked (id, album_id, artist_id) rows, diversified,
// truncated, and emitted a single playlist.
// Discovery mixes: one generic producer + a per-mix spec. Each mix fetches
// ranked (id, album_id, artist_id) rows, diversifies, truncates, and emits a
// single playlist.
//
// Day-keying is a per-mix property captured by `dailyRotate`:
//
@@ -40,7 +39,7 @@ import (
// Discover so shuffle-on-play has a varied pool within a day.
const discoveryMixLen = 100
// Minimum viable mix sizes (issue #1246): below the floor the variant
// Minimum viable mix sizes: below the floor the variant
// is withheld entirely so Home renders its "listen more to unlock"
// placeholder — a playlist with a couple of songs reads as a build
// bug, not a mix. Album-coherent mixes (NewForYou / FirstListens) get
@@ -68,7 +67,7 @@ type discoveryMixSpec struct {
// minLen is the minimum viable mix size: a finished pool below it
// is withheld (no playlist row) so the client renders the locked
// placeholder instead of a mix that looks built-wrong (#1246).
// placeholder instead of a mix that looks built-wrong.
minLen int
// fetch returns the raw ranked rows. dateStr is supplied for
@@ -109,10 +108,10 @@ func produceDiscoveryMix(spec discoveryMixSpec) systemPlaylistProducer {
// so each day's downstream truncate-to-N surfaces a different slice
// while contiguous-block ordering inside the slice is preserved.
//
// Rotation happens WITHIN each contiguous same-pick-kind block
// (#1267): tiered mixes arrive tier-ordered, and a whole-pool
// Rotation happens WITHIN each contiguous same-pick-kind block:
// tiered mixes arrive tier-ordered, and a whole-pool
// rotation would hoist tier-3 filler above tier-1's exact fits.
// Untiered pools are a single block, which reduces to the original
// Untiered pools are a single block, which reduces to a plain
// whole-pool rotation (same seed, same first draw). Empty /
// single-element pools pass through unchanged.
func rotateForDay(pool []discoverTrack, userID pgtype.UUID, dateStr string) []discoverTrack {
+8 -8
View File
@@ -106,8 +106,8 @@ func likeArtist(t *testing.T, pool *pgxpool.Pool, userID, artistID pgtype.UUID)
}
func TestListDeepCutsTracks_SkippedTracksExcluded(t *testing.T) {
// #1257: eligibility used to count only unskipped plays, so a track
// skipped repeatedly (and never finished) read as "barely heard".
// Eligibility counts skipped plays too, so a track skipped repeatedly
// (and never finished) does not read as "barely heard".
pool := newPool(t)
q := dbq.New(pool)
u := seedUser(t, pool, "dc1")
@@ -150,8 +150,8 @@ func TestListDeepCutsTracks_SkippedTracksExcluded(t *testing.T) {
}
func TestListOnThisDayTracks_WrapsYearBoundary(t *testing.T) {
// #1256: plain ABS on day-of-year made Dec 28 vs Jan 3 read as 359
// days apart. The build date parameter anchors "today" so the wrap
// The day-of-year window wraps the year: plain ABS would make Dec 28 vs
// Jan 3 read as 359 days apart. The build date parameter anchors "today" so the wrap
// is testable at any run date.
pool := newPool(t)
q := dbq.New(pool)
@@ -182,7 +182,7 @@ func TestListOnThisDayTracks_WrapsYearBoundary(t *testing.T) {
}
func TestListNewForYouTracks_TiersAndConsumption(t *testing.T) {
// #1267: tiered eligibility with consume-on-play. Tier 1 = fresh
// Tiered eligibility with consume-on-play. Tier 1 = fresh
// affinity albums; tier 2 = affinity albums from the wider 90-day
// window; tier 3 = fresh additions without affinity; any attempted
// (>=30s) track consumes the album out of the mix entirely.
@@ -240,8 +240,8 @@ func TestListNewForYouTracks_TiersAndConsumption(t *testing.T) {
}
func TestListFirstListensTracks_AttemptThreshold(t *testing.T) {
// #1268: "attempted" is track-level at >=30s. A 2-second brush no
// longer disqualifies an album; barely-attempted albums step back
// "Attempted" is track-level at >=30s. A 2-second brush does not
// disqualify an album; barely-attempted albums step back
// to tier 2 with the attempted tracks themselves excluded.
pool := newPool(t)
q := dbq.New(pool)
@@ -297,7 +297,7 @@ func TestListFirstListensTracks_AttemptThreshold(t *testing.T) {
}
func TestListRediscoverTracks_SkippedResurfacingCoolsDown(t *testing.T) {
// #1258: a skip on a rediscover-sourced play is a declined
// A skip on a rediscover-sourced play is a declined
// invitation — the track sits out ~90 days instead of re-qualifying
// the next day. Skips from other surfaces don't cool down, and the
// cooldown expires.
+2 -2
View File
@@ -21,7 +21,7 @@ func idsOf(pool []discoverTrack) []byte {
func TestRotateForDay_RotatesWithinTierBlocks(t *testing.T) {
// Tiered pools arrive tier-ordered; rotation must vary the daily
// slice WITHIN each tier without hoisting tier-3 filler above
// tier-1's exact fits (#1267).
// tier-1's exact fits.
pool := []discoverTrack{
mixTrack(1, pickKindTier1), mixTrack(2, pickKindTier1), mixTrack(3, pickKindTier1),
mixTrack(4, pickKindTier2), mixTrack(5, pickKindTier2),
@@ -80,7 +80,7 @@ func TestRotateForDay_UntieredPoolIsWholeRotation(t *testing.T) {
func TestFinishMix_PropagatesPickKind(t *testing.T) {
// Tier stamps ride discoverTrack through diversify/truncate into
// the rankedCandidates that insertSystemPlaylist persists (#1267).
// the rankedCandidates that insertSystemPlaylist persists.
pool := []discoverTrack{mixTrack(1, pickKindTier1), mixTrack(2, pickKindTier2)}
got := finishMix(pool, false)
if len(got) != 2 {
+11 -15
View File
@@ -115,7 +115,7 @@ func TestBuildSystemPlaylists_SufficientActivity(t *testing.T) {
t.Errorf("songs_like_artist row should have non-NULL seed_artist_id")
}
case "discover", "deep_cuts", "rediscover", "new_for_you", "on_this_day", "first_listens":
// Discover + the #411 discovery mixes are all seedless —
// Discover and the discovery mixes are all seedless —
// no seed_artist_id required.
default:
t.Errorf("unknown system_variant=%q", *r.SystemVariant)
@@ -124,7 +124,7 @@ func TestBuildSystemPlaylists_SufficientActivity(t *testing.T) {
t.Errorf("row %s: empty track_count", uuidString(r.ID))
}
// For-You / Discover / the discovery mixes are sized to ~100
// (#352/#411); songs_like_artist stays at systemMixLength (25).
// songs_like_artist stays at systemMixLength (25).
if r.TrackCount > 100 {
t.Errorf("row %s: track_count=%d exceeds 100", uuidString(r.ID), r.TrackCount)
}
@@ -138,10 +138,10 @@ func TestBuildSystemPlaylists_SufficientActivity(t *testing.T) {
}
func TestBuildSystemPlaylists_StaleActivity_SongsLikeSurvives(t *testing.T) {
// #1255: the seed-artist query's old hard 7-day window emptied the
// pool after a quiet week, and the atomic-replace build then deleted
// every "Songs like X" mix. With the tiered fallback, plays that are
// ~20 days old (outside 7d, inside 30d) must still seed the mixes —
// A hard 7-day seed window would empty the pool after a quiet week, and
// the atomic-replace build would then delete every "Songs like X" mix.
// With the tiered fallback, plays that are ~20 days old (outside 7d,
// inside 30d) must still seed the mixes —
// and the built tracks carry the tier2 stamp so metrics can compare
// stale-seeded mixes against fresh ones.
pool := newPool(t)
@@ -352,16 +352,12 @@ func seedSharedArtistLibrary(
}
// The seed artist's own tracks are ELIGIBLE for its "Songs like" mix, and are
// bounded by the diversity cap rather than excluded outright (#3881).
//
// produceSeedMixes used to filter them out — "Songs like X excludes X's own
// songs" — which threw away the nearest neighbours of the seed TRACK and then
// reached further out to replace them. Operator, 2026-09-10: "it should also
// be able to include music from the same artist."
// bounded by the diversity cap rather than excluded outright: excluding them
// would throw away the nearest neighbours of the seed TRACK.
//
// Asserted end-to-end rather than by reading the source, because the guard has
// to survive the filter coming back in a different shape — and because an
// absence check would now match the comment explaining why the filter is gone.
// to survive a filter coming back in a different shape — and because an
// absence check would match the comment explaining why there is no filter.
func TestBuildSystemPlaylists_SongsLikeIncludesItsSeedArtist(t *testing.T) {
pool := newPool(t)
logger := discardLogger()
@@ -403,7 +399,7 @@ func TestBuildSystemPlaylists_SongsLikeIncludesItsSeedArtist(t *testing.T) {
}
// The bound is what makes inclusion safe. Without it, "include the
// seed artist" becomes "the mix is mostly the seed artist", which is
// the radio failure (#3882) arriving on a different surface.
// the one-artist radio arriving on a different surface.
if own > maxPerArtist {
t.Errorf("a songs_like mix carries %d tracks by its own seed artist "+
"out of %d; the per-artist cap (%d) is not being applied",
+3 -3
View File
@@ -1,5 +1,5 @@
// you_might_like.go builds the per-user "You might like" Home rows
// (#790): in-library albums/artists the listener doesn't actively spin
// you_might_like.go builds the per-user "You might like" Home rows:
// in-library albums/artists the listener doesn't actively spin
// but is predicted to enjoy. It reuses the For-You candidate engine
// (similarity + like-weighted scoring) and rolls the per-track scores up
// to album/artist granularity, gated on a minimum listening history so a
@@ -84,7 +84,7 @@ func buildYouMightLike(
return youMightLikeResult{built: false}
}
// One rotating seed is right here (unlike For-You's multi-seed
// blend, #1269): the row is a short shelf, not a mix, and a single
// blend): the row is a short shelf, not a mix, and a single
// neighborhood per day keeps it coherent.
daily := pickDailySeeds(seeds, userID, dateStr, 1)
if len(daily) == 0 {
@@ -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
+8 -8
View File
@@ -12,7 +12,7 @@ import (
)
// ReasonLibrary: on the list because the library held it when the list
// began (#5567).
// began.
const ReasonLibrary = "library"
// libraryMatchBudget bounds the time one pass spends matching albums to
@@ -33,17 +33,17 @@ const libraryMatchBatch = 25
// 4 an album holding every track of an edition, and more, is that group
const libraryMatcher = 4
// listLibrary puts the library's albums on the wanted list as held, once
// (operator, 2026-10-10: "make a record saying that the import has
// happened so later changes like unwanting hold and dont auto import from
// the file directory"). It runs a step each pass until done:
// listLibrary puts the library's albums on the wanted list as held, once,
// so that from then on the list is the record of what the library keeps: an
// album taken off it stays off, and files dropped in by hand are not listed.
// It runs a step each pass until done:
//
// - the first step stamps when the listing began, which is its cutoff:
// an album the library gains afterwards, dropped in by hand, is never
// listed;
// - every album whose tags name its release group and artist goes on the
// list at once;
// - every other album is matched to MusicBrainz (#5569), within a time
// - every other album is matched to MusicBrainz, within a time
// budget a pass, and listed as it is matched;
// - once none is left to match, the listing is stamped done, with a count
// of the albums no match was found for.
@@ -51,8 +51,8 @@ const libraryMatcher = 4
// Each album decided is recorded in library_album_matches, so an album taken
// off the list stays off. Once done the listing runs again only for albums
// an older matcher refused (libraryMatcher), refused albums whose tracks
// have changed since, and library tracks a re-tag filed under a new album
// (#5579). A person matches the rest by hand, or leaves them off, in the
// have changed since, and library tracks a re-tag filed under a new album.
// A person matches the rest by hand, or leaves them off, in the
// Collection's Unmatched section (MatchAlbumTo, LeaveAlbumOff).
func (s *Service) listLibrary(ctx context.Context, res *PassResult) error {
q := dbq.New(s.pool)
+1 -1
View File
@@ -70,7 +70,7 @@ func matchOf(t *testing.T, pool *pgxpool.Pool, album pgtype.UUID) (outcome, meth
return
}
// The library's albums go on the list as held, once (#5567). Tags that name
// The library's albums go on the list as held, once. Tags that name
// the group list it at once; a release id is named through MusicBrainz;
// what nothing matches is recorded with why. Taken off the list, an album
// stays off, and one added to the library afterwards is never listed.
+4 -5
View File
@@ -15,11 +15,10 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/tags"
)
// Matching a library album by hand (#5579): the Collection's Unmatched
// section. A person names the MusicBrainz
// album an album is, by a link or a search, and it goes on the list as held;
// or leaves it off for good, as a local band's album MusicBrainz will never
// have.
// Matching a library album by hand: the Collection's Unmatched section. A
// person names the MusicBrainz album an album is, by a link or a search, and
// it goes on the list as held; or leaves it off for good, as a local band's
// album MusicBrainz will never have.
const matchManual = "manual"
+2 -3
View File
@@ -32,8 +32,7 @@ func TestParseMusicBrainzRef(t *testing.T) {
}
// A person names the MusicBrainz album an unmatched album is, and it goes
// on the list as held; or leaves it off, and matching never tries it again
// (#5579).
// on the list as held; or leaves it off, and matching never tries it again.
func TestMatchAlbumTo_ByLinkOrLeaveItOff_Integration(t *testing.T) {
pool := newPool(t)
ctx := context.Background()
@@ -120,7 +119,7 @@ func TestMatchAlbumTo_ByLinkOrLeaveItOff_Integration(t *testing.T) {
// A re-tag files a library track under a new album row. That album is the
// library's, not one dropped in by hand, so the listing matches it; the
// old row, its tracks gone, is no longer unmatched (#5579).
// old row, its tracks gone, is no longer unmatched.
func TestListLibrary_MatchesARetaggedAlbum_Integration(t *testing.T) {
pool := newPool(t)
ctx := context.Background()
+4 -6
View File
@@ -16,13 +16,11 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/words"
)
// Matching a library album to its MusicBrainz release group (#5569).
// Matching a library album to its MusicBrainz release group.
//
// Everything in the library came from a service that matched it against
// MusicBrainz (operator, 2026-10-10: "they should all exist in music brainz
// we need to use their meta data or a search system to link them up"), so
// every album should have a group. The ids its tags carry are taken as
// they are. Without them, the groups called what the album is called, from
// Music from any mainstream source was matched against MusicBrainz on its
// way in, so nearly every album has a group. The ids its tags carry are
// taken as they are. Without them, the groups called what the album is called, from
// the artist's discography and then from a search, are checked against the
// album's own tracks, and the one its tracks agree with is taken.
+1 -1
View File
@@ -40,7 +40,7 @@ var (
// which every profile passes on its primary type alone.
//
// A version its title names counts as a secondary type MusicBrainz left
// off (#5573): "Common People (Recorded For BBC Radio 1)" is live whatever
// off: "Common People (Recorded For BBC Radio 1)" is live whatever
// its types say, and a version no type names, an acoustic one, no profile
// passes.
func Passes(p dbq.ReleaseTypeProfile, title, primary string, secondary []string) bool {
+4 -5
View File
@@ -18,8 +18,7 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/release"
)
// Searching for wants on a schedule (M502 #5521), ported from Fabled
// Librarian's internal/wanted. The schedule decides which wants and when;
// Searching for wants on a schedule. The schedule decides which wants and when;
// internal/acquire judges what a search finds and grabs.
// Search outcomes, matching wants_search_outcome_check.
@@ -32,7 +31,7 @@ const (
const (
// searchTick is how often the schedule looks for wants due; the
// operator's setting is how long a want waits between searches.
// search interval setting is how long a want waits between searches.
searchTick = 10 * time.Minute
// searchWindow is how long one pass searches: most of a tick, so a pass
// ends before the next begins.
@@ -194,7 +193,7 @@ type searchRow struct {
artist, title, primary, date, group string
secondary []string
// upgrade, heldTier and held: a held album wanted for a better copy
// (#5524), and the copy it has.
// and the copy it has.
upgrade, heldTier, held string
}
@@ -346,7 +345,7 @@ func (s *Searcher) Take(ctx context.Context, id pgtype.UUID, key string, force b
}
// load reads a want for a search: an open one, or a held album wanted for a
// better copy (#5524).
// better copy.
func (s *Searcher) load(ctx context.Context, id pgtype.UUID) (acquire.Want, error) {
row, err := dbq.New(s.pool).GetWantForSearch(ctx, id)
if err != nil {
+1 -1
View File
@@ -28,7 +28,7 @@ const ramFeed = `<?xml version="1.0"?>
</channel></rss>`
// searchStack is a fake indexer feed and a fake FabledExchange download
// client, configured the way the operator would in Integrations.
// client, configured as an admin would in Admin › Acquisition.
func searchStack(t *testing.T, ctx context.Context, store *acquire.Store) *atomic.Int32 {
t.Helper()
var added atomic.Int32
+2 -2
View File
@@ -11,8 +11,8 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/release"
)
// Upgrades (M502 #5524): a held album stays wanted when the copy the library
// has is below the quality the operator settles for (acquisition_settings.
// Upgrades: a held album stays wanted when the copy the library has is below
// the quality the install settles for (acquisition_settings.
// upgrade_until), or when one of its tracks is a video rip. The search
// schedule then looks for a better release weekly, and the import replaces
// the old tracks with the new ones, carrying their history across.
+3 -4
View File
@@ -99,7 +99,7 @@ func TestCheckUpgrades_AHeldAlbumBelowTheCutoffIsWantedAgain_Integration(t *test
t.Fatalf("due for search: %+v, %v", due, err)
}
// Upgrades switched off: no longer wanted again.
// Upgrades switched off: the album is not wanted again.
if _, err := pool.Exec(ctx, `UPDATE acquisition_settings SET upgrade_until = ''`); err != nil {
t.Fatal(err)
}
@@ -138,9 +138,8 @@ func TestCheckUpgrades_ARipIsReplacedWhateverTheSetting_Integration(t *testing.T
}
// A missing track with no disc number is on disc 1, so a present 1/2 stands in
// for a missing "2". Air's Love 2 stayed wanted over one such row and was
// downloaded again for nothing (2026-10-20). A missing track no present track
// covers still counts.
// for a missing "2", and the album is not downloaded again for nothing. A
// missing track no present track covers still counts.
func TestWantHoldings_AMissingTrackWithNoDiscIsOnDiscOne_Integration(t *testing.T) {
pool := newPool(t)
ctx := context.Background()
+17 -19
View File
@@ -1,10 +1,9 @@
// Package wanted is what Minstrel is looking for (M502 #5520): followed
// artists, each on a release-type profile, and wanted release groups. A want
// comes from an approved request, a followed artist's new release, or an
// album whose files went missing; it is held once the library has a full
// copy. The library's own albums were put on the list as held once (#5567),
// so the list is the record of what the library keeps. Searching for open
// wants is #5521's.
// Package wanted is what Minstrel is looking for: followed artists, each on
// a release-type profile, and wanted release groups. A want comes from an
// approved request, a followed artist's new release, or an album whose files
// went missing; it is held once the library has a full copy. The library's
// own albums are put on the list as held once, so the list is the record of
// what the library keeps. Searching for open wants is the Searcher's.
package wanted
import (
@@ -47,11 +46,11 @@ type Service struct {
logger *slog.Logger
now func() time.Time
// releaseGroup names the group of a MusicBrainz release id, to move a
// want recorded under a release id (#5241) to its group. A field so tests
// want recorded under a release id to its group. A field so tests
// stand in for MusicBrainz.
releaseGroup func(ctx context.Context, releaseMBID string) (string, error)
// upgradesChecked is when held albums were last judged for an upgrade
// (#5524); the worker alone touches it.
// the worker alone touches it.
upgradesChecked time.Time
}
@@ -196,9 +195,9 @@ type PassResult struct {
Baselined int // of those, first reads
Wanted int // wants recorded from follows
Held int // wants the library now answers
Listed int // library albums put on the list as held (#5567)
Listed int // library albums put on the list as held
// Upgrades: held albums wanted for a better copy, when the hourly check
// ran this pass (#5524).
// ran this pass.
Upgrades int
}
@@ -211,7 +210,7 @@ func (s *Service) Pass(ctx context.Context) (PassResult, error) {
if ctx.Err() != nil {
return res, ctx.Err()
}
// The one-time listing of the library (#5567); a failure waits for the
// The one-time listing of the library; a failure waits for the
// next pass and does not hold back settling.
if err := s.listLibrary(ctx, &res); err != nil && ctx.Err() == nil {
s.logger.Warn("wanted: list the library", "err", err)
@@ -321,9 +320,9 @@ func (s *Service) applyFollow(ctx context.Context, q *dbq.Queries, f dbq.Follow)
return wanted, q.MarkFollowChecked(ctx, f.ArtistMbid)
}
// dropRefusedVersion takes off the list a group the follow wanted before
// Passes read the version its title names (#5573), while it is still only
// wanted: one the operator holds, or wanted for another reason, stays.
// dropRefusedVersion takes off the list a group a follow wants that Passes
// now refuses for the version its title names, while it is still only
// wanted: one the library holds, or wanted for another reason, stays.
func (s *Service) dropRefusedVersion(ctx context.Context, q *dbq.Queries, group, title string) error {
v, ok := release.VersionInTitle(title)
if !ok {
@@ -464,7 +463,7 @@ func Shortest(releases []dbq.MbRelease) int {
}
// moveToGroup handles a want MusicBrainz has no release group under: one
// recorded under a release id before #5241. It is moved to the release's
// recorded under a release id. It is moved to the release's
// group, with the requests that name it; when the group is already wanted,
// this copy is dropped.
func (s *Service) moveToGroup(ctx context.Context, w dbq.Want) (bool, error) {
@@ -509,9 +508,8 @@ func (s *Service) moveToGroup(ctx context.Context, w dbq.Want) (bool, error) {
// missing track is left without a track on disk in its place;
// - otherwise an album of the group with at least as many tracks on disk as
// the shortest official edition;
// - and when the catalogue cannot say how long the group is, the test from
// #5263: a track arrived since the want, or nothing missing before it is
// still missing.
// - and when the catalogue cannot say how long the group is: a track
// arrived since the want, or nothing missing before it is still missing.
//
// Only albums with a track on disk count.
func (s *Service) answeredBy(ctx context.Context, q *dbq.Queries, w dbq.Want, shortest int) (pgtype.UUID, bool, error) {
+4 -4
View File
@@ -42,7 +42,7 @@ func TestPasses_PrimaryListedAndEverySecondaryAllowed(t *testing.T) {
{withLive, "Live at Koko", "Album", []string{"Live"}, true},
{withLive, "Live & Rare", "Album", []string{"Live", "Compilation"}, false},
{withLive, "Like Phantoms, Forever", "EP", nil, false},
// MusicBrainz types these plain Singles; their titles say otherwise (#5573).
// MusicBrainz types these plain Singles; their titles say otherwise.
{standard, "Common People (Recorded For BBC Radio 1)", "Single", nil, false},
{standard, "Welcome to the Black Parade (Steve Aoki remix)", "Single", nil, false},
{standard, "The Five of Us Are Dying (Rough mix)", "Single", nil, false},
@@ -185,7 +185,7 @@ const (
theBallad = "11111111-0000-4000-8000-000000000003" // 2023 album, not held
newSingle = "11111111-0000-4000-8000-000000000004" // appears after the baseline
oldSingle = "11111111-0000-4000-8000-000000000005" // 1997 single, catalogued late
bbcSingle = "11111111-0000-4000-8000-000000000006" // live, typed a plain Single (#5573)
bbcSingle = "11111111-0000-4000-8000-000000000006" // live, typed a plain Single
parkRelease = "22222222-0000-4000-8000-000000000001"
)
@@ -238,7 +238,7 @@ func newFakeMB(t *testing.T) *fakeMB {
case r.URL.Path == "/release":
_, _ = fmt.Fprint(w, `{"release-count": 0, "releases": []}`)
case r.URL.Path == "/release-group" && strings.Contains(r.URL.Query().Get("query"), "releasegroup:"):
// The library listing's search by artist and title (#5569).
// The library listing's search by artist and title.
found := ""
if strings.Contains(r.URL.Query().Get("query"), "Ballad") {
found = group(theBallad, "The Ballad of Darren", "Album", "", "2023-07-21")
@@ -338,7 +338,7 @@ func TestFollow_WantExistingWantsWhatPassesAndIsNotHeld_Integration(t *testing.T
f := newFakeMB(t)
s := newService(t, pool, f)
_ = seedHeld(t, q, "Parklife", parkRelease, parklife, 2)
// Wanted before the follow read the version a title names (#5573).
// Wanted before the follow refused the version its title names.
if _, err := WantWith(ctx, q, WantParams{GroupMBID: bbcSingle, ArtistMBID: blur, ArtistName: "Blur",
Title: "Song 2 (Recorded For BBC Radio 1)", PrimaryType: "Single", Reason: ReasonFollow}); err != nil {
t.Fatal(err)