diff --git a/docs/superpowers/plans/2026-06-24-control-logic-arrays.md b/docs/superpowers/plans/2026-06-24-control-logic-arrays.md new file mode 100644 index 0000000..862c0a2 --- /dev/null +++ b/docs/superpowers/plans/2026-06-24-control-logic-arrays.md @@ -0,0 +1,2175 @@ +# Control-Logic Array + Scalar Local Variables Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Port the declared-local-variable feature (scalar AND array, with sizing +policies) from the client-side panel-logic engine to the server-side control-logic +engine (`internal/controllogic/`) and its editor (`ControlLogicEditor.tsx`). + +**Architecture:** The control-logic engine is currently `float64`-only: expressions +evaluate to `float64`, locals are an implicit `map[string]float64`, and there is no +declaration UI. This plan introduces a value union `Value = float64 | []Value` mirroring +the frontend's `ArrVal`, makes the expression evaluator value-polymorphic (array literals +`[a,b]`, postfix indexing `arr[i]`, array functions), adds `Graph.StateVars []StateVar` +declarations that initialise the locals map (with sizing policies), adds `action.array.*` +nodes, and adds a `LocalVars` declaration UI (reused from `LogicEditor.tsx`). Lua and the +debug overlay are adapted to carry array values without breaking their scalar paths. + +**Tech Stack:** Go 1.22+ (`internal/controllogic`), Preact 10 + TypeScript +(`ControlLogicEditor.tsx`), esbuild bundling (no npm). The frontend `web/src/lib/expr.ts` +and `web/src/lib/arraypolicy.ts` are the canonical port templates — the Go code must +match their semantics exactly. + +## Global Constraints + +- **Value model:** `Value` = `float64` (leaf, booleans as 1/0) OR `[]Value` (array). + Implemented in Go as `type Value = any` with leaves `float64` and arrays `[]Value`. +- **Sizing policies** (match `arraypolicy.ts` exactly): `dynamic` → cap at + `ARRAY_MAX = 1_000_000` dropping oldest; `capped` → keep ≤ `capacity` dropping oldest + (FIFO/ring); `fixed` → exactly `capacity` (truncate / zero-pad), never grow/shrink. +- **Negative indices** resolve relative to length (`idx(i, len)`); out-of-range is an + error caught by `safeEval` → NaN (scalar) or skipped (array node). +- **No npm / Node.** Frontend builds via `make frontend` (esbuild, strips types, NO + typecheck). Typecheck on demand: `cd web && npx tsc --noEmit -p tsconfig.json`. +- **Baseline tsc noise to ALWAYS ignore:** TS2604 Fragment, TS2322 'key'/RowProps + (TableWidget.tsx:210), TS7044 implicit-any 'e'. `` reminders are stale + mid-edit snapshots — verify with filtered tsc/grep. +- **Do NOT `git add web/dist`** (gitignored). Commit source only. +- **Control-logic graphs persist as JSON** (`internal/controllogic/store.go`, + `json.MarshalIndent`). A new `StateVars` field with a `json:"statevars,omitempty"` tag + round-trips automatically. +- **Scope note:** Control logic gets `action.array.push/set/remove/pop/clear` but NOT + `action.export` (CSV download is browser-only; the server engine has no browser). Lua + remains scalar-only (reading an array local from Lua yields NaN). +- Verify clean at the end: `make frontend`, `make backend`, `go build ./...`, + `go vet ./...`, `go test ./... -race`, `gofmt -l internal/`. + +--- + +### Task 1: Go value model + sizing helpers (`internal/controllogic/value.go`) + +Port `web/src/lib/arraypolicy.ts` plus the `asNum`/`asArr`/`idx` narrowing from +`web/src/lib/expr.ts` into a new Go file. This is pure, dependency-free, and fully +unit-testable in isolation — no engine wiring yet. + +**Files:** +- Create: `internal/controllogic/value.go` +- Test: `internal/controllogic/value_test.go` + +**Interfaces:** +- Produces (consumed by Tasks 3, 4, 5): + - `type Value = any` — leaf is `float64`, array is `[]Value`. + - `const ARRAY_MAX = 1_000_000` + - `type StateVar struct { Name, Type, Initial, Unit, Elem, Sizing string; Low, High float64; Capacity int }` with JSON tags. + - `func asNum(v Value) (float64, error)` — `float64` leaf, else error. + - `func asArr(v Value) ([]Value, error)` — `[]Value`, else error. + - `func normalizeValue(v any) Value` — coerce JSON-decoded `[]interface{}` / `float64` / `bool` / `int` into canonical (`float64` leaves, `[]Value` arrays). + - `func idxResolve(i float64, length int) (int, error)` — negative-relative, range-checked. + - `func parseInitialArray(sv StateVar) []Value` — initial contents (mirrors arraypolicy). + - `func applySizing(arr []Value, sv StateVar) []Value` — clamp to policy. + +- [ ] **Step 1: Write the failing test** — `internal/controllogic/value_test.go` + +```go +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) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/controllogic/ -run 'TestAsNum|TestIdx|TestNormalize|TestParseInitial|TestApplySizing'` +Expected: FAIL (undefined: asNum, asArr, idxResolve, normalizeValue, StateVar, Value, parseInitialArray, applySizing) + +- [ ] **Step 3: Write minimal implementation** — `internal/controllogic/value.go` + +```go +// 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 + case []Value: + 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 + } +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/controllogic/ -run 'TestAsNum|TestIdx|TestNormalize|TestParseInitial|TestApplySizing'` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/controllogic/value.go internal/controllogic/value_test.go +git commit -m "controllogic: add Value union + sizing helpers (port of arraypolicy.ts)" +``` + +--- + +### Task 2: `Graph.StateVars` + store round-trip + +Add the declarations field to the graph model and confirm it survives JSON persistence. +`store.go` serialises with `json.MarshalIndent` and unmarshals `[]Graph`, so the new +field round-trips with no store changes — this task adds the field and a guard test. + +**Files:** +- Modify: `internal/controllogic/model.go` (Graph struct) +- Test: `internal/controllogic/store_test.go` (add a test; create the file if absent) + +**Interfaces:** +- Consumes: `StateVar` (Task 1). +- Produces: `Graph.StateVars []StateVar` (consumed by Task 4 for locals init, Task 7 for the editor). + +- [ ] **Step 1: Write the failing test** + +Add to `internal/controllogic/store_test.go` (create the file with this package header if it does not exist): + +```go +package controllogic + +import "testing" + +func TestStoreRoundTripStateVars(t *testing.T) { + dir := t.TempDir() + st, err := NewStore(dir) // NewStore takes the storage DIRECTORY + if err != nil { + t.Fatal(err) + } + g := Graph{ + ID: "g1", + Name: "with-vars", + StateVars: []StateVar{ + {Name: "count", Type: "number", Initial: "0"}, + {Name: "buf", Type: "array", Initial: "[1,2]", Elem: "number", Sizing: "capped", Capacity: 5}, + }, + } + if err := st.Save(g); err != nil { // Save returns only error + t.Fatal(err) + } + st2, err := NewStore(dir) + if err != nil { + t.Fatal(err) + } + got, err := st2.Get("g1") // Get returns (Graph, error); ErrNotFound if absent + if err != nil { + t.Fatal(err) + } + if len(got.StateVars) != 2 || got.StateVars[1].Name != "buf" || got.StateVars[1].Capacity != 5 { + t.Fatalf("statevars not round-tripped: %#v", got.StateVars) + } +} +``` + +The store API is confirmed (`internal/controllogic/store.go`): `NewStore(storageDir string) +(*Store, error)`, `Save(g Graph) error`, `Get(id string) (Graph, error)` (returns +`ErrNotFound`). The new `StateVars` field round-trips via the existing `json.MarshalIndent` +in `saveLocked` with no store changes. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/controllogic/ -run TestStoreRoundTripStateVars` +Expected: FAIL — `Graph` has no field `StateVars`. + +- [ ] **Step 3: Write minimal implementation** + +In `internal/controllogic/model.go`, add to the `Graph` struct (after `Groups`): + +```go + // StateVars declares graph-local variables (scalar or array). Live values are + // instantiated in memory per generation from these declarations; only the + // declarations persist. Mirrors the panel-logic statevars feature. + StateVars []StateVar `json:"statevars,omitempty"` +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/controllogic/ -run TestStoreRoundTripStateVars` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/controllogic/model.go internal/controllogic/store_test.go +git commit -m "controllogic: add Graph.StateVars declarations (persisted via store JSON)" +``` + +--- + +### Task 3: Make `expr.go` value-polymorphic + +Rewrite `internal/controllogic/expr.go` to evaluate to `Value` (port of `expr.ts`): array +literals `[a,b]`, postfix indexing `arr[i]`, array functions, scalar/array function split, +`min`/`max` dual dispatch. `EvalExpr`/`EvalBool` keep their `float64`/`bool` returns +(scalar callers unaffected); a new `EvalValue` returns the full `Value`. The `Resolver` +type changes from `func(...) float64` to `func(...) Value` — this ripples into Task 4's +resolver closures and any test resolvers. + +**Files:** +- Rewrite: `internal/controllogic/expr.go` +- Test: `internal/controllogic/expr_test.go` (create or extend) + +**Interfaces:** +- Consumes: `Value`, `asNum`, `asArr`, `idxResolve` (Task 1). +- Produces (consumed by Tasks 4, 5): + - `type Resolver func(ds, name string) Value` + - `func EvalValue(src string, resolve Resolver) Value` + - `func EvalExpr(src string, resolve Resolver) float64` (NaN if parse fails OR result is an array) + - `func EvalBool(src string, resolve Resolver) bool` + - `func CollectRefs(src string) []RefLite` (now also walks `arr` + `index` nodes) + - `func CheckExpr(src string) string` + +- [ ] **Step 1: Write the failing test** — `internal/controllogic/expr_test.go` + +```go +package controllogic + +import ( + "math" + "reflect" + "testing" +) + +func numResolver(vals map[string]Value) Resolver { + return func(ds, name string) Value { + if v, ok := vals[ds+":"+name]; ok { + return v + } + return math.NaN() + } +} + +func TestEvalValueScalar(t *testing.T) { + R := numResolver(nil) + if got := EvalExpr("2 + 3 * 4", R); got != 14 { + t.Fatalf("scalar = %v", got) + } + if !EvalBool("1 < 2 && 3 >= 3", R) { + t.Fatal("bool expr should be true") + } +} + +func TestEvalValueArrayLiteralAndIndex(t *testing.T) { + R := numResolver(nil) + got := EvalValue("[1, 2, 3]", R) + if !reflect.DeepEqual(got, []Value{1.0, 2.0, 3.0}) { + t.Fatalf("array literal = %#v", got) + } + if v := EvalExpr("[10,20,30][-1]", R); v != 30 { + t.Fatalf("index -1 = %v", v) + } +} + +func TestEvalArrayFuncs(t *testing.T) { + R := numResolver(map[string]Value{"local:buf": []Value{3.0, 1.0, 2.0}}) + if v := EvalExpr("len(buf)", R); v != 3 { + t.Fatalf("len = %v", v) + } + if v := EvalExpr("sum(buf)", R); v != 6 { + t.Fatalf("sum = %v", v) + } + if v := EvalExpr("max(buf)", R); v != 3 { + t.Fatalf("max(array) = %v", v) + } + if v := EvalExpr("max(1, 9, 4)", R); v != 9 { + t.Fatalf("max(scalars) = %v", v) + } + got := EvalValue("push(buf, 7)", R) + if !reflect.DeepEqual(got, []Value{3.0, 1.0, 2.0, 7.0}) { + t.Fatalf("push = %#v", got) + } +} + +func TestEvalExprArrayYieldsNaN(t *testing.T) { + if v := EvalExpr("[1,2]", numResolver(nil)); !math.IsNaN(v) { + t.Fatalf("array via EvalExpr should be NaN, got %v", v) + } +} + +func TestCollectRefsArray(t *testing.T) { + refs := CollectRefs("[{ds:a}, b[0]] ") + keys := map[string]bool{} + for _, r := range refs { + keys[r.DS+":"+r.Name] = true + } + if !keys["ds:a"] || !keys["local:b"] { + t.Fatalf("refs = %#v", refs) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/controllogic/ -run 'TestEval|TestCollectRefsArray'` +Expected: FAIL (undefined `EvalValue`; `Resolver` returns float64 so `numResolver` won't compile; no array support). + +- [ ] **Step 3: Write minimal implementation** — replace the entire contents of `internal/controllogic/expr.go` + +```go +// Small, safe expression evaluator — a Go port of web/src/lib/expr.ts. +// +// Supports numbers, booleans (true/false → 1/0), arithmetic (+ - * / %), +// comparison (< <= > >= == !=), boolean (&& || !), ternary (a ? b : c), +// parentheses, array literals ([a, b, c]), postfix indexing (arr[i]), and a set +// of math + array functions. Two kinds of variable reference are resolved live: +// +// {ds:name} a data-source signal value (brace content split on FIRST ':'). +// bareIdent a graph-local state variable (data source "local"). +// +// Values are either a scalar (float64; booleans 1/0) or an array ([]Value). The +// evaluator never uses reflection or eval; it walks a parsed AST against a +// caller-supplied Resolver. +package controllogic + +import ( + "fmt" + "math" + "strconv" + "strings" + "sync" +) + +// Resolver returns the current value of a signal/local reference. +type Resolver func(ds, name string) Value + +// RefLite identifies one signal/local reference read by an expression. +type RefLite struct { + DS string + Name string +} + +// ── AST ────────────────────────────────────────────────────────────────────── + +type exprNode interface{ eval(R Resolver) Value } + +type numNode struct{ v float64 } +type sigNode struct{ ds, name string } +type varNode struct{ name string } +type arrNode struct{ items []exprNode } +type indexNode struct{ a, i exprNode } +type unNode struct { + op string + a exprNode +} +type binNode struct { + op string + a, b exprNode +} +type ternNode struct{ c, a, b exprNode } +type callNode struct { + fn string + args []exprNode +} + +func mustNum(v Value) float64 { + f, err := asNum(v) + if err != nil { + panic(err) + } + return f +} +func mustArr(v Value) []Value { + a, err := asArr(v) + if err != nil { + panic(err) + } + return a +} + +func (n numNode) eval(R Resolver) Value { return n.v } +func (n sigNode) eval(R Resolver) Value { return R(n.ds, n.name) } +func (n varNode) eval(R Resolver) Value { return R("local", n.name) } +func (n arrNode) eval(R Resolver) Value { + out := make([]Value, len(n.items)) + for i, it := range n.items { + out[i] = it.eval(R) + } + return out +} +func (n indexNode) eval(R Resolver) Value { + arr := mustArr(n.a.eval(R)) + k, err := idxResolve(mustNum(n.i.eval(R)), len(arr)) + if err != nil { + panic(err) + } + return arr[k] +} +func (n unNode) eval(R Resolver) Value { + if n.op == "-" { + return -mustNum(n.a.eval(R)) + } + if mustNum(n.a.eval(R)) == 0 { + return 1.0 + } + return 0.0 +} +func (n ternNode) eval(R Resolver) Value { + if mustNum(n.c.eval(R)) != 0 { + return n.a.eval(R) + } + return n.b.eval(R) +} +func (n callNode) eval(R Resolver) Value { + args := make([]Value, len(n.args)) + for i, a := range n.args { + args[i] = a.eval(R) + } + // min/max: scalar-variadic OR single-array form. + if n.fn == "min" || n.fn == "max" { + if len(args) == 1 { + if arr, ok := args[0].([]Value); ok { + return reduceMinMax(n.fn, arr) + } + } + nums := make([]Value, len(args)) + copy(nums, args) + return reduceMinMax(n.fn, nums) + } + if af, ok := arrFuncs[n.fn]; ok { + return af(args) + } + if sf, ok := scalarFuncs[n.fn]; ok { + nums := make([]float64, len(args)) + for i, a := range args { + nums[i] = mustNum(a) + } + return sf(nums) + } + panic(fmt.Errorf("unknown function %q", n.fn)) +} +func (n binNode) eval(R Resolver) Value { + a, b := mustNum(n.a.eval(R)), mustNum(n.b.eval(R)) + switch n.op { + case "+": + return a + b + case "-": + return a - b + case "*": + return a * b + case "/": + return a / b + case "%": + return math.Mod(a, b) + case "<": + return boolf(a < b) + case "<=": + return boolf(a <= b) + case ">": + return boolf(a > b) + case ">=": + return boolf(a >= b) + case "==": + return boolf(a == b) + case "!=": + return boolf(a != b) + case "&&": + return boolf(a != 0 && b != 0) + case "||": + return boolf(a != 0 || b != 0) + } + panic(fmt.Errorf("unknown operator %q", n.op)) +} + +func boolf(b bool) float64 { + if b { + return 1 + } + return 0 +} + +func reduceMinMax(fn string, arr []Value) Value { + if len(arr) == 0 { + if fn == "min" { + return math.Inf(1) + } + return math.Inf(-1) + } + m := mustNum(arr[0]) + for _, x := range arr[1:] { + v := mustNum(x) + if fn == "min" { + m = math.Min(m, v) + } else { + m = math.Max(m, v) + } + } + return m +} + +// ── Functions ──────────────────────────────────────────────────────────────── + +var scalarFuncs = map[string]func([]float64) float64{ + "abs": func(a []float64) float64 { return math.Abs(a[0]) }, + "sqrt": func(a []float64) float64 { return math.Sqrt(a[0]) }, + "floor": func(a []float64) float64 { return math.Floor(a[0]) }, + "ceil": func(a []float64) float64 { return math.Ceil(a[0]) }, + "round": func(a []float64) float64 { return math.Round(a[0]) }, + "sign": func(a []float64) float64 { return float64(signOf(a[0])) }, + "pow": func(a []float64) float64 { return math.Pow(a[0], a[1]) }, + "log": func(a []float64) float64 { return math.Log(a[0]) }, + "exp": func(a []float64) float64 { return math.Exp(a[0]) }, + "sin": func(a []float64) float64 { return math.Sin(a[0]) }, + "cos": func(a []float64) float64 { return math.Cos(a[0]) }, +} + +var arrFuncs = map[string]func([]Value) Value{ + "len": func(a []Value) Value { return float64(len(mustArr(a[0]))) }, + "sum": func(a []Value) Value { + s := 0.0 + for _, x := range mustArr(a[0]) { + s += mustNum(x) + } + return s + }, + "mean": func(a []Value) Value { + r := mustArr(a[0]) + if len(r) == 0 { + return 0.0 + } + s := 0.0 + for _, x := range r { + s += mustNum(x) + } + return s / float64(len(r)) + }, + "slice": func(a []Value) Value { + r := mustArr(a[0]) + s := 0 + e := len(r) + if len(a) > 1 { + s = clampIdx(int(mustNum(a[1])), len(r)) + } + if len(a) > 2 { + e = clampIdx(int(mustNum(a[2])), len(r)) + } + if s > e { + s = e + } + out := make([]Value, 0, e-s) + out = append(out, r[s:e]...) + return out + }, + "concat": func(a []Value) Value { return append(append([]Value{}, mustArr(a[0])...), mustArr(a[1])...) }, + "reverse": func(a []Value) Value { r := append([]Value{}, mustArr(a[0])...); reverse(r); return r }, + "sort": func(a []Value) Value { + r := append([]Value{}, mustArr(a[0])...) + sortNum(r) + return r + }, + "scale": func(a []Value) Value { + r := mustArr(a[0]) + k := mustNum(a[1]) + out := make([]Value, len(r)) + for i, x := range r { + out[i] = mustNum(x) * k + } + return out + }, + "add": func(a []Value) Value { return zipNum(mustArr(a[0]), mustArr(a[1]), func(x, y float64) float64 { return x + y }) }, + "sub": func(a []Value) Value { return zipNum(mustArr(a[0]), mustArr(a[1]), func(x, y float64) float64 { return x - y }) }, + "push": func(a []Value) Value { + return append(append([]Value{}, mustArr(a[0])...), a[1]) + }, + "set": func(a []Value) Value { + r := append([]Value{}, mustArr(a[0])...) + k, err := idxResolve(mustNum(a[1]), len(r)) + if err != nil { + panic(err) + } + r[k] = a[2] + return r + }, + "insert": func(a []Value) Value { + r := append([]Value{}, mustArr(a[0])...) + k := int(mustNum(a[1])) + if k < 0 { + k = 0 + } + if k > len(r) { + k = len(r) + } + r = append(r, nil) + copy(r[k+1:], r[k:]) + r[k] = a[2] + return r + }, + "remove": func(a []Value) Value { + r := append([]Value{}, mustArr(a[0])...) + k, err := idxResolve(mustNum(a[1]), len(r)) + if err != nil { + panic(err) + } + return append(r[:k], r[k+1:]...) + }, + "pop": func(a []Value) Value { + r := mustArr(a[0]) + if len(r) == 0 { + return []Value{} + } + return append([]Value{}, r[:len(r)-1]...) + }, + "shift": func(a []Value) Value { + r := mustArr(a[0]) + if len(r) == 0 { + return []Value{} + } + return append([]Value{}, r[1:]...) + }, + "indexOf": func(a []Value) Value { + r := mustArr(a[0]) + for i, x := range r { + if valEq(x, a[1]) { + return float64(i) + } + } + return -1.0 + }, + "contains": func(a []Value) Value { + r := mustArr(a[0]) + for _, x := range r { + if valEq(x, a[1]) { + return 1.0 + } + } + return 0.0 + }, + "fill": func(a []Value) Value { + n := int(mustNum(a[0])) + if n < 0 { + n = 0 + } + out := make([]Value, n) + for i := range out { + out[i] = a[1] + } + return out + }, +} + +func signOf(x float64) int { + switch { + case x > 0: + return 1 + case x < 0: + return -1 + default: + return 0 + } +} +func clampIdx(i, length int) int { + if i < 0 { + i = length + i + } + if i < 0 { + i = 0 + } + if i > length { + i = length + } + return i +} +func reverse(r []Value) { + for i, j := 0, len(r)-1; i < j; i, j = i+1, j-1 { + r[i], r[j] = r[j], r[i] + } +} +func sortNum(r []Value) { + for i := 1; i < len(r); i++ { + for j := i; j > 0 && mustNum(r[j-1]) > mustNum(r[j]); j-- { + r[j-1], r[j] = r[j], r[j-1] + } + } +} +func zipNum(x, y []Value, f func(a, b float64) float64) []Value { + n := len(x) + if len(y) < n { + n = len(y) + } + out := make([]Value, 0, n) + for i := 0; i < n; i++ { + out = append(out, f(mustNum(x[i]), mustNum(y[i]))) + } + return out +} +func valEq(a, b Value) bool { + af, aok := a.(float64) + bf, bok := b.(float64) + return aok && bok && af == bf +} + +// ── Tokenizer ──────────────────────────────────────────────────────────────── + +type tok struct { + k string + v string +} + +func tokenize(src string) ([]tok, error) { + var toks []tok + two := map[string]bool{"<=": true, ">=": true, "==": true, "!=": true, "&&": true, "||": true} + r := []rune(src) + i := 0 + for i < len(r) { + c := r[i] + switch { + case c == ' ' || c == '\t' || c == '\n' || c == '\r': + i++ + continue + case c == '{': + end := -1 + for j := i + 1; j < len(r); j++ { + if r[j] == '}' { + end = j + break + } + } + if end < 0 { + return nil, fmt.Errorf("unterminated { in expression") + } + toks = append(toks, tok{k: "sig", v: string(r[i+1 : end])}) + i = end + 1 + continue + } + if isDigit(c) || (c == '.' && i+1 < len(r) && isDigit(r[i+1])) { + j := i + 1 + for j < len(r) && (isDigit(r[j]) || r[j] == '.') { + j++ + } + toks = append(toks, tok{k: "num", v: string(r[i:j])}) + i = j + continue + } + if isIdentStart(c) { + j := i + 1 + for j < len(r) && isIdentPart(r[j]) { + j++ + } + toks = append(toks, tok{k: "ident", v: string(r[i:j])}) + i = j + continue + } + if i+1 < len(r) { + pair := string(r[i : i+2]) + if two[pair] { + toks = append(toks, tok{k: pair}) + i += 2 + continue + } + } + if strings.ContainsRune("+-*/%<>!()?:,[]", c) { + toks = append(toks, tok{k: string(c)}) + i++ + continue + } + return nil, fmt.Errorf("unexpected character %q in expression", string(c)) + } + return toks, nil +} + +func isDigit(c rune) bool { return c >= '0' && c <= '9' } +func isIdentStart(c rune) bool { return c == '_' || (c >= 'A' && c <= 'Z') || (c >= 'a' && c <= 'z') } +func isIdentPart(c rune) bool { return isIdentStart(c) || isDigit(c) } + +// ── Parser (recursive descent) ──────────────────────────────────────────────── + +type parser struct { + toks []tok + p int +} + +func (ps *parser) peek() (tok, bool) { + if ps.p < len(ps.toks) { + return ps.toks[ps.p], true + } + return tok{}, false +} + +func (ps *parser) eat(k string) (tok, error) { + if ps.p >= len(ps.toks) { + return tok{}, fmt.Errorf("unexpected end of expression") + } + t := ps.toks[ps.p] + if k != "" && t.k != k { + return tok{}, fmt.Errorf("expected %q in expression", k) + } + ps.p++ + return t, nil +} + +func parse(src string) (exprNode, error) { + toks, err := tokenize(src) + if err != nil { + return nil, err + } + ps := &parser{toks: toks} + root, err := ps.ternary() + if err != nil { + return nil, err + } + if ps.p < len(ps.toks) { + return nil, fmt.Errorf("trailing tokens in expression") + } + return root, nil +} + +func (ps *parser) atom() (exprNode, error) { + t, ok := ps.peek() + if !ok { + return nil, fmt.Errorf("unexpected end of expression") + } + switch t.k { + case "num": + ps.eat("") + v, err := strconv.ParseFloat(t.v, 64) + if err != nil { + return nil, fmt.Errorf("bad number %q", t.v) + } + return numNode{v: v}, nil + case "[": + ps.eat("[") + var items []exprNode + if nx, ok := ps.peek(); ok && nx.k != "]" { + a, err := ps.ternary() + if err != nil { + return nil, err + } + items = append(items, a) + for { + nx2, ok := ps.peek() + if !ok || nx2.k != "," { + break + } + ps.eat(",") + a, err := ps.ternary() + if err != nil { + return nil, err + } + items = append(items, a) + } + } + if _, err := ps.eat("]"); err != nil { + return nil, err + } + return arrNode{items: items}, nil + case "sig": + ps.eat("") + idx := strings.IndexByte(t.v, ':') + if idx < 0 { + return sigNode{ds: t.v, name: ""}, nil + } + return sigNode{ds: t.v[:idx], name: t.v[idx+1:]}, nil + case "ident": + ps.eat("") + id := t.v + if id == "true" { + return numNode{v: 1}, nil + } + if id == "false" { + return numNode{v: 0}, nil + } + if nx, ok := ps.peek(); ok && nx.k == "(" { + ps.eat("(") + var args []exprNode + if nx2, ok := ps.peek(); ok && nx2.k != ")" { + a, err := ps.ternary() + if err != nil { + return nil, err + } + args = append(args, a) + for { + nx3, ok := ps.peek() + if !ok || nx3.k != "," { + break + } + ps.eat(",") + a, err := ps.ternary() + if err != nil { + return nil, err + } + args = append(args, a) + } + } + if _, err := ps.eat(")"); err != nil { + return nil, err + } + if !knownFunc(id) { + return nil, fmt.Errorf("unknown function %q", id) + } + return callNode{fn: id, args: args}, nil + } + return varNode{name: id}, nil + case "(": + ps.eat("(") + e, err := ps.ternary() + if err != nil { + return nil, err + } + if _, err := ps.eat(")"); err != nil { + return nil, err + } + return e, nil + } + return nil, fmt.Errorf("unexpected token %q in expression", t.k) +} + +func knownFunc(id string) bool { + if id == "min" || id == "max" { + return true + } + if _, ok := arrFuncs[id]; ok { + return true + } + _, ok := scalarFuncs[id] + return ok +} + +func (ps *parser) primary() (exprNode, error) { + n, err := ps.atom() + if err != nil { + return nil, err + } + for { + nx, ok := ps.peek() + if !ok || nx.k != "[" { + return n, nil + } + ps.eat("[") + i, err := ps.ternary() + if err != nil { + return nil, err + } + if _, err := ps.eat("]"); err != nil { + return nil, err + } + n = indexNode{a: n, i: i} + } +} + +func (ps *parser) unary() (exprNode, error) { + if t, ok := ps.peek(); ok && (t.k == "-" || t.k == "!") { + ps.eat("") + a, err := ps.unary() + if err != nil { + return nil, err + } + return unNode{op: t.k, a: a}, nil + } + return ps.primary() +} + +func (ps *parser) binLevel(next func() (exprNode, error), ops ...string) (exprNode, error) { + a, err := next() + if err != nil { + return nil, err + } + for { + t, ok := ps.peek() + if !ok || !contains(ops, t.k) { + return a, nil + } + op, _ := ps.eat("") + b, err := next() + if err != nil { + return nil, err + } + a = binNode{op: op.k, a: a, b: b} + } +} + +func (ps *parser) mul() (exprNode, error) { return ps.binLevel(ps.unary, "*", "/", "%") } +func (ps *parser) add() (exprNode, error) { return ps.binLevel(ps.mul, "+", "-") } +func (ps *parser) cmp() (exprNode, error) { return ps.binLevel(ps.add, "<", "<=", ">", ">=") } +func (ps *parser) eq() (exprNode, error) { return ps.binLevel(ps.cmp, "==", "!=") } +func (ps *parser) and() (exprNode, error) { return ps.binLevel(ps.eq, "&&") } +func (ps *parser) or() (exprNode, error) { return ps.binLevel(ps.and, "||") } + +func (ps *parser) ternary() (exprNode, error) { + c, err := ps.or() + if err != nil { + return nil, err + } + if t, ok := ps.peek(); ok && t.k == "?" { + ps.eat("?") + a, err := ps.ternary() + if err != nil { + return nil, err + } + if _, err := ps.eat(":"); err != nil { + return nil, err + } + b, err := ps.ternary() + if err != nil { + return nil, err + } + return ternNode{c: c, a: a, b: b}, nil + } + return c, nil +} + +func contains(s []string, v string) bool { + for _, x := range s { + if x == v { + return true + } + } + return false +} + +// ── Cache + public API ───────────────────────────────────────────────────────── + +type cacheEntry struct { + node exprNode + err error +} + +var ( + cacheMu sync.Mutex + cache = map[string]cacheEntry{} +) + +func parseCached(src string) (exprNode, error) { + cacheMu.Lock() + e, ok := cache[src] + cacheMu.Unlock() + if ok { + return e.node, e.err + } + n, err := parse(src) + cacheMu.Lock() + cache[src] = cacheEntry{node: n, err: err} + cacheMu.Unlock() + return n, err +} + +// EvalValue evaluates an expression, returning the full Value (number or array). +// Returns NaN on parse/eval failure. +func EvalValue(src string, resolve Resolver) Value { + n, err := parseCached(src) + if err != nil { + return math.NaN() + } + return safeEval(n, resolve) +} + +func safeEval(n exprNode, resolve Resolver) (out Value) { + defer func() { + if recover() != nil { + out = math.NaN() + } + }() + return n.eval(resolve) +} + +// EvalExpr evaluates an expression to a scalar; returns NaN on parse/eval +// failure OR when the result is an array. +func EvalExpr(src string, resolve Resolver) float64 { + v := EvalValue(src, resolve) + if f, ok := v.(float64); ok { + return f + } + return math.NaN() +} + +// EvalBool reports whether the expression evaluates to a nonzero, non-NaN scalar. +func EvalBool(src string, resolve Resolver) bool { + v := EvalExpr(src, resolve) + return !math.IsNaN(v) && v != 0 +} + +// CollectRefs returns every signal/local reference an expression reads. +func CollectRefs(src string) []RefLite { + root, err := parseCached(src) + if err != nil { + return nil + } + var out []RefLite + seen := map[string]bool{} + add := func(ds, name string) { + k := ds + "\x00" + name + if !seen[k] { + seen[k] = true + out = append(out, RefLite{DS: ds, Name: name}) + } + } + var walk func(n exprNode) + walk = func(n exprNode) { + switch t := n.(type) { + case sigNode: + add(t.ds, t.name) + case varNode: + add("local", t.name) + case arrNode: + for _, it := range t.items { + walk(it) + } + case indexNode: + walk(t.a) + walk(t.i) + case unNode: + walk(t.a) + case binNode: + walk(t.a) + walk(t.b) + case ternNode: + walk(t.c) + walk(t.a) + walk(t.b) + case callNode: + for _, a := range t.args { + walk(a) + } + } + } + walk(root) + return out +} + +// CheckExpr validates an expression; returns an error message or "" if it parses. +func CheckExpr(src string) string { + if strings.TrimSpace(src) == "" { + return "" + } + if _, err := parse(src); err != nil { + return err.Error() + } + return "" +} +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/controllogic/ -run 'TestEval|TestCollectRefsArray'` +Expected: PASS. Then run the FULL package to surface Resolver-signature breakage in +existing files: `go build ./internal/controllogic/` — expect compile errors in +`engine.go`/`lua.go`/`debug.go` (their resolver closures return `float64`). Those are +fixed in Tasks 4 and 6; if the build must stay green between tasks, the implementer may +temporarily adapt the closures with a `Value`-returning wrapper, but the real fix lands +in Task 4. **Record any such temporary shim in the report so Task 4 removes it.** + +- [ ] **Step 5: Commit** + +```bash +git add internal/controllogic/expr.go internal/controllogic/expr_test.go +git commit -m "controllogic: make expr evaluator value-polymorphic (arrays, indexing, array funcs)" +``` + +--- + +**END OF PART 1 OF THE PLAN (Tasks 1-3, backend value+expr foundation).** +Tasks 4-8 (engine locals+resolver, array action nodes, lua/debug adaptation, frontend +editor, tests+docs) are specified in the continuation appended below. + +--- + +### Task 4: Engine locals as `Value` + init from declarations + value-aware resolver/write + +Make the running engine carry array-capable locals: change `compiledGraph.locals` to +`map[string]Value`, add a `decls map[string]StateVar` built from `g.StateVars`, initialise +locals from declarations at `compile`, make `setLocal` apply sizing, make the `resolve` +closure return `Value`, and make `write` value-aware (local target → sized setLocal; +`ds:name` target → scalar `Source.Write`, arrays rejected). Update `action.write` to use +`EvalValue`. This removes any temporary Resolver shim introduced in Task 3. + +**Files:** +- Modify: `internal/controllogic/engine.go` +- Test: `internal/controllogic/engine_test.go` (create or extend) + +**Interfaces:** +- Consumes: `Value`, `StateVar`, `parseInitialArray`, `applySizing` (Task 1); `Resolver`, + `EvalValue`, `EvalExpr`, `EvalBool`, `CollectRefs` (Task 3); `Graph.StateVars` (Task 2). +- Produces: a `compiledGraph` whose `locals map[string]Value` is initialised from declared + statevars; `getLocal(name) Value`; `setLocal(name string, v Value)` (sizing-aware); + `Engine.write(cg, target string, val Value)`. + +- [ ] **Step 1: Write the failing test** — `internal/controllogic/engine_test.go` + +```go +package controllogic + +import ( + "reflect" + "testing" +) + +func TestCompileInitsLocalsFromDecls(t *testing.T) { + g := Graph{ + ID: "g", Name: "n", + StateVars: []StateVar{ + {Name: "count", Type: "number", Initial: "7"}, + {Name: "flag", Type: "bool", Initial: "true"}, + {Name: "buf", Type: "array", Initial: "[1,2,3]", Sizing: "capped", Capacity: 4}, + }, + } + cg := compile(g) + if got := cg.getLocal("count"); got != Value(7.0) { + t.Fatalf("count = %#v", got) + } + if got := cg.getLocal("flag"); got != Value(1.0) { + t.Fatalf("flag = %#v", got) + } + if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{1.0, 2.0, 3.0}) { + t.Fatalf("buf = %#v", got) + } +} + +func TestSetLocalAppliesSizing(t *testing.T) { + g := Graph{ID: "g", Name: "n", StateVars: []StateVar{ + {Name: "buf", Type: "array", Initial: "[]", Sizing: "capped", Capacity: 2}, + }} + cg := compile(g) + cg.setLocal("buf", []Value{1.0, 2.0, 3.0, 4.0}) + if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{3.0, 4.0}) { + t.Fatalf("sized buf = %#v", got) + } +} + +func TestResolverReturnsLocalValue(t *testing.T) { + g := Graph{ID: "g", Name: "n", StateVars: []StateVar{ + {Name: "buf", Type: "array", Initial: "[10,20]", Sizing: "dynamic"}, + }} + cg := compile(g) + R := func(ds, name string) Value { + if ds == "local" { + return cg.getLocal(name) + } + return 0.0 + } + if v := EvalExpr("buf[1]", R); v != 20 { + t.Fatalf("buf[1] = %v", v) + } + if v := EvalExpr("sum(buf)", R); v != 30 { + t.Fatalf("sum(buf) = %v", v) + } +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/controllogic/ -run 'TestCompileInitsLocals|TestSetLocalApplies|TestResolverReturnsLocal'` +Expected: FAIL — `locals` is `map[string]float64`; `getLocal` returns `float64`; no decls init. + +- [ ] **Step 3: Write minimal implementation** — edits to `internal/controllogic/engine.go` + +1. In the `compiledGraph` struct, change the locals field and add decls: + +```go + locals map[string]Value + decls map[string]StateVar +``` + +2. In `compile(g Graph)`, change the struct literal field `locals: map[string]float64{}` to + `locals: map[string]Value{}` and add `decls: map[string]StateVar{}` next to it. Then, + AFTER the `for _, n := range g.Nodes { cg.byId[n.ID] = n }` loop (before the wire loop, + or anywhere before `return cg`), initialise locals from declarations: + +```go + for _, sv := range g.StateVars { + cg.decls[sv.Name] = sv + if sv.Type == "array" { + cg.locals[sv.Name] = applySizing(parseInitialArray(sv), sv) + } else { + cg.locals[sv.Name] = parseScalarInitial(sv) + } + } +``` + +3. Add the scalar-initial helper (near `setLocal`): + +```go +func parseScalarInitial(sv StateVar) float64 { + s := strings.TrimSpace(sv.Initial) + switch s { + case "true": + return 1 + case "false": + return 0 + } + f, err := strconv.ParseFloat(s, 64) + if err != nil { + return 0 + } + return f +} +``` + +4. Replace `setLocal` / `getLocal`: + +```go +func (cg *compiledGraph) setLocal(name string, v Value) { + cg.stateMu.Lock() + if sv, ok := cg.decls[name]; ok && sv.Type == "array" { + if arr, isArr := v.([]Value); isArr { + v = applySizing(arr, sv) + } + } + cg.locals[name] = v + cg.stateMu.Unlock() +} + +func (cg *compiledGraph) getLocal(name string) Value { + cg.stateMu.Lock() + defer cg.stateMu.Unlock() + v, ok := cg.locals[name] + if !ok { + return 0.0 + } + return v +} +``` + +5. In the `resolve` closure inside `activate` (currently `func(ds, name string) float64`), + change its return type to `Value`. The body is unchanged except `sys:dt` and the + `liveGet` calls return `float64` (which is a `Value`) — they need no edit. Result: + +```go + resolve := func(ds, name string) Value { + switch ds { + case "sys": + if name == "dt" { + return dt + } + return cg.engine.liveGet("sys", name) + case "local": + return cg.getLocal(name) + default: + return cg.engine.liveGet(ds, name) + } + } +``` + + Also change the `runCtx.resolve` field type from `Resolver` — it is already `Resolver`, + which now returns `Value`, so no change is needed there. + +6. Replace `write` to be value-aware: + +```go +// write applies an action.write/lua-set/config-read to a target: a bare/local +// name updates a graph-local var (arrays sized per its declaration); a ds:name +// target writes a scalar to the data source (arrays cannot be written and are +// dropped). +func (e *Engine) write(cg *compiledGraph, target string, val Value) { + ds, name, ok := parseRef(target) + if !ok { + return + } + if ds == "local" { + cg.setLocal(name, val) + return + } + f, isNum := val.(float64) + if !isNum || math.IsNaN(f) { + return + } + if cg.dryRun { + return // simulate: no real data-source write + } + src, ok := e.broker.Source(ds) + if !ok { + e.log.Warn("control logic: write to unknown data source", "ds", ds, "signal", name) + return + } + ev := audit.Event{ + Actor: cg.name, + ActorType: audit.ActorSystem, + Action: "signal.write", + DS: ds, + Signal: name, + Value: strconv.FormatFloat(f, 'g', -1, 64), + Detail: "control logic: " + cg.name, + Outcome: audit.OutcomeOK, + } + if err := src.Write(e.root, name, f); err != nil { + e.log.Warn("control logic: write failed", "ds", ds, "signal", name, "err", err) + ev.Outcome = audit.OutcomeError + ev.Error = err.Error() + } + e.audit.Record(ev) +} +``` + +7. Update `action.write` in `run()` to evaluate a full value and debug-emit a scalar + projection (array writes show NaN in the badge but still write the local): + +```go + case "action.write": + val := EvalValue(node.param("expr"), ctx.resolve) + if f, ok := val.(float64); ok { + cg.emitDebug(node.ID, f, true) + } else { + cg.emitDebug(node.ID, val, true) + } + cg.engine.write(cg, node.param("target"), val) + cg.follow(node.ID, "out", ctx) +``` + + **NOTE:** step 7's `emitDebug(node.ID, val, true)` array branch depends on Task 6 + widening `emitDebug`'s `value` parameter to `Value`. If Task 6 has not yet landed, + temporarily keep the scalar-only `cg.emitDebug(node.ID, EvalExpr(...), true)` form and + record it in the report; Task 6 restores the value form. (Build order runs Task 6 + before Task 7, so this resolves within the backend phase.) + +8. The `runLua` callback `func(target string, val float64) { cg.engine.write(cg, target, val) }` + compiles unchanged (`val` float64 is a `Value`). The `action.config.read` call + `cg.engine.write(cg, node.param("target"), v)` (v float64) also compiles unchanged. + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/controllogic/ -run 'TestCompileInitsLocals|TestSetLocalApplies|TestResolverReturnsLocal'` +Then: `go build ./internal/controllogic/` (expect remaining errors ONLY in lua.go/debug.go +if Task 6 not yet done — see note). If building the whole package now, apply Task 6 first +or temporarily adapt. The two reviewer-visible deliverables here are the three passing +tests and a value-aware `write`. + +- [ ] **Step 5: Commit** + +```bash +git add internal/controllogic/engine.go internal/controllogic/engine_test.go +git commit -m "controllogic: locals as Value, init from declarations, value-aware write" +``` + +--- + +### Task 5: `action.array.*` nodes in the engine + +Add the five array action nodes to `run()` and their expression-ref collection to +`compile()`, mirroring the panel-logic handlers (`web/src/lib/logic.ts` lines 627-686). +Params match panel logic exactly: `push{array,expr}`, `set{array,index,expr}`, +`remove{array,index}`, `pop{array}`, `clear{array}`. + +**Files:** +- Modify: `internal/controllogic/engine.go` +- Test: `internal/controllogic/engine_test.go` (extend) + +**Interfaces:** +- Consumes: value-aware `setLocal`/`getLocal`/`write`, `EvalValue`, `EvalExpr`, + `applySizing`, `decls` (Task 4); array funcs/idx (Tasks 1, 3). +- Produces: engine handling of node kinds `action.array.push|set|remove|pop|clear`. + +- [ ] **Step 1: Write the failing test** — add to `internal/controllogic/engine_test.go` + +```go +func runFlowOnce(t *testing.T, g Graph, triggerID string) *compiledGraph { + t.Helper() + cg := compile(g) + // Minimal resolver: sys:dt=0, locals from cg, live=NaN. + R := func(ds, name string) Value { + switch ds { + case "local": + return cg.getLocal(name) + default: + return 0.0 + } + } + ctx := &runCtx{fired: triggerID, resolve: R} + cg.follow(triggerID, "out", ctx) + return cg +} + +func TestArrayPushNode(t *testing.T) { + g := Graph{ + ID: "g", Name: "n", + StateVars: []StateVar{{Name: "buf", Type: "array", Initial: "[1]", Sizing: "dynamic"}}, + Nodes: []Node{ + {ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}}, + {ID: "p", Kind: "action.array.push", Params: map[string]string{"array": "buf", "expr": "5"}}, + }, + Wires: []Wire{{From: "t", To: "p"}}, + } + cg := runFlowOnce(t, g, "t") + if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{1.0, 5.0}) { + t.Fatalf("after push buf = %#v", got) + } +} + +func TestArrayClearNode(t *testing.T) { + g := Graph{ + ID: "g", Name: "n", + StateVars: []StateVar{{Name: "buf", Type: "array", Initial: "[1,2,3]", Sizing: "fixed", Capacity: 3}}, + Nodes: []Node{ + {ID: "t", Kind: "trigger.timer", Params: map[string]string{"interval": "1000"}}, + {ID: "c", Kind: "action.array.clear", Params: map[string]string{"array": "buf"}}, + }, + Wires: []Wire{{From: "t", To: "c"}}, + } + cg := runFlowOnce(t, g, "t") + // fixed sizing → clear yields zero-padded length-3. + if got := cg.getLocal("buf"); !reflect.DeepEqual(got, []Value{0.0, 0.0, 0.0}) { + t.Fatalf("after clear buf = %#v", got) + } +} +``` + +**NOTE:** open `internal/controllogic/model.go` and confirm the `Node`/`Wire` field names +and the params-map field (`Params`). Adjust the literals above to the real shapes before +running. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/controllogic/ -run 'TestArrayPushNode|TestArrayClearNode'` +Expected: FAIL — array node kinds hit the `default` branch (no mutation). + +- [ ] **Step 3: Write minimal implementation** — edits to `internal/controllogic/engine.go` + +1. Add a nested-index assignment helper (port of `setPath` from logic.ts) near `write`: + +```go +// setPath sets arr[path[0]]...[path[n-1]] = v, creating intermediate arrays and +// resolving negative indices relative to each sub-array's length. +func setPath(arr []Value, path []int, v Value) []Value { + if len(path) == 0 { + return arr + } + k := path[0] + if k < 0 { + k = len(arr) + k + } + if k < 0 { + return arr + } + for len(arr) <= k { + arr = append(arr, 0.0) + } + if len(path) == 1 { + arr[k] = v + return arr + } + sub, _ := arr[k].([]Value) + arr[k] = setPath(sub, path[1:], v) + return arr +} +``` + +2. Add the five cases to the `run()` switch (place them just before `action.dialog` or + after `action.delay`): + +```go + case "action.array.push": + name := strings.TrimSpace(node.param("array")) + if name != "" { + val := EvalValue(node.param("expr"), ctx.resolve) + cur, _ := cg.getLocal(name).([]Value) + cg.setLocal(name, append(append([]Value{}, cur...), val)) + } + cg.follow(node.ID, "out", ctx) + + case "action.array.set": + name := strings.TrimSpace(node.param("array")) + if name != "" { + cur, _ := cg.getLocal(name).([]Value) + arr := append([]Value{}, cur...) + var path []int + ok := true + for _, s := range strings.Split(node.param("index"), ",") { + f := EvalExpr(strings.TrimSpace(s), ctx.resolve) + if math.IsNaN(f) { + ok = false + break + } + path = append(path, int(f)) + } + val := EvalValue(node.param("expr"), ctx.resolve) + if ok && len(path) > 0 { + arr = setPath(arr, path, val) + } + cg.setLocal(name, arr) + } + cg.follow(node.ID, "out", ctx) + + case "action.array.remove": + name := strings.TrimSpace(node.param("array")) + if name != "" { + cur, _ := cg.getLocal(name).([]Value) + arr := append([]Value{}, cur...) + i := int(EvalExpr(node.param("index"), ctx.resolve)) + k := i + if k < 0 { + k = len(arr) + k + } + if k >= 0 && k < len(arr) { + arr = append(arr[:k], arr[k+1:]...) + } + cg.setLocal(name, arr) + } + cg.follow(node.ID, "out", ctx) + + case "action.array.pop": + name := strings.TrimSpace(node.param("array")) + if name != "" { + cur, _ := cg.getLocal(name).([]Value) + arr := append([]Value{}, cur...) + if len(arr) > 0 { + arr = arr[:len(arr)-1] + } + cg.setLocal(name, arr) + } + cg.follow(node.ID, "out", ctx) + + case "action.array.clear": + name := strings.TrimSpace(node.param("array")) + if name != "" { + cg.setLocal(name, []Value{}) // setLocal applies sizing (fixed → zero-pad) + } + cg.follow(node.ID, "out", ctx) +``` + +3. In `compile()`'s node loop, add ref-collection for the value-bearing array nodes so + their expressions subscribe to referenced signals/locals: + +```go + case "action.array.push", "action.array.set": + wantExpr(n.param("expr")) + wantExpr(n.param("index")) + case "action.array.remove": + wantExpr(n.param("index")) +``` + +- [ ] **Step 4: Run test to verify it passes** + +Run: `go test ./internal/controllogic/ -run 'TestArrayPushNode|TestArrayClearNode'` +Expected: PASS + +- [ ] **Step 5: Commit** + +```bash +git add internal/controllogic/engine.go internal/controllogic/engine_test.go +git commit -m "controllogic: add action.array.* nodes (push/set/remove/pop/clear)" +``` + +--- + +### Task 6: Adapt `lua.go` + `debug.go` to `Value` + +Make the debug event value-polymorphic (`Value any`) and keep Lua scalar-only safely +(reading an array local yields NaN). This closes the backend compile and restores the +array branch of `action.write`'s debug emit (Task 4 step 7). + +**Files:** +- Modify: `internal/controllogic/debug.go`, `internal/controllogic/lua.go` +- Test: `internal/controllogic/engine_test.go` (extend) or build-only + +**Interfaces:** +- Consumes: `Value` (Task 1), value-returning `Resolver` (Task 3). +- Produces: `DebugEvent.Value any`; `emitDebug(nodeID string, value Value, hasValue bool)`; + Lua `get` returns NaN for array locals. + +- [ ] **Step 1: Write the failing test** — add to `internal/controllogic/engine_test.go` + +```go +func TestEmitDebugAcceptsArray(t *testing.T) { + // Compile-level guard: emitDebug must accept a Value (array) argument. + g := Graph{ID: "g", Name: "n"} + cg := compile(g) + cg.engine = &Engine{} // no observer installed; emitDebug must not panic + cg.emitDebug("x", []Value{1.0, 2.0}, true) + cg.emitDebug("x", 3.0, true) +} +``` + +**NOTE:** confirm `Engine` is constructible as `&Engine{}` for this guard, or use the +package's existing test harness/helper for building an Engine. If `debugWatch`/`debugObs` +are `atomic.Value` with nil zero values, `emitDebug` already short-circuits safely; if not, +adjust the test to install a no-op observer. + +- [ ] **Step 2: Run test to verify it fails** + +Run: `go test ./internal/controllogic/ -run TestEmitDebugAcceptsArray` +Expected: FAIL to COMPILE — `emitDebug` and `DebugEvent.Value` are `float64`. + +- [ ] **Step 3: Write minimal implementation** + +In `internal/controllogic/debug.go`: +- Change `DebugEvent.Value` from `float64` to `any` (keep the `json:"value"` tag). +- Change `emitDebug` signature to `func (cg *compiledGraph) emitDebug(nodeID string, value Value, hasValue bool)` and pass `Value: value` in the `DebugEvent` literal. + +In `internal/controllogic/lua.go`: +- Add `"math"` to imports. +- In the `get` host function, the resolver now returns `Value`; narrow to a scalar: + +```go + L.SetGlobal("get", L.NewFunction(func(s *lua.LState) int { + target := s.CheckString(1) + ds, name, ok := parseRef(target) + v := math.NaN() + if ok && lr.curResolve != nil { + if f, isNum := lr.curResolve(ds, name).(float64); isNum { + v = f + } + } + s.Push(lua.LNumber(v)) + return 1 + })) +``` + + `curResolve Resolver` already returns `Value` after Task 3; `curSet func(target string, val float64)` is unchanged (Lua writes scalars). + +- [ ] **Step 4: Run test + full backend build** + +Run: `go test ./internal/controllogic/ -run TestEmitDebugAcceptsArray` +Then: `go build ./... && go vet ./internal/controllogic/ && go test ./internal/controllogic/ -race` +Expected: PASS / clean. Confirm Task 4 step 7's array `emitDebug(node.ID, val, true)` form +now compiles (revert any temporary shim). + +- [ ] **Step 5: Commit** + +```bash +git add internal/controllogic/debug.go internal/controllogic/lua.go internal/controllogic/engine_test.go +git commit -m "controllogic: debug value is Value (array-capable); lua get narrows to scalar" +``` + +--- + +### Task 7: Frontend — declaration UI + array nodes in `ControlLogicEditor.tsx` + +Add the local-variable declaration UI (reuse `LocalVars` from `LogicEditor.tsx`), the five +array node kinds (palette + labels + inspectors), and thread `statevars` through the graph +state. Serialisation is automatic: the graph object is `JSON.stringify`'d on save (line +247), so adding `statevars` to `CLGraph` persists it. The Go side already round-trips it +(Task 2). + +**Files:** +- Modify: `web/src/LogicEditor.tsx` (export `LocalVars`) +- Modify: `web/src/ControlLogicEditor.tsx` +- Reference (read for inspector patterns): `web/src/LogicEditor.tsx` array node inspectors + (the `action.array.*` blocks) and its `LocalVars` placement. + +**Interfaces:** +- Consumes: `StateVar` (from `./lib/types`), `LocalVars` (exported from LogicEditor), + `checkExpr` (already imported, now array-aware via Task 3 on the TS side — already done + in Phase 1). +- Produces: a control-logic editor that declares scalar+array locals and edits array nodes. + +- [ ] **Step 1: Export `LocalVars` from `LogicEditor.tsx`** + +Change `function LocalVars({ ... })` (line 1451) to `export function LocalVars({ ... })`. +Verify it has no panel-only dependencies (it uses only `StateVar`, `useState`, `Fragment`, +and CSS classes — all available in ControlLogicEditor). + +- [ ] **Step 2: Add types + palette + labels in `ControlLogicEditor.tsx`** + +a. Import `StateVar` and `LocalVars`: + +```tsx +import { LocalVars } from './LogicEditor'; +import type { StateVar } from './lib/types'; +``` + +b. Extend `CLNodeKind` (after `'action.delay'` group) with: + +```tsx + | 'action.array.push' + | 'action.array.set' + | 'action.array.remove' + | 'action.array.pop' + | 'action.array.clear' +``` + +c. Add `statevars?: StateVar[];` to the `CLGraph` interface (after `wires`/`groups`). + +d. Add PALETTE entries (after the `action.delay`/`action.log` entries): + +```tsx + { kind: 'action.array.push', label: 'Array push', params: { array: '', expr: '' } }, + { kind: 'action.array.set', label: 'Array set', params: { array: '', index: '0', expr: '' } }, + { kind: 'action.array.remove', label: 'Array remove', params: { array: '', index: '0' } }, + { kind: 'action.array.pop', label: 'Array pop', params: { array: '' } }, + { kind: 'action.array.clear', label: 'Array clear', params: { array: '' } }, +``` + +e. Add the matching `KIND_LABEL` entries: + +```tsx + 'action.array.push': 'Array push', + 'action.array.set': 'Array set', + 'action.array.remove': 'Array remove', + 'action.array.pop': 'Array pop', + 'action.array.clear': 'Array clear', +``` + +- [ ] **Step 3: Render `LocalVars` and thread statevars** + +In the editor body (the `graph && (...` block around lines 366-383), render the +declaration UI. Place it in the `cl-graph-bar` or a sibling block, wired to `patchGraph`: + +```tsx + patchGraph({ statevars: vars })} /> +``` + +`patchGraph` already shallow-merges into the graph and marks dirty (it is used for +`name`/`enabled`/`scope`). Confirm `patchGraph`'s type accepts `statevars`; since `CLGraph` +now has the field, `patchGraph({ statevars })` type-checks. + +- [ ] **Step 4: Add array node inspectors** + +In the inspector switch (the `selected` node param editors, ~lines 1140-1200 region for +`action.write`), add blocks for the five array kinds. Build an `arrayLocals` list from the +graph's array statevars and render a dropdown selector (mirror LogicEditor's array node +inspectors). Compute once near the inspector render: + +```tsx + const arrayLocals = (graph.statevars ?? []) + .filter(v => v.type === 'array').map(v => v.name); +``` + +Then, for `selected.kind === 'action.array.push'` etc.: + +```tsx + {selected.kind.startsWith('action.array.') && ( + + + + {(selected.kind === 'action.array.set' || selected.kind === 'action.array.remove') && ( + + + patchParams(selected.id, { index: (e.target as HTMLInputElement).value })} /> + + )} + {(selected.kind === 'action.array.push' || selected.kind === 'action.array.set') && ( + + + patchParams(selected.id, { expr: v })} /> + + )} + + )} +``` + +**NOTE:** match the ACTUAL expression-input component the editor uses (the summary shows an +expression editor wired with `onChange={(v) => patchParams(selected.id, { expr: v })}` near +line 1154 — reuse that exact component, named here `ExprInput` as a placeholder). Read the +`action.write` inspector and copy its expression-input element verbatim. Ensure the new +block does not collide with the existing per-kind `if` ladder (guard with the same +selection pattern the surrounding code uses). + +- [ ] **Step 5: Build + typecheck** + +Run: `make frontend` +Expected: builds clean (esbuild). +Run: `cd web && npx tsc --noEmit -p tsconfig.json 2>&1 | grep -E 'ControlLogicEditor|LogicEditor'` +Expected: no NEW errors beyond the baseline noise (TS2604 Fragment, TS2322 key/RowProps, +TS7044 'e'). Fix any real new error. + +- [ ] **Step 6: Commit** + +```bash +git add web/src/LogicEditor.tsx web/src/ControlLogicEditor.tsx +git commit -m "ControlLogicEditor: local-var declarations + array action nodes" +``` + +--- + +### Task 8: Full verification sweep + docs + +Run the complete build/test gauntlet and update documentation. + +**Files:** +- Modify: `docs/TECHNICAL_SPEC.md` (control-logic section: note Value model, statevars, + array nodes), `TODO.md` (mark the relevant item), and the control-logic help text if one + exists (grep `HelpModal.tsx` / any control-logic help for the node list). + +- [ ] **Step 1: Backend gauntlet** + +Run: `make backend && go build ./... && go vet ./... && go test ./... -race && gofmt -l internal/` +Expected: all clean; `gofmt -l` prints nothing. + +- [ ] **Step 2: Frontend build** + +Run: `make frontend` +Expected: clean. Then `make all` for the embedded binary. + +- [ ] **Step 3: Update docs** + +- In `docs/TECHNICAL_SPEC.md`, in the control-logic section, document: locals now carry a + `Value` (scalar or array); graphs may declare `statevars` (scalar+array with sizing + policies dynamic/capped/fixed); expressions support array literals/indexing/array + functions; new `action.array.push/set/remove/pop/clear` nodes; Lua remains scalar-only; + CSV export is panel-only (not in control logic). +- In `TODO.md`, mark the control-logic array/scalar local-variable item complete. +- If a control-logic node reference exists in help text, add the array nodes. + +- [ ] **Step 4: Commit** + +```bash +git add docs/TECHNICAL_SPEC.md TODO.md web/src/HelpModal.tsx +git commit -m "docs: control-logic array+scalar locals (Value model, statevars, array nodes)" +``` + +--- + +## Recommended build order + +1. **Task 1** — value model + sizing (pure, isolated). +2. **Task 2** — Graph.StateVars + store round-trip. +3. **Task 3** — value-polymorphic expr.go (Resolver signature change ripples; may need a + brief shim until Task 4/6). +4. **Task 4** — engine locals + resolver + write. +5. **Task 5** — array action nodes. +6. **Task 6** — lua/debug adaptation (closes the backend compile). +7. **Task 7** — frontend editor. +8. **Task 8** — verification + docs. + +Backend phase (Tasks 1-6) should leave `go build ./...` and `go test ./... -race` green +before starting the frontend. + +## Riskiest parts + +- **Resolver signature change (Task 3 → 4/6):** `Resolver` goes from `float64` to `Value`, + touching every closure and the lua/debug call sites. The plan sequences Task 6 right + after the engine so the package compiles before the frontend. Any temporary shim must be + recorded and removed. +- **Sizing on write:** `setLocal` must apply `applySizing` using `decls`, exactly as + `writeLocalState` does on the panel side, or capped/fixed arrays drift from panel + semantics. The `TestSetLocalAppliesSizing` and `TestArrayClearNode` tests guard this. +- **Concurrency:** `locals`/`decls` are read/written under `cg.stateMu`. `decls` is built + once in `compile` before the graph runs (no concurrent writers), so it needs no lock for + reads inside the already-locked `setLocal`. Run `go test ./internal/controllogic -race`. +- **Lua arrays:** out of scope — `get` returns NaN for array locals; `set` writes scalars + only. Documented, not a bug. + +## Test strategy + +- **Go unit (`internal/controllogic`)**: value/sizing round-trips (Task 1); store + round-trip of statevars (Task 2); expr scalar + array literal/index/funcs + min/max + dual-dispatch + CollectRefs over arrays + array-yields-NaN-via-EvalExpr (Task 3); + locals-init-from-decls, setLocal sizing, resolver returns Value (Task 4); array node + mutations incl. fixed-sizing clear (Task 5); emitDebug accepts array (Task 6); + `-race` across the package. +- **Frontend**: `make frontend` + filtered `tsc` clean (Task 7). +- **Manual** (`make all`, `go run ./cmd/uopi`): create a control-logic graph, declare an + array local (capped, capacity 5), add a timer→array.push flow, enable+save, confirm the + array grows and caps; restart the server and confirm the declaration persists in the + control-logic JSON; open the debug/simulate view and confirm nodes activate. + +## Build / verification + +`make frontend` then `make backend`, `go build ./...`, `go vet ./...`, +`go test ./... -race`, `gofmt -l internal/` — all clean.