feat(mixes): tiered rebuilds for New for you + First listens (rule #131)
Both mixes move from a single hard eligibility rule to the tiered ladder, with their tier stamped onto playlist_tracks.pick_kind via the #1270 provenance pipeline. New for you (#1267) — consume on play, degrade by stepping back: - "Consumed" = any track attempted >=30s; played albums leave the mix at the next build instead of crowding it until the calendar window expires. - Tier 1: unconsumed albums added <30d by direct-affinity artists. Tier 2: unconsumed affinity albums from the wider 30-90d window — added while you weren't looking. Tier 3: any unconsumed album added <90d, newest first. First listens (#1268) — track-level "attempted" threshold: - A 2-second accidental brush no longer disqualifies a whole album; "attempted" is duration_played_ms >= 30000 per track. - Tier 1: albums with zero attempted tracks. Tier 2: barely-attempted albums (<=25% of tracks reached 30s), minus the attempted tracks themselves. The artist-affinity ordering signal also moves to the >=30s definition so skip-only contact doesn't read as trust. Producer plumbing: fetch adapters map the tier column onto pick kinds, finishMix propagates PickKind into the persisted candidates, and rotateForDay now rotates within contiguous same-pick-kind blocks so daily rotation can't hoist tier-3 filler above tier-1's exact fits (untiered pools are one block — original behavior). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
This commit is contained in:
@@ -169,6 +169,21 @@ 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
|
||||
// vocabulary. Distinct from pickKindForSeedTier, whose 0-based tiers
|
||||
// count fallback steps of the seed pool rather than eligibility rungs.
|
||||
func pickKindForMixTier(tier int32) string {
|
||||
switch tier {
|
||||
case 1:
|
||||
return pickKindTier1
|
||||
case 2:
|
||||
return pickKindTier2
|
||||
default:
|
||||
return pickKindTier3
|
||||
}
|
||||
}
|
||||
|
||||
const systemMixLength = 25
|
||||
|
||||
// systemMixWeights are the fixed scoring weights used by the cron worker.
|
||||
|
||||
@@ -105,21 +105,39 @@ func produceDiscoveryMix(spec discoveryMixSpec) systemPlaylistProducer {
|
||||
}
|
||||
}
|
||||
|
||||
// rotateForDay rotates pool left by a daily-deterministic offset so
|
||||
// each day's downstream truncate-to-N surfaces a different slice of
|
||||
// the pool while contiguous-block ordering inside the slice is
|
||||
// preserved. Empty / single-element pools pass through unchanged.
|
||||
// rotateForDay rotates the pool left by a daily-deterministic offset
|
||||
// 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 would hoist tier-3 filler above tier-1's exact fits.
|
||||
// Untiered pools are a single block, which reduces to the original
|
||||
// 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 {
|
||||
n := len(pool)
|
||||
if n <= 1 {
|
||||
return pool
|
||||
}
|
||||
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
|
||||
offset := rng.Intn(n)
|
||||
rotated := make([]discoverTrack, 0, n)
|
||||
rotated = append(rotated, pool[offset:]...)
|
||||
rotated = append(rotated, pool[:offset]...)
|
||||
return rotated
|
||||
out := make([]discoverTrack, 0, n)
|
||||
for start := 0; start < n; {
|
||||
end := start + 1
|
||||
for end < n && pool[end].PickKind == pool[start].PickKind {
|
||||
end++
|
||||
}
|
||||
block := pool[start:end]
|
||||
if len(block) > 1 {
|
||||
offset := rng.Intn(len(block))
|
||||
out = append(out, block[offset:]...)
|
||||
out = append(out, block[:offset]...)
|
||||
} else {
|
||||
out = append(out, block...)
|
||||
}
|
||||
start = end
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// discoveryMixSpecs is the concrete spec list used by the registry in
|
||||
@@ -173,7 +191,10 @@ var discoveryMixSpecs = []discoveryMixSpec{
|
||||
}
|
||||
out := make([]discoverTrack, len(rows))
|
||||
for i, r := range rows {
|
||||
out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
|
||||
out[i] = discoverTrack{
|
||||
ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID,
|
||||
PickKind: pickKindForMixTier(r.Tier),
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
@@ -207,7 +228,10 @@ var discoveryMixSpecs = []discoveryMixSpec{
|
||||
}
|
||||
out := make([]discoverTrack, len(rows))
|
||||
for i, r := range rows {
|
||||
out[i] = discoverTrack{ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID}
|
||||
out[i] = discoverTrack{
|
||||
ID: r.ID, AlbumID: r.AlbumID, ArtistID: r.ArtistID,
|
||||
PickKind: pickKindForMixTier(r.Tier),
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
},
|
||||
@@ -244,7 +268,7 @@ func finishMix(rows []discoverTrack, diversify bool) []rankedCandidate {
|
||||
}
|
||||
tracks := make([]rankedCandidate, len(pool))
|
||||
for i, t := range pool {
|
||||
tracks[i] = rankedCandidate{TrackID: t.ID}
|
||||
tracks[i] = rankedCandidate{TrackID: t.ID, PickKind: t.PickKind}
|
||||
}
|
||||
return tracks
|
||||
}
|
||||
|
||||
@@ -57,6 +57,54 @@ func idSet[T any](rows []T, id func(T) pgtype.UUID) map[pgtype.UUID]bool {
|
||||
return out
|
||||
}
|
||||
|
||||
// seedPlayWithDuration inserts a completed play with an explicit
|
||||
// duration_played_ms — the "attempted >= 30s" queries key off it.
|
||||
func seedPlayWithDuration(t *testing.T, pool *pgxpool.Pool, userID, trackID pgtype.UUID, startedAt time.Time, durationMs int32) {
|
||||
t.Helper()
|
||||
_, err := pool.Exec(context.Background(), `
|
||||
WITH s AS (
|
||||
INSERT INTO play_sessions (user_id, started_at, last_event_at)
|
||||
VALUES ($1, $3, $3)
|
||||
RETURNING id
|
||||
)
|
||||
INSERT INTO play_events (user_id, track_id, session_id, started_at, duration_played_ms)
|
||||
SELECT $1, $2, s.id, $3, $4 FROM s
|
||||
`, userID, trackID, startedAt, durationMs)
|
||||
if err != nil {
|
||||
t.Fatalf("seed play with duration: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// seedAlbumForArtist creates a fresh album under an existing artist —
|
||||
// the tiered album queries need several albums sharing one artist_id.
|
||||
func seedAlbumForArtist(t *testing.T, pool *pgxpool.Pool, title string, artistID pgtype.UUID) pgtype.UUID {
|
||||
t.Helper()
|
||||
al, err := dbq.New(pool).UpsertAlbum(context.Background(), dbq.UpsertAlbumParams{
|
||||
Title: title, SortTitle: title, ArtistID: artistID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("seed album for artist: %v", err)
|
||||
}
|
||||
return al.ID
|
||||
}
|
||||
|
||||
func backdateAlbum(t *testing.T, pool *pgxpool.Pool, albumID pgtype.UUID, to time.Time) {
|
||||
t.Helper()
|
||||
if _, err := pool.Exec(context.Background(),
|
||||
`UPDATE albums SET created_at = $2 WHERE id = $1`, albumID, to); err != nil {
|
||||
t.Fatalf("backdate album: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func likeArtist(t *testing.T, pool *pgxpool.Pool, userID, artistID pgtype.UUID) {
|
||||
t.Helper()
|
||||
if _, err := pool.Exec(context.Background(),
|
||||
`INSERT INTO general_likes_artists (user_id, artist_id) VALUES ($1, $2)`,
|
||||
userID, artistID); err != nil {
|
||||
t.Fatalf("like artist: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
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".
|
||||
@@ -133,6 +181,121 @@ func TestListOnThisDayTracks_WrapsYearBoundary(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestListNewForYouTracks_TiersAndConsumption(t *testing.T) {
|
||||
// #1267: 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.
|
||||
pool := newPool(t)
|
||||
q := dbq.New(pool)
|
||||
u := seedUser(t, pool, "nfy1")
|
||||
now := time.Now().UTC()
|
||||
|
||||
// Affinity artist via a like; anchor track establishes the artist.
|
||||
anchor := seedTrack(t, pool, "nfy1-anchor", "nfy1-artistX")
|
||||
likeArtist(t, pool, u.ID, anchor.ArtistID)
|
||||
backdateAlbum(t, pool, anchor.AlbumID, now.Add(-200*24*time.Hour)) // out of every window
|
||||
|
||||
// Fresh affinity album → tier 1.
|
||||
freshAlbum := seedAlbumForArtist(t, pool, "nfy1-fresh", anchor.ArtistID)
|
||||
freshTrack := seedTrackForArtist(t, pool, "nfy1-fresh-1", freshAlbum, anchor.ArtistID)
|
||||
// Affinity album added 60 days ago → tier 2.
|
||||
olderAlbum := seedAlbumForArtist(t, pool, "nfy1-older", anchor.ArtistID)
|
||||
olderTrack := seedTrackForArtist(t, pool, "nfy1-older-1", olderAlbum, anchor.ArtistID)
|
||||
backdateAlbum(t, pool, olderAlbum, now.Add(-60*24*time.Hour))
|
||||
// Fresh album by an unrelated artist → tier 3.
|
||||
otherTrack := seedTrack(t, pool, "nfy1-other-1", "nfy1-artistY")
|
||||
// Fresh affinity album with one attempted track → consumed, absent.
|
||||
consumedAlbum := seedAlbumForArtist(t, pool, "nfy1-consumed", anchor.ArtistID)
|
||||
consumedTrack := seedTrackForArtist(t, pool, "nfy1-consumed-1", consumedAlbum, anchor.ArtistID)
|
||||
seedPlayWithDuration(t, pool, u.ID, consumedTrack.ID, now.Add(-time.Hour), 35_000)
|
||||
// Affinity album added 120 days ago → outside every window, absent.
|
||||
ancientAlbum := seedAlbumForArtist(t, pool, "nfy1-ancient", anchor.ArtistID)
|
||||
ancientTrack := seedTrackForArtist(t, pool, "nfy1-ancient-1", ancientAlbum, anchor.ArtistID)
|
||||
backdateAlbum(t, pool, ancientAlbum, now.Add(-120*24*time.Hour))
|
||||
|
||||
rows, err := q.ListNewForYouTracks(context.Background(), u.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListNewForYouTracks: %v", err)
|
||||
}
|
||||
tiers := map[pgtype.UUID]int32{}
|
||||
for _, r := range rows {
|
||||
tiers[r.ID] = r.Tier
|
||||
}
|
||||
if got := tiers[freshTrack.ID]; got != 1 {
|
||||
t.Errorf("fresh affinity album tier = %d, want 1", got)
|
||||
}
|
||||
if got := tiers[olderTrack.ID]; got != 2 {
|
||||
t.Errorf("60-day affinity album tier = %d, want 2", got)
|
||||
}
|
||||
if got := tiers[otherTrack.ID]; got != 3 {
|
||||
t.Errorf("fresh non-affinity album tier = %d, want 3", got)
|
||||
}
|
||||
if _, in := tiers[consumedTrack.ID]; in {
|
||||
t.Error("album with an attempted track must be consumed out of the mix")
|
||||
}
|
||||
if _, in := tiers[ancientTrack.ID]; in {
|
||||
t.Error("album added 120 days ago must be outside every window")
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
// to tier 2 with the attempted tracks themselves excluded.
|
||||
pool := newPool(t)
|
||||
q := dbq.New(pool)
|
||||
u := seedUser(t, pool, "fl1")
|
||||
now := time.Now().UTC()
|
||||
|
||||
// Never touched → tier 1.
|
||||
untouched := seedTrack(t, pool, "fl1-untouched-1", "fl1-artistA")
|
||||
// One 2-second accidental brush → still tier 1 (the headline bug).
|
||||
brushed := seedTrack(t, pool, "fl1-brushed-1", "fl1-artistB")
|
||||
seedPlayWithDuration(t, pool, u.ID, brushed.ID, now.Add(-time.Hour), 2_000)
|
||||
// 4-track album with one attempted (25%) → tier 2, attempted track
|
||||
// excluded, siblings present.
|
||||
barely1 := seedTrack(t, pool, "fl1-barely-1", "fl1-artistC")
|
||||
barely2 := seedTrackForArtist(t, pool, "fl1-barely-2", barely1.AlbumID, barely1.ArtistID)
|
||||
barely3 := seedTrackForArtist(t, pool, "fl1-barely-3", barely1.AlbumID, barely1.ArtistID)
|
||||
barely4 := seedTrackForArtist(t, pool, "fl1-barely-4", barely1.AlbumID, barely1.ArtistID)
|
||||
seedPlayWithDuration(t, pool, u.ID, barely1.ID, now.Add(-time.Hour), 40_000)
|
||||
// 2-track album with one attempted (50%) → past the brush
|
||||
// threshold, absent entirely.
|
||||
explored1 := seedTrack(t, pool, "fl1-explored-1", "fl1-artistD")
|
||||
explored2 := seedTrackForArtist(t, pool, "fl1-explored-2", explored1.AlbumID, explored1.ArtistID)
|
||||
seedPlayWithDuration(t, pool, u.ID, explored1.ID, now.Add(-time.Hour), 40_000)
|
||||
|
||||
rows, err := q.ListFirstListensTracks(context.Background(), u.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("ListFirstListensTracks: %v", err)
|
||||
}
|
||||
tiers := map[pgtype.UUID]int32{}
|
||||
for _, r := range rows {
|
||||
tiers[r.ID] = r.Tier
|
||||
}
|
||||
if got := tiers[untouched.ID]; got != 1 {
|
||||
t.Errorf("untouched album tier = %d, want 1", got)
|
||||
}
|
||||
if got := tiers[brushed.ID]; got != 1 {
|
||||
t.Errorf("2-second brush must not disqualify: tier = %d, want 1", got)
|
||||
}
|
||||
if _, in := tiers[barely1.ID]; in {
|
||||
t.Error("the attempted track itself is not a first listen")
|
||||
}
|
||||
for _, tr := range []dbq.Track{barely2, barely3, barely4} {
|
||||
if got := tiers[tr.ID]; got != 2 {
|
||||
t.Errorf("sibling of attempted track tier = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
if _, in := tiers[explored1.ID]; in {
|
||||
t.Error("half-attempted album must be excluded (attempted track)")
|
||||
}
|
||||
if _, in := tiers[explored2.ID]; in {
|
||||
t.Error("half-attempted album must be excluded (sibling)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestListRediscoverTracks_SkippedResurfacingCoolsDown(t *testing.T) {
|
||||
// #1258: a skip on a rediscover-sourced play is a declined
|
||||
// invitation — the track sits out ~90 days instead of re-qualifying
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
package playlists
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgtype"
|
||||
)
|
||||
|
||||
func mixTrack(b byte, kind string) discoverTrack {
|
||||
return discoverTrack{ID: pgtype.UUID{Bytes: [16]byte{b}, Valid: true}, PickKind: kind}
|
||||
}
|
||||
|
||||
func idsOf(pool []discoverTrack) []byte {
|
||||
out := make([]byte, len(pool))
|
||||
for i, t := range pool {
|
||||
out[i] = t.ID.Bytes[0]
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
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).
|
||||
pool := []discoverTrack{
|
||||
mixTrack(1, pickKindTier1), mixTrack(2, pickKindTier1), mixTrack(3, pickKindTier1),
|
||||
mixTrack(4, pickKindTier2), mixTrack(5, pickKindTier2),
|
||||
mixTrack(6, pickKindTier3),
|
||||
}
|
||||
u := pgtype.UUID{Bytes: [16]byte{42}, Valid: true}
|
||||
got := rotateForDay(pool, u, "2026-07-03")
|
||||
if len(got) != len(pool) {
|
||||
t.Fatalf("len = %d, want %d", len(got), len(pool))
|
||||
}
|
||||
wantKinds := []string{
|
||||
pickKindTier1, pickKindTier1, pickKindTier1,
|
||||
pickKindTier2, pickKindTier2, pickKindTier3,
|
||||
}
|
||||
for i, k := range wantKinds {
|
||||
if got[i].PickKind != k {
|
||||
t.Fatalf("pos %d kind = %q, want %q (tier order broken: %v)",
|
||||
i, got[i].PickKind, k, idsOf(got))
|
||||
}
|
||||
}
|
||||
// Each block is a rotation of its input: contiguity check for the
|
||||
// 3-element tier-1 block (successor relation preserved cyclically).
|
||||
wantOrder := map[byte]byte{1: 2, 2: 3, 3: 1}
|
||||
for i := 0; i < 2; i++ {
|
||||
if got[i+1].ID.Bytes[0] != wantOrder[got[i].ID.Bytes[0]] {
|
||||
t.Errorf("tier1 block is not a rotation: %v", idsOf(got[:3]))
|
||||
break
|
||||
}
|
||||
}
|
||||
// Determinism within a day.
|
||||
again := rotateForDay(pool, u, "2026-07-03")
|
||||
for i := range got {
|
||||
if got[i].ID != again[i].ID {
|
||||
t.Fatal("rotation must be deterministic for the same (user, day)")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRotateForDay_UntieredPoolIsWholeRotation(t *testing.T) {
|
||||
// Untiered pools (all PickKind "") are one block — the original
|
||||
// whole-pool rotation semantics.
|
||||
pool := []discoverTrack{mixTrack(1, ""), mixTrack(2, ""), mixTrack(3, ""), mixTrack(4, "")}
|
||||
got := rotateForDay(pool, pgtype.UUID{Bytes: [16]byte{7}, Valid: true}, "2026-07-03")
|
||||
if len(got) != 4 {
|
||||
t.Fatalf("len = %d, want 4", len(got))
|
||||
}
|
||||
wantNext := map[byte]byte{1: 2, 2: 3, 3: 4, 4: 1}
|
||||
for i := 0; i < 3; i++ {
|
||||
cur, next := got[i].ID.Bytes[0], got[i+1].ID.Bytes[0]
|
||||
if wantNext[cur] != next {
|
||||
t.Errorf("not a rotation of the input: %v", idsOf(got))
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFinishMix_PropagatesPickKind(t *testing.T) {
|
||||
// Tier stamps ride discoverTrack through diversify/truncate into
|
||||
// the rankedCandidates that insertSystemPlaylist persists (#1267).
|
||||
pool := []discoverTrack{mixTrack(1, pickKindTier1), mixTrack(2, pickKindTier2)}
|
||||
got := finishMix(pool, false)
|
||||
if len(got) != 2 {
|
||||
t.Fatalf("len = %d, want 2", len(got))
|
||||
}
|
||||
if got[0].PickKind != pickKindTier1 || got[1].PickKind != pickKindTier2 {
|
||||
t.Errorf("pick kinds = %q, %q — want tier1, tier2", got[0].PickKind, got[1].PickKind)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPickKindForMixTier(t *testing.T) {
|
||||
cases := []struct {
|
||||
tier int32
|
||||
want string
|
||||
}{{1, pickKindTier1}, {2, pickKindTier2}, {3, pickKindTier3}, {9, pickKindTier3}}
|
||||
for _, c := range cases {
|
||||
if got := pickKindForMixTier(c.tier); got != c.want {
|
||||
t.Errorf("pickKindForMixTier(%d) = %q, want %q", c.tier, got, c.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user