Files
minstrel/internal/recsettings/service.go
T
bvandeusenandClaude Opus 5.5 e44025435e
release / govulncheck (push) Successful in 15s
release / web (push) Successful in 1m23s
release / go (push) Successful in 1m36s
release / integration (push) Successful in 5m4s
release / android (push) Successful in 5m5s
release / Build signed APK (releases and dev) (push) Successful in 5m18s
release / Attach APK to the Release (tag releases only) (push) Skipped
release / Build + push container image (push) Successful in 30s
release / Verify release artifacts (tag releases only) (push) Skipped
fix: ListenBrainz similarity is stored relative to the seed's best, and the candidate arms keep tier order (#6177)
LB's similarity scores are counts (16 to 1445 for recordings, to 10,875
for artists), and they reached a scorer that reads similarity as 0 to 1.
One edge added 64 to 5,800 points under Songs-like, so likes, skips and
recency stopped mattering for any candidate with an edge.

- ResolveListenBrainzTrackEdges divides each edge by the seed's best
  score in its whole cached answer. The cache keeps the raw answer.
- The worker stores matched and unmatched artist rows over the seed's
  top artist.
- Migration 0096 rescales the rows already stored.
- The candidate arms hold sim_score bands so tier 1 stays above tier 2:
  lb_similar 0.5 + 0.5 x edge, tag_overlap halved, likes_overlap 0.3.
  Otherwise a seed's weaker LB edges fell below a shared genre tag.

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

