153 lines
4.5 KiB
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)
|
|
}
|