The payoff slice. Until now a candidate's only claim on a slot was "some
artist you play is adjacent to it in a similarity graph" — a fact that says
nothing about whether the music sounds like anything you like. Now the
candidate's own folksonomy tags (cached by slice 5) are compared against
the user's taste-profile tags, so the deck ranks on taste and can say WHY.
The blend is MULTIPLICATIVE — score × (1 + weight × overlap) — and that
choice carries the whole safety argument:
- An untagged candidate has overlap 0, so its score is EXACTLY unchanged.
Tag coverage is permanently partial (#2376); it must cost a candidate
nothing, not sink it (rule #131).
- Nothing can leapfrog on tags alone. An additive term with a large
weight would let a near-zero-similarity artist outrank a strong match
for sharing one popular tag, which reads as noise.
- Weight 0 restores pure similarity order bit-for-bit, so the operator's
knob has a real off position.
overlap = Σ(shared) candWeight × normalizedTasteWeight ÷ Σ(all) candWeight.
Normalizing the taste side by the user's strongest tag makes the score
comparable across users (taste weights accumulate with listening, so a
heavy listener's raw numbers dwarf a new user's while meaning the same
thing). Dividing by the candidate's own mass makes it comparable across
candidates, so a densely-tagged artist can't win on tag count alone.
Applied to the whole over-fetched pool BEFORE selectSuggestions, so the
rotation and diversity rules operate on blended scores — boosting only the
twelve already chosen by similarity would leave the re-ranking undone.
A query failure is returned, NOT degraded past. Graceful degradation is
for expected absence (no taste profile, no cached tags) and both are
handled explicitly as empty inputs; swallowing a real error would hide a
broken DB behind a subtly worse ranking that nothing reports.
Migration 0051 adds a FOURTH tuning scope rather than columns on
taste_tuning, because snooze_days lives here too and a snooze must never
be read as taste signal (#2374) — filing it under 'taste' would put it one
careless join from the leak that design forbids. Expanding
recommendation_tuning_audit's CHECK is in the same migration per rule #36,
and a test asserts the audit row lands, which is what would catch its
absence.
snooze_days moves out of a Go constant onto the tuning card (rule #25),
closing the deferral from #2374.
Tag-overlap tests use deliberately SKEWED fixtures: an evenly-matching pool
cannot exercise a re-ranking, since every candidate gets the same
multiplier and the order is unchanged whether the blend works or not.
Admin UI + client attribution follow in this batch — rule #27.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
256 lines
9.0 KiB
Go
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, which is exactly the shape rule #101 rules out.
|
|
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
|
|
}
|