Files
minstrel/internal/playlists/system.go
T
bvandeusen 042f9919fe fix(server/playlists): redistributeSlots double-counting + tieBreakHash avalanche
Two real algorithm bugs in F-T1's For-You composition + Discover
allocator. Both surfaced as failing unit tests under go test -race.

1. redistributeSlots was re-redistributing a bucket's full deficit
   on every pass instead of just the residual. The loop computed
   `deficit = b.want - final[i]` each iteration, but final[i] for
   a deficit bucket never increases (its supply is exhausted), so
   pass N saw the same deficit as pass N-1 and kept shoveling it
   to peers. For [want:40 avail:100, want:30 avail:0, want:30 avail:100],
   four passes pushed cross-user's deficit into dormant+random four
   times each, hitting the 100-slot clamp at the end and producing
   [50, 0, 50] instead of the spec'd [55, 0, 45].

   Fix: track per-source `redistributed[i]` and subtract it from the
   deficit each pass. Multi-pass behavior still works for the case
   where a peer's supply runs out mid-distribution.

2. tieBreakHash used FNV-1a 64-bit with trackID + dateStr appended.
   For dateStrs differing only in the last character ("2026-05-07"
   vs "2026-05-08"), the FNV state diverged only in low bits at the
   final byte; multiplication by FNV_prime propagates upward but the
   relative ordering of 60 small candidate UUIDs (which differ only
   in their last byte) ended up identical across the two dates. The
   For-You head/tail test asserted that the tail's first 5 should
   change across days; it didn't.

   Fix: switch to SHA-256 truncated to 8 bytes. SHA-256 has full
   avalanche, so any single-bit input change roughly half-flips the
   output bits and meaningfully reorders.

The hash isn't security-load-bearing; we just need strong avalanche
for tiny dateStr deltas. Determinism (same inputs → same output) is
preserved.
2026-05-07 18:33:06 -04:00

528 lines
19 KiB
Go

