docs: wanted, dbtest, recommendation and playlists comments say what the code does (#6277)

The same rewrite as d191a282, for four packages whose comments carried the
most history: tuning stories, quotes from the operator, single-install
measurements in library_scale.go, and 'used to' accounts of bugs. The
constraint each story protected stays as a present-tense reason (why tier 3
is reduced not removed, why the randomised arms take an order seed, why the
seed artist is eligible in Songs-like). Comments naming system_cron.go and
config.RecommendationConfig, neither of which holds what they claimed, now
name the scheduler and internal/recsettings.

Checked: non-comment tokens identical to HEAD; gofmt clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 12:19:15 -04:00
co-authored by Claude Opus 5.5
parent d191a28217
commit fcf5b7e5a8
48 changed files with 391 additions and 501 deletions
+9 -11
View File
@@ -11,7 +11,7 @@ import (
const (
discoverTotalSlots = 100
// Bucket allocation, pushed toward *targeted* novelty (Scribe #1488):
// Bucket allocation, pushed toward *targeted* novelty:
// the taste-matched-unheard arm gets the plurality, dormant stays a
// familiar anchor, and the crude random arm is trimmed to a
// serendipity sliver. On a single-user server cross_user is empty and
@@ -28,8 +28,8 @@ const (
// discoverTrack is the common shape used by the bucket allocator. The
// three sqlc-generated row types collapse into this internal struct so
// downstream functions don't need to be generic over them. PickKind is
// the originating bucket (taste_unheard/dormant/cross_user/random,
// #1270/#1488), stamped in the row adapters so it survives the
// the originating bucket (taste_unheard/dormant/cross_user/random),
// stamped in the row adapters so it survives the
// interleave — bucket identity is what makes the slot allocation
// measurable in the metrics.
type discoverTrack struct {
@@ -95,8 +95,8 @@ type discoverPools struct {
}
func loadDiscoverPools(ctx context.Context, q *dbq.Queries, logger *slog.Logger, userID pgtype.UUID, dateStr string) discoverPools {
// The caps go into the query too (#5356): applied only here, after its
// LIMIT, they left a heavily tagged artist or two owning the whole bucket.
// The caps go into the query too: applied only here, after its LIMIT, they
// would leave a heavily tagged artist or two owning the whole bucket.
tasteRows, err := q.ListTasteUnheardTracksForDiscover(ctx, dbq.ListTasteUnheardTracksForDiscoverParams{
UserID: userID, DateSeed: dateStr,
MaxPerAlbum: discoverMaxTracksPerAlbum, MaxPerArtist: discoverMaxTracksPerArtist,
@@ -229,12 +229,10 @@ type bucketRequest struct {
// peer's supply ran out mid-distribution and the residual needs
// to roll to other peers in a later pass.
//
// Bug history (May 2026): an earlier version recomputed
// `deficit = b.want - final[i]` every pass without subtracting
// previously-redistributed amounts, so a bucket with permanently
// 0 final and want=30 kept handing out 30 to peers each pass. The
// `redistributed` array below tracks per-source absorbed deficit so
// subsequent passes only move the residual.
// The `redistributed` array tracks how much of each source's deficit has
// been absorbed, so later passes move only the residual: recomputing
// `b.want - final[i]` each pass would hand out a permanently empty bucket's
// whole want again on every pass.
func redistributeSlots(buckets []bucketRequest) []int {
n := len(buckets)
final := make([]int, n)
+5 -5
View File
@@ -29,11 +29,11 @@ func seedTasteTag(t *testing.T, pool *pgxpool.Pool, userID pgtype.UUID, tag stri
}
}
// TestListTasteUnheardTracksForDiscover_CapsBeforeLimit is the deploy's shape
// (#5356): one artist whose every track carries the whole taste profile
// outscores everything on summed weight. Capped only after the LIMIT, that
// artist took all 120 rows and the bucket shrank to its 3. Capped before, the
// artist keeps 3 and the single-tag artists below it fill the rest.
// TestListTasteUnheardTracksForDiscover_CapsBeforeLimit: one artist whose every
// track carries the whole taste profile outscores everything on summed
// weight. Capped only after the LIMIT, that artist would take all 120 rows
// and the bucket shrink to its 3. Capped before, the artist keeps 3 and the
// single-tag artists below it fill the rest.
func TestListTasteUnheardTracksForDiscover_CapsBeforeLimit(t *testing.T) {
pool := newPool(t)
ctx := context.Background()
+1 -1
View File
@@ -163,7 +163,7 @@ func TestInterleaveBuckets_DedupsAcrossBuckets(t *testing.T) {
}
func TestInterleaveBuckets_PreservesBucketPickKind(t *testing.T) {
// Bucket provenance (#1270) is stamped on discoverTrack before the
// Bucket provenance is stamped on discoverTrack before the
// interleave and must survive it — including dedup, where a track in
// two buckets keeps the stamp of the bucket it was taken from.
dormant := []discoverTrack{
+3 -3
View File
@@ -246,7 +246,7 @@ func TestPickTopN_DiversityCap(t *testing.T) {
func TestPickHeadAndTail_MarksPickKinds(t *testing.T) {
// 100-deep pool with headN=20, tailN=5: the 20 head entries are the
// taste picks, the 5 tail entries the freshness injection (#1249).
// taste picks, the 5 tail entries the freshness injection.
in := make([]recommendation.Candidate, 0, 100)
for i := 0; i < 100; i++ {
in = append(in, makeCand(i+1, i+1, i+1, float64(100-i)))
@@ -323,7 +323,7 @@ func TestHeadQuotas(t *testing.T) {
}
func TestPickQuotaHead_BlendsSeeds(t *testing.T) {
// Seed 0's neighborhood out-scores everything (#1269): without
// Seed 0's neighborhood out-scores everything: without
// quotas the head would be all seed-0. With headN=10 and quotas
// [5,3,2], the walk takes the best 5 from seed 0, then seeds 1 and
// 2 get their guaranteed slots from deeper ranks.
@@ -385,7 +385,7 @@ func TestPickQuotaHead_ThinSeedSpillsBestFirst(t *testing.T) {
}
func TestPickWeightedTail_PrefersEarlyRanks(t *testing.T) {
// The fresh tail's sample is rank-weighted (#1269): across many
// The fresh tail's sample is rank-weighted: across many
// days, the mean selected rank must sit well below the uniform
// draw's ~99.5 for a 200-deep pool.
pool := make([]recommendation.Candidate, 0, 200)
+7 -8
View File
@@ -1,5 +1,4 @@
// Per-user system-playlist scheduler (#392 Half B). Replaces the
// 24h-anchored cron loop in system_cron.go (deleted in the same arc).
// Per-user system-playlist scheduler.
//
// Each active user gets one gocron daily job at 03:00 in their stored
// timezone. The scheduler reconciles its in-memory job set with the
@@ -34,7 +33,7 @@ import (
// dailyRebuildHour is the local-time hour at which each user's system
// playlists rebuild. 03:00 is well past most "today" listening and
// before morning use; matches the operator framing in the design doc.
// before morning use.
const dailyRebuildHour = 3
// reconciliationInterval drives the hourly pass that brings the
@@ -48,7 +47,7 @@ type Scheduler struct {
pool *pgxpool.Pool
logger *slog.Logger
dataDir string
bus *eventbus.Bus // #968: announce rebuilds so clients self-refresh
bus *eventbus.Bus // announces rebuilds so clients self-refresh
mu sync.Mutex
jobs map[pgtype.UUID]uuid.UUID // user_id → gocron job id
@@ -245,10 +244,10 @@ func (s *Scheduler) rebuildUserDaily(ctx context.Context, userID pgtype.UUID, no
"user_id", uuidStringPL(userID), "err", err)
return
}
// #968: tell the user's connected clients their home content was
// regenerated, so a tab/app left open across the rebuild refreshes its
// system-playlist + You-might-like views (and a stale active queue can
// re-pull) instead of serving yesterday's snapshot until a manual reload.
// Tell the user's connected clients their home content was regenerated, so
// a tab/app left open across the rebuild refreshes its system-playlist +
// You-might-like views (and a stale active queue can re-pull) instead of
// serving yesterday's snapshot until a manual reload.
s.publishRebuilt(userID)
}
+1 -1
View File
@@ -28,7 +28,7 @@ func markMissing(t *testing.T, pool *pgxpool.Pool, trackID pgtype.UUID) {
}
// TestPickTopPlayedTracksForUser_LikedTierSkipsMissing: with no plays, For
// You seeds from likes. A liked track whose file is gone is not a seed (#2701).
// You seeds from likes. A liked track whose file is gone is not a seed.
func TestPickTopPlayedTracksForUser_LikedTierSkipsMissing(t *testing.T) {
pool := newPool(t)
u := seedUser(t, pool, "seedmissing")
+4 -4
View File
@@ -48,9 +48,9 @@ func TestPickSeedArtistsFromRows_Empty(t *testing.T) {
}
func TestPickKindForSeedTier(t *testing.T) {
// The seed query's fallback tiers map onto the rule-#131 ladder:
// fresh 7-day engagement is the exact desire, everything past the
// 30-day step-back collapses into the far tier (#1255).
// The seed query's fallback tiers map onto the tier ladder: fresh
// 7-day engagement is the exact desire, everything past the 30-day
// step-back collapses into the far tier.
cases := []struct {
tier int32
want string
@@ -99,7 +99,7 @@ func TestUserIDHash_DifferentUserChangesHash(t *testing.T) {
// pickDailySeeds shuffles the candidate pool daily-deterministically
// and takes up to n — For-You uses n=forYouSeedCount for its
// multi-seed blend (#1269), Songs-like uses n=songsLikeSeedCount via
// multi-seed blend, Songs-like uses n=songsLikeSeedCount via
// the pickSeedArtistsForDay wrapper. Verifies determinism within a day,
// variation across days, and graceful degradation on small pools.
+2 -2
View File
@@ -1,6 +1,6 @@
// Package playlists implements the user-facing playlist CRUD service.
//
// Visibility model (from the M7 #352 design spec):
// Visibility model:
// - Playlists are private by default. Only the owner sees private rows
// in List, Get, and is allowed to mutate them via Update / Delete.
// - is_public = true makes a playlist readable by any authenticated
@@ -112,7 +112,7 @@ type PlaylistTrack struct {
AddedAt pgtype.Timestamptz
// Unavailable reports that the track still exists in the library but
// its file is currently missing from disk (tracks.missing_since is
// set, #2527). The entry keeps its place in the playlist — the user
// set). The entry keeps its place in the playlist — the user
// put it there — but nothing should try to play it.
Unavailable bool
}
@@ -51,7 +51,7 @@ func newPool(t *testing.T) *pgxpool.Pool {
}
// seedUser inserts a `test-`-prefixed user (so dbtest.ResetDB can
// clean it without touching the operator's admin row) and returns
// clean it without touching the developer's admin row) and returns
// the persisted user record.
func seedUser(t *testing.T, pool *pgxpool.Pool, name string) dbq.User {
t.Helper()
+3 -3
View File
@@ -10,9 +10,9 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
)
// A mix holds a song once (M498 #5438) and never a held-back video rip
// (#5439): of a single and its album cut, the better-ranked copy stays; a rip
// is left out and the next copy of its song takes the place.
// A mix holds a song once and never a held-back video rip: of a single and its
// album cut, the better-ranked copy stays; a rip is left out and the next copy
// of its song takes the place.
func TestOneCopyPerSong_Integration(t *testing.T) {
pool := newPool(t)
ctx := context.Background()
+66 -82
View File
@@ -1,5 +1,5 @@
// Package playlists' system.go implements the system-generated mix
// builder (M7 #352 slice 2). The cron loop and lazy fallback both call
// builder. The daily scheduler and the on-demand refresh both call
// BuildSystemPlaylists; the helpers in this file (pickSeedArtists,
// pickRepresentativeTrack, tieBreakHash) are the pure parts.
package playlists
@@ -52,15 +52,10 @@ func pickSeedArtistsFromRows(rows []seedArtistRow) []pgtype.UUID {
// daily-deterministic ordering. Same inputs always produce the same
// output; different inputs almost always differ.
//
// Switched from FNV-1a (May 2026) because two date strings differing
// only in the last character (e.g. "2026-05-07" vs "2026-05-08") gave
// hash values whose RELATIVE ORDERING across our small candidate pools
// was identical — the dateStr-driven divergence concentrated in low
// bits that lost out to high-bit ordering during sort. SHA-256
// truncated to 8 bytes has full avalanche, so any single-byte input
// change roughly half-flips the output bits and reorders meaningfully.
// The hash isn't security-load-bearing; we just want strong avalanche
// for tiny input deltas.
// SHA-256 truncated to 8 bytes, not FNV-1a: two date strings differing only
// in the last character must reorder the pool, and FNV's divergence for such
// inputs sits in low bits that lose to high-bit ordering during the sort.
// SHA-256 has full avalanche. The hash isn't security-load-bearing.
func tieBreakHash(trackID pgtype.UUID, dateStr string) uint64 {
h := sha256.New()
if trackID.Valid {
@@ -111,12 +106,9 @@ func pickDailySeeds(pool []pgtype.UUID, userID pgtype.UUID, dateStr string, n in
}
// songsLikeSeedCount is how many "Songs like {artist}" mixes to build
// each day (#1491). Bumped from 3 when Songs-like was promoted to its
// own dedicated Home row — the best-performing surface (low skip / high
// completion) was buried as 3 tiles in the shared Playlists carousel, so
// the wider row now shows a wider spread. Drawn from PickSeedArtists'
// deeper top-12 pool via a daily-deterministic shuffle so the set still
// rotates day to day rather than pinning the same six artists.
// each day, enough to fill Songs-like's own Home row. Drawn from
// PickSeedArtists' deeper top-12 pool via a daily-deterministic shuffle so
// the set still rotates day to day rather than pinning the same six artists.
const songsLikeSeedCount = 6
// pickSeedArtistsForDay picks up to songsLikeSeedCount seed artists for
@@ -127,10 +119,10 @@ func pickSeedArtistsForDay(pool []pgtype.UUID, userID pgtype.UUID, dateStr strin
}
// rankedCandidate is a (track_id, score) pair used during in-memory
// sorting before insert into playlist_tracks. T5 fills these from
// sorting before insert into playlist_tracks, filled from
// recommendation.Candidate scores. PickKind is the track's provenance
// within its mix — For-You's head/tail split (#1249) and Discover's
// buckets (#1270); empty persists as NULL (variant doesn't stamp yet).
// within its mix — For-You's head/tail split and Discover's buckets;
// empty persists as NULL (a variant that doesn't stamp).
type rankedCandidate struct {
TrackID pgtype.UUID
Score float64
@@ -140,8 +132,8 @@ type rankedCandidate struct {
// Pick kinds, persisted on playlist_tracks.pick_kind so plays can
// attribute skips to the population that sourced the track — For You's
// "taste engine missing" vs "freshness tax", Discover's three buckets
// (#1270). Values mirror the CHECK in migration 0041; taste_unheard is
// Discover's taste-targeted-novelty arm (#1488).
// Values mirror the CHECK in migration 0041; taste_unheard is Discover's
// taste-targeted-novelty arm.
const (
pickKindTaste = "taste"
pickKindFresh = "fresh"
@@ -155,7 +147,7 @@ const (
)
// pickKindForSeedTier maps PickSeedArtists' seed-pool fallback tier
// onto the rule-#131 pick-kind ladder: fresh 7-day engagement pins the
// onto the tiered pick-kind ladder: fresh 7-day engagement pins the
// mix's exact desire (tier1), the 30-day window steps back a little
// (tier2), and all-time / liked-only seeds are the far fallback
// (tier3). Stamped mix-wide — seed staleness is a property of the
@@ -172,7 +164,7 @@ func pickKindForSeedTier(tier int32) string {
}
// pickKindForMixTier maps a tiered mix query's 1-based tier column
// (numbered to match rule #131's ladder directly) onto the pick-kind
// (numbered to match the tier ladder directly) onto the pick-kind
// vocabulary. Distinct from pickKindForSeedTier, whose 0-based tiers
// count fallback steps of the seed pool rather than eligibility rungs.
func pickKindForMixTier(tier int32) string {
@@ -194,12 +186,11 @@ const systemMixLength = 25
// scores within a day, but near-tied candidates reshuffle across days
// so the playlist doesn't feel frozen.
//
// DB-tunable since #1250: the recsettings service pushes the current
// daily_mix profile via SetSystemMixWeights at boot and on every admin
// change (coverart Configure() pattern — no signature threading, live
// effect without restart). The literal here is only the pre-push
// value; shipped defaults live in recsettings.ShippedDailyMixWeights,
// which must stay in sync with it.
// DB-tunable: the recsettings service pushes the current daily_mix profile via
// SetSystemMixWeights at boot and on every admin change (coverart Configure()
// pattern — no signature threading, live effect without restart). The literal
// here is only the pre-push value; shipped defaults live in
// recsettings.ShippedDailyMixWeights, which must stay in sync with it.
var (
systemTuningMu sync.RWMutex
systemMixWeights = recommendation.ScoringWeights{
@@ -210,16 +201,16 @@ var (
JitterMagnitude: 0.1,
ContextWeight: 0.5,
SimilarityWeight: 1.5,
// Taste profile (#796 phase 2): the daily mixes are the primary
// Taste profile: the daily mixes are the primary
// taste-driven surface, so they lean on it. TasteMatchScore is in
// [-1,+1], so 1.5 makes a strong taste fit comparable to a like boost
// while passive avoidance (negative) gently demotes.
TasteWeight: 1.5,
// Time-of-day/weekday context affinity (#1531), in [-1,+1]. Starts
// uniform with radio pending trend data.
// Time-of-day/weekday context affinity, in [-1,+1]. The same as
// radio's until trend data says otherwise.
ContextTimeWeight: 1.0,
}
// Songs-like's own profile (#3881). Pre-push literal only; shipped
// Songs-like's own profile. Pre-push literal only; shipped
// defaults live in recsettings.ShippedSongsLikeWeights and must stay in
// sync with it, exactly as systemMixWeights does above.
//
@@ -239,7 +230,7 @@ var (
}
systemTasteConfig = taste.DefaultConfig()
// Sizes the candidate pool to the library (#3880). Cached because the
// Sizes the candidate pool to the library. Cached because the
// count is a full table scan and the daily build runs it once per user;
// within the TTL every user in a build shares one count.
systemLibrarySize = recommendation.NewLibrarySize(nil)
@@ -253,13 +244,13 @@ func SetSystemMixWeights(w recommendation.ScoringWeights) {
systemMixWeights = w
}
// SetSongsLikeWeights installs the songs_like scoring profile (#3881).
// SetSongsLikeWeights installs the songs_like scoring profile.
// Same push model as SetSystemMixWeights — recsettings calls it on boot and
// after every knob turn, so a tuning change takes effect on the next daily
// build with no restart.
//
// Separate from systemMixWeights because Songs-like and For-You want opposite
// things: For-You roams, Songs-like must not. Sharing one profile is what made
// things: For-You roams, Songs-like must not. Sharing one profile makes
// "Songs like X" wander.
func SetSongsLikeWeights(w recommendation.ScoringWeights) {
systemTuningMu.Lock()
@@ -268,7 +259,7 @@ func SetSongsLikeWeights(w recommendation.ScoringWeights) {
}
// SetTasteConfig installs the taste-profile build configuration
// (half-life + engagement curve, #1250). Same push model as
// (half-life + engagement curve). Same push model as
// SetSystemMixWeights.
func SetTasteConfig(c taste.Config) {
systemTuningMu.Lock()
@@ -276,13 +267,11 @@ func SetTasteConfig(c taste.Config) {
systemTasteConfig = c
}
// dailyOrderSeed is the value the randomised candidate arms order by (#3889).
// dailyOrderSeed is the value the randomised candidate arms order by.
//
// Per (user, day) so a same-day rebuild draws the SAME set — which is what
// TestBuildSystemPlaylists_DailyNonceDeterminism asserts and what those arms
// only ever achieved by accident before, when their limits happened to exceed
// the eligible rows. It changes on the day boundary, so the mixes still move
// daily.
// Per (user, day) so a same-day rebuild draws the SAME set, which is what
// TestBuildSystemPlaylists_DailyNonceDeterminism asserts. It changes on the
// day boundary, so the mixes still move daily.
func dailyOrderSeed(userID pgtype.UUID, dateStr string) string {
return uuidStringPL(userID) + ":" + dateStr
}
@@ -326,7 +315,7 @@ const (
)
// forYouSeedCount is how many of the user's top-5 tracks seed each
// day's build (#1269). A single rotating seed made the mix bipolar
// day's build. A single rotating seed would make the mix bipolar
// day-to-day — swinging whole neighborhoods as the rotation moved
// between dissimilar anchors; blending 3 spans neighborhoods within
// one day and smooths the swing across days.
@@ -334,7 +323,7 @@ const forYouSeedCount = 3
// headQuotas fixes each seed's share of the For-You head so one
// neighborhood can't monopolize it even when its candidates out-score
// the others (#1269): 50/30/20 for three seeds, 60/40 for two, all of
// the others: 50/30/20 for three seeds, 60/40 for two, all of
// it for one. Rounding remainder goes to the primary seed; a thin
// seed's unfilled quota spills best-first in pickQuotaHead.
func headQuotas(numSeeds, headN int) []int {
@@ -401,7 +390,7 @@ func pickQuotaHead(capped []recommendation.Candidate, seedOf map[pgtype.UUID]int
return head
}
// forYouTailHalfLifeRanks tunes the fresh tail's rank bias (#1269): a
// forYouTailHalfLifeRanks tunes the fresh tail's rank bias: a
// candidate's sampling weight halves every 50 ranks, so the 101st-best
// candidate is far likelier than the 401st-best instead of equal —
// freshness keeps its "you'll probably enjoy this" half.
@@ -450,8 +439,8 @@ func pickWeightedTail(tailPool []recommendation.Candidate, dateStr string, tailN
// because this sort IS the selection: the caller caps and truncates in the
// order this returns, so whatever profile ranks here decides which tracks
// reach the playlist. Scoring with daily_mix here and re-scoring with
// songs_like afterwards would have let the new profile relabel tracks it had
// no part in choosing — inert where it matters (#3881).
// songs_like afterwards would let the profile relabel tracks it had no part
// in choosing — inert where it matters.
func scoreAndSortCandidates(cands []recommendation.Candidate, userID pgtype.UUID, dateStr string, now time.Time, weights recommendation.ScoringWeights) []recommendation.Candidate {
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
type scored struct {
@@ -603,9 +592,9 @@ func systemForYouSourceLimits() recommendation.CandidateSourceLimits {
RandomFill: 150,
// For-You / You-might-like are the taste-driven surfaces, so pull a
// deep slice of the user's top taste-profile artists into the pool
// (#796 phase 2b). Empty for cold-start users (no profile yet).
// Empty for cold-start users (no profile yet).
TasteOverlap: 80,
// Household co-play (#1533): a deeper collaborative slice for the
// Household co-play: a deeper collaborative slice for the
// taste surfaces. Empty on single-user servers.
UserCoplay: 40,
}
@@ -613,7 +602,7 @@ func systemForYouSourceLimits() recommendation.CandidateSourceLimits {
// produceForYou: blend candidate pools from up to forYouSeedCount of
// the user's top-5 played tracks (rotating daily via pickDailySeeds),
// then head+tail composition with per-seed head quotas (#1269). The
// then head+tail composition with per-seed head quotas. The
// base seed query failing is fatal; a per-seed candidate-load failure
// is logged and that seed just contributes nothing.
func produceForYou(
@@ -667,7 +656,7 @@ func produceForYou(
// produceSeedMixes: up to songsLikeSeedCount "Songs like {artist}"
// mixes. Seed artists rotate daily-deterministically; the seed query falls back
// through widening engagement windows (#1255) and every returned row
// through widening engagement windows and every returned row
// shares the winning tier, stamped onto the built tracks as their
// pick_kind. The base seed-artist query failing is fatal; per-artist
// failures are logged + skipped.
@@ -714,12 +703,12 @@ func produceSeedMixes(
continue
}
zeroVec := recommendation.SessionVector{Seed: true}
// Songs-like's own pool shape, not the default (#3881). Same total
// size; the composition shifts toward arms that actually measure
// distance from the seed. The default gave ~29% of candidates a
// sim_score of literally 0.
// Songs-like's own pool shape, not the default. Same total size; the
// composition shifts toward arms that actually measure distance from
// the seed, where the default gives ~29% of candidates a sim_score of
// 0.
//
// Then scaled to the library (#3880): a bigger collection should put
// Then scaled to the library: a bigger collection should put
// more genuinely-similar candidates in reach, not the same ~170
// regardless. The weights still rank sim_score-0 arms last, so the
// growth reaches coherence rather than working against it.
@@ -737,16 +726,11 @@ func produceSeedMixes(
}
// The seed artist's own songs are ELIGIBLE here, deliberately.
//
// This used to filter them out — "Songs like X excludes X's own
// songs" — which reads as obviously right and is not. The seed is a
// TRACK, and the tracks most likely to sound like it are usually the
// rest of that artist's catalogue; excluding them threw away the
// nearest neighbours of the very thing the mix is built around, and
// then reached further out to replace them. On a surface whose whole
// job is staying in one neighbourhood, that is backwards.
//
// Operator, 2026-09-10: "it should also be able to include music from
// the same artist."
// "Songs like X excludes X's own songs" reads as obviously right and
// is not. The seed is a TRACK, and the tracks most likely to sound
// like it are usually the rest of that artist's catalogue; excluding
// them would throw away the nearest neighbours of the very thing the
// mix is built around, and reach further out to replace them.
//
// Domination is bounded by the cap rather than by exclusion, which is
// the distinction that makes this safe: capCandidatesByAlbumAndArtist
@@ -791,12 +775,12 @@ func produceDiscover(
return []builtPlaylist{{Name: "Discover", Variant: "discover", Tracks: tracks}}, nil
}
// BuildSystemPlaylists builds the user's daily system mixes (one For-You +
// up to songsLikeSeedCount Songs-like-{seed} mixes). Atomic-replace inside one tx;
// concurrency-guarded via system_playlist_runs.in_flight; deterministic
// within a day via tieBreakHash(track_id, now.UTC().Format("2006-01-02")).
// BuildSystemPlaylists builds the user's daily system mixes (one For-You + up
// to songsLikeSeedCount Songs-like-{seed} mixes). Atomic-replace inside one tx;
// concurrency-guarded via system_playlist_runs.in_flight; deterministic within
// a day via tieBreakHash(track_id, now.UTC().Format("2006-01-02")).
//
// Callable from both the cron loop (system_cron.go) and the lazy fallback.
// Called by the daily scheduler and by the on-demand refresh endpoints.
// Returns nil when the user has no library activity to build from. Errors
// are captured in system_playlist_runs.last_error before returning.
func BuildSystemPlaylists(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger, userID pgtype.UUID, now time.Time, dataDir string) error {
@@ -929,13 +913,13 @@ func BuildSystemPlaylists(ctx context.Context, pool *pgxpool.Pool, logger *slog.
// truncates to n. Used by Songs-like-X (and as the fallback inside
// pickHeadAndTail for small pools).
func pickTopN(cands []recommendation.Candidate, userID pgtype.UUID, dateStr string, now time.Time, n int) []rankedCandidate {
// songs_like, not daily_mix (#3881). produceSeedMixes is this function's
// only caller, so the switch moves exactly one surface — For-You ranks
// through pickHeadAndTail and keeps the broader daily_mix profile.
// songs_like, not daily_mix. produceSeedMixes is this function's only
// caller, so this moves exactly one surface — For-You ranks through
// pickHeadAndTail and keeps the broader daily_mix profile.
//
// The SAME profile does the selection sort and the final score. Passing
// one and using the other is the subtle version of this bug: the playlist
// would still be chosen by daily_mix and merely wear songs_like numbers.
// one and using the other would leave the playlist chosen by daily_mix
// and merely wearing songs_like numbers.
weights := currentSongsLikeWeights()
sorted := scoreAndSortCandidates(cands, userID, dateStr, now, weights)
capped := capCandidatesByAlbumAndArtist(sorted)
@@ -955,7 +939,7 @@ func pickTopN(cands []recommendation.Candidate, userID pgtype.UUID, dateStr stri
// pickHeadAndTail picks headN taste anchors from the score-sorted pool
// (under per-seed quotas when seedOf/numSeeds describe a multi-seed
// blend, #1269) plus tailN freshness picks sampled rank-weighted from
// blend) plus tailN freshness picks sampled rank-weighted from
// positions 2*headN onward. Caps applied before the head/tail split.
// Used by For-You only.
//
@@ -1022,7 +1006,7 @@ func pickHeadAndTail(
// Combine: head first (score-sorted under quotas), then the fresh
// sample. "First similar, then surprise" reads naturally in
// playback. Head entries are the taste picks; tail entries are the
// freshness injection (#1249) — the split the metrics page
// freshness injection — the split the metrics page
// attributes skips to.
combined := make([]recommendation.Candidate, 0, len(head)+len(tail))
combined = append(combined, head...)
@@ -1044,7 +1028,7 @@ func pickHeadAndTail(
}
// insertSystemPlaylist inserts a kind='system' playlists row plus its
// playlist_tracks via AppendPlaylistTrack (slice 1's existing query, which
// playlist_tracks via AppendPlaylistTrack (which
// does the snapshot lookup from tracks/albums/artists). Then recomputes
// rollups. Returns the new playlist's ID so the caller can drive
// post-commit collage generation.
@@ -1101,8 +1085,8 @@ func insertSystemPlaylist(ctx context.Context, qtx *dbq.Queries, userID pgtype.U
}
// oneCopyPerSong keeps the first of a song's copies on different releases
// (M498 #5438): a mix holds the song once, in its best-ranked place. A video
// rip held back from the mixes (#5439) is left out, and a clean copy of the
// a mix holds the song once, in its best-ranked place. A video rip held back
// from the mixes is left out, and a clean copy of the
// same song can take its place.
func oneCopyPerSong(ctx context.Context, qtx *dbq.Queries, tracks []rankedCandidate) ([]rankedCandidate, error) {
ids := make([]pgtype.UUID, len(tracks))
+8 -9
View File
@@ -11,10 +11,9 @@ import (
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// Discovery mixes (#419-423). One generic producer + per-mix spec.
// Replaces the five near-identical produceXxx functions that all
// fetched ranked (id, album_id, artist_id) rows, diversified,
// truncated, and emitted a single playlist.
// Discovery mixes: one generic producer + a per-mix spec. Each mix fetches
// ranked (id, album_id, artist_id) rows, diversifies, truncates, and emits a
// single playlist.
//
// Day-keying is a per-mix property captured by `dailyRotate`:
//
@@ -40,7 +39,7 @@ import (
// Discover so shuffle-on-play has a varied pool within a day.
const discoveryMixLen = 100
// Minimum viable mix sizes (issue #1246): below the floor the variant
// Minimum viable mix sizes: below the floor the variant
// is withheld entirely so Home renders its "listen more to unlock"
// placeholder — a playlist with a couple of songs reads as a build
// bug, not a mix. Album-coherent mixes (NewForYou / FirstListens) get
@@ -68,7 +67,7 @@ type discoveryMixSpec struct {
// minLen is the minimum viable mix size: a finished pool below it
// is withheld (no playlist row) so the client renders the locked
// placeholder instead of a mix that looks built-wrong (#1246).
// placeholder instead of a mix that looks built-wrong.
minLen int
// fetch returns the raw ranked rows. dateStr is supplied for
@@ -109,10 +108,10 @@ func produceDiscoveryMix(spec discoveryMixSpec) systemPlaylistProducer {
// so each day's downstream truncate-to-N surfaces a different slice
// while contiguous-block ordering inside the slice is preserved.
//
// Rotation happens WITHIN each contiguous same-pick-kind block
// (#1267): tiered mixes arrive tier-ordered, and a whole-pool
// Rotation happens WITHIN each contiguous same-pick-kind block:
// tiered mixes arrive tier-ordered, and a whole-pool
// rotation would hoist tier-3 filler above tier-1's exact fits.
// Untiered pools are a single block, which reduces to the original
// Untiered pools are a single block, which reduces to a plain
// whole-pool rotation (same seed, same first draw). Empty /
// single-element pools pass through unchanged.
func rotateForDay(pool []discoverTrack, userID pgtype.UUID, dateStr string) []discoverTrack {
+8 -8
View File
@@ -106,8 +106,8 @@ func likeArtist(t *testing.T, pool *pgxpool.Pool, userID, artistID pgtype.UUID)
}
func TestListDeepCutsTracks_SkippedTracksExcluded(t *testing.T) {
// #1257: eligibility used to count only unskipped plays, so a track
// skipped repeatedly (and never finished) read as "barely heard".
// Eligibility counts skipped plays too, so a track skipped repeatedly
// (and never finished) does not read as "barely heard".
pool := newPool(t)
q := dbq.New(pool)
u := seedUser(t, pool, "dc1")
@@ -150,8 +150,8 @@ func TestListDeepCutsTracks_SkippedTracksExcluded(t *testing.T) {
}
func TestListOnThisDayTracks_WrapsYearBoundary(t *testing.T) {
// #1256: plain ABS on day-of-year made Dec 28 vs Jan 3 read as 359
// days apart. The build date parameter anchors "today" so the wrap
// The day-of-year window wraps the year: plain ABS would make Dec 28 vs
// Jan 3 read as 359 days apart. The build date parameter anchors "today" so the wrap
// is testable at any run date.
pool := newPool(t)
q := dbq.New(pool)
@@ -182,7 +182,7 @@ func TestListOnThisDayTracks_WrapsYearBoundary(t *testing.T) {
}
func TestListNewForYouTracks_TiersAndConsumption(t *testing.T) {
// #1267: tiered eligibility with consume-on-play. Tier 1 = fresh
// Tiered eligibility with consume-on-play. Tier 1 = fresh
// affinity albums; tier 2 = affinity albums from the wider 90-day
// window; tier 3 = fresh additions without affinity; any attempted
// (>=30s) track consumes the album out of the mix entirely.
@@ -240,8 +240,8 @@ func TestListNewForYouTracks_TiersAndConsumption(t *testing.T) {
}
func TestListFirstListensTracks_AttemptThreshold(t *testing.T) {
// #1268: "attempted" is track-level at >=30s. A 2-second brush no
// longer disqualifies an album; barely-attempted albums step back
// "Attempted" is track-level at >=30s. A 2-second brush does not
// disqualify an album; barely-attempted albums step back
// to tier 2 with the attempted tracks themselves excluded.
pool := newPool(t)
q := dbq.New(pool)
@@ -297,7 +297,7 @@ func TestListFirstListensTracks_AttemptThreshold(t *testing.T) {
}
func TestListRediscoverTracks_SkippedResurfacingCoolsDown(t *testing.T) {
// #1258: a skip on a rediscover-sourced play is a declined
// A skip on a rediscover-sourced play is a declined
// invitation — the track sits out ~90 days instead of re-qualifying
// the next day. Skips from other surfaces don't cool down, and the
// cooldown expires.
+2 -2
View File
@@ -21,7 +21,7 @@ func idsOf(pool []discoverTrack) []byte {
func TestRotateForDay_RotatesWithinTierBlocks(t *testing.T) {
// Tiered pools arrive tier-ordered; rotation must vary the daily
// slice WITHIN each tier without hoisting tier-3 filler above
// tier-1's exact fits (#1267).
// tier-1's exact fits.
pool := []discoverTrack{
mixTrack(1, pickKindTier1), mixTrack(2, pickKindTier1), mixTrack(3, pickKindTier1),
mixTrack(4, pickKindTier2), mixTrack(5, pickKindTier2),
@@ -80,7 +80,7 @@ func TestRotateForDay_UntieredPoolIsWholeRotation(t *testing.T) {
func TestFinishMix_PropagatesPickKind(t *testing.T) {
// Tier stamps ride discoverTrack through diversify/truncate into
// the rankedCandidates that insertSystemPlaylist persists (#1267).
// the rankedCandidates that insertSystemPlaylist persists.
pool := []discoverTrack{mixTrack(1, pickKindTier1), mixTrack(2, pickKindTier2)}
got := finishMix(pool, false)
if len(got) != 2 {
+11 -15
View File
@@ -115,7 +115,7 @@ func TestBuildSystemPlaylists_SufficientActivity(t *testing.T) {
t.Errorf("songs_like_artist row should have non-NULL seed_artist_id")
}
case "discover", "deep_cuts", "rediscover", "new_for_you", "on_this_day", "first_listens":
// Discover + the #411 discovery mixes are all seedless —
// Discover and the discovery mixes are all seedless —
// no seed_artist_id required.
default:
t.Errorf("unknown system_variant=%q", *r.SystemVariant)
@@ -124,7 +124,7 @@ func TestBuildSystemPlaylists_SufficientActivity(t *testing.T) {
t.Errorf("row %s: empty track_count", uuidString(r.ID))
}
// For-You / Discover / the discovery mixes are sized to ~100
// (#352/#411); songs_like_artist stays at systemMixLength (25).
// songs_like_artist stays at systemMixLength (25).
if r.TrackCount > 100 {
t.Errorf("row %s: track_count=%d exceeds 100", uuidString(r.ID), r.TrackCount)
}
@@ -138,10 +138,10 @@ func TestBuildSystemPlaylists_SufficientActivity(t *testing.T) {
}
func TestBuildSystemPlaylists_StaleActivity_SongsLikeSurvives(t *testing.T) {
// #1255: the seed-artist query's old hard 7-day window emptied the
// pool after a quiet week, and the atomic-replace build then deleted
// every "Songs like X" mix. With the tiered fallback, plays that are
// ~20 days old (outside 7d, inside 30d) must still seed the mixes —
// A hard 7-day seed window would empty the pool after a quiet week, and
// the atomic-replace build would then delete every "Songs like X" mix.
// With the tiered fallback, plays that are ~20 days old (outside 7d,
// inside 30d) must still seed the mixes —
// and the built tracks carry the tier2 stamp so metrics can compare
// stale-seeded mixes against fresh ones.
pool := newPool(t)
@@ -352,16 +352,12 @@ func seedSharedArtistLibrary(
}
// The seed artist's own tracks are ELIGIBLE for its "Songs like" mix, and are
// bounded by the diversity cap rather than excluded outright (#3881).
//
// produceSeedMixes used to filter them out — "Songs like X excludes X's own
// songs" — which threw away the nearest neighbours of the seed TRACK and then
// reached further out to replace them. Operator, 2026-09-10: "it should also
// be able to include music from the same artist."
// bounded by the diversity cap rather than excluded outright: excluding them
// would throw away the nearest neighbours of the seed TRACK.
//
// Asserted end-to-end rather than by reading the source, because the guard has
// to survive the filter coming back in a different shape — and because an
// absence check would now match the comment explaining why the filter is gone.
// to survive a filter coming back in a different shape — and because an
// absence check would match the comment explaining why there is no filter.
func TestBuildSystemPlaylists_SongsLikeIncludesItsSeedArtist(t *testing.T) {
pool := newPool(t)
logger := discardLogger()
@@ -403,7 +399,7 @@ func TestBuildSystemPlaylists_SongsLikeIncludesItsSeedArtist(t *testing.T) {
}
// The bound is what makes inclusion safe. Without it, "include the
// seed artist" becomes "the mix is mostly the seed artist", which is
// the radio failure (#3882) arriving on a different surface.
// the one-artist radio arriving on a different surface.
if own > maxPerArtist {
t.Errorf("a songs_like mix carries %d tracks by its own seed artist "+
"out of %d; the per-artist cap (%d) is not being applied",
+3 -3
View File
@@ -1,5 +1,5 @@
// you_might_like.go builds the per-user "You might like" Home rows
// (#790): in-library albums/artists the listener doesn't actively spin
// you_might_like.go builds the per-user "You might like" Home rows:
// in-library albums/artists the listener doesn't actively spin
// but is predicted to enjoy. It reuses the For-You candidate engine
// (similarity + like-weighted scoring) and rolls the per-track scores up
// to album/artist granularity, gated on a minimum listening history so a
@@ -84,7 +84,7 @@ func buildYouMightLike(
return youMightLikeResult{built: false}
}
// One rotating seed is right here (unlike For-You's multi-seed
// blend, #1269): the row is a short shelf, not a mix, and a single
// blend): the row is a short shelf, not a mix, and a single
// neighborhood per day keeps it coherent.
daily := pickDailySeeds(seeds, userID, dateStr, 1)
if len(daily) == 0 {