// Value model for control-logic locals/expressions. A Value is either a scalar // (float64; booleans are 1/0) or an array ([]Value). This is the Go port of // web/src/lib/arraypolicy.ts (sizing) plus the asNum/asArr/idx narrowing from // web/src/lib/expr.ts. Pure, dependency-free. package controllogic import ( "encoding/json" "fmt" "strings" ) // Value is a scalar (float64) or an array ([]Value). type Value = any // ARRAY_MAX is the global hard cap on dynamic array length (drops oldest). const ARRAY_MAX = 1_000_000 // StateVar declares a graph-local variable. Mirrors web/src/lib/types.ts StateVar. type StateVar struct { Name string `json:"name"` Type string `json:"type,omitempty"` // number|bool|string|array (default number) Initial string `json:"initial"` // initial value, stored as a string Unit string `json:"unit,omitempty"` Low float64 `json:"low,omitempty"` High float64 `json:"high,omitempty"` Elem string `json:"elem,omitempty"` // array-only: number|bool|array Sizing string `json:"sizing,omitempty"` // array-only: dynamic|capped|fixed Capacity int `json:"capacity,omitempty"` // array-only } func asNum(v Value) (float64, error) { f, ok := v.(float64) if !ok { return 0, fmt.Errorf("expected a number, got an array") } return f, nil } func asArr(v Value) ([]Value, error) { a, ok := v.([]Value) if !ok { return nil, fmt.Errorf("expected an array, got a number") } return a, nil } // idxResolve resolves a possibly-negative index against length; range-checked. func idxResolve(i float64, length int) (int, error) { k := int(i) // truncates toward zero, matching Math.trunc if k < 0 { k = length + k } if k < 0 || k >= length { return 0, fmt.Errorf("index %v out of range (len %d)", i, length) } return k, nil } // normalizeValue coerces an arbitrary decoded value (e.g. from JSON: float64, // bool, []interface{}) into a canonical Value (float64 leaves, []Value arrays). func normalizeValue(v any) Value { switch t := v.(type) { case float64: return t case float32: return float64(t) case int: return float64(t) case int64: return float64(t) case bool: if t { return 1.0 } return 0.0 case []interface{}: out := make([]Value, len(t)) for i, e := range t { out[i] = normalizeValue(e) } return out default: return 0.0 } } func zeroFill(n int) []Value { if n < 0 { n = 0 } out := make([]Value, n) for i := range out { out[i] = 0.0 } return out } // parseInitialArray returns the starting contents of an array local. Mirrors // arraypolicy.ts parseInitialArray. func parseInitialArray(sv StateVar) []Value { cap := sv.Capacity raw := strings.TrimSpace(sv.Initial) var parsed []Value if raw != "" { var j interface{} if err := json.Unmarshal([]byte(raw), &j); err == nil { if arr, ok := j.([]interface{}); ok { parsed = normalizeValue(arr).([]Value) } } } if sv.Sizing == "fixed" { if parsed == nil { return zeroFill(cap) } out := make([]Value, 0, cap) for i := 0; i < len(parsed) && i < cap; i++ { out = append(out, parsed[i]) } for len(out) < cap { out = append(out, 0.0) } return out } if parsed == nil { return []Value{} } return parsed } // applySizing clamps arr to the declared sizing policy. Mirrors arraypolicy.ts. func applySizing(arr []Value, sv StateVar) []Value { cap := sv.Capacity switch sv.Sizing { case "fixed": out := make([]Value, 0, cap) for i := 0; i < len(arr) && i < cap; i++ { out = append(out, arr[i]) } for len(out) < cap { out = append(out, 0.0) } return out case "capped": if len(arr) > cap { return arr[len(arr)-cap:] } return arr default: if len(arr) > ARRAY_MAX { return arr[len(arr)-ARRAY_MAX:] } return arr } }