diff --git a/cmd/minstrel/main.go b/cmd/minstrel/main.go index 4e887257..b9e9d00c 100644 --- a/cmd/minstrel/main.go +++ b/cmd/minstrel/main.go @@ -25,6 +25,7 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/lidarrrequests" "git.fabledsword.com/bvandeusen/minstrel/internal/logging" "git.fabledsword.com/bvandeusen/minstrel/internal/playlists" + "git.fabledsword.com/bvandeusen/minstrel/internal/recsettings" "git.fabledsword.com/bvandeusen/minstrel/internal/scrobble" "git.fabledsword.com/bvandeusen/minstrel/internal/scrobble/listenbrainz" "git.fabledsword.com/bvandeusen/minstrel/internal/server" @@ -146,6 +147,16 @@ func run() error { coverEnricher := coverart.NewEnricher(pool, logger.With("component", "coverart"), coverSettings) coverEnricher.DataDir = cfg.Storage.DataDir + // Recommendation tuning lab (#1250): seeds shipped defaults on first + // boot and pushes the daily_mix weights + taste config into package + // playlists — must precede the scheduler so the first builds score + // with the operator's tuned values, not the pre-push literals. + recSettings, err := recsettings.New(ctx, pool, logger.With("component", "recsettings")) + if err != nil { + logger.Error("recommendation settings service init failed", "err", err) + os.Exit(1) + } + // One unified scan chain: library walk → MBID backfill → cover enrich. // Boot-time scan and manual-trigger scans share this path; results land // in scan_runs for the admin overview. @@ -294,6 +305,7 @@ func run() error { }, cfg.Events, cfg.Recommendation, cfg.Storage.DataDir, cfg.Branding, coverEnricher, coverSettings, scanner, scanCfg) srv.Bus = bus srv.PlaylistScheduler = playlistScheduler + srv.RecSettings = recSettings srv.StreamSecret = cfg.StreamSecret httpServer := &http.Server{ Addr: cfg.Server.Address, diff --git a/internal/api/admin_recommendation_tuning.go b/internal/api/admin_recommendation_tuning.go new file mode 100644 index 00000000..672a2924 --- /dev/null +++ b/internal/api/admin_recommendation_tuning.go @@ -0,0 +1,152 @@ +// Admin recommendation-tuning endpoints (#1250): the defaults- +// discovery lab. GET returns current values + shipped defaults for +// every scope; PATCH applies a partial update to one scope; reset +// restores a scope to shipped defaults. Every change writes an audit +// row (consumed by the metrics trend view, #1251). +package api + +import ( + "encoding/json" + "errors" + "net/http" + + "github.com/go-chi/chi/v5" + + "git.fabledsword.com/bvandeusen/minstrel/internal/apierror" + "git.fabledsword.com/bvandeusen/minstrel/internal/recommendation" + "git.fabledsword.com/bvandeusen/minstrel/internal/recsettings" +) + +// weightsResp is one weight profile on the wire, keyed by the same +// snake_case field names the PATCH body accepts. +type weightsResp struct { + BaseWeight float64 `json:"base_weight"` + LikeBoost float64 `json:"like_boost"` + RecencyWeight float64 `json:"recency_weight"` + SkipPenalty float64 `json:"skip_penalty"` + JitterMagnitude float64 `json:"jitter_magnitude"` + ContextWeight float64 `json:"context_weight"` + SimilarityWeight float64 `json:"similarity_weight"` + TasteWeight float64 `json:"taste_weight"` +} + +func weightsRespFrom(w recommendation.ScoringWeights) weightsResp { + return weightsResp{ + BaseWeight: w.BaseWeight, + LikeBoost: w.LikeBoost, + RecencyWeight: w.RecencyWeight, + SkipPenalty: w.SkipPenalty, + JitterMagnitude: w.JitterMagnitude, + ContextWeight: w.ContextWeight, + SimilarityWeight: w.SimilarityWeight, + TasteWeight: w.TasteWeight, + } +} + +type tasteTuningResp struct { + HalfLifeDays float64 `json:"half_life_days"` + EngagementHardSkip float64 `json:"engagement_hard_skip"` + EngagementNeutral float64 `json:"engagement_neutral"` + EngagementFull float64 `json:"engagement_full"` +} + +func tasteRespFrom(t recsettings.TasteTuning) tasteTuningResp { + return tasteTuningResp{ + HalfLifeDays: t.HalfLifeDays, + EngagementHardSkip: t.EngagementHardSkip, + EngagementNeutral: t.EngagementNeutral, + EngagementFull: t.EngagementFull, + } +} + +// tuningSnapshot is both the GET response and the post-mutation echo: +// current values alongside shipped defaults so the card can mark +// which knobs deviate. +type tuningSnapshot struct { + Profiles map[string]weightsResp `json:"profiles"` + Taste tasteTuningResp `json:"taste"` + Shipped struct { + Profiles map[string]weightsResp `json:"profiles"` + Taste tasteTuningResp `json:"taste"` + } `json:"shipped"` +} + +func (h *handlers) tuningSnapshot() tuningSnapshot { + var out tuningSnapshot + out.Profiles = map[string]weightsResp{ + recsettings.ScopeRadio: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeRadio)), + recsettings.ScopeDailyMix: weightsRespFrom(h.recSettings.Weights(recsettings.ScopeDailyMix)), + } + out.Taste = tasteRespFrom(h.recSettings.Taste()) + out.Shipped.Profiles = map[string]weightsResp{ + recsettings.ScopeRadio: weightsRespFrom(recsettings.ShippedRadioWeights()), + recsettings.ScopeDailyMix: weightsRespFrom(recsettings.ShippedDailyMixWeights()), + } + out.Shipped.Taste = tasteRespFrom(recsettings.ShippedTasteTuning()) + return out +} + +// handleGetRecommendationTuning implements GET /api/admin/recommendation-tuning. +func (h *handlers) handleGetRecommendationTuning(w http.ResponseWriter, r *http.Request) { + writeJSON(w, http.StatusOK, h.tuningSnapshot()) +} + +// patchTuningReq carries the partial update: field name → new value, +// using the same snake_case names the GET response emits. +type patchTuningReq struct { + Values map[string]float64 `json:"values"` +} + +// handlePatchRecommendationTuning implements +// PATCH /api/admin/recommendation-tuning/{scope}. +func (h *handlers) handlePatchRecommendationTuning(w http.ResponseWriter, r *http.Request) { + scope := chi.URLParam(r, "scope") + var body patchTuningReq + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + writeErr(w, apierror.BadRequest("bad_body", "invalid JSON")) + return + } + if len(body.Values) == 0 { + writeErr(w, apierror.BadRequest("bad_body", "values is empty")) + return + } + + var err error + if scope == recsettings.ScopeTaste { + err = h.recSettings.UpdateTaste(r.Context(), body.Values) + } else { + err = h.recSettings.UpdateProfile(r.Context(), scope, body.Values) + } + if err != nil { + writeTuningErr(w, h, scope, err) + return + } + writeJSON(w, http.StatusOK, h.tuningSnapshot()) +} + +// handleResetRecommendationTuning implements +// POST /api/admin/recommendation-tuning/{scope}/reset. +func (h *handlers) handleResetRecommendationTuning(w http.ResponseWriter, r *http.Request) { + scope := chi.URLParam(r, "scope") + if err := h.recSettings.Reset(r.Context(), scope); err != nil { + writeTuningErr(w, h, scope, err) + return + } + writeJSON(w, http.StatusOK, h.tuningSnapshot()) +} + +// writeTuningErr maps recsettings validation errors to 400s and +// everything else to a logged 500. +func writeTuningErr(w http.ResponseWriter, h *handlers, scope string, err error) { + switch { + case errors.Is(err, recsettings.ErrUnknownScope): + writeErr(w, &apierror.Error{ + Status: http.StatusNotFound, Code: "not_found", Message: "no such tuning scope", + }) + case errors.Is(err, recsettings.ErrUnknownField), errors.Is(err, recsettings.ErrOutOfRange): + writeErr(w, apierror.BadRequest("invalid_tuning", err.Error())) + default: + h.logger.Error("admin: recommendation tuning", "scope", scope, "err", err) + writeErr(w, apierror.InternalMsg("tuning update failed", err)) + } +} diff --git a/internal/api/admin_recommendation_tuning_test.go b/internal/api/admin_recommendation_tuning_test.go new file mode 100644 index 00000000..4c6bba88 --- /dev/null +++ b/internal/api/admin_recommendation_tuning_test.go @@ -0,0 +1,110 @@ +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/go-chi/chi/v5" + + "git.fabledsword.com/bvandeusen/minstrel/internal/recsettings" +) + +func newTuningRouter(h *handlers) chi.Router { + r := chi.NewRouter() + r.Get("/api/admin/recommendation-tuning", h.handleGetRecommendationTuning) + r.Patch("/api/admin/recommendation-tuning/{scope}", h.handlePatchRecommendationTuning) + r.Post("/api/admin/recommendation-tuning/{scope}/reset", h.handleResetRecommendationTuning) + return r +} + +func decodeTuning(t *testing.T, rec *httptest.ResponseRecorder) tuningSnapshot { + t.Helper() + var snap tuningSnapshot + if err := json.Unmarshal(rec.Body.Bytes(), &snap); err != nil { + t.Fatalf("decode: %v", err) + } + return snap +} + +func TestRecommendationTuning_GetReturnsShippedDefaults(t *testing.T) { + h, _ := testHandlers(t) + req := httptest.NewRequest(http.MethodGet, "/api/admin/recommendation-tuning", nil) + rec := httptest.NewRecorder() + newTuningRouter(h).ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", rec.Code) + } + snap := decodeTuning(t, rec) + if snap.Profiles["radio"].TasteWeight != 1.0 || snap.Profiles["daily_mix"].TasteWeight != 1.5 { + t.Errorf("profiles = %+v, want shipped taste weights 1.0 / 1.5", snap.Profiles) + } + if snap.Taste.HalfLifeDays != 75 { + t.Errorf("taste half-life = %v, want shipped 75", snap.Taste.HalfLifeDays) + } + if snap.Shipped.Profiles["radio"] != snap.Profiles["radio"] { + t.Error("untouched values must equal shipped defaults") + } +} + +func TestRecommendationTuning_PatchAndReset(t *testing.T) { + h, _ := testHandlers(t) + r := newTuningRouter(h) + + req := httptest.NewRequest(http.MethodPatch, "/api/admin/recommendation-tuning/radio", + strings.NewReader(`{"values":{"taste_weight": 2.5}}`)) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("patch status = %d, want 200 (%s)", rec.Code, rec.Body.String()) + } + snap := decodeTuning(t, rec) + if snap.Profiles["radio"].TasteWeight != 2.5 { + t.Errorf("patched taste_weight = %v, want 2.5", snap.Profiles["radio"].TasteWeight) + } + // The change is live for the radio scoring path. + if got := h.recSettings.Weights(recsettings.ScopeRadio).TasteWeight; got != 2.5 { + t.Errorf("service taste_weight = %v, want 2.5 (live effect)", got) + } + + req = httptest.NewRequest(http.MethodPost, "/api/admin/recommendation-tuning/radio/reset", nil) + rec = httptest.NewRecorder() + r.ServeHTTP(rec, req) + if rec.Code != http.StatusOK { + t.Fatalf("reset status = %d, want 200", rec.Code) + } + snap = decodeTuning(t, rec) + if snap.Profiles["radio"].TasteWeight != 1.0 { + t.Errorf("reset taste_weight = %v, want shipped 1.0", snap.Profiles["radio"].TasteWeight) + } +} + +func TestRecommendationTuning_PatchErrors(t *testing.T) { + h, _ := testHandlers(t) + r := newTuningRouter(h) + cases := []struct { + name, path, body string + want int + }{ + {"unknown scope", "/api/admin/recommendation-tuning/banana", + `{"values":{"taste_weight":1}}`, http.StatusNotFound}, + {"unknown field", "/api/admin/recommendation-tuning/radio", + `{"values":{"vibes":1}}`, http.StatusBadRequest}, + {"out of range", "/api/admin/recommendation-tuning/taste", + `{"values":{"engagement_neutral":2}}`, http.StatusBadRequest}, + {"empty values", "/api/admin/recommendation-tuning/radio", + `{"values":{}}`, http.StatusBadRequest}, + {"bad json", "/api/admin/recommendation-tuning/radio", + `{`, http.StatusBadRequest}, + } + for _, c := range cases { + req := httptest.NewRequest(http.MethodPatch, c.path, strings.NewReader(c.body)) + rec := httptest.NewRecorder() + r.ServeHTTP(rec, req) + if rec.Code != c.want { + t.Errorf("%s: status = %d, want %d", c.name, rec.Code, c.want) + } + } +} diff --git a/internal/api/api.go b/internal/api/api.go index 5a7e4bfe..521fe2b6 100644 --- a/internal/api/api.go +++ b/internal/api/api.go @@ -22,16 +22,18 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/mailer" "git.fabledsword.com/bvandeusen/minstrel/internal/playevents" "git.fabledsword.com/bvandeusen/minstrel/internal/playlists" + "git.fabledsword.com/bvandeusen/minstrel/internal/recsettings" "git.fabledsword.com/bvandeusen/minstrel/internal/tracks" ) // Mount attaches /api/* handlers to r. Public endpoints (login) are outside // RequireUser; everything else is gated by the middleware. The events writer // is shared with the Subsonic mount so /rest/scrobble feeds the same store. -func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte) { +func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playevents.Writer, recCfg config.RecommendationConfig, recSettings *recsettings.Service, lidarrCfg *lidarrconfig.Service, lidarrReqs *lidarrrequests.Service, lidarrQuar *lidarrquarantine.Service, tracksSvc *tracks.Service, playlistsSvc *playlists.Service, coverEnricher *coverart.Enricher, coverSettings *coverart.SettingsService, scanner *library.Scanner, scanCfg library.RunScanConfig, dataDir string, sender mailer.Sender, bus *eventbus.Bus, playlistScheduler *playlists.Scheduler, streamSecret []byte) { rng := rand.New(rand.NewSource(rand.Int63())) h := &handlers{ pool: pool, logger: logger, events: events, recCfg: recCfg, + recSettings: recSettings, rng: rng.Float64, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs, @@ -201,6 +203,12 @@ func Mount(r chi.Router, pool *pgxpool.Pool, logger *slog.Logger, events *playev admin.Get("/smtp-config", h.handleGetSMTPConfig) admin.Put("/smtp-config", h.handleUpdateSMTPConfig) admin.Post("/smtp-config/test", h.handleTestSMTPConfig) + + // Recommendation tuning lab (#1250): scoring-weight + // profiles + taste-build knobs, DB-backed, live effect. + admin.Get("/recommendation-tuning", h.handleGetRecommendationTuning) + admin.Patch("/recommendation-tuning/{scope}", h.handlePatchRecommendationTuning) + admin.Post("/recommendation-tuning/{scope}/reset", h.handleResetRecommendationTuning) }) authed.Get("/playlists", h.handleListPlaylists) @@ -223,6 +231,7 @@ type handlers struct { logger *slog.Logger events *playevents.Writer recCfg config.RecommendationConfig + recSettings *recsettings.Service rng func() float64 lidarrCfg *lidarrconfig.Service lidarrRequests *lidarrrequests.Service diff --git a/internal/api/auth_test.go b/internal/api/auth_test.go index 333256bf..ae564b9c 100644 --- a/internal/api/auth_test.go +++ b/internal/api/auth_test.go @@ -29,6 +29,7 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/mailer" "git.fabledsword.com/bvandeusen/minstrel/internal/playevents" "git.fabledsword.com/bvandeusen/minstrel/internal/playlists" + "git.fabledsword.com/bvandeusen/minstrel/internal/recsettings" "git.fabledsword.com/bvandeusen/minstrel/internal/tracks" ) @@ -56,11 +57,12 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) { dbtest.ResetDB(t, pool) w := playevents.NewWriter(pool, logger, 30*time.Minute, 0.5, 30000) recCfg := config.RecommendationConfig{ - BaseWeight: 1.0, LikeBoost: 2.0, RecencyWeight: 1.0, - SkipPenalty: 1.0, JitterMagnitude: 0.1, - ContextWeight: 2.0, SimilarityWeight: 2.0, RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200, } + recSettings, err := recsettings.New(context.Background(), pool, logger) + if err != nil { + t.Fatalf("recsettings: %v", err) + } lidarrCfg := lidarrconfig.New(pool) lidarrReqs := lidarrrequests.NewService(pool, lidarrCfg, nil, nil) lidarrQuar := lidarrquarantine.NewService(pool, lidarrCfg, nil) @@ -70,7 +72,7 @@ func testHandlers(t *testing.T) (*handlers, *pgxpool.Pool) { dataDir := t.TempDir() tracksSvc := tracks.NewService(pool, logger, nil, dataDir) playlistsSvc := playlists.NewService(pool, logger, dataDir) - h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs, lidarrQuarantine: lidarrQuar, tracks: tracksSvc, playlists: playlistsSvc, dataDir: dataDir, scanner: nil, scanCfg: library.RunScanConfig{}, mailer: &mailer.FakeSender{}} + h := &handlers{pool: pool, logger: logger, events: w, recCfg: recCfg, recSettings: recSettings, rng: func() float64 { return 0.5 }, lidarrCfg: lidarrCfg, lidarrRequests: lidarrReqs, lidarrQuarantine: lidarrQuar, tracks: tracksSvc, playlists: playlistsSvc, dataDir: dataDir, scanner: nil, scanCfg: library.RunScanConfig{}, mailer: &mailer.FakeSender{}} return h, pool } diff --git a/internal/api/library_test.go b/internal/api/library_test.go index 09d36cf7..370da98e 100644 --- a/internal/api/library_test.go +++ b/internal/api/library_test.go @@ -465,7 +465,7 @@ func TestRoutesRegisteredInMount(t *testing.T) { r := chi.NewRouter() w := playevents.NewWriter(h.pool, slog.New(slog.NewTextHandler(io.Discard, nil)), 30*time.Minute, 0.5, 30000) - Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil) + Mount(r, h.pool, h.logger, w, config.RecommendationConfig{RadioSize: 50, RadioSizeMax: 200, RecentlyPlayedHours: 1}, h.recSettings, h.lidarrCfg, h.lidarrRequests, h.lidarrQuarantine, h.tracks, h.playlists, h.coverart, h.coverSettings, h.scanner, h.scanCfg, h.dataDir, nil, eventbus.New(), nil, nil) paths := []string{ "/api/artists", diff --git a/internal/api/radio.go b/internal/api/radio.go index c99a26f2..5aa68f6c 100644 --- a/internal/api/radio.go +++ b/internal/api/radio.go @@ -15,6 +15,7 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/apierror" "git.fabledsword.com/bvandeusen/minstrel/internal/db/dbq" "git.fabledsword.com/bvandeusen/minstrel/internal/recommendation" + "git.fabledsword.com/bvandeusen/minstrel/internal/recsettings" ) // RadioResponse is the body of GET /api/radio. @@ -100,16 +101,9 @@ func (h *handlers) handleRadio(w http.ResponseWriter, r *http.Request) { } } - weights := recommendation.ScoringWeights{ - BaseWeight: h.recCfg.BaseWeight, - LikeBoost: h.recCfg.LikeBoost, - RecencyWeight: h.recCfg.RecencyWeight, - SkipPenalty: h.recCfg.SkipPenalty, - JitterMagnitude: h.recCfg.JitterMagnitude, - ContextWeight: h.recCfg.ContextWeight, - SimilarityWeight: h.recCfg.SimilarityWeight, - TasteWeight: h.recCfg.TasteWeight, - } + // Scoring weights come from the DB-backed tuning lab (#1250) — + // read per request so an admin change takes effect live. + weights := h.recSettings.Weights(recsettings.ScopeRadio) picks := recommendation.Shuffle(candidates, weights, time.Now().UTC(), h.rng, limit-1) out := make([]TrackRef, 0, len(picks)+1) diff --git a/internal/config/config.go b/internal/config/config.go index dfd42189..dd2e4ac8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -88,20 +88,14 @@ type EventsConfig struct { SkipMaxDurationPlayedMs int `yaml:"skip_max_duration_played_ms"` } -// RecommendationConfig governs the M3 weighted-shuffle scoring (spec §6). -// All weights are operator-tunable; defaults match the spec recommendations. +// RecommendationConfig holds the radio path's operational knobs. The +// scoring WEIGHTS moved to DB-backed admin settings (#1250, +// internal/recsettings) — YAML is bootstrap-only; anything an operator +// tunes lives in the UI with live effect. type RecommendationConfig struct { - BaseWeight float64 `yaml:"base_weight"` - LikeBoost float64 `yaml:"like_boost"` - RecencyWeight float64 `yaml:"recency_weight"` - SkipPenalty float64 `yaml:"skip_penalty"` - JitterMagnitude float64 `yaml:"jitter_magnitude"` - ContextWeight float64 `yaml:"context_weight"` - SimilarityWeight float64 `yaml:"similarity_weight"` - TasteWeight float64 `yaml:"taste_weight"` - RecentlyPlayedHours int `yaml:"recently_played_hours"` - RadioSize int `yaml:"radio_size"` - RadioSizeMax int `yaml:"radio_size_max"` + RecentlyPlayedHours int `yaml:"recently_played_hours"` + RadioSize int `yaml:"radio_size"` + RadioSizeMax int `yaml:"radio_size_max"` } func Default() Config { @@ -120,16 +114,6 @@ func Default() Config { SkipMaxDurationPlayedMs: 30000, }, Recommendation: RecommendationConfig{ - BaseWeight: 1.0, - LikeBoost: 2.0, - RecencyWeight: 1.0, - SkipPenalty: 1.0, - JitterMagnitude: 0.1, - ContextWeight: 2.0, - SimilarityWeight: 2.0, - // Radio is seed-directed (the user picked a direction), so taste - // is a lighter nudge here than in the daily mixes (1.5). - TasteWeight: 1.0, RecentlyPlayedHours: 1, RadioSize: 50, RadioSizeMax: 200, diff --git a/internal/db/dbq/models.go b/internal/db/dbq/models.go index 12f5ae8b..29528972 100644 --- a/internal/db/dbq/models.go +++ b/internal/db/dbq/models.go @@ -437,6 +437,27 @@ type PlaylistTrack struct { PickKind *string } +type RecommendationTuningAudit struct { + ID int64 + ChangedAt pgtype.Timestamptz + Scope string + Action string + Changes []byte +} + +type RecommendationWeightProfile struct { + Profile string + BaseWeight float64 + LikeBoost float64 + RecencyWeight float64 + SkipPenalty float64 + JitterMagnitude float64 + ContextWeight float64 + SimilarityWeight float64 + TasteWeight float64 + UpdatedAt pgtype.Timestamptz +} + type RegistrationSetting struct { ID bool Mode string @@ -525,6 +546,15 @@ type TasteProfileTag struct { UpdatedAt pgtype.Timestamptz } +type TasteTuning struct { + Singleton bool + HalfLifeDays float64 + EngagementHardSkip float64 + EngagementNeutral float64 + EngagementFull float64 + UpdatedAt pgtype.Timestamptz +} + type Track struct { ID pgtype.UUID Title string diff --git a/internal/db/dbq/recommendation_tuning.sql.go b/internal/db/dbq/recommendation_tuning.sql.go new file mode 100644 index 00000000..d41c0f19 --- /dev/null +++ b/internal/db/dbq/recommendation_tuning.sql.go @@ -0,0 +1,272 @@ +// Code generated by sqlc. DO NOT EDIT. +// versions: +// sqlc v1.31.1 +// source: recommendation_tuning.sql + +package dbq + +import ( + "context" +) + +const getTasteTuning = `-- name: GetTasteTuning :one +SELECT singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at FROM taste_tuning WHERE singleton = true +` + +func (q *Queries) GetTasteTuning(ctx context.Context) (TasteTuning, error) { + row := q.db.QueryRow(ctx, getTasteTuning) + var i TasteTuning + err := row.Scan( + &i.Singleton, + &i.HalfLifeDays, + &i.EngagementHardSkip, + &i.EngagementNeutral, + &i.EngagementFull, + &i.UpdatedAt, + ) + return i, err +} + +const insertTuningAudit = `-- name: InsertTuningAudit :exec +INSERT INTO recommendation_tuning_audit (scope, action, changes) +VALUES ($1, $2, $3) +` + +type InsertTuningAuditParams struct { + Scope string + Action string + Changes []byte +} + +// changes is a jsonb array of {field, old, new} objects. +func (q *Queries) InsertTuningAudit(ctx context.Context, arg InsertTuningAuditParams) error { + _, err := q.db.Exec(ctx, insertTuningAudit, arg.Scope, arg.Action, arg.Changes) + return err +} + +const listTuningAudit = `-- name: ListTuningAudit :many +SELECT id, changed_at, scope, action, changes + FROM recommendation_tuning_audit + ORDER BY changed_at DESC, id DESC + LIMIT $1 +` + +// Newest first; consumed by the metrics trend view (#1251) to annotate +// knob turns on the timeline. +func (q *Queries) ListTuningAudit(ctx context.Context, limit int32) ([]RecommendationTuningAudit, error) { + rows, err := q.db.Query(ctx, listTuningAudit, limit) + if err != nil { + return nil, err + } + defer rows.Close() + var items []RecommendationTuningAudit + for rows.Next() { + var i RecommendationTuningAudit + if err := rows.Scan( + &i.ID, + &i.ChangedAt, + &i.Scope, + &i.Action, + &i.Changes, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const listWeightProfiles = `-- name: ListWeightProfiles :many +SELECT profile, base_weight, like_boost, recency_weight, skip_penalty, jitter_magnitude, context_weight, similarity_weight, taste_weight, updated_at FROM recommendation_weight_profiles ORDER BY profile +` + +func (q *Queries) ListWeightProfiles(ctx context.Context) ([]RecommendationWeightProfile, error) { + rows, err := q.db.Query(ctx, listWeightProfiles) + if err != nil { + return nil, err + } + defer rows.Close() + var items []RecommendationWeightProfile + for rows.Next() { + var i RecommendationWeightProfile + if err := rows.Scan( + &i.Profile, + &i.BaseWeight, + &i.LikeBoost, + &i.RecencyWeight, + &i.SkipPenalty, + &i.JitterMagnitude, + &i.ContextWeight, + &i.SimilarityWeight, + &i.TasteWeight, + &i.UpdatedAt, + ); err != nil { + return nil, err + } + items = append(items, i) + } + if err := rows.Err(); err != nil { + return nil, err + } + return items, nil +} + +const updateTasteTuning = `-- name: UpdateTasteTuning :one +UPDATE taste_tuning + SET half_life_days = $1, + engagement_hard_skip = $2, + engagement_neutral = $3, + engagement_full = $4, + updated_at = now() + WHERE singleton = true +RETURNING singleton, half_life_days, engagement_hard_skip, engagement_neutral, engagement_full, updated_at +` + +type UpdateTasteTuningParams struct { + HalfLifeDays float64 + EngagementHardSkip float64 + EngagementNeutral float64 + EngagementFull float64 +} + +func (q *Queries) UpdateTasteTuning(ctx context.Context, arg UpdateTasteTuningParams) (TasteTuning, error) { + row := q.db.QueryRow(ctx, updateTasteTuning, + arg.HalfLifeDays, + arg.EngagementHardSkip, + arg.EngagementNeutral, + arg.EngagementFull, + ) + var i TasteTuning + err := row.Scan( + &i.Singleton, + &i.HalfLifeDays, + &i.EngagementHardSkip, + &i.EngagementNeutral, + &i.EngagementFull, + &i.UpdatedAt, + ) + return i, err +} + +const updateWeightProfile = `-- name: UpdateWeightProfile :one +UPDATE recommendation_weight_profiles + SET base_weight = $2, + like_boost = $3, + recency_weight = $4, + skip_penalty = $5, + jitter_magnitude = $6, + context_weight = $7, + similarity_weight = $8, + taste_weight = $9, + updated_at = now() + WHERE profile = $1 +RETURNING profile, base_weight, like_boost, recency_weight, skip_penalty, jitter_magnitude, context_weight, similarity_weight, taste_weight, updated_at +` + +type UpdateWeightProfileParams struct { + Profile string + BaseWeight float64 + LikeBoost float64 + RecencyWeight float64 + SkipPenalty float64 + JitterMagnitude float64 + ContextWeight float64 + SimilarityWeight float64 + TasteWeight float64 +} + +func (q *Queries) UpdateWeightProfile(ctx context.Context, arg UpdateWeightProfileParams) (RecommendationWeightProfile, error) { + row := q.db.QueryRow(ctx, updateWeightProfile, + arg.Profile, + arg.BaseWeight, + arg.LikeBoost, + arg.RecencyWeight, + arg.SkipPenalty, + arg.JitterMagnitude, + arg.ContextWeight, + arg.SimilarityWeight, + arg.TasteWeight, + ) + var i RecommendationWeightProfile + err := row.Scan( + &i.Profile, + &i.BaseWeight, + &i.LikeBoost, + &i.RecencyWeight, + &i.SkipPenalty, + &i.JitterMagnitude, + &i.ContextWeight, + &i.SimilarityWeight, + &i.TasteWeight, + &i.UpdatedAt, + ) + return i, err +} + +const upsertTasteTuningDefaults = `-- name: UpsertTasteTuningDefaults :exec +INSERT INTO taste_tuning ( + singleton, half_life_days, engagement_hard_skip, + engagement_neutral, engagement_full +) VALUES (true, $1, $2, $3, $4) +ON CONFLICT (singleton) DO NOTHING +` + +type UpsertTasteTuningDefaultsParams struct { + HalfLifeDays float64 + EngagementHardSkip float64 + EngagementNeutral float64 + EngagementFull float64 +} + +func (q *Queries) UpsertTasteTuningDefaults(ctx context.Context, arg UpsertTasteTuningDefaultsParams) error { + _, err := q.db.Exec(ctx, upsertTasteTuningDefaults, + arg.HalfLifeDays, + arg.EngagementHardSkip, + arg.EngagementNeutral, + arg.EngagementFull, + ) + return err +} + +const upsertWeightProfileDefaults = `-- name: UpsertWeightProfileDefaults :exec + +INSERT INTO recommendation_weight_profiles ( + profile, base_weight, like_boost, recency_weight, skip_penalty, + jitter_magnitude, context_weight, similarity_weight, taste_weight +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) +ON CONFLICT (profile) DO NOTHING +` + +type UpsertWeightProfileDefaultsParams struct { + Profile string + BaseWeight float64 + LikeBoost float64 + RecencyWeight float64 + SkipPenalty float64 + JitterMagnitude float64 + ContextWeight float64 + SimilarityWeight float64 + TasteWeight float64 +} + +// Recommendation tuning lab queries (#1250). Seeding happens via the +// recsettings boot reconcile; shipped defaults live in Go only. +// Boot reconcile: insert the shipped defaults for a profile if the row +// doesn't exist yet. Never overwrites operator-tuned values. +func (q *Queries) UpsertWeightProfileDefaults(ctx context.Context, arg UpsertWeightProfileDefaultsParams) error { + _, err := q.db.Exec(ctx, upsertWeightProfileDefaults, + arg.Profile, + arg.BaseWeight, + arg.LikeBoost, + arg.RecencyWeight, + arg.SkipPenalty, + arg.JitterMagnitude, + arg.ContextWeight, + arg.SimilarityWeight, + arg.TasteWeight, + ) + return err +} diff --git a/internal/db/migrations/0040_recommendation_tuning.down.sql b/internal/db/migrations/0040_recommendation_tuning.down.sql new file mode 100644 index 00000000..9575d0eb --- /dev/null +++ b/internal/db/migrations/0040_recommendation_tuning.down.sql @@ -0,0 +1,3 @@ +DROP TABLE recommendation_tuning_audit; +DROP TABLE taste_tuning; +DROP TABLE recommendation_weight_profiles; diff --git a/internal/db/migrations/0040_recommendation_tuning.up.sql b/internal/db/migrations/0040_recommendation_tuning.up.sql new file mode 100644 index 00000000..7aba7c30 --- /dev/null +++ b/internal/db/migrations/0040_recommendation_tuning.up.sql @@ -0,0 +1,57 @@ +-- Recommendation tuning lab (milestone #127, Scribe #1250). +-- +-- The scoring weights move out of YAML (radio profile) and out of the +-- systemMixWeights hard-code (daily_mix profile) into DB-backed +-- settings with live effect — the defaults-discovery lab (decision +-- #1247): the operator turns knobs here to FIND good values, which +-- then get baked into shipped defaults; end users and other operators +-- should never need this card. +-- +-- Rows are seeded by the recsettings service's boot reconcile (the +-- coverart SettingsService pattern), not by this migration, so shipped +-- defaults live in exactly one place (Go). + +CREATE TABLE recommendation_weight_profiles ( + profile text PRIMARY KEY + CONSTRAINT recommendation_weight_profiles_profile_check + CHECK (profile IN ('radio', 'daily_mix')), + base_weight double precision NOT NULL, + like_boost double precision NOT NULL, + recency_weight double precision NOT NULL, + skip_penalty double precision NOT NULL, + jitter_magnitude double precision NOT NULL, + context_weight double precision NOT NULL, + similarity_weight double precision NOT NULL, + taste_weight double precision NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now() +); + +-- Taste-profile build knobs: engagement half-life + the completion→ +-- engagement curve points (taste.Config's tunable subset). +CREATE TABLE taste_tuning ( + singleton boolean PRIMARY KEY DEFAULT true + CONSTRAINT taste_tuning_singleton_check CHECK (singleton), + half_life_days double precision NOT NULL, + engagement_hard_skip double precision NOT NULL, + engagement_neutral double precision NOT NULL, + engagement_full double precision NOT NULL, + updated_at timestamptz NOT NULL DEFAULT now() +); + +-- Every knob turn writes one audit row; the metrics trend view +-- (#1251) annotates these on its timeline so cause→effect is visible +-- after a change. changes is a jsonb array of {field, old, new}. +CREATE TABLE recommendation_tuning_audit ( + id bigserial PRIMARY KEY, + changed_at timestamptz NOT NULL DEFAULT now(), + scope text NOT NULL + CONSTRAINT recommendation_tuning_audit_scope_check + CHECK (scope IN ('radio', 'daily_mix', 'taste')), + action text NOT NULL + CONSTRAINT recommendation_tuning_audit_action_check + CHECK (action IN ('update', 'reset')), + changes jsonb NOT NULL +); + +CREATE INDEX recommendation_tuning_audit_changed_at_idx + ON recommendation_tuning_audit (changed_at DESC); diff --git a/internal/db/queries/recommendation_tuning.sql b/internal/db/queries/recommendation_tuning.sql new file mode 100644 index 00000000..760ea716 --- /dev/null +++ b/internal/db/queries/recommendation_tuning.sql @@ -0,0 +1,61 @@ +-- Recommendation tuning lab queries (#1250). Seeding happens via the +-- recsettings boot reconcile; shipped defaults live in Go only. + +-- name: UpsertWeightProfileDefaults :exec +-- Boot reconcile: insert the shipped defaults for a profile if the row +-- doesn't exist yet. Never overwrites operator-tuned values. +INSERT INTO recommendation_weight_profiles ( + profile, base_weight, like_boost, recency_weight, skip_penalty, + jitter_magnitude, context_weight, similarity_weight, taste_weight +) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) +ON CONFLICT (profile) DO NOTHING; + +-- name: ListWeightProfiles :many +SELECT * FROM recommendation_weight_profiles ORDER BY profile; + +-- name: UpdateWeightProfile :one +UPDATE recommendation_weight_profiles + SET base_weight = $2, + like_boost = $3, + recency_weight = $4, + skip_penalty = $5, + jitter_magnitude = $6, + context_weight = $7, + similarity_weight = $8, + taste_weight = $9, + updated_at = now() + WHERE profile = $1 +RETURNING *; + +-- name: UpsertTasteTuningDefaults :exec +INSERT INTO taste_tuning ( + singleton, half_life_days, engagement_hard_skip, + engagement_neutral, engagement_full +) VALUES (true, $1, $2, $3, $4) +ON CONFLICT (singleton) DO NOTHING; + +-- name: GetTasteTuning :one +SELECT * FROM taste_tuning WHERE singleton = true; + +-- name: UpdateTasteTuning :one +UPDATE taste_tuning + SET half_life_days = $1, + engagement_hard_skip = $2, + engagement_neutral = $3, + engagement_full = $4, + updated_at = now() + WHERE singleton = true +RETURNING *; + +-- name: InsertTuningAudit :exec +-- changes is a jsonb array of {field, old, new} objects. +INSERT INTO recommendation_tuning_audit (scope, action, changes) +VALUES ($1, $2, $3); + +-- name: ListTuningAudit :many +-- Newest first; consumed by the metrics trend view (#1251) to annotate +-- knob turns on the timeline. +SELECT id, changed_at, scope, action, changes + FROM recommendation_tuning_audit + ORDER BY changed_at DESC, id DESC + LIMIT $1; diff --git a/internal/dbtest/reset.go b/internal/dbtest/reset.go index eba016c9..b8c1465a 100644 --- a/internal/dbtest/reset.go +++ b/internal/dbtest/reset.go @@ -62,6 +62,11 @@ var dataTables = []string{ // (never recreates the singleton, seeded once by 0018); ResetDB // resets its counter via UPDATE below instead. "cover_art_provider_settings", + // recsettings.New reconciles shipped defaults on every construction + // (#1250), so truncating gives each test pristine tuning values. + "recommendation_weight_profiles", + "taste_tuning", + "recommendation_tuning_audit", "tracks", "albums", "artists", diff --git a/internal/playlists/scheduler.go b/internal/playlists/scheduler.go index 97ab098f..c68d2da7 100644 --- a/internal/playlists/scheduler.go +++ b/internal/playlists/scheduler.go @@ -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) } diff --git a/internal/playlists/system.go b/internal/playlists/system.go index 3b68bc45..5103c14f 100644 --- a/internal/playlists/system.go +++ b/internal/playlists/system.go @@ -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, } } diff --git a/internal/playlists/you_might_like.go b/internal/playlists/you_might_like.go index 8d94cc77..27ccaf58 100644 --- a/internal/playlists/you_might_like.go +++ b/internal/playlists/you_might_like.go @@ -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 diff --git a/internal/recsettings/patch.go b/internal/recsettings/patch.go new file mode 100644 index 00000000..e3c78ab6 --- /dev/null +++ b/internal/recsettings/patch.go @@ -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 +} diff --git a/internal/recsettings/service.go b/internal/recsettings/service.go new file mode 100644 index 00000000..4a1a0b0b --- /dev/null +++ b/internal/recsettings/service.go @@ -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, + } +} diff --git a/internal/recsettings/service_test.go b/internal/recsettings/service_test.go new file mode 100644 index 00000000..65019ff3 --- /dev/null +++ b/internal/recsettings/service_test.go @@ -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)) + } +} diff --git a/internal/server/server.go b/internal/server/server.go index 248340c1..05b3cb65 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -27,6 +27,7 @@ import ( "git.fabledsword.com/bvandeusen/minstrel/internal/mailer" "git.fabledsword.com/bvandeusen/minstrel/internal/playevents" "git.fabledsword.com/bvandeusen/minstrel/internal/playlists" + "git.fabledsword.com/bvandeusen/minstrel/internal/recsettings" "git.fabledsword.com/bvandeusen/minstrel/internal/subsonic" "git.fabledsword.com/bvandeusen/minstrel/internal/tracks" "git.fabledsword.com/bvandeusen/minstrel/web" @@ -88,6 +89,13 @@ type Server struct { // PUT /api/me/timezone and POST /api/auth/register can call // Refresh synchronously. PlaylistScheduler *playlists.Scheduler + // RecSettings is the DB-backed recommendation tuning lab (#1250): + // scoring-weight profiles + taste-build knobs. Constructed in + // cmd/minstrel/main.go (it pushes daily-mix weights into package + // playlists at boot); the API layer reads radio weights per request + // and serves the admin tuning endpoints from it. Router() constructs + // a fallback when nil (tests). + RecSettings *recsettings.Service // StreamSecret is the HMAC key used by /api/cast/stream-token to // mint signed UPnP / Sonos stream URLs and by /api/tracks/{id}/stream // to verify them. Sourced from config.Config.StreamSecret. Tests that @@ -143,7 +151,18 @@ func (s *Server) Router() http.Handler { if bus == nil { bus = eventbus.New() } - api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.LibraryScanner, s.ScanCfg, s.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret) + recSettings := s.RecSettings + if recSettings == nil { + // Test contexts construct Server directly without main.go's + // boot wiring; reconcile here so radio + the admin tuning + // endpoints work against the same pool. + var err error + recSettings, err = recsettings.New(context.Background(), s.Pool, s.Logger) + if err != nil { + s.Logger.Error("server: recsettings boot failed", "err", err) + } + } + api.Mount(r, s.Pool, s.Logger, writer, s.RecommendationCfg, recSettings, lidarrCfg, lidarrReqs, lidarrQuar, tracksSvc, playlistsSvc, s.CoverEnricher, s.CoverSettings, s.LibraryScanner, s.ScanCfg, s.DataDir, smtpSender, bus, s.PlaylistScheduler, s.StreamSecret) // /api/admin/scan is the only admin route owned by the server package // (it needs the Scanner). Register it as a single inline-middleware // route — using r.Route("/api/admin", ...) here would create a second diff --git a/web/src/lib/api/tuning.ts b/web/src/lib/api/tuning.ts new file mode 100644 index 00000000..cc7cea2d --- /dev/null +++ b/web/src/lib/api/tuning.ts @@ -0,0 +1,49 @@ +import { api } from './client'; + +// Mirrors internal/api/admin_recommendation_tuning.go (#1250): the +// recommendation tuning lab. Field names are the server's snake_case +// keys, used verbatim in PATCH bodies. + +export type WeightProfile = { + base_weight: number; + like_boost: number; + recency_weight: number; + skip_penalty: number; + jitter_magnitude: number; + context_weight: number; + similarity_weight: number; + taste_weight: number; +}; + +export type TasteTuning = { + half_life_days: number; + engagement_hard_skip: number; + engagement_neutral: number; + engagement_full: number; +}; + +export type TuningScope = 'radio' | 'daily_mix' | 'taste'; + +export type TuningSnapshot = { + profiles: Record<'radio' | 'daily_mix', WeightProfile>; + taste: TasteTuning; + shipped: { + profiles: Record<'radio' | 'daily_mix', WeightProfile>; + taste: TasteTuning; + }; +}; + +export function getTuning(): Promise { + return api.get('/api/admin/recommendation-tuning'); +} + +export function patchTuning( + scope: TuningScope, + values: Record +): Promise { + return api.patch(`/api/admin/recommendation-tuning/${scope}`, { values }); +} + +export function resetTuning(scope: TuningScope): Promise { + return api.post(`/api/admin/recommendation-tuning/${scope}/reset`, {}); +} diff --git a/web/src/lib/components/AdminTabs.svelte b/web/src/lib/components/AdminTabs.svelte index f7acbabd..08a21264 100644 --- a/web/src/lib/components/AdminTabs.svelte +++ b/web/src/lib/components/AdminTabs.svelte @@ -10,6 +10,7 @@ { href: '/admin/quarantine', label: 'Quarantine' }, { href: '/admin/playback-errors', label: 'Playback errors' }, { href: '/admin/diagnostics', label: 'Diagnostics' }, + { href: '/admin/tuning', label: 'Tuning' }, { href: '/admin/users', label: 'Users' } ]; diff --git a/web/src/lib/components/AdminTabs.test.ts b/web/src/lib/components/AdminTabs.test.ts index 2163c178..171b4d3e 100644 --- a/web/src/lib/components/AdminTabs.test.ts +++ b/web/src/lib/components/AdminTabs.test.ts @@ -52,7 +52,7 @@ describe('AdminTabs', () => { ); }); - test('renders all seven tabs in order', () => { + test('renders all eight tabs in order', () => { state.pageUrl = new URL('http://localhost/admin'); render(AdminTabs); const links = screen.getAllByRole('link'); @@ -63,6 +63,7 @@ describe('AdminTabs', () => { 'Quarantine', 'Playback errors', 'Diagnostics', + 'Tuning', 'Users' ]); }); diff --git a/web/src/routes/admin/tuning/+page.svelte b/web/src/routes/admin/tuning/+page.svelte new file mode 100644 index 00000000..7569ce71 --- /dev/null +++ b/web/src/routes/admin/tuning/+page.svelte @@ -0,0 +1,250 @@ + + + + {pageTitle('Tuning')} + + +
+
+

