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
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
// 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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,222 @@
|
||||
package recsettings
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"github.com/jackc/pgx/v5/pgxpool"
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/dbtest"
|
||||
)
|
||||
|
||||
func newPool(t *testing.T) *pgxpool.Pool {
|
||||
t.Helper()
|
||||
if testing.Short() {
|
||||
t.Skip("skipping integration test in -short mode")
|
||||
}
|
||||
dsn := os.Getenv("MINSTREL_TEST_DATABASE_URL")
|
||||
if dsn == "" {
|
||||
t.Skip("MINSTREL_TEST_DATABASE_URL not set")
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(io.Discard, nil))
|
||||
if err := db.Migrate(dsn, logger); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
pool, err := pgxpool.New(context.Background(), dsn)
|
||||
if err != nil {
|
||||
t.Fatalf("pool: %v", err)
|
||||
}
|
||||
t.Cleanup(pool.Close)
|
||||
dbtest.ResetDB(t, pool)
|
||||
return pool
|
||||
}
|
||||
|
||||
func newService(t *testing.T, pool *pgxpool.Pool) *Service {
|
||||
t.Helper()
|
||||
s, err := New(context.Background(), pool, slog.New(slog.NewTextHandler(io.Discard, nil)))
|
||||
if err != nil {
|
||||
t.Fatalf("recsettings.New: %v", err)
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func auditRows(t *testing.T, pool *pgxpool.Pool) []struct {
|
||||
Scope, Action string
|
||||
Changes []fieldChange
|
||||
} {
|
||||
t.Helper()
|
||||
rows, err := pool.Query(context.Background(),
|
||||
`SELECT scope, action, changes FROM recommendation_tuning_audit ORDER BY id`)
|
||||
if err != nil {
|
||||
t.Fatalf("query audit: %v", err)
|
||||
}
|
||||
defer rows.Close()
|
||||
var out []struct {
|
||||
Scope, Action string
|
||||
Changes []fieldChange
|
||||
}
|
||||
for rows.Next() {
|
||||
var scope, action string
|
||||
var raw []byte
|
||||
if err := rows.Scan(&scope, &action, &raw); err != nil {
|
||||
t.Fatalf("scan audit: %v", err)
|
||||
}
|
||||
var changes []fieldChange
|
||||
if err := json.Unmarshal(raw, &changes); err != nil {
|
||||
t.Fatalf("unmarshal audit changes: %v", err)
|
||||
}
|
||||
out = append(out, struct {
|
||||
Scope, Action string
|
||||
Changes []fieldChange
|
||||
}{scope, action, changes})
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func TestNew_SeedsShippedDefaults(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
s := newService(t, pool)
|
||||
if got := s.Weights(ScopeRadio); got != ShippedRadioWeights() {
|
||||
t.Errorf("radio weights = %+v, want shipped defaults", got)
|
||||
}
|
||||
if got := s.Weights(ScopeDailyMix); got != ShippedDailyMixWeights() {
|
||||
t.Errorf("daily_mix weights = %+v, want shipped defaults", got)
|
||||
}
|
||||
if got := s.Taste(); got != ShippedTasteTuning() {
|
||||
t.Errorf("taste tuning = %+v, want shipped defaults", got)
|
||||
}
|
||||
// Seeding must not write audit rows — nothing changed.
|
||||
if rows := auditRows(t, pool); len(rows) != 0 {
|
||||
t.Errorf("boot reconcile wrote %d audit rows, want 0", len(rows))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateProfile_PersistsAndAudits(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
s := newService(t, pool)
|
||||
if err := s.UpdateProfile(context.Background(), ScopeRadio,
|
||||
map[string]float64{"taste_weight": 2.5, "skip_penalty": 3.0}); err != nil {
|
||||
t.Fatalf("UpdateProfile: %v", err)
|
||||
}
|
||||
got := s.Weights(ScopeRadio)
|
||||
if got.TasteWeight != 2.5 || got.SkipPenalty != 3.0 {
|
||||
t.Errorf("weights = %+v, want taste 2.5 / skip 3.0", got)
|
||||
}
|
||||
// The other profile is untouched.
|
||||
if s.Weights(ScopeDailyMix) != ShippedDailyMixWeights() {
|
||||
t.Error("daily_mix must be unaffected by a radio update")
|
||||
}
|
||||
// Values survive a fresh boot (persisted, not just cached), and the
|
||||
// reconcile's ON CONFLICT DO NOTHING doesn't clobber tuned rows.
|
||||
s2 := newService(t, pool)
|
||||
if got := s2.Weights(ScopeRadio); got.TasteWeight != 2.5 {
|
||||
t.Errorf("rebooted taste_weight = %v, want 2.5", got.TasteWeight)
|
||||
}
|
||||
rows := auditRows(t, pool)
|
||||
if len(rows) != 1 {
|
||||
t.Fatalf("audit rows = %d, want 1", len(rows))
|
||||
}
|
||||
if rows[0].Scope != ScopeRadio || rows[0].Action != "update" || len(rows[0].Changes) != 2 {
|
||||
t.Errorf("audit row = %+v, want radio/update with 2 changes", rows[0])
|
||||
}
|
||||
// Changes are field-sorted: skip_penalty before taste_weight.
|
||||
if rows[0].Changes[0].Field != "skip_penalty" || rows[0].Changes[0].New != 3.0 {
|
||||
t.Errorf("changes[0] = %+v, want skip_penalty → 3.0", rows[0].Changes[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateProfile_Validation(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
s := newService(t, pool)
|
||||
cases := []struct {
|
||||
name string
|
||||
scope string
|
||||
patch map[string]float64
|
||||
want error
|
||||
}{
|
||||
{"unknown scope", "banana", map[string]float64{"taste_weight": 1}, ErrUnknownScope},
|
||||
{"unknown field", ScopeRadio, map[string]float64{"vibes": 1}, ErrUnknownField},
|
||||
{"over bound", ScopeRadio, map[string]float64{"taste_weight": 11}, ErrOutOfRange},
|
||||
{"negative jitter", ScopeRadio, map[string]float64{"jitter_magnitude": -0.1}, ErrOutOfRange},
|
||||
}
|
||||
for _, c := range cases {
|
||||
if err := s.UpdateProfile(context.Background(), c.scope, c.patch); !errors.Is(err, c.want) {
|
||||
t.Errorf("%s: err = %v, want %v", c.name, err, c.want)
|
||||
}
|
||||
}
|
||||
if s.Weights(ScopeRadio) != ShippedRadioWeights() {
|
||||
t.Error("rejected patches must not partially apply")
|
||||
}
|
||||
if rows := auditRows(t, pool); len(rows) != 0 {
|
||||
t.Errorf("rejected patches wrote %d audit rows, want 0", len(rows))
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateTaste_CurveOrderingEnforced(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
s := newService(t, pool)
|
||||
// Moving neutral above full must reject on the PATCHED result.
|
||||
err := s.UpdateTaste(context.Background(), map[string]float64{"engagement_neutral": 0.95})
|
||||
if !errors.Is(err, ErrOutOfRange) {
|
||||
t.Fatalf("err = %v, want ErrOutOfRange (curve ordering)", err)
|
||||
}
|
||||
// A coherent multi-point move is fine.
|
||||
if err := s.UpdateTaste(context.Background(), map[string]float64{
|
||||
"engagement_neutral": 0.40, "engagement_full": 0.95, "half_life_days": 30,
|
||||
}); err != nil {
|
||||
t.Fatalf("UpdateTaste: %v", err)
|
||||
}
|
||||
cfg := s.TasteConfig()
|
||||
if cfg.HalfLifeDays != 30 || cfg.Engagement.NeutralCompletion != 0.40 {
|
||||
t.Errorf("taste config = %+v, want tuned values", cfg)
|
||||
}
|
||||
// WindowDays scales with the half-life at the shipped ratio.
|
||||
if cfg.WindowDays != 30*3.6 {
|
||||
t.Errorf("WindowDays = %v, want %v", cfg.WindowDays, 30*3.6)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReset_RestoresShippedAndAudits(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
s := newService(t, pool)
|
||||
// Reset with nothing changed is a no-op (no audit row).
|
||||
if err := s.Reset(context.Background(), ScopeDailyMix); err != nil {
|
||||
t.Fatalf("no-op reset: %v", err)
|
||||
}
|
||||
if rows := auditRows(t, pool); len(rows) != 0 {
|
||||
t.Fatalf("no-op reset wrote audit rows")
|
||||
}
|
||||
if err := s.UpdateProfile(context.Background(), ScopeDailyMix,
|
||||
map[string]float64{"similarity_weight": 4}); err != nil {
|
||||
t.Fatalf("UpdateProfile: %v", err)
|
||||
}
|
||||
if err := s.Reset(context.Background(), ScopeDailyMix); err != nil {
|
||||
t.Fatalf("Reset: %v", err)
|
||||
}
|
||||
if s.Weights(ScopeDailyMix) != ShippedDailyMixWeights() {
|
||||
t.Error("reset must restore shipped defaults")
|
||||
}
|
||||
rows := auditRows(t, pool)
|
||||
if len(rows) != 2 || rows[1].Action != "reset" {
|
||||
t.Fatalf("audit rows = %+v, want update then reset", rows)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdate_NoOpWritesNoAudit(t *testing.T) {
|
||||
pool := newPool(t)
|
||||
s := newService(t, pool)
|
||||
shipped := ShippedRadioWeights()
|
||||
if err := s.UpdateProfile(context.Background(), ScopeRadio,
|
||||
map[string]float64{"taste_weight": shipped.TasteWeight}); err != nil {
|
||||
t.Fatalf("UpdateProfile: %v", err)
|
||||
}
|
||||
if rows := auditRows(t, pool); len(rows) != 0 {
|
||||
t.Errorf("no-op update wrote %d audit rows, want 0", len(rows))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user