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
This commit is contained in:
@@ -0,0 +1,178 @@
|
||||
// patch.go — field-name mapping + validation for the tuning patches.
|
||||
// Wire field names are the snake_case column names; the admin API and
|
||||
// web card use them verbatim.
|
||||
package recsettings
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrUnknownScope = errors.New("unknown tuning scope")
|
||||
ErrUnknownField = errors.New("unknown tuning field")
|
||||
ErrOutOfRange = errors.New("tuning value out of range")
|
||||
)
|
||||
|
||||
// weightBound caps every scoring weight's magnitude. The scoring
|
||||
// terms are all in [-1, 1] before weighting, so ±10 is far past any
|
||||
// useful setting — the bound exists to catch typos (e.g. 100 for
|
||||
// 1.00), not to constrain exploration.
|
||||
const weightBound = 10.0
|
||||
|
||||
// weightField describes one patchable ScoringWeights field.
|
||||
type weightField struct {
|
||||
get func(recommendation.ScoringWeights) float64
|
||||
set func(*recommendation.ScoringWeights, float64)
|
||||
// nonNegative marks fields where a negative value is meaningless
|
||||
// (a negative jitter magnitude or skip penalty inverts intent in a
|
||||
// way the score formula already expresses through its sign).
|
||||
nonNegative bool
|
||||
}
|
||||
|
||||
var weightFields = map[string]weightField{
|
||||
"base_weight": {
|
||||
get: func(w recommendation.ScoringWeights) float64 { return w.BaseWeight },
|
||||
set: func(w *recommendation.ScoringWeights, v float64) { w.BaseWeight = v },
|
||||
},
|
||||
"like_boost": {
|
||||
get: func(w recommendation.ScoringWeights) float64 { return w.LikeBoost },
|
||||
set: func(w *recommendation.ScoringWeights, v float64) { w.LikeBoost = v },
|
||||
},
|
||||
"recency_weight": {
|
||||
get: func(w recommendation.ScoringWeights) float64 { return w.RecencyWeight },
|
||||
set: func(w *recommendation.ScoringWeights, v float64) { w.RecencyWeight = v },
|
||||
},
|
||||
"skip_penalty": {
|
||||
get: func(w recommendation.ScoringWeights) float64 { return w.SkipPenalty },
|
||||
set: func(w *recommendation.ScoringWeights, v float64) { w.SkipPenalty = v },
|
||||
nonNegative: true,
|
||||
},
|
||||
"jitter_magnitude": {
|
||||
get: func(w recommendation.ScoringWeights) float64 { return w.JitterMagnitude },
|
||||
set: func(w *recommendation.ScoringWeights, v float64) { w.JitterMagnitude = v },
|
||||
nonNegative: true,
|
||||
},
|
||||
"context_weight": {
|
||||
get: func(w recommendation.ScoringWeights) float64 { return w.ContextWeight },
|
||||
set: func(w *recommendation.ScoringWeights, v float64) { w.ContextWeight = v },
|
||||
},
|
||||
"similarity_weight": {
|
||||
get: func(w recommendation.ScoringWeights) float64 { return w.SimilarityWeight },
|
||||
set: func(w *recommendation.ScoringWeights, v float64) { w.SimilarityWeight = v },
|
||||
},
|
||||
"taste_weight": {
|
||||
get: func(w recommendation.ScoringWeights) float64 { return w.TasteWeight },
|
||||
set: func(w *recommendation.ScoringWeights, v float64) { w.TasteWeight = v },
|
||||
},
|
||||
}
|
||||
|
||||
// applyWeightPatch validates and applies a partial update, returning
|
||||
// the new weights and the list of actual changes (values equal to the
|
||||
// current setting are dropped, so a re-submitted form is a no-op).
|
||||
func applyWeightPatch(
|
||||
current recommendation.ScoringWeights, patch map[string]float64,
|
||||
) (recommendation.ScoringWeights, []fieldChange, error) {
|
||||
next := current
|
||||
var changes []fieldChange
|
||||
for field, v := range patch {
|
||||
f, ok := weightFields[field]
|
||||
if !ok {
|
||||
return current, nil, fmt.Errorf("%w: %q", ErrUnknownField, field)
|
||||
}
|
||||
if v < -weightBound || v > weightBound {
|
||||
return current, nil, fmt.Errorf("%w: %s = %v (|v| must be <= %v)",
|
||||
ErrOutOfRange, field, v, weightBound)
|
||||
}
|
||||
if f.nonNegative && v < 0 {
|
||||
return current, nil, fmt.Errorf("%w: %s = %v (must be >= 0)",
|
||||
ErrOutOfRange, field, v)
|
||||
}
|
||||
old := f.get(next)
|
||||
if old == v {
|
||||
continue
|
||||
}
|
||||
f.set(&next, v)
|
||||
changes = append(changes, fieldChange{Field: field, Old: old, New: v})
|
||||
}
|
||||
return next, changes, nil
|
||||
}
|
||||
|
||||
// Taste tuning bounds. The half-life window is generous — from "taste
|
||||
// is last week" to "taste is a decade" — and the curve points must
|
||||
// stay ordered inside [0, 1] or the engagement ramps degenerate.
|
||||
const (
|
||||
tasteHalfLifeMin = 1.0
|
||||
tasteHalfLifeMax = 3650.0
|
||||
)
|
||||
|
||||
// applyTastePatch validates and applies a partial taste update. The
|
||||
// curve-ordering invariant (hard_skip < neutral < full) is checked on
|
||||
// the PATCHED result, so a patch may move several points at once.
|
||||
func applyTastePatch(current TasteTuning, patch map[string]float64) (TasteTuning, []fieldChange, error) {
|
||||
next := current
|
||||
var changes []fieldChange
|
||||
for field, v := range patch {
|
||||
var target *float64
|
||||
switch field {
|
||||
case "half_life_days":
|
||||
if v < tasteHalfLifeMin || v > tasteHalfLifeMax {
|
||||
return current, nil, fmt.Errorf("%w: %s = %v (must be in [%v, %v])",
|
||||
ErrOutOfRange, field, v, tasteHalfLifeMin, tasteHalfLifeMax)
|
||||
}
|
||||
target = &next.HalfLifeDays
|
||||
case "engagement_hard_skip":
|
||||
target = &next.EngagementHardSkip
|
||||
case "engagement_neutral":
|
||||
target = &next.EngagementNeutral
|
||||
case "engagement_full":
|
||||
target = &next.EngagementFull
|
||||
default:
|
||||
return current, nil, fmt.Errorf("%w: %q", ErrUnknownField, field)
|
||||
}
|
||||
if field != "half_life_days" && (v < 0 || v > 1) {
|
||||
return current, nil, fmt.Errorf("%w: %s = %v (must be in [0, 1])",
|
||||
ErrOutOfRange, field, v)
|
||||
}
|
||||
if *target == v {
|
||||
continue
|
||||
}
|
||||
changes = append(changes, fieldChange{Field: field, Old: *target, New: v})
|
||||
*target = v
|
||||
}
|
||||
if !(next.EngagementHardSkip < next.EngagementNeutral &&
|
||||
next.EngagementNeutral < next.EngagementFull) {
|
||||
return current, nil, fmt.Errorf(
|
||||
"%w: engagement curve must satisfy hard_skip < neutral < full (got %v < %v < %v)",
|
||||
ErrOutOfRange, next.EngagementHardSkip, next.EngagementNeutral, next.EngagementFull)
|
||||
}
|
||||
return next, changes, nil
|
||||
}
|
||||
|
||||
// diffWeights returns per-field changes from a to b (empty when equal).
|
||||
func diffWeights(a, b recommendation.ScoringWeights) []fieldChange {
|
||||
var out []fieldChange
|
||||
for field, f := range weightFields {
|
||||
if f.get(a) != f.get(b) {
|
||||
out = append(out, fieldChange{Field: field, Old: f.get(a), New: f.get(b)})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// diffTaste returns per-field changes from a to b (empty when equal).
|
||||
func diffTaste(a, b TasteTuning) []fieldChange {
|
||||
var out []fieldChange
|
||||
add := func(field string, oldV, newV float64) {
|
||||
if oldV != newV {
|
||||
out = append(out, fieldChange{Field: field, Old: oldV, New: newV})
|
||||
}
|
||||
}
|
||||
add("half_life_days", a.HalfLifeDays, b.HalfLifeDays)
|
||||
add("engagement_hard_skip", a.EngagementHardSkip, b.EngagementHardSkip)
|
||||
add("engagement_neutral", a.EngagementNeutral, b.EngagementNeutral)
|
||||
add("engagement_full", a.EngagementFull, b.EngagementFull)
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user