Recommendation tuning

+

+ The defaults-discovery lab. Changes apply live — radio on the next request, daily mixes on + the next rebuild — and every change is recorded so the metrics page can tie outcome shifts + to knob turns. Found-good values get baked into shipped defaults; a dot marks knobs that + currently deviate from them. +

+
+ + {#if loadFailed} +

Couldn't load tuning settings.

+ {:else if !snapshot} +

Loading…

+ {:else} +
+ {#each profileScopes as p (p.scope)} +
+
+

{p.label}

+

{p.blurb}

+
+
+ {#each weightFields as f (f.key)} +
+ + +
+ {/each} +
+
+ + +
+
+ {/each} +
+ +
+
+

Taste profile build

+

+ How plays become the learned taste profile: the influence half-life and the + completion→engagement curve (must stay ordered: hard-skip < neutral < full). +

+
+
+ {#each tasteFields as f (f.key)} +
+ + +
+ {/each} +
+
+ + +
+
+ {/if} +
diff --git a/web/src/routes/admin/tuning/tuning.test.ts b/web/src/routes/admin/tuning/tuning.test.ts new file mode 100644 index 00000000..52456b68 --- /dev/null +++ b/web/src/routes/admin/tuning/tuning.test.ts @@ -0,0 +1,114 @@ +import { describe, expect, test, vi, beforeEach } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; +import type { TuningSnapshot } from '$lib/api/tuning'; + +vi.mock('$lib/api/tuning', async (orig) => { + const actual = (await orig()) as Record; + return { + ...actual, + getTuning: vi.fn(), + patchTuning: vi.fn(), + resetTuning: vi.fn() + }; +}); + +const pushToast = vi.fn(); +vi.mock('$lib/stores/toast.svelte', () => ({ pushToast: (...a: unknown[]) => pushToast(...a) })); + +import TuningPage from './+page.svelte'; +import { getTuning, patchTuning, resetTuning } from '$lib/api/tuning'; + +const weights = (over: Partial> = {}) => ({ + base_weight: 1, + like_boost: 2, + recency_weight: 1, + skip_penalty: 2, + jitter_magnitude: 0.1, + context_weight: 0.5, + similarity_weight: 1.5, + taste_weight: 1.5, + ...over +}); + +const taste = (over: Partial> = {}) => ({ + half_life_days: 75, + engagement_hard_skip: 0.05, + engagement_neutral: 0.3, + engagement_full: 0.9, + ...over +}); + +function snapshot(over: Partial = {}): TuningSnapshot { + return { + profiles: { radio: weights({ taste_weight: 1 }), daily_mix: weights() }, + taste: taste(), + shipped: { + profiles: { radio: weights({ taste_weight: 1 }), daily_mix: weights() }, + taste: taste() + }, + ...over + } as TuningSnapshot; +} + +beforeEach(() => { + vi.clearAllMocks(); +}); + +describe('Admin tuning page', () => { + test('renders both profiles and the taste card with current values', async () => { + (getTuning as ReturnType).mockResolvedValue(snapshot()); + render(TuningPage); + await waitFor(() => expect(screen.getByText('Radio')).toBeInTheDocument()); + expect(screen.getByText('Daily mixes')).toBeInTheDocument(); + expect(screen.getByText('Taste profile build')).toBeInTheDocument(); + const radioTaste = screen.getByLabelText(/taste weight/i, { + selector: '#radio-taste_weight' + }) as HTMLInputElement; + expect(radioTaste.value).toBe('1'); + const halfLife = document.getElementById('taste-half_life_days') as HTMLInputElement; + expect(halfLife.value).toBe('75'); + }); + + test('save sends only the changed fields for the scope', async () => { + (getTuning as ReturnType).mockResolvedValue(snapshot()); + (patchTuning as ReturnType).mockResolvedValue(snapshot()); + render(TuningPage); + await waitFor(() => expect(screen.getByText('Radio')).toBeInTheDocument()); + const input = document.getElementById('radio-taste_weight') as HTMLInputElement; + await fireEvent.input(input, { target: { value: '2.5' } }); + await fireEvent.click(screen.getByRole('button', { name: /save radio/i })); + await waitFor(() => + expect(patchTuning).toHaveBeenCalledWith('radio', { taste_weight: 2.5 }) + ); + }); + + test('save with no changes calls nothing and says so', async () => { + (getTuning as ReturnType).mockResolvedValue(snapshot()); + render(TuningPage); + await waitFor(() => expect(screen.getByText('Radio')).toBeInTheDocument()); + await fireEvent.click(screen.getByRole('button', { name: /save radio/i })); + expect(patchTuning).not.toHaveBeenCalled(); + await waitFor(() => expect(pushToast).toHaveBeenCalledWith('Nothing changed.')); + }); + + test('reset calls resetTuning for the scope', async () => { + (getTuning as ReturnType).mockResolvedValue(snapshot()); + (resetTuning as ReturnType).mockResolvedValue(snapshot()); + render(TuningPage); + await waitFor(() => expect(screen.getByText('Taste profile build')).toBeInTheDocument()); + const resetButtons = screen.getAllByRole('button', { name: /reset to defaults/i }); + await fireEvent.click(resetButtons[resetButtons.length - 1]); + await waitFor(() => expect(resetTuning).toHaveBeenCalledWith('taste')); + }); + + test('marks knobs that deviate from shipped defaults', async () => { + const snap = snapshot(); + snap.profiles.daily_mix = weights({ similarity_weight: 4 }); + (getTuning as ReturnType).mockResolvedValue(snap); + render(TuningPage); + await waitFor(() => expect(screen.getByText('Daily mixes')).toBeInTheDocument()); + expect( + screen.getByTitle(/deviates from the shipped default \(1\.5\)/i) + ).toBeInTheDocument(); + }); +});