Files
minstrel/internal/recsettings/patch.go
T
bvandeusenandClaude Opus 5.5 d191a28217 docs: server comments say what the code does, not how it came to be (#6277)
Comment blocks in cmd/minstrel and 30 smaller internal packages, rewritten
to rule 32's standard with each citation read in context: task, milestone
and rule ids go, and so do the stories they were attached to ("used to",
"Before #N", measurements from one install, quotes, pointers to Fabled
Librarian's records and to docs/superpowers files that are not in the
repo). Where a story wrapped a real constraint, the constraint stays as a
present-tense fact.

Comments that had gone stale are corrected:
- taste.Config: the tuned values are overlaid by internal/recsettings;
  it is no longer "a later follow-up".
- StorageConfig: the data dir holds art, caches and the stream secret,
  not only playlist covers.
- the Subsonic album list and getUser comments describe what they do
  today rather than a milestone that would change it.

Checked: the non-comment token stream of every changed file matches
HEAD, and gofmt -l is empty.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-23 12:15:05 -04:00

256 lines
9.0 KiB
Go

// 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 },
},
"context_time_weight": {
get: func(w recommendation.ScoringWeights) float64 { return w.ContextTimeWeight },
set: func(w *recommendation.ScoringWeights, v float64) { w.ContextTimeWeight = 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
case "enriched_tag_scale":
target = &next.EnrichedTagScale
case "era_scale":
target = &next.EraScale
case "mood_scale":
target = &next.MoodScale
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
}
// Discover tuning bounds.
const (
// A tag-overlap weight above this stops being a boost and becomes the
// ranking — at 10, a perfect match multiplies similarity by 11, which lets
// tag agreement swamp the similarity signal entirely. The bound is for
// typos, not to constrain exploration; the multiplicative blend keeps even
// the maximum from reordering an untagged candidate.
tagOverlapWeightMax = 10.0
// Snooze duration: at least a day (anything less isn't a snooze, it's a
// flicker), at most a year — past that it's a permanent dismissal wearing a
// snooze's clothes, and suggestions have no permanent "never show" control.
snoozeDaysMin = 1.0
snoozeDaysMax = 365.0
)
// applyDiscoverPatch validates and applies a partial Discover update.
func applyDiscoverPatch(
current DiscoverTuning, patch map[string]float64,
) (DiscoverTuning, []fieldChange, error) {
next := current
var changes []fieldChange
for field, v := range patch {
var target *float64
switch field {
case "tag_overlap_weight":
if v < 0 || v > tagOverlapWeightMax {
return current, nil, fmt.Errorf("%w: %s = %v (must be in [0, %v])",
ErrOutOfRange, field, v, tagOverlapWeightMax)
}
target = &next.TagOverlapWeight
case "snooze_days":
if v < snoozeDaysMin || v > snoozeDaysMax {
return current, nil, fmt.Errorf("%w: %s = %v (must be in [%v, %v])",
ErrOutOfRange, field, v, snoozeDaysMin, snoozeDaysMax)
}
target = &next.SnoozeDays
default:
return current, nil, fmt.Errorf("%w: %q", ErrUnknownField, field)
}
if *target == v {
continue
}
changes = append(changes, fieldChange{Field: field, Old: *target, New: v})
*target = v
}
return next, changes, nil
}
// diffDiscover returns per-field changes from a to b (empty when equal).
func diffDiscover(a, b DiscoverTuning) []fieldChange {
var out []fieldChange
if a.TagOverlapWeight != b.TagOverlapWeight {
out = append(out, fieldChange{
Field: "tag_overlap_weight", Old: a.TagOverlapWeight, New: b.TagOverlapWeight,
})
}
if a.SnoozeDays != b.SnoozeDays {
out = append(out, fieldChange{
Field: "snooze_days", Old: a.SnoozeDays, New: b.SnoozeDays,
})
}
return out
}
// 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)
add("enriched_tag_scale", a.EnrichedTagScale, b.EnrichedTagScale)
add("era_scale", a.EraScale, b.EraScale)
add("mood_scale", a.MoodScale, b.MoodScale)
return out
}