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:
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user