Files
doormile_backend/internal/ai/registry/thresholds.go

153 lines
4.5 KiB
Go

package registry
import (
"encoding/json"
"fmt"
"math"
"sort"
"strings"
)
// ThresholdSpec is one tunable number on a skill: its default and the range an
// operator may move it within. The ranges are the product's safety rails — a
// stall timeout of 0 minutes or a cash cap of ₹10 lakh is a typo, not a policy.
type ThresholdSpec struct {
Key string `json:"key"`
Label string `json:"label"`
Unit string `json:"unit,omitempty"`
Default float64 `json:"default"`
Min float64 `json:"min"`
Max float64 `json:"max"`
Step float64 `json:"step"`
}
// onStep reports whether v sits on the spec's step grid, measured from Min.
// Tolerant of float noise: 0.7 must pass a 0.05 step starting at 0.5.
func (s ThresholdSpec) onStep(v float64) bool {
if s.Step <= 0 {
return true
}
n := (v - s.Min) / s.Step
return math.Abs(n-math.Round(n)) < 1e-6
}
func (s ThresholdSpec) check(v float64) error {
if math.IsNaN(v) || math.IsInf(v, 0) {
return fmt.Errorf("%s must be a number", s.Key)
}
if v < s.Min || v > s.Max {
return fmt.Errorf("%s must be between %s and %s", s.Key, fmtNum(s.Min), fmtNum(s.Max))
}
if !s.onStep(v) {
return fmt.Errorf("%s must move in steps of %s from %s", s.Key, fmtNum(s.Step), fmtNum(s.Min))
}
return nil
}
func fmtNum(v float64) string {
return strings.TrimRight(strings.TrimRight(fmt.Sprintf("%.4f", v), "0"), ".")
}
// EffectiveThresholds is what a skill actually runs with: the stored value for
// every key the schema still defines and that is still in range, the default
// for everything else. Keys the schema no longer has are dropped. A schema
// change in code therefore never strands a skill on a value it can no longer
// validate, and never needs a data migration.
func EffectiveThresholds(schema []ThresholdSpec, stored map[string]float64) map[string]float64 {
out := make(map[string]float64, len(schema))
for _, s := range schema {
v, ok := stored[s.Key]
if !ok || s.check(v) != nil {
v = s.Default
}
out[s.Key] = v
}
return out
}
// ApplyThresholdPatch validates an operator's patch against the schema and
// returns the full resulting set. Unknown keys and non-numbers are refused, not
// ignored: a misspelt key silently doing nothing is exactly the failure an
// operator cannot see. All keys are checked before any error is returned, so
// the message lists every problem at once.
func ApplyThresholdPatch(schema []ThresholdSpec, current map[string]float64, patch map[string]any) (map[string]float64, error) {
byKey := make(map[string]ThresholdSpec, len(schema))
for _, s := range schema {
byKey[s.Key] = s
}
next := EffectiveThresholds(schema, current)
var problems []string
keys := make([]string, 0, len(patch))
for k := range patch {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
spec, known := byKey[k]
if !known {
problems = append(problems, fmt.Sprintf("%s is not a threshold of this skill", k))
continue
}
v, isNum := patch[k].(float64)
if !isNum {
problems = append(problems, fmt.Sprintf("%s must be a number", k))
continue
}
if err := spec.check(v); err != nil {
problems = append(problems, err.Error())
continue
}
next[k] = v
}
if len(problems) > 0 {
return nil, &ValidationError{Msg: strings.Join(problems, "; ")}
}
return next, nil
}
// ParseThresholds reads a stored jsonb thresholds document. Empty, null or
// malformed reads as "nothing stored", which EffectiveThresholds turns into
// the defaults — a bad row degrades to defaults rather than failing a read.
func ParseThresholds(raw string) map[string]float64 {
out := map[string]float64{}
if strings.TrimSpace(raw) == "" {
return out
}
_ = json.Unmarshal([]byte(raw), &out)
if out == nil {
out = map[string]float64{}
}
return out
}
// ParseSchema reads a stored thresholds schema. Malformed reads as no schema.
func ParseSchema(raw string) []ThresholdSpec {
var out []ThresholdSpec
if strings.TrimSpace(raw) == "" {
return []ThresholdSpec{}
}
if err := json.Unmarshal([]byte(raw), &out); err != nil || out == nil {
return []ThresholdSpec{}
}
return out
}
// DefaultThresholds is the value set a freshly seeded skill starts with.
func DefaultThresholds(schema []ThresholdSpec) map[string]float64 {
return EffectiveThresholds(schema, nil)
}
func mustJSON(v any) string {
b, err := json.Marshal(v)
if err != nil {
// Only ever called on values built in this package; a failure here is
// a programming error, and the seed tests exercise every one.
panic(err)
}
return string(b)
}