// Package playlists' system.go implements the system-generated mix
// builder (M7 #352 slice 2). The cron loop and lazy fallback both call
// BuildSystemPlaylists; the helpers in this file (pickSeedArtists,
// pickRepresentativeTrack, tieBreakHash) are the pure parts.
package playlists
import (
"context"
"crypto/sha256"
"encoding/binary"
"errors"
"fmt"
"log/slog"
"sort"
"time"
"github.com/jackc/pgx/v5"
"github.com/jackc/pgx/v5/pgtype"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
)
// seedArtistRow mirrors the sqlc-generated PickSeedArtistsRow shape.
// Defined locally so unit tests don't need a real DB.
type seedArtistRow struct {
ArtistID pgtype.UUID
Score int64
}
// pickSeedArtistsFromRows projects sqlc rows into the seed list. The
// SQL already orders by score DESC + artist_id and LIMIT 3, so this is
// just a column projection — but pulling it into a function keeps the
// call-site readable and makes the post-fetch path testable without
// a database.
func pickSeedArtistsFromRows(rows []seedArtistRow) []pgtype.UUID {
out := make([]pgtype.UUID, 0, len(rows))
for _, r := range rows {
out = append(out, r.ArtistID)
}
return out
}
// tieBreakHash returns a deterministic 64-bit hash of (track_id, date_str).
// Used to break score ties and to drive the For-You tail sample's
// 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.
func tieBreakHash(trackID pgtype.UUID, dateStr string) uint64 {
h := sha256.New()
if trackID.Valid {
_, _ = h.Write(trackID.Bytes[:])
}
_, _ = h.Write([]byte(dateStr))
sum := h.Sum(nil)
return binary.BigEndian.Uint64(sum[:8])
}
// rankedCandidate is a (track_id, score) pair used during in-memory
// sorting before insert into playlist_tracks. T5 fills these from
// recommendation.Candidate scores.
type rankedCandidate struct {
TrackID pgtype.UUID
Score float64
}
const systemMixLength = 25
// systemMixWeights are the fixed scoring weights used by the cron worker.
// JitterMagnitude is 0 because daily determinism comes from tieBreakHash;
// any randomness would defeat the within-day stability invariant.
var systemMixWeights = recommendation.ScoringWeights{
BaseWeight: 1.0,
LikeBoost: 2.0,
RecencyWeight: 1.0,
SkipPenalty: 2.0,
JitterMagnitude: 0.0,
ContextWeight: 0.5,
SimilarityWeight: 1.5,
}
// noopRNG returns 0 for any call. Combined with JitterMagnitude=0 it makes
// recommendation.Score fully deterministic.
func noopRNG() float64 { return 0 }
// forYouHeadN is the number of top-scored tracks that anchor the For-You
// playlist. forYouTailN is the number of diversity picks sampled from the
// tail of the score-sorted pool (positions 2*forYouHeadN onward), injected
// after the head to give users a daily-deterministic surprise without
// compromising quality. Songs-like-X keeps simple pickTopN (the seed-artist
// context already frames the "you'll like this" promise).
const (
forYouHeadN = 20
forYouTailN = 5
)
// scoreAndSortCandidates scores every candidate with recommendation.Score
// and returns a new slice sorted by score DESC (ties broken by
// tieBreakHash). Pure — no truncation, no cap.
func scoreAndSortCandidates(cands []recommendation.Candidate, dateStr string, now time.Time) []recommendation.Candidate {
type scored struct {
c recommendation.Candidate
score float64
}
pairs := make([]scored, len(cands))
for i, c := range cands {
pairs[i] = scored{c: c, score: recommendation.Score(c.Inputs, systemMixWeights, now, noopRNG)}
}
sort.SliceStable(pairs, func(i, j int) bool {
if pairs[i].score != pairs[j].score {
return pairs[i].score > pairs[j].score
}
return tieBreakHash(pairs[i].c.Track.ID, dateStr) < tieBreakHash(pairs[j].c.Track.ID, dateStr)
})
out := make([]recommendation.Candidate, len(pairs))
for i, p := range pairs {
out[i] = p.c
}
return out
}
// capCandidatesByAlbumAndArtist trims a candidate list so no single
// album appears more than discoverMaxTracksPerAlbum times and no
// single artist appears more than discoverMaxTracksPerArtist times.
// Mirrors capByAlbumAndArtist (which operates on []discoverTrack);
// here the input/output is []recommendation.Candidate so For-You and
// Songs-like-X can apply the same diversity caps as Discover.
//
// Preserves input order. Reuses the discoverMax* constants —
// playlists across all three system variants get consistent
// diversity behavior.
func capCandidatesByAlbumAndArtist(cands []recommendation.Candidate) []recommendation.Candidate {
albumCount := map[pgtype.UUID]int{}
artistCount := map[pgtype.UUID]int{}
out := make([]recommendation.Candidate, 0, len(cands))
for _, c := range cands {
if albumCount[c.Track.AlbumID] >= discoverMaxTracksPerAlbum {
continue
}
if artistCount[c.Track.ArtistID] >= discoverMaxTracksPerArtist {
continue
}
albumCount[c.Track.AlbumID]++
artistCount[c.Track.ArtistID]++
out = append(out, c)
}
return out
}
// BuildSystemPlaylists builds the user's daily system mixes (one For-You +
// up to 3 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.
// 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 {
q := dbq.New(pool)
dateStr := now.UTC().Format("2006-01-02")
dateOnly := pgtype.Date{Time: now.UTC(), Valid: true}
// Concurrency guard: try to claim the run row.
if _, err := q.TryClaimSystemPlaylistRun(ctx, userID); err != nil {
if errors.Is(err, pgx.ErrNoRows) {
logger.Debug("system playlist build: another run is in flight",
"user_id", uuidStringPL(userID))
return nil
}
return fmt.Errorf("claim run: %w", err)
}
// On any return path, mark the run finished. Failure capture is
// best-effort; success is updated in the happy path.
var buildErr error
defer func() {
if buildErr == nil {
if err := q.FinishSystemPlaylistRun(ctx, dbq.FinishSystemPlaylistRunParams{
UserID: userID,
LastRunAt: pgtype.Timestamptz{Time: now.UTC(), Valid: true},
LastRunDate: dateOnly,
}); err != nil {
logger.Warn("system playlist: failed to mark run finished; in_flight may be stuck",
"user_id", uuidStringPL(userID), "err", err)
}
} else {
errStr := buildErr.Error()
if err := q.FailSystemPlaylistRun(ctx, dbq.FailSystemPlaylistRunParams{
UserID: userID,
LastError: &errStr,
}); err != nil {
logger.Warn("system playlist: failed to record build failure",
"user_id", uuidStringPL(userID), "err", err)
}
}
}()
// 1. For-You: synth seed from top recently-played track, pull candidates.
forYouSeed, err := q.PickTopPlayedTrackForUser(ctx, userID)
if err != nil && !errors.Is(err, pgx.ErrNoRows) {
buildErr = fmt.Errorf("pick for-you seed: %w", err)
return buildErr
}
var forYouTracks []rankedCandidate
if err == nil && forYouSeed.Valid {
zeroVec := recommendation.SessionVector{Seed: true}
cands, cerr := recommendation.LoadCandidatesFromSimilarity(
ctx, q, userID, forYouSeed,
1, // recentlyPlayedHours — small to avoid filtering the seed's recent neighbourhood
zeroVec,
[]pgtype.UUID{forYouSeed},
recommendation.DefaultCandidateSourceLimits(),
)
if cerr == nil {
// For-You uses head+tail composition: forYouHeadN top-similarity
// tracks + forYouTailN tail-sampled to inject daily-deterministic
// surprise. Songs-like-X keeps pickTopN (top-25 with caps) since
// the seed-artist context already provides the "you'll like this"
// framing.
forYouTracks = pickHeadAndTail(cands, dateStr, now, forYouHeadN, forYouTailN)
} else {
logger.Warn("system playlist: for-you candidates load failed; skipping",
"user_id", uuidStringPL(userID), "err", cerr)
}
}
// 2. Seed artists for "Songs like {X}".
seedRows, err := q.PickSeedArtists(ctx, userID)
if err != nil {
buildErr = fmt.Errorf("pick seed artists: %w", err)
return buildErr
}
seedRowsLocal := make([]seedArtistRow, 0, len(seedRows))
for _, r := range seedRows {
seedRowsLocal = append(seedRowsLocal, seedArtistRow{
ArtistID: r.ArtistID, Score: r.Score,
})
}
seeds := pickSeedArtistsFromRows(seedRowsLocal)
type seedMix struct {
ArtistID pgtype.UUID
ArtistName string
Tracks []rankedCandidate
}
seedMixes := make([]seedMix, 0, len(seeds))
for _, artistID := range seeds {
artistRow, aerr := q.GetArtistByID(ctx, artistID)
if aerr != nil {
logger.Warn("system playlist: seed artist load failed; skipping",
"artist_id", uuidStringPL(artistID), "err", aerr)
continue
}
seedTrack, terr := q.PickTopPlayedTrackForArtistByUser(ctx, dbq.PickTopPlayedTrackForArtistByUserParams{
UserID: userID, ArtistID: artistID,
})
if terr != nil || !seedTrack.Valid {
continue
}
zeroVec := recommendation.SessionVector{Seed: true}
cands, cerr := recommendation.LoadCandidatesFromSimilarity(
ctx, q, userID, seedTrack, 1, zeroVec, []pgtype.UUID{seedTrack},
recommendation.DefaultCandidateSourceLimits(),
)
if cerr != nil {
logger.Warn("system playlist: seed candidates load failed; skipping",
"artist_id", uuidStringPL(artistID), "err", cerr)
continue
}
// Keep only tracks NOT by the seed artist (we want "songs like X",
// not X's own songs).
filtered := make([]recommendation.Candidate, 0, len(cands))
for _, c := range cands {
if !pgtypeUUIDEqual(c.Track.ArtistID, artistID) {
filtered = append(filtered, c)
}
}
tracks := pickTopN(filtered, dateStr, now, systemMixLength)
seedMixes = append(seedMixes, seedMix{
ArtistID: artistID,
ArtistName: artistRow.Name,
Tracks: tracks,
})
}
// 4. Discover: surface unheard tracks, biased toward dormant artists.
discoverTracks, derr := buildDiscoverCandidates(ctx, q, userID, dateStr)
if derr != nil {
logger.Warn("system playlist: discover candidates load failed; skipping",
"user_id", uuidStringPL(userID), "err", derr)
discoverTracks = nil
}
// 3. Atomic replace inside a transaction.
tx, err := pool.Begin(ctx)
if err != nil {
buildErr = fmt.Errorf("begin tx: %w", err)
return buildErr
}
defer func() { _ = tx.Rollback(ctx) }()
qtx := dbq.New(tx)
if err := qtx.DeleteSystemPlaylistsForUser(ctx, userID); err != nil {
buildErr = fmt.Errorf("delete old system playlists: %w", err)
return buildErr
}
// Track the playlist IDs we create so we can generate collage covers
// for each one after the transaction commits. Collage generation
// reads the playlist's tracks via a separate query that won't see
// uncommitted rows, so the work happens post-commit.
createdIDs := make([]pgtype.UUID, 0, 1+len(seedMixes))
if len(forYouTracks) > 0 {
id, err := insertSystemPlaylist(ctx, qtx, userID, "For You", "for_you",
pgtype.UUID{}, forYouTracks)
if err != nil {
buildErr = fmt.Errorf("insert for-you: %w", err)
return buildErr
}
createdIDs = append(createdIDs, id)
}
for _, m := range seedMixes {
if len(m.Tracks) == 0 {
continue
}
name := fmt.Sprintf("Songs like %s", m.ArtistName)
id, err := insertSystemPlaylist(ctx, qtx, userID, name, "songs_like_artist",
m.ArtistID, m.Tracks)
if err != nil {
buildErr = fmt.Errorf("insert songs-like %s: %w", uuidStringPL(m.ArtistID), err)
return buildErr
}
createdIDs = append(createdIDs, id)
}
if len(discoverTracks) > 0 {
id, err := insertSystemPlaylist(ctx, qtx, userID, "Discover", "discover",
pgtype.UUID{}, discoverTracks)
if err != nil {
buildErr = fmt.Errorf("insert discover: %w", err)
return buildErr
}
createdIDs = append(createdIDs, id)
}
if err := tx.Commit(ctx); err != nil {
buildErr = fmt.Errorf("commit tx: %w", err)
return buildErr
}
// Post-commit: generate a 4-cell collage cover for each system
// playlist from the contributing tracks' album covers. The collage
// gracefully falls back to the FabledSword glyph in cells whose
// album lacks cover art, so a partially-covered library still
// produces a sensible visual rather than an empty placeholder.
// Errors are non-fatal — the playlist is already committed; a
// missing collage just leaves cover_path NULL until next build.
if dataDir != "" {
for _, plID := range createdIDs {
if _, cerr := GenerateCollage(ctx, pool, plID, dataDir); cerr != nil {
logger.Warn("system playlist: collage generation failed",
"playlist_id", uuidStringPL(plID), "err", cerr)
}
}
}
return nil
}
// pickTopN sorts candidates by score DESC, applies per-album (<=2) /
// per-artist (<=3) diversity caps matching Discover's behavior, then
// truncates to n. Used by Songs-like-X (and as the fallback inside
// pickHeadAndTail for small pools).
func pickTopN(cands []recommendation.Candidate, dateStr string, now time.Time, n int) []rankedCandidate {
sorted := scoreAndSortCandidates(cands, dateStr, now)
capped := capCandidatesByAlbumAndArtist(sorted)
if len(capped) > n {
capped = capped[:n]
}
out := make([]rankedCandidate, len(capped))
for i, c := range capped {
out[i] = rankedCandidate{
TrackID: c.Track.ID,
Score: recommendation.Score(c.Inputs, systemMixWeights, now, noopRNG),
}
}
return out
}
// pickHeadAndTail picks headN from the score-sorted head plus tailN from
// positions 2*headN onward (the tail), with the tail sampled
// daily-deterministically via tieBreakHash. Caps applied before the
// head/tail split. Used by For-You only.
//
// The "tail" — candidates ranked beyond 2*headN — is still similarity-
// related (every candidate passed the similarity filter) but isn't among
// the obvious top hits. Sampling from there gives the user variety they'll
// probably enjoy without resorting to genuinely random unrelated tracks.
//
// Falls back to standard pickTopN behavior when the candidate pool is too
// small to support a meaningful head/tail split (capped pool <=
// headN+tailN, or no candidates at or beyond position 2*headN).
func pickHeadAndTail(cands []recommendation.Candidate, dateStr string, now time.Time, headN, tailN int) []rankedCandidate {
sorted := scoreAndSortCandidates(cands, dateStr, now)
capped := capCandidatesByAlbumAndArtist(sorted)
total := headN + tailN
if len(capped) <= total {
// Pool too small for a head/tail split — return up to total entries.
if len(capped) < total {
total = len(capped)
}
out := make([]rankedCandidate, total)
for i := 0; i < total; i++ {
out[i] = rankedCandidate{
TrackID: capped[i].Track.ID,
Score: recommendation.Score(capped[i].Inputs, systemMixWeights, now, noopRNG),
}
}
return out
}
head := capped[:headN]
tailStart := 2 * headN
if tailStart >= len(capped) {
tailStart = headN
}
// Defensive copy so that sorting the tail pool does not mutate capped.
tailPool := append([]recommendation.Candidate{}, capped[tailStart:]...)
// Sort tail pool by tieBreakHash (daily-deterministic), take tailN.
// Sample is stable across requests within a day but varies across days.
sort.SliceStable(tailPool, func(i, j int) bool {
return tieBreakHash(tailPool[i].Track.ID, dateStr) < tieBreakHash(tailPool[j].Track.ID, dateStr)
})
tail := tailPool
if len(tail) > tailN {
tail = tail[:tailN]
}
// Combine: head order preserved (score-sorted), tail in tieBreakHash
// order. "First similar, then surprise" reads naturally in playback.
combined := make([]recommendation.Candidate, 0, len(head)+len(tail))
combined = append(combined, head...)
combined = append(combined, tail...)
out := make([]rankedCandidate, len(combined))
for i, c := range combined {
out[i] = rankedCandidate{
TrackID: c.Track.ID,
Score: recommendation.Score(c.Inputs, systemMixWeights, now, noopRNG),
}
}
return out
}
// insertSystemPlaylist inserts a kind='system' playlists row plus its
// playlist_tracks via AppendPlaylistTrack (slice 1's existing query, 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.
//
// Pass seed_artist_id with Valid=false (zero pgtype.UUID) for the For-You
// variant; the CreateSystemPlaylist query persists NULL.
//
// cover_path is always inserted as NULL — the post-commit collage step
// in BuildSystemPlaylists overrides it with a 4-cell composite of the
// contributing tracks' album art.
func insertSystemPlaylist(ctx context.Context, qtx *dbq.Queries, userID pgtype.UUID,
name, variant string, seedArtistID pgtype.UUID,
tracks []rankedCandidate) (pgtype.UUID, error) {
variantStr := variant
p, err := qtx.CreateSystemPlaylist(ctx, dbq.CreateSystemPlaylistParams{
UserID: userID,
Name: name,
SystemVariant: &variantStr,
SeedArtistID: seedArtistID,
CoverPath: nil,
})
if err != nil {
return pgtype.UUID{}, fmt.Errorf("insert playlist row: %w", err)
}
for _, t := range tracks {
if _, err := qtx.AppendPlaylistTrack(ctx, dbq.AppendPlaylistTrackParams{
PlaylistID: p.ID,
TrackID: t.TrackID,
}); err != nil {
// Track may have been deleted between candidate-load and insert;
// skip silently rather than failing the whole build.
if errors.Is(err, pgx.ErrNoRows) {
continue
}
return pgtype.UUID{}, fmt.Errorf("append track %s: %w", uuidStringPL(t.TrackID), err)
}
}
if err := qtx.UpdatePlaylistRollups(ctx, p.ID); err != nil {
return pgtype.UUID{}, fmt.Errorf("update rollups: %w", err)
}
return p.ID, nil
}
// uuidStringPL renders a pgtype.UUID as the canonical 8-4-4-4-12 form.
// (pgtype.UUID's String method exists on some pgx versions but not others;
// also the playlists package needs this independent of test helpers.)
// Suffix `PL` distinguishes it from any test helper named uuidString.
func uuidStringPL(u pgtype.UUID) string {
if !u.Valid {
return "<nil>"
}
return fmt.Sprintf("%x-%x-%x-%x-%x",
u.Bytes[0:4], u.Bytes[4:6], u.Bytes[6:8], u.Bytes[8:10], u.Bytes[10:16])
}