f0c08e7326
Milestone #160 Opt 2b (mood half of the era+mood option). A fourth taste facet alongside artists + genre tags + eras: signed weights over canonical mood buckets (melancholic / energetic / chill / …) derived from a track's enriched folksonomy tags (#1490). - internal/mood: shared vocabulary — Of(tags) maps folksonomy tags to canonical mood buckets (synonyms collapse). Imported by both the taste builder and the scorer so a track's mood is derived identically. - Migration 0047: taste_profile_moods table + taste_tuning.mood_scale (DEFAULT 0.5). - Build side (internal/taste): Config.MoodScale ([0,1] damper, mirrors EraScale); accumulate folds each play/like's mood buckets at base*MoodScale; persist atomic-replaces the mood rows. - Scorer (internal/recommendation): TasteProfile gains a mood term (own tanh scale + additive 0.12 share, so it never weakens the existing signal when a track has no mood tags). Match now takes the candidate's mood buckets; loaded per candidate (ListTrackTagsForTracks → mood.Of) in the primary similarity loader only — the near-whole-library fallback pool passes nil (mood → 0) to avoid a full-library tag scan. - Tuning lab: mood_scale threaded through recsettings + admin API + web card ("Mood weight" row) + Go/web tests. Coverage is partial (grows with tag enrichment; richer once Last.fm is keyed), so mood is a supplement — neutral for tracks with no mood tags. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
402 lines
13 KiB
Go
402 lines
13 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, the completion→engagement curve points, the enriched-tag
|
|
// weight (how much folksonomy tags count vs raw ID3 genre, #1520), and the
|
|
// era-facet weight (how strongly a decade-play imprints, #1530).
|
|
type TasteTuning struct {
|
|
HalfLifeDays float64
|
|
EngagementHardSkip float64
|
|
EngagementNeutral float64
|
|
EngagementFull float64
|
|
EnrichedTagScale float64
|
|
EraScale float64
|
|
MoodScale 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.
|
|
// ContextTimeWeight starts uniform (1.0) across both profiles pending
|
|
// trend data (#1531); split them once the metrics view justifies it.
|
|
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,
|
|
ContextTimeWeight: 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,
|
|
ContextTimeWeight: 1.0,
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
EnrichedTagScale: d.EnrichedTagScale,
|
|
EraScale: d.EraScale,
|
|
MoodScale: d.MoodScale,
|
|
}
|
|
}
|
|
|
|
// 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,
|
|
EnrichedTagScale: st.EnrichedTagScale,
|
|
EraScale: st.EraScale,
|
|
MoodScale: st.MoodScale,
|
|
}); 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,
|
|
EnrichedTagScale: tt.EnrichedTagScale,
|
|
EraScale: tt.EraScale,
|
|
MoodScale: tt.MoodScale,
|
|
}
|
|
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,
|
|
}
|
|
cfg.EnrichedTagScale = t.EnrichedTagScale
|
|
cfg.EraScale = t.EraScale
|
|
cfg.MoodScale = t.MoodScale
|
|
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,
|
|
EnrichedTagScale: t.EnrichedTagScale,
|
|
EraScale: t.EraScale,
|
|
MoodScale: t.MoodScale,
|
|
}); 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,
|
|
ContextTimeWeight: w.ContextTimeWeight,
|
|
}
|
|
}
|
|
|
|
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,
|
|
ContextTimeWeight: w.ContextTimeWeight,
|
|
}
|
|
}
|
|
|
|
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,
|
|
ContextTimeWeight: r.ContextTimeWeight,
|
|
}
|
|
}
|