Files
minstrel/internal/recommendation/library_scale.go
T
bvandeusenandClaude Opus 5.5 5bcbc80555
release / govulncheck (push) Successful in 31s
release / web (push) Successful in 1m37s
release / go (push) Successful in 1m46s
release / integration (push) Successful in 5m20s
release / android (push) Successful in 5m22s
release / Build signed APK (releases and dev) (push) Successful in 5m39s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 1m29s
release / Verify release artifacts (tag releases only) (push) Skipped
fix: the taste arm round-robins across taste artists, and tag ties follow the order seed (#3880)
Measured on the operator's library (26,005 tracks, pool scale 2.28): the
arms the library scale grows all have far more rows than it asks for, and
two arms collapsed whatever their limits were.

- taste_overlap ordered by weight then track id, so the strongest taste
  artist's catalogue filled it: 20 tracks of saib. (197 tracks) out of 174
  taste artists, the same lowest-id tracks every time. It now takes one
  track per taste artist, strongest first, md5-seeded, as similar_artists
  does since #5297.
- tag_overlap broke ties in no order. A one-tag seed ties 7,804 tracks,
  and the LIMIT took whichever Postgres reached first, which the order
  seed did not control. Ties now follow md5(id || seed).
- library_scale.go records the per-arm measurement beside the reference
  size it leaves as is.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-21 12:12:58 -04:00

201 lines
8.4 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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 (#3880).
//
// DefaultCandidateSourceLimits returns what its own comment calls "the v1
// hardcoded constants per spec" — ~170 candidates, identical for a 500-track
// library and a 100,000-track one. Operator, 2026-09-10: "is the pool that we
// draw from somehow scaled to the amount of music in the library... my earlier
// understanding of the tuning and work may have been skewed by what was in my
// library."
//
// It was not, and the consequence compounds. 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. RandomFill, whose entire
// job is exploration, becomes a thinner and noisier slice of a more diverse
// corpus at exactly the moment diversity rises.
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.
//
// ASSUMED, NOT MEASURED. The size the v1 constants were actually tuned
// against is unrecorded; 5,000 is a plausible mid-size library and a
// deliberately conservative place to start growing. This constant is the
// single thing to change.
//
// Measured on the operator's library, 26,005 tracks (scale 2.28), for the
// 15 most-played seeds and two Moe Shop tracks (#3880, 2026-10-21):
// lb_similar 0–6 edges (3 of 17 seeds have any): never reaches
// even the base limit, so scaling it costs nothing
// similar_artists 860–6,307 tracks over 7–33 artists: always full
// tag_overlap 4,658–19,658 tracks: always full
// likes_overlap 193–497 liked tracks sharing a tag: always full
// taste_overlap 20,045 tracks over 174 artists: always full
// coplay 0 rows: a single-user server
// So the arms the scale grows all have far more rows than it asks for, and
// the tuned-against size cannot be recovered from data; the number stays
// until 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. That
// also keeps this safe against #3889 — growing an arm ordered by unseeded
// random() is fine, shrinking one is what breaks 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, because a new one would
// need sqlc regeneration (rule 28, and see #3889 for where that blocks).
// 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. That is deliberate rather than defensive habit. The api
// handlers struct is built directly by a dozen tests that cannot know about
// every field, and a nil here previously panicked inside a radio request —
// turning a missing pool-sizing HINT into a 500. 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. Rule 156: 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)
}