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