diff --git a/internal/controllogic/value.go b/internal/controllogic/value.go new file mode 100644 index 0000000..921743d --- /dev/null +++ b/internal/controllogic/value.go @@ -0,0 +1,156 @@ +// 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 + } +} diff --git a/internal/controllogic/value_test.go b/internal/controllogic/value_test.go new file mode 100644 index 0000000..5aae8be --- /dev/null +++ b/internal/controllogic/value_test.go @@ -0,0 +1,67 @@ +package controllogic + +import ( + "reflect" + "testing" +) + +func TestAsNumAsArr(t *testing.T) { + if n, err := asNum(3.0); err != nil || n != 3 { + t.Fatalf("asNum(3)=%v,%v", n, err) + } + if _, err := asNum([]Value{1.0}); err == nil { + t.Fatal("asNum(array) should error") + } + if a, err := asArr([]Value{1.0, 2.0}); err != nil || len(a) != 2 { + t.Fatalf("asArr=%v,%v", a, err) + } + if _, err := asArr(3.0); err == nil { + t.Fatal("asArr(number) should error") + } +} + +func TestIdxResolve(t *testing.T) { + if k, err := idxResolve(-1, 3); err != nil || k != 2 { + t.Fatalf("idx(-1,3)=%v,%v", k, err) + } + if _, err := idxResolve(3, 3); err == nil { + t.Fatal("idx(3,3) should be out of range") + } +} + +func TestNormalizeValue(t *testing.T) { + got := normalizeValue([]interface{}{1.0, true, []interface{}{2.0}}) + want := []Value{1.0, 1.0, []Value{2.0}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("normalize=%#v want %#v", got, want) + } + if normalizeValue(5) != Value(5.0) { + t.Fatalf("normalize(int) = %#v", normalizeValue(5)) + } +} + +func TestParseInitialArray(t *testing.T) { + fixed := parseInitialArray(StateVar{Type: "array", Sizing: "fixed", Capacity: 3, Initial: "[1,2]"}) + if !reflect.DeepEqual(fixed, []Value{1.0, 2.0, 0.0}) { + t.Fatalf("fixed init = %#v", fixed) + } + dyn := parseInitialArray(StateVar{Type: "array", Sizing: "dynamic", Initial: "[5,6,7]"}) + if !reflect.DeepEqual(dyn, []Value{5.0, 6.0, 7.0}) { + t.Fatalf("dynamic init = %#v", dyn) + } + empty := parseInitialArray(StateVar{Type: "array", Sizing: "dynamic", Initial: ""}) + if len(empty) != 0 { + t.Fatalf("empty init = %#v", empty) + } +} + +func TestApplySizing(t *testing.T) { + capped := applySizing([]Value{1.0, 2.0, 3.0, 4.0}, StateVar{Sizing: "capped", Capacity: 2}) + if !reflect.DeepEqual(capped, []Value{3.0, 4.0}) { + t.Fatalf("capped = %#v", capped) + } + fixed := applySizing([]Value{1.0}, StateVar{Sizing: "fixed", Capacity: 3}) + if !reflect.DeepEqual(fixed, []Value{1.0, 0.0, 0.0}) { + t.Fatalf("fixed = %#v", fixed) + } +}