// 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" ) // TasteTuning is the tunable subset of taste.Config: the engagement // half-life and the completion→engagement curve points. type TasteTuning struct { HalfLifeDays float64 EngagementHardSkip float64 EngagementNeutral float64 EngagementFull 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. 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, } } // 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, } } // 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, } } // 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 } // 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(), } { 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, }); err != nil { return fmt.Errorf("seed taste 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) } 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, } 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.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 } // 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, } 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 profile != ScopeRadio && profile != ScopeDailyMix { 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) } // 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 { switch scope { case ScopeRadio, ScopeDailyMix: shipped := ShippedRadioWeights() if scope == ScopeDailyMix { shipped = ShippedDailyMixWeights() } changes := diffWeights(s.Weights(scope), shipped) if len(changes) == 0 { return nil } return s.persistProfile(ctx, scope, shipped, "reset", changes) case ScopeTaste: shipped := ShippedTasteTuning() changes := diffTaste(s.Taste(), shipped) if len(changes) == 0 { return nil } return s.persistTaste(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, }); 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 } // 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, } } 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, } } 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, } }