581 lines
20 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 recsettings is the DB-backed home of the recommendation
// tuning knobs (#1250): the two scoring-weight profiles (radio /
// daily_mix) and the taste-profile build settings (engagement
// half-life + completion curve). It follows the coverart
// SettingsService pattern — boot reconcile seeds shipped defaults for
// missing rows, values are cached under a RWMutex, and every change
// takes effect live (the daily_mix profile + taste config are pushed
// into package playlists; radio reads the cache per request).
//
// Framing (decision #1247): this is the defaults-discovery lab. The
// operator turns knobs to FIND good values; found-good values get
// baked back into the Shipped* functions below as new shipped
// defaults. End users and other operators should never need the card.
package recsettings
import (
"context"
"encoding/json"
"fmt"
"log/slog"
"sort"
"sync"
"github.com/jackc/pgx/v5/pgxpool"
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
"git.fabledsword.com/bvandeusen/minstrel/internal/playlists"
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
"git.fabledsword.com/bvandeusen/minstrel/internal/taste"
)
// Scope names — the three tunable groups. radio + daily_mix are weight
// profiles; taste is the profile-build settings singleton.
const (
ScopeRadio = "radio"
ScopeDailyMix = "daily_mix"
ScopeTaste = "taste"
// ScopeDiscover is the Discover request surface (#2377). Its own scope
// rather than columns on taste: SnoozeDays lives here, and a snooze must
// never be read as taste signal (#2374) — filing it under taste would put
// it one careless join from the leak that design forbids.
ScopeDiscover = "discover"
// ScopeSongsLike is the "Songs like {X}" surface (#3881). It shared
// daily_mix with For-You until 2026-09-10, and that sharing WAS the bug:
// the two surfaces want opposite things. For-You answers "what will they
// enjoy today" and is supposed to roam; Songs-like answers "what sounds
// like THIS" and is the tightest surface in the product. One set of
// weights cannot serve both, and the broad answer was winning.
ScopeSongsLike = "songs_like"
)
// TasteTuning is the tunable subset of taste.Config: the engagement
// half-life, the completion→engagement curve points, the enriched-tag
// weight (how much folksonomy tags count vs raw ID3 genre, #1520), and the
// era-facet weight (how strongly a decade-play imprints, #1530).
type TasteTuning struct {
HalfLifeDays float64
EngagementHardSkip float64
EngagementNeutral float64
EngagementFull float64
EnrichedTagScale float64
EraScale float64
MoodScale float64
}
// ShippedRadioWeights are the shipped radio-profile defaults (moved
// here from config.RecommendationConfig — YAML is bootstrap-only,
// rule: config in UI). Radio is seed-directed (the user picked a
// direction), so taste is a lighter nudge than in the daily mixes.
// ContextTimeWeight starts uniform (1.0) across both profiles pending
// trend data (#1531); split them once the metrics view justifies it.
func ShippedRadioWeights() recommendation.ScoringWeights {
return recommendation.ScoringWeights{
BaseWeight: 1.0,
LikeBoost: 2.0,
RecencyWeight: 1.0,
SkipPenalty: 1.0,
JitterMagnitude: 0.1,
ContextWeight: 2.0,
SimilarityWeight: 2.0,
TasteWeight: 1.0,
ContextTimeWeight: 1.0,
}
}
// ShippedDailyMixWeights are the shipped daily_mix-profile defaults.
// Must stay in sync with the pre-push literal in playlists/system.go.
func ShippedDailyMixWeights() recommendation.ScoringWeights {
return recommendation.ScoringWeights{
BaseWeight: 1.0,
LikeBoost: 2.0,
RecencyWeight: 1.0,
SkipPenalty: 2.0,
JitterMagnitude: 0.1,
ContextWeight: 0.5,
SimilarityWeight: 1.5,
TasteWeight: 1.5,
ContextTimeWeight: 1.0,
}
}
// ShippedSongsLikeWeights are the shipped songs_like-profile defaults
// (#3881). The whole point is that SIMILARITY DOMINATES; every other
// profile balances it against taste and engagement, and this one must not.
//
// The failure being corrected, arithmetic from the daily_mix profile that
// this surface used to share:
//
// unrelated track, liked, not played recently → 1.0 + 2.0 + 1.0 = 4.0
// PERFECT similarity match, not liked → 1.0 + 1.5 = 2.5
//
// Liking something outranked sounding like the seed, because LikeBoost (2.0)
// exceeded SimilarityWeight's entire range (1.5) and TasteWeight (1.5, and
// seed-independent) matched it outright.
//
// THE PROPERTY THESE NUMBERS ENCODE, which is what to preserve if they are
// retuned: the similarity term's range must exceed the combined range of
// every seed-INDEPENDENT differentiator, so that a closer match cannot be
// beaten on the strength of likes, freshness and taste alone.
//
// seed-independent spread = LikeBoost 0.5 + Recency 0.25
// + Taste 0.25 + ContextTime 0.5
// + jitter 0.05 = 1.55
// similarity spread = 0 → 4.0
//
// So a similarity advantage of ~0.39 (1.55/4.0) wins outright regardless of
// everything else, while tracks within that band still get ordered by what
// the user likes and has not heard lately. Tight, not deaf.
//
// BaseWeight stays 1.0: it is identical for every candidate and so
// differentiates nothing — it sets the floor, not the shape. SkipPenalty
// stays 2.0 because a track the user skips is still unwanted no matter how
// similar it is.
//
// These are DEFAULTS, not settings (rule 25) — the operator turns them in the
// admin tuning card and good values get baked back here. They are a
// defensible starting point rather than a measured optimum: the per-arm fill
// rates and the real sim_score distribution are still unknown (#3879), and
// `likes_overlap` contributes a flat 0.3 that a high SimilarityWeight
// amplifies. Expect to move these once that lands.
func ShippedSongsLikeWeights() recommendation.ScoringWeights {
return recommendation.ScoringWeights{
BaseWeight: 1.0, // same for all candidates; differentiates nothing
LikeBoost: 0.5, // was 2.0 — a tie-break among similar tracks, not an override
RecencyWeight: 0.25, // was 1.0 — freshness must not outrank sounding right
SkipPenalty: 2.0, // unchanged — a skipped track stays unwanted
JitterMagnitude: 0.05, // was 0.1 — less shuffle on a coherence surface
ContextWeight: 0.5,
SimilarityWeight: 4.0, // was 1.5 — dominant, by design
TasteWeight: 0.25, // was 1.5 — seed-INDEPENDENT, so demoted hard
ContextTimeWeight: 0.5, // was 1.0
}
}
// shippedWeightsFor returns the shipped defaults for a weight-profile scope,
// or ok=false if the scope is not a weight profile. Single source for the
// three call sites (seed, update-validation, reset) so adding a fourth
// profile cannot be half-wired — which is how a scope ends up seedable but
// not resettable.
func shippedWeightsFor(scope string) (recommendation.ScoringWeights, bool) {
switch scope {
case ScopeRadio:
return ShippedRadioWeights(), true
case ScopeDailyMix:
return ShippedDailyMixWeights(), true
case ScopeSongsLike:
return ShippedSongsLikeWeights(), true
}
return recommendation.ScoringWeights{}, false
}
// DiscoverTuning is the tunable set for the Discover request surface (#2377).
type DiscoverTuning struct {
// TagOverlapWeight scales the taste-tag term: score × (1 + w × overlap).
// 0 disables it and restores pure similarity ranking.
TagOverlapWeight float64
// SnoozeDays is the default "not right now" duration (#2374).
SnoozeDays float64
}
// ShippedDiscoverTuning are the shipped Discover defaults.
//
// TagOverlapWeight 1.0 lets a perfect tag match at most double a candidate's
// similarity score — enough to reorder the deck meaningfully, not enough for a
// popular-tag coincidence to beat a genuinely strong similarity match. It is a
// starting point for the tuning lab, not a tuned value: the honest way to pick
// it is the metrics trend view after some real use.
//
// SnoozeDays 90 matches the operator's approved shape: long enough that a
// parked suggestion stops nagging, short enough that a taste shift brings it
// back on its own.
func ShippedDiscoverTuning() DiscoverTuning {
return DiscoverTuning{
TagOverlapWeight: 1.0,
SnoozeDays: 90,
}
}
// ShippedTasteTuning mirrors taste.DefaultConfig's tunable subset.
func ShippedTasteTuning() TasteTuning {
d := taste.DefaultConfig()
return TasteTuning{
HalfLifeDays: d.HalfLifeDays,
EngagementHardSkip: d.Engagement.HardSkip,
EngagementNeutral: d.Engagement.NeutralCompletion,
EngagementFull: d.Engagement.FullCompletion,
EnrichedTagScale: d.EnrichedTagScale,
EraScale: d.EraScale,
MoodScale: d.MoodScale,
}
}
// Service caches the tuning values and owns their DB persistence +
// audit trail. Construct with New at boot.
type Service struct {
pool *pgxpool.Pool
logger *slog.Logger
mu sync.RWMutex
profiles map[string]recommendation.ScoringWeights
taste TasteTuning
discover DiscoverTuning
}
// New boots the service: seeds shipped defaults for missing rows,
// loads the current values, and pushes the daily_mix weights + taste
// config into package playlists so the daily builds pick them up.
func New(ctx context.Context, pool *pgxpool.Pool, logger *slog.Logger) (*Service, error) {
s := &Service{
pool: pool,
logger: logger,
profiles: map[string]recommendation.ScoringWeights{},
}
if err := s.reconcile(ctx); err != nil {
return nil, fmt.Errorf("recsettings boot: %w", err)
}
return s, nil
}
// reconcile seeds missing rows with shipped defaults, reads everything
// back into the cache, and pushes the playlist-side values.
func (s *Service) reconcile(ctx context.Context) error {
q := dbq.New(s.pool)
for profile, w := range map[string]recommendation.ScoringWeights{
ScopeRadio: ShippedRadioWeights(),
ScopeDailyMix: ShippedDailyMixWeights(),
ScopeSongsLike: ShippedSongsLikeWeights(),
} {
if err := q.UpsertWeightProfileDefaults(ctx, upsertParams(profile, w)); err != nil {
return fmt.Errorf("seed profile %q: %w", profile, err)
}
}
st := ShippedTasteTuning()
if err := q.UpsertTasteTuningDefaults(ctx, dbq.UpsertTasteTuningDefaultsParams{
HalfLifeDays: st.HalfLifeDays,
EngagementHardSkip: st.EngagementHardSkip,
EngagementNeutral: st.EngagementNeutral,
EngagementFull: st.EngagementFull,
EnrichedTagScale: st.EnrichedTagScale,
EraScale: st.EraScale,
MoodScale: st.MoodScale,
}); err != nil {
return fmt.Errorf("seed taste tuning: %w", err)
}
sd := ShippedDiscoverTuning()
if err := q.UpsertDiscoverTuningDefaults(ctx, dbq.UpsertDiscoverTuningDefaultsParams{
TagOverlapWeight: sd.TagOverlapWeight,
SnoozeDays: sd.SnoozeDays,
}); err != nil {
return fmt.Errorf("seed discover tuning: %w", err)
}
rows, err := q.ListWeightProfiles(ctx)
if err != nil {
return fmt.Errorf("list weight profiles: %w", err)
}
tt, err := q.GetTasteTuning(ctx)
if err != nil {
return fmt.Errorf("get taste tuning: %w", err)
}
dt, err := q.GetDiscoverTuning(ctx)
if err != nil {
return fmt.Errorf("get discover tuning: %w", err)
}
s.mu.Lock()
s.profiles = map[string]recommendation.ScoringWeights{}
for _, r := range rows {
s.profiles[r.Profile] = weightsFromRow(r)
}
s.taste = TasteTuning{
HalfLifeDays: tt.HalfLifeDays,
EngagementHardSkip: tt.EngagementHardSkip,
EngagementNeutral: tt.EngagementNeutral,
EngagementFull: tt.EngagementFull,
EnrichedTagScale: tt.EnrichedTagScale,
EraScale: tt.EraScale,
MoodScale: tt.MoodScale,
}
s.discover = DiscoverTuning{
TagOverlapWeight: dt.TagOverlapWeight,
SnoozeDays: dt.SnoozeDays,
}
s.mu.Unlock()
s.push()
return nil
}
// push installs the daily-build values into package playlists (the
// coverart Configure() pattern). Radio needs no push — its handler
// reads Weights(ScopeRadio) per request.
func (s *Service) push() {
playlists.SetSystemMixWeights(s.Weights(ScopeDailyMix))
playlists.SetSongsLikeWeights(s.Weights(ScopeSongsLike))
playlists.SetTasteConfig(s.TasteConfig())
}
// Weights returns the cached weights for a profile scope. Unknown
// scopes return the shipped radio defaults (defensive; callers pass
// the Scope* constants).
func (s *Service) Weights(profile string) recommendation.ScoringWeights {
s.mu.RLock()
defer s.mu.RUnlock()
if w, ok := s.profiles[profile]; ok {
return w
}
return ShippedRadioWeights()
}
// Taste returns the cached taste-tuning values.
func (s *Service) Taste() TasteTuning {
s.mu.RLock()
defer s.mu.RUnlock()
return s.taste
}
// Discover returns the cached Discover-tuning values. Read per request by the
// suggestions handler, so an admin change takes effect on the next refresh
// with no restart (rule #25).
func (s *Service) Discover() DiscoverTuning {
s.mu.RLock()
defer s.mu.RUnlock()
return s.discover
}
// TasteConfig assembles the full taste.Config the profile builder
// consumes: shipped non-tunable knobs (like bonuses, floors, caps)
// plus the tuned half-life and curve. WindowDays scales with the
// half-life at the shipped ratio (270/75 = 3.6 half-lives) — the
// window is a query-cost bound, not an independent knob.
func (s *Service) TasteConfig() taste.Config {
t := s.Taste()
cfg := taste.DefaultConfig()
cfg.HalfLifeDays = t.HalfLifeDays
cfg.WindowDays = t.HalfLifeDays * 3.6
cfg.Engagement = taste.EngagementParams{
HardSkip: t.EngagementHardSkip,
NeutralCompletion: t.EngagementNeutral,
FullCompletion: t.EngagementFull,
}
cfg.EnrichedTagScale = t.EnrichedTagScale
cfg.EraScale = t.EraScale
cfg.MoodScale = t.MoodScale
return cfg
}
// fieldChange is one entry of an audit row's changes array.
type fieldChange struct {
Field string `json:"field"`
Old float64 `json:"old"`
New float64 `json:"new"`
}
// UpdateProfile applies a partial update to one weight profile.
// Unknown fields and out-of-range values reject the whole patch. A
// no-op patch (all values equal to current) writes no audit row.
func (s *Service) UpdateProfile(ctx context.Context, profile string, patch map[string]float64) error {
if _, ok := shippedWeightsFor(profile); !ok {
return fmt.Errorf("%w: %q", ErrUnknownScope, profile)
}
current := s.Weights(profile)
next, changes, err := applyWeightPatch(current, patch)
if err != nil {
return err
}
if len(changes) == 0 {
return nil
}
return s.persistProfile(ctx, profile, next, "update", changes)
}
// UpdateTaste applies a partial update to the taste tuning singleton.
func (s *Service) UpdateTaste(ctx context.Context, patch map[string]float64) error {
current := s.Taste()
next, changes, err := applyTastePatch(current, patch)
if err != nil {
return err
}
if len(changes) == 0 {
return nil
}
return s.persistTaste(ctx, next, "update", changes)
}
// UpdateDiscover applies a partial update to the Discover tuning singleton.
func (s *Service) UpdateDiscover(ctx context.Context, patch map[string]float64) error {
current := s.Discover()
next, changes, err := applyDiscoverPatch(current, patch)
if err != nil {
return err
}
if len(changes) == 0 {
return nil
}
return s.persistDiscover(ctx, next, "update", changes)
}
// Reset restores a scope to its shipped defaults, with one audit row
// carrying the full diff. A scope already at defaults is a no-op.
func (s *Service) Reset(ctx context.Context, scope string) error {
if shipped, ok := shippedWeightsFor(scope); ok {
// Every weight profile resets the same way; the per-scope defaults
// come from one place so a new profile cannot be seedable but not
// resettable. This was an if/else over two hard-coded scopes until
// songs_like made it three.
changes := diffWeights(s.Weights(scope), shipped)
if len(changes) == 0 {
return nil
}
return s.persistProfile(ctx, scope, shipped, "reset", changes)
}
switch scope {
case ScopeTaste:
shipped := ShippedTasteTuning()
changes := diffTaste(s.Taste(), shipped)
if len(changes) == 0 {
return nil
}
return s.persistTaste(ctx, shipped, "reset", changes)
case ScopeDiscover:
shipped := ShippedDiscoverTuning()
changes := diffDiscover(s.Discover(), shipped)
if len(changes) == 0 {
return nil
}
return s.persistDiscover(ctx, shipped, "reset", changes)
default:
return fmt.Errorf("%w: %q", ErrUnknownScope, scope)
}
}
// persistProfile writes the profile row + audit entry, refreshes the
// cache, and pushes daily-build values.
func (s *Service) persistProfile(
ctx context.Context, profile string, w recommendation.ScoringWeights,
action string, changes []fieldChange,
) error {
q := dbq.New(s.pool)
if _, err := q.UpdateWeightProfile(ctx, updateParams(profile, w)); err != nil {
return fmt.Errorf("update profile %q: %w", profile, err)
}
if err := s.audit(ctx, q, profile, action, changes); err != nil {
return err
}
s.mu.Lock()
s.profiles[profile] = w
s.mu.Unlock()
s.push()
return nil
}
func (s *Service) persistTaste(
ctx context.Context, t TasteTuning, action string, changes []fieldChange,
) error {
q := dbq.New(s.pool)
if _, err := q.UpdateTasteTuning(ctx, dbq.UpdateTasteTuningParams{
HalfLifeDays: t.HalfLifeDays,
EngagementHardSkip: t.EngagementHardSkip,
EngagementNeutral: t.EngagementNeutral,
EngagementFull: t.EngagementFull,
EnrichedTagScale: t.EnrichedTagScale,
EraScale: t.EraScale,
MoodScale: t.MoodScale,
}); err != nil {
return fmt.Errorf("update taste tuning: %w", err)
}
if err := s.audit(ctx, q, ScopeTaste, action, changes); err != nil {
return err
}
s.mu.Lock()
s.taste = t
s.mu.Unlock()
s.push()
return nil
}
// persistDiscover writes the discover row + audit entry and refreshes the
// cache. No push(): unlike taste and daily_mix, nothing precomputes from these
// — the suggestions handler reads Discover() per request.
func (s *Service) persistDiscover(
ctx context.Context, d DiscoverTuning, action string, changes []fieldChange,
) error {
q := dbq.New(s.pool)
if _, err := q.UpdateDiscoverTuning(ctx, dbq.UpdateDiscoverTuningParams{
TagOverlapWeight: d.TagOverlapWeight,
SnoozeDays: d.SnoozeDays,
}); err != nil {
return fmt.Errorf("update discover tuning: %w", err)
}
if err := s.audit(ctx, q, ScopeDiscover, action, changes); err != nil {
return err
}
s.mu.Lock()
s.discover = d
s.mu.Unlock()
return nil
}
// audit writes one recommendation_tuning_audit row. Changes are
// sorted by field so rows are deterministic and diff-friendly.
func (s *Service) audit(
ctx context.Context, q *dbq.Queries, scope, action string, changes []fieldChange,
) error {
sort.Slice(changes, func(i, j int) bool { return changes[i].Field < changes[j].Field })
payload, err := json.Marshal(changes)
if err != nil {
return fmt.Errorf("marshal audit changes: %w", err)
}
if err := q.InsertTuningAudit(ctx, dbq.InsertTuningAuditParams{
Scope: scope, Action: action, Changes: payload,
}); err != nil {
return fmt.Errorf("insert audit row: %w", err)
}
return nil
}
func upsertParams(profile string, w recommendation.ScoringWeights) dbq.UpsertWeightProfileDefaultsParams {
return dbq.UpsertWeightProfileDefaultsParams{
Profile: profile,
BaseWeight: w.BaseWeight,
LikeBoost: w.LikeBoost,
RecencyWeight: w.RecencyWeight,
SkipPenalty: w.SkipPenalty,
JitterMagnitude: w.JitterMagnitude,
ContextWeight: w.ContextWeight,
SimilarityWeight: w.SimilarityWeight,
TasteWeight: w.TasteWeight,
ContextTimeWeight: w.ContextTimeWeight,
}
}
func updateParams(profile string, w recommendation.ScoringWeights) dbq.UpdateWeightProfileParams {
return dbq.UpdateWeightProfileParams{
Profile: profile,
BaseWeight: w.BaseWeight,
LikeBoost: w.LikeBoost,
RecencyWeight: w.RecencyWeight,
SkipPenalty: w.SkipPenalty,
JitterMagnitude: w.JitterMagnitude,
ContextWeight: w.ContextWeight,
SimilarityWeight: w.SimilarityWeight,
TasteWeight: w.TasteWeight,
ContextTimeWeight: w.ContextTimeWeight,
}
}
func weightsFromRow(r dbq.RecommendationWeightProfile) recommendation.ScoringWeights {
return recommendation.ScoringWeights{
BaseWeight: r.BaseWeight,
LikeBoost: r.LikeBoost,
RecencyWeight: r.RecencyWeight,
SkipPenalty: r.SkipPenalty,
JitterMagnitude: r.JitterMagnitude,
ContextWeight: r.ContextWeight,
SimilarityWeight: r.SimilarityWeight,
TasteWeight: r.TasteWeight,
ContextTimeWeight: r.ContextTimeWeight,
}
}