// 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 }