diff --git a/internal/dbtest/reset.go b/internal/dbtest/reset.go index d6cffc58..de71db8d 100644 --- a/internal/dbtest/reset.go +++ b/internal/dbtest/reset.go @@ -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`, diff --git a/internal/playlists/discover.go b/internal/playlists/discover.go index e30d3c31..a33cc1bd 100644 --- a/internal/playlists/discover.go +++ b/internal/playlists/discover.go @@ -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) diff --git a/internal/playlists/discover_taste_db_test.go b/internal/playlists/discover_taste_db_test.go index 81772972..a6084d61 100644 --- a/internal/playlists/discover_taste_db_test.go +++ b/internal/playlists/discover_taste_db_test.go @@ -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() diff --git a/internal/playlists/discover_test.go b/internal/playlists/discover_test.go index fc1d54a8..61583f8c 100644 --- a/internal/playlists/discover_test.go +++ b/internal/playlists/discover_test.go @@ -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{ diff --git a/internal/playlists/foryou_test.go b/internal/playlists/foryou_test.go index 2a102f9e..398dccd5 100644 --- a/internal/playlists/foryou_test.go +++ b/internal/playlists/foryou_test.go @@ -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) diff --git a/internal/playlists/scheduler.go b/internal/playlists/scheduler.go index c68d2da7..fe65c3a5 100644 --- a/internal/playlists/scheduler.go +++ b/internal/playlists/scheduler.go @@ -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) } diff --git a/internal/playlists/seed_missing_db_test.go b/internal/playlists/seed_missing_db_test.go index 25826b09..22cbaa09 100644 --- a/internal/playlists/seed_missing_db_test.go +++ b/internal/playlists/seed_missing_db_test.go @@ -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") diff --git a/internal/playlists/seed_selection_test.go b/internal/playlists/seed_selection_test.go index 14822422..9a95509f 100644 --- a/internal/playlists/seed_selection_test.go +++ b/internal/playlists/seed_selection_test.go @@ -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. diff --git a/internal/playlists/service.go b/internal/playlists/service.go index b6ae36fe..8661525c 100644 --- a/internal/playlists/service.go +++ b/internal/playlists/service.go @@ -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 } diff --git a/internal/playlists/service_test_helpers_test.go b/internal/playlists/service_test_helpers_test.go index f206cfd3..a2c57df6 100644 --- a/internal/playlists/service_test_helpers_test.go +++ b/internal/playlists/service_test_helpers_test.go @@ -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() diff --git a/internal/playlists/song_copies_test.go b/internal/playlists/song_copies_test.go index 5488b1b2..afdfbffd 100644 --- a/internal/playlists/song_copies_test.go +++ b/internal/playlists/song_copies_test.go @@ -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() diff --git a/internal/playlists/system.go b/internal/playlists/system.go index 807d38f8..e6fe1940 100644 --- a/internal/playlists/system.go +++ b/internal/playlists/system.go @@ -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)) diff --git a/internal/playlists/system_mixes.go b/internal/playlists/system_mixes.go index 2ca6ef78..f602ce00 100644 --- a/internal/playlists/system_mixes.go +++ b/internal/playlists/system_mixes.go @@ -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 { diff --git a/internal/playlists/system_mixes_db_test.go b/internal/playlists/system_mixes_db_test.go index a2a65c09..0445b058 100644 --- a/internal/playlists/system_mixes_db_test.go +++ b/internal/playlists/system_mixes_db_test.go @@ -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. diff --git a/internal/playlists/system_mixes_test.go b/internal/playlists/system_mixes_test.go index 64e863d0..bbb2da08 100644 --- a/internal/playlists/system_mixes_test.go +++ b/internal/playlists/system_mixes_test.go @@ -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 { diff --git a/internal/playlists/system_test.go b/internal/playlists/system_test.go index b297fc24..e5a0719e 100644 --- a/internal/playlists/system_test.go +++ b/internal/playlists/system_test.go @@ -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", diff --git a/internal/playlists/you_might_like.go b/internal/playlists/you_might_like.go index 2e4d4bc1..171657f0 100644 --- a/internal/playlists/you_might_like.go +++ b/internal/playlists/you_might_like.go @@ -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 { diff --git a/internal/recommendation/candidate_tags_integration_test.go b/internal/recommendation/candidate_tags_integration_test.go index 19cefbc8..ef24982a 100644 --- a/internal/recommendation/candidate_tags_integration_test.go +++ b/internal/recommendation/candidate_tags_integration_test.go @@ -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) diff --git a/internal/recommendation/candidates.go b/internal/recommendation/candidates.go index 30798ea0..ace767cb 100644 --- a/internal/recommendation/candidates.go +++ b/internal/recommendation/candidates.go @@ -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( diff --git a/internal/recommendation/candidates_v2_test.go b/internal/recommendation/candidates_v2_test.go index 49520993..e59f17f7 100644 --- a/internal/recommendation/candidates_v2_test.go +++ b/internal/recommendation/candidates_v2_test.go @@ -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() diff --git a/internal/recommendation/context.go b/internal/recommendation/context.go index 6f94345d..ee676576 100644 --- a/internal/recommendation/context.go +++ b/internal/recommendation/context.go @@ -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 diff --git a/internal/recommendation/context_device_integration_test.go b/internal/recommendation/context_device_integration_test.go index 484c5d62..7d76ac2a 100644 --- a/internal/recommendation/context_device_integration_test.go +++ b/internal/recommendation/context_device_integration_test.go @@ -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) { diff --git a/internal/recommendation/library_scale.go b/internal/recommendation/library_scale.go index bd689808..1252e3a1 100644 --- a/internal/recommendation/library_scale.go +++ b/internal/recommendation/library_scale.go @@ -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() diff --git a/internal/recommendation/library_scale_test.go b/internal/recommendation/library_scale_test.go index 918ce409..e06875bb 100644 --- a/internal/recommendation/library_scale_test.go +++ b/internal/recommendation/library_scale_test.go @@ -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} { diff --git a/internal/recommendation/score.go b/internal/recommendation/score.go index 3876519b..d5007cd0 100644 --- a/internal/recommendation/score.go +++ b/internal/recommendation/score.go @@ -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) diff --git a/internal/recommendation/sessionvector.go b/internal/recommendation/sessionvector.go index 33e6a645..824ee91c 100644 --- a/internal/recommendation/sessionvector.go +++ b/internal/recommendation/sessionvector.go @@ -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 == ',' diff --git a/internal/recommendation/shuffle.go b/internal/recommendation/shuffle.go index 91173c22..718c6cb3 100644 --- a/internal/recommendation/shuffle.go +++ b/internal/recommendation/shuffle.go @@ -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] } diff --git a/internal/recommendation/shuffle_caps_test.go b/internal/recommendation/shuffle_caps_test.go index 9ba78ecd..0fd5f6ab 100644 --- a/internal/recommendation/shuffle_caps_test.go +++ b/internal/recommendation/shuffle_caps_test.go @@ -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. diff --git a/internal/recommendation/shuffle_test.go b/internal/recommendation/shuffle_test.go index 9e36fe22..776a43d2 100644 --- a/internal/recommendation/shuffle_test.go +++ b/internal/recommendation/shuffle_test.go @@ -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" diff --git a/internal/recommendation/similarity.go b/internal/recommendation/similarity.go index 3062a7f3..b327ade0 100644 --- a/internal/recommendation/similarity.go +++ b/internal/recommendation/similarity.go @@ -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). diff --git a/internal/recommendation/suggestions.go b/internal/recommendation/suggestions.go index 348ebf6d..b7cb8b4f 100644 --- a/internal/recommendation/suggestions.go +++ b/internal/recommendation/suggestions.go @@ -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 { diff --git a/internal/recommendation/suggestions_integration_test.go b/internal/recommendation/suggestions_integration_test.go index e5ca0126..e8f50bd8 100644 --- a/internal/recommendation/suggestions_integration_test.go +++ b/internal/recommendation/suggestions_integration_test.go @@ -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) diff --git a/internal/recommendation/suggestions_select_test.go b/internal/recommendation/suggestions_select_test.go index c73ad91c..138a3a73 100644 --- a/internal/recommendation/suggestions_select_test.go +++ b/internal/recommendation/suggestions_select_test.go @@ -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") diff --git a/internal/recommendation/tagoverlap.go b/internal/recommendation/tagoverlap.go index 8e2d3bbd..afde5eaf 100644 --- a/internal/recommendation/tagoverlap.go +++ b/internal/recommendation/tagoverlap.go @@ -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 diff --git a/internal/recommendation/tagoverlap_test.go b/internal/recommendation/tagoverlap_test.go index e66269e4..e18cec86 100644 --- a/internal/recommendation/tagoverlap_test.go +++ b/internal/recommendation/tagoverlap_test.go @@ -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) diff --git a/internal/recommendation/taste.go b/internal/recommendation/taste.go index 46fd37f6..004dd6a4 100644 --- a/internal/recommendation/taste.go +++ b/internal/recommendation/taste.go @@ -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 diff --git a/internal/wanted/library.go b/internal/wanted/library.go index 9add993a..ad397b25 100644 --- a/internal/wanted/library.go +++ b/internal/wanted/library.go @@ -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) diff --git a/internal/wanted/library_test.go b/internal/wanted/library_test.go index f503d67d..b153497e 100644 --- a/internal/wanted/library_test.go +++ b/internal/wanted/library_test.go @@ -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. diff --git a/internal/wanted/manual.go b/internal/wanted/manual.go index 5bbe91f4..a58e8d45 100644 --- a/internal/wanted/manual.go +++ b/internal/wanted/manual.go @@ -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" diff --git a/internal/wanted/manual_test.go b/internal/wanted/manual_test.go index 7478fb8a..c76fe0d1 100644 --- a/internal/wanted/manual_test.go +++ b/internal/wanted/manual_test.go @@ -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() diff --git a/internal/wanted/match.go b/internal/wanted/match.go index 6fe0452d..a536299b 100644 --- a/internal/wanted/match.go +++ b/internal/wanted/match.go @@ -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. diff --git a/internal/wanted/profiles.go b/internal/wanted/profiles.go index 3cd86f3b..d1e86764 100644 --- a/internal/wanted/profiles.go +++ b/internal/wanted/profiles.go @@ -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 { diff --git a/internal/wanted/search.go b/internal/wanted/search.go index ef915866..5c8f0102 100644 --- a/internal/wanted/search.go +++ b/internal/wanted/search.go @@ -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 { diff --git a/internal/wanted/search_test.go b/internal/wanted/search_test.go index 7dc5649e..407dbd22 100644 --- a/internal/wanted/search_test.go +++ b/internal/wanted/search_test.go @@ -28,7 +28,7 @@ const ramFeed = ` ` // 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 diff --git a/internal/wanted/upgrade.go b/internal/wanted/upgrade.go index a5959626..5b620286 100644 --- a/internal/wanted/upgrade.go +++ b/internal/wanted/upgrade.go @@ -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. diff --git a/internal/wanted/upgrade_test.go b/internal/wanted/upgrade_test.go index 51c43edb..d9837bba 100644 --- a/internal/wanted/upgrade_test.go +++ b/internal/wanted/upgrade_test.go @@ -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() diff --git a/internal/wanted/wanted.go b/internal/wanted/wanted.go index 019e1775..afbbec71 100644 --- a/internal/wanted/wanted.go +++ b/internal/wanted/wanted.go @@ -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) { diff --git a/internal/wanted/wanted_test.go b/internal/wanted/wanted_test.go index a25e9cdf..e4512c31 100644 --- a/internal/wanted/wanted_test.go +++ b/internal/wanted/wanted_test.go @@ -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)