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