Files
minstrel/internal/recommendation/library_scale.go
T
bvandeusenandClaude Opus 5.5 fcf5b7e5a8 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>
2026-10-23 12:19:15 -04:00

182 lines
7.1 KiB
Go

package recommendation
import (
"context"
"math"
"sync"
"time"
"github.com/jackc/pgx/v5/pgtype"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
)
// Scaling the candidate pool to the library.
//
// DefaultCandidateSourceLimits is ~170 candidates, whatever the library's
// size. Unscaled, the pool samples a shrinking FRACTION of the library as it
// grows — 17% of 1,000 tracks, 1.7% of 10,000, 0.17% of 100,000 — so the
// ceiling on how much of a collection can ever surface stays flat while the
// collection does not, and RandomFill, whose job is exploration, becomes a
// thinner slice of a more diverse corpus.
const (
// libraryScaleReference is the library size at which the base limits
// apply unchanged. Below it nothing scales, so small libraries keep
// today's behaviour exactly.
//
// An assumption, not a measurement: 5,000 is a plausible mid-size library
// and a deliberately conservative place to start growing. On a large
// library the arms the scale grows (similar_artists, tag_overlap,
// likes_overlap, taste_overlap) hold far more rows than it asks for, while
// lb_similar rarely reaches even its base limit. Change this if a library
// shows a scaled arm running dry or a pool too slow.
libraryScaleReference = 5000
// maxLibraryScale bounds growth so a very large library does not turn
// every recommendation query into a huge scan. At 4x the pool tops out
// around 500 candidates, which is still cheap to score in memory.
maxLibraryScale = 4.0
)
// libraryScale is sqrt rather than linear on purpose. Linear growth would put
// a 100,000-track library at a 3,400-candidate pool — a slow query, slow
// scoring, and far past the point where more candidates improve the answer.
// Square-root growth keeps the pool meaningfully proportional while staying
// bounded: 1x at the reference, 2x at four times it, 4x at sixteen times.
//
// Never returns below 1: the base limits are a floor, not a midpoint. Growing
// an arm ordered by unseeded random() is harmless; shrinking one would break
// same-day determinism.
func libraryScale(libraryTracks int64) float64 {
if libraryTracks <= libraryScaleReference {
return 1.0
}
f := math.Sqrt(float64(libraryTracks) / float64(libraryScaleReference))
return math.Min(f, maxLibraryScale)
}
// ScaleForLibrary grows the library-bounded arms of a limit set for a library
// of the given size, and leaves the rest alone.
//
// THE SCALING IS NOT UNIFORM, and that is the substance of it rather than a
// refinement. An arm's limit only matters if there are rows for it to cut off,
// so what an arm is BOUNDED BY decides whether library size can help it:
//
// scaled LBSimilar, SimilarArtist, TagOverlap — bounded by similarity
// and tag data, which grows as the library does. A bigger library
// means more of ListenBrainz's returned MBIDs survive the
// local-library filter in similarity/worker.go.
// scaled RandomFill — the exploration arm, and the one the complaint is
// really about. This is where "fraction of the library" lives.
// unscaled LikesOverlap — bounded by the USER's likes.
// unscaled UserCoplay — bounded by the INSTANCE's co-play graph.
// unscaled TasteOverlap — bounded by the taste profile's artists.
//
// Raising the last three would not sample more of the library; it would
// sample more of a set that did not grow, which is churn rather than reach.
// Leaving TasteOverlap alone has a second benefit: it and RandomFill are the
// two arms carrying sim_score 0, so this does not inflate the
// seed-independent share as fast as a uniform scale would.
func ScaleForLibrary(base CandidateSourceLimits, libraryTracks int64) CandidateSourceLimits {
f := libraryScale(libraryTracks)
grow := func(n int) int { return int(float64(n) * f) } // f >= 1, so never shrinks
return CandidateSourceLimits{
LBSimilar: grow(base.LBSimilar),
SimilarArtist: grow(base.SimilarArtist),
TagOverlap: grow(base.TagOverlap),
RandomFill: grow(base.RandomFill),
LikesOverlap: base.LikesOverlap,
UserCoplay: base.UserCoplay,
TasteOverlap: base.TasteOverlap,
}
}
// CountLibraryTracks returns the whole library's track count.
//
// Reuses CountTracksMatching with an empty pattern — `title ILIKE '%%'`
// matches every row — rather than adding a query.
// The user id is left invalid so the quarantine anti-join is skipped: this
// number sizes a pool, and a per-user view of it would be false precision.
func CountLibraryTracks(ctx context.Context, q *dbq.Queries) (int64, error) {
return q.CountTracksMatching(ctx, dbq.CountTracksMatchingParams{
Column1: "",
UserID: pgtype.UUID{}, // invalid → NULL → no quarantine filter
})
}
// librarySizeTTL is how long a counted size is reused. Library size changes
// only when a scan runs, so minutes are plenty — and the count is a full
// table scan (the ILIKE defeats every index), which is why it is not done
// per request.
const librarySizeTTL = 5 * time.Minute
// librarySizeTimeout bounds the count itself — see loadWithDeadline.
const librarySizeTimeout = 3 * time.Second
// LibrarySize memoises the library track count.
//
// Degrades rather than fails: a count that errors or times out leaves the
// previous value in place, and a zero (never yet counted) scales to the base
// limits — which is exactly today's behaviour. Nothing about sizing a
// candidate pool justifies failing the request it is sizing.
type LibrarySize struct {
mu sync.Mutex
now func() time.Time // injectable for tests
at time.Time
size int64
}
// NewLibrarySize returns an empty cache. The zero value works too; this
// exists so tests can pin the clock.
func NewLibrarySize(now func() time.Time) *LibrarySize {
return &LibrarySize{now: now}
}
// Get returns the cached size, refreshing through load when stale.
//
// A NIL RECEIVER IS VALID and means "no cache": the count still runs, it is
// just not memoised. The api handlers struct is built directly by a dozen
// tests that cannot know about every field, and a panic here would turn a
// missing pool-sizing HINT into a 500 inside a radio request.
// Uncached-but-correct is the right failure for something whose whole
// contract is that it degrades.
func (l *LibrarySize) Get(ctx context.Context, load func(context.Context) (int64, error)) int64 {
if l == nil {
n, err := loadWithDeadline(ctx, load)
if err != nil {
return 0 // scales to the base limits
}
return n
}
l.mu.Lock()
defer l.mu.Unlock()
now := time.Now
if l.now != nil {
now = l.now
}
if !l.at.IsZero() && now().Sub(l.at) < librarySizeTTL {
return l.size
}
n, err := loadWithDeadline(ctx, load)
if err != nil {
// Keep the last known value and re-try at the next call rather than
// stamping `at`, so a transient failure does not pin a stale number
// for the whole TTL.
return l.size
}
l.size, l.at = n, now()
return l.size
}
// loadWithDeadline bounds the count: the caller is a user waiting on a radio,
// and a pool-sizing hint is never worth hanging for.
func loadWithDeadline(ctx context.Context, load func(context.Context) (int64, error)) (int64, error) {
cctx, cancel := context.WithTimeout(ctx, librarySizeTimeout)
defer cancel()
return load(cctx)
}