# 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{}: // note: []Value == []interface{} since Value = any, so this covers both 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.