Files
minstrel/internal/recsettings/service.go
T
bvandeusen 0d0a8f46b1
test-go / test (push) Failing after 14s
test-web / test (push) Successful in 34s
test-go / integration (push) Successful in 4m42s
feat(tuning): scoring weights → DB-backed admin tuning lab
The recommendation scoring knobs move out of YAML (radio profile) and
out of the systemMixWeights hard-code (daily_mix profile) into
DB-backed settings with live effect (#1250) — the defaults-discovery
lab per decision #1247: the operator turns knobs to find good values,
which then get baked back into shipped defaults; end users and other
operators should never need the card.

- Migration 0040: recommendation_weight_profiles (radio / daily_mix,
  8 weight columns), taste_tuning singleton (engagement half-life +
  completion-curve points), recommendation_tuning_audit (one row per
  change with a {field, old, new} diff — the trend view's markers,
  #1251).
- internal/recsettings: boot reconcile seeds shipped defaults without
  clobbering tuned rows (coverart SettingsService pattern), validates
  patches (bounds, curve ordering), writes audit rows, and pushes
  daily_mix weights + taste config into package playlists. No-op
  patches write no audit row.
- playlists gains SetSystemMixWeights / SetTasteConfig swap points
  under a RWMutex — no signature threading through the producers; the
  scheduler's taste rebuild reads the pushed config.
- Radio reads its weight profile from the service per request; the 8
  weight fields leave config.RecommendationConfig (YAML keeps only
  RecentlyPlayedHours / RadioSize / RadioSizeMax).
- Admin API: GET/PATCH/reset under /api/admin/recommendation-tuning,
  echoing current + shipped values.
- Web: new admin Tuning tab — two weight profiles side by side, taste
  card, per-scope save (changed fields only) + reset, deviation dots
  against shipped defaults.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01TsF3cNoKrqCYsU78cXC8U6
2026-07-03 09:22:03 -04:00

375 lines
12 KiB
Go

// 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,
}
}