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:
@@ -236,7 +236,7 @@ func (s *Scheduler) runStartupCatchUp(ctx context.Context, users []dbq.ListActiv
|
||||
// build can read a fresh profile (phase-2 consumption); both steps are
|
||||
// best-effort and a failure in one is logged without blocking the other.
|
||||
func (s *Scheduler) rebuildUserDaily(ctx context.Context, userID pgtype.UUID, now time.Time) {
|
||||
if err := taste.BuildTasteProfile(ctx, s.pool, s.logger, userID, taste.DefaultConfig()); err != nil {
|
||||
if err := taste.BuildTasteProfile(ctx, s.pool, s.logger, userID, currentTasteConfig()); err != nil {
|
||||
s.logger.Warn("scheduler: taste profile rebuild failed",
|
||||
"user_id", uuidStringPL(userID), "err", err)
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ import (
|
||||
"math"
|
||||
"math/rand"
|
||||
"sort"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/jackc/pgx/v5"
|
||||
@@ -23,6 +24,7 @@ import (
|
||||
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/recommendation"
|
||||
"git.fabledsword.com/bvandeusen/minstrel/internal/taste"
|
||||
)
|
||||
|
||||
// seedArtistRow mirrors the sqlc-generated PickSeedArtistsRow shape.
|
||||
@@ -175,24 +177,64 @@ func pickKindForMixTier(tier int32) string {
|
||||
|
||||
const systemMixLength = 25
|
||||
|
||||
// systemMixWeights are the fixed scoring weights used by the cron worker.
|
||||
// systemMixWeights are the scoring weights used by the daily builds.
|
||||
// JitterMagnitude is small (0.1) and combined with a userIDHash-seeded
|
||||
// RNG (see scoreAndSortCandidates) — same (user, day) produces same
|
||||
// scores within a day, but near-tied candidates reshuffle across days
|
||||
// so the playlist doesn't feel frozen.
|
||||
var systemMixWeights = recommendation.ScoringWeights{
|
||||
BaseWeight: 1.0,
|
||||
LikeBoost: 2.0,
|
||||
RecencyWeight: 1.0,
|
||||
SkipPenalty: 2.0,
|
||||
JitterMagnitude: 0.1,
|
||||
ContextWeight: 0.5,
|
||||
SimilarityWeight: 1.5,
|
||||
// Taste profile (#796 phase 2): the daily mixes are the primary
|
||||
// taste-driven surface, so they lean on it. TasteMatchScore is in
|
||||
// [-1,+1], so 1.5 makes a strong taste fit comparable to a like boost
|
||||
// while passive avoidance (negative) gently demotes.
|
||||
TasteWeight: 1.5,
|
||||
//
|
||||
// DB-tunable since #1250: the recsettings service pushes the current
|
||||
// daily_mix profile via SetSystemMixWeights at boot and on every admin
|
||||
// change (coverart Configure() pattern — no signature threading, live
|
||||
// effect without restart). The literal here is only the pre-push
|
||||
// value; shipped defaults live in recsettings.ShippedDailyMixWeights,
|
||||
// which must stay in sync with it.
|
||||
var (
|
||||
systemTuningMu sync.RWMutex
|
||||
systemMixWeights = recommendation.ScoringWeights{
|
||||
BaseWeight: 1.0,
|
||||
LikeBoost: 2.0,
|
||||
RecencyWeight: 1.0,
|
||||
SkipPenalty: 2.0,
|
||||
JitterMagnitude: 0.1,
|
||||
ContextWeight: 0.5,
|
||||
SimilarityWeight: 1.5,
|
||||
// Taste profile (#796 phase 2): the daily mixes are the primary
|
||||
// taste-driven surface, so they lean on it. TasteMatchScore is in
|
||||
// [-1,+1], so 1.5 makes a strong taste fit comparable to a like boost
|
||||
// while passive avoidance (negative) gently demotes.
|
||||
TasteWeight: 1.5,
|
||||
}
|
||||
systemTasteConfig = taste.DefaultConfig()
|
||||
)
|
||||
|
||||
// SetSystemMixWeights installs the current daily_mix scoring weights.
|
||||
// Called by the recsettings service at boot and on admin updates.
|
||||
func SetSystemMixWeights(w recommendation.ScoringWeights) {
|
||||
systemTuningMu.Lock()
|
||||
defer systemTuningMu.Unlock()
|
||||
systemMixWeights = w
|
||||
}
|
||||
|
||||
// SetTasteConfig installs the taste-profile build configuration
|
||||
// (half-life + engagement curve, #1250). Same push model as
|
||||
// SetSystemMixWeights.
|
||||
func SetTasteConfig(c taste.Config) {
|
||||
systemTuningMu.Lock()
|
||||
defer systemTuningMu.Unlock()
|
||||
systemTasteConfig = c
|
||||
}
|
||||
|
||||
func currentSystemMixWeights() recommendation.ScoringWeights {
|
||||
systemTuningMu.RLock()
|
||||
defer systemTuningMu.RUnlock()
|
||||
return systemMixWeights
|
||||
}
|
||||
|
||||
func currentTasteConfig() taste.Config {
|
||||
systemTuningMu.RLock()
|
||||
defer systemTuningMu.RUnlock()
|
||||
return systemTasteConfig
|
||||
}
|
||||
|
||||
// forYouHeadN is the number of top-scored tracks that anchor the For-You
|
||||
@@ -355,9 +397,10 @@ func scoreAndSortCandidates(cands []recommendation.Candidate, userID pgtype.UUID
|
||||
sort.SliceStable(ordered, func(i, j int) bool {
|
||||
return uuidLessPL(ordered[i].Track.ID, ordered[j].Track.ID)
|
||||
})
|
||||
weights := currentSystemMixWeights()
|
||||
pairs := make([]scored, len(ordered))
|
||||
for i, c := range ordered {
|
||||
pairs[i] = scored{c: c, score: recommendation.Score(c.Inputs, systemMixWeights, now, rng.Float64)}
|
||||
pairs[i] = scored{c: c, score: recommendation.Score(c.Inputs, weights, now, rng.Float64)}
|
||||
}
|
||||
sort.SliceStable(pairs, func(i, j int) bool {
|
||||
if pairs[i].score != pairs[j].score {
|
||||
@@ -784,11 +827,12 @@ func pickTopN(cands []recommendation.Candidate, userID pgtype.UUID, dateStr stri
|
||||
capped = capped[:n]
|
||||
}
|
||||
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
|
||||
weights := currentSystemMixWeights()
|
||||
out := make([]rankedCandidate, len(capped))
|
||||
for i, c := range capped {
|
||||
out[i] = rankedCandidate{
|
||||
TrackID: c.Track.ID,
|
||||
Score: recommendation.Score(c.Inputs, systemMixWeights, now, rng.Float64),
|
||||
Score: recommendation.Score(c.Inputs, weights, now, rng.Float64),
|
||||
}
|
||||
}
|
||||
return out
|
||||
@@ -815,6 +859,7 @@ func pickHeadAndTail(
|
||||
sorted := scoreAndSortCandidates(cands, userID, dateStr, now)
|
||||
capped := capCandidatesByAlbumAndArtist(sorted)
|
||||
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
|
||||
weights := currentSystemMixWeights()
|
||||
|
||||
total := headN + tailN
|
||||
if len(capped) <= total {
|
||||
@@ -828,7 +873,7 @@ func pickHeadAndTail(
|
||||
for i := 0; i < total; i++ {
|
||||
out[i] = rankedCandidate{
|
||||
TrackID: capped[i].Track.ID,
|
||||
Score: recommendation.Score(capped[i].Inputs, systemMixWeights, now, rng.Float64),
|
||||
Score: recommendation.Score(capped[i].Inputs, weights, now, rng.Float64),
|
||||
PickKind: pickKindTaste,
|
||||
}
|
||||
}
|
||||
@@ -875,7 +920,7 @@ func pickHeadAndTail(
|
||||
}
|
||||
out[i] = rankedCandidate{
|
||||
TrackID: c.Track.ID,
|
||||
Score: recommendation.Score(c.Inputs, systemMixWeights, now, rng.Float64),
|
||||
Score: recommendation.Score(c.Inputs, weights, now, rng.Float64),
|
||||
PickKind: kind,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -127,11 +127,12 @@ func rollUpCandidates(
|
||||
cands []recommendation.Candidate, userID pgtype.UUID, dateStr string, now time.Time,
|
||||
) (albumIDs, artistIDs []pgtype.UUID) {
|
||||
rng := rand.New(rand.NewSource(int64(userIDHash(userID, dateStr))))
|
||||
weights := currentSystemMixWeights()
|
||||
albumScores := map[pgtype.UUID][]float64{}
|
||||
artistScores := map[pgtype.UUID][]float64{}
|
||||
albumArtist := map[pgtype.UUID]pgtype.UUID{}
|
||||
for _, c := range cands {
|
||||
s := recommendation.Score(c.Inputs, systemMixWeights, now, rng.Float64)
|
||||
s := recommendation.Score(c.Inputs, weights, now, rng.Float64)
|
||||
if c.Track.AlbumID.Valid {
|
||||
albumScores[c.Track.AlbumID] = append(albumScores[c.Track.AlbumID], s)
|
||||
albumArtist[c.Track.AlbumID] = c.Track.ArtistID
|
||||
|
||||
Reference in New Issue
Block a user