Files
uopi/docs/superpowers/plans/2026-06-24-control-logic-arrays.md
Martino Ferrari 336095c052 plan updated
2026-06-26 14:08:28 +02:00

64 KiB

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'. <new-diagnostics> 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 testinternal/controllogic/value_test.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 implementationinternal/controllogic/value.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
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):

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):

	// 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
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 testinternal/controllogic/expr_test.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
// 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
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 testinternal/controllogic/engine_test.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:
	locals map[string]Value
	decls  map[string]StateVar
  1. 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:
	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)
		}
	}
  1. Add the scalar-initial helper (near setLocal):
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
}
  1. Replace setLocal / getLocal:
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
}
  1. 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:
	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.

  1. Replace write to be value-aware:
// 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)
}
  1. 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):
	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.)

  1. 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
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

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:
// 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
}
  1. Add the five cases to the run() switch (place them just before action.dialog or after action.delay):
	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)
  1. In compile()'s node loop, add ref-collection for the value-bearing array nodes so their expressions subscribe to referenced signals/locals:
		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
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

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:
		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
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:

import { LocalVars } from './LogicEditor';
import type { StateVar } from './lib/types';

b. Extend CLNodeKind (after 'action.delay' group) with:

  | '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):

  { 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:

  '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 && (<Fragment>... block around lines 366-383), render the declaration UI. Place it in the cl-graph-bar or a sibling block, wired to patchGraph:

                <LocalVars
                  statevars={graph.statevars ?? []}
                  onChange={(vars) => 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:

                const arrayLocals = (graph.statevars ?? [])
                  .filter(v => v.type === 'array').map(v => v.name);

Then, for selected.kind === 'action.array.push' etc.:

              {selected.kind.startsWith('action.array.') && (
                <Fragment>
                  <label class="prop-label">Array</label>
                  <select class="prop-select"
                    value={selected.params.array ?? ''}
                    onChange={(e) => patchParams(selected.id, { array: (e.target as HTMLSelectElement).value })}>
                    <option value=""> select array </option>
                    {arrayLocals.map(n => <option key={n} value={n}>{n}</option>)}
                    {selected.params.array && !arrayLocals.includes(selected.params.array) &&
                      <option value={selected.params.array}>{selected.params.array} (unknown)</option>}
                  </select>
                  {(selected.kind === 'action.array.set' || selected.kind === 'action.array.remove') && (
                    <Fragment>
                      <label class="prop-label">Index{selected.kind === 'action.array.set' ? ' (comma-separated for nested)' : ''}</label>
                      <input class="prop-input" value={selected.params.index ?? ''}
                        onInput={(e) => patchParams(selected.id, { index: (e.target as HTMLInputElement).value })} />
                    </Fragment>
                  )}
                  {(selected.kind === 'action.array.push' || selected.kind === 'action.array.set') && (
                    <Fragment>
                      <label class="prop-label">Value (expression)</label>
                      <ExprInput value={selected.params.expr ?? ''}
                        onChange={(v) => patchParams(selected.id, { expr: v })} />
                    </Fragment>
                  )}
                </Fragment>
              )}

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
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

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)"

  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.