Files
uopi/docs/superpowers/specs/2026-06-24-local-arrays-design.md
Martino Ferrari e4e67ee0c2 Add design spec for local array values in flow engines
Approved design for first-class array-valued local variables across the
panel-logic (TS) and control-logic (Go) engines: array StateVar declarations
(dynamic/capped/fixed sizing), an array-aware expression language shared by
both engines, sizing-policy-aware mutation nodes (unifying the existing
accumulate/export/clear arrays), persistence, and widget binding.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 10:32:11 +02:00

13 KiB

Design: Local array values for the node-editor flow engines

Date: 2026-06-24 Status: Approved (design phase) TODO refs: "Logic editor → add full support to local array values: dynamic, dynamic but capped max, fixed size etc; array functions should work with new local array" and "Control loop → add full support to server side array values".

1. Goal & scope

Add first-class array-valued local variables to both flow engines:

  • Panel logic (client TS): web/src/lib/logic.ts, LogicEditor.tsx, web/src/lib/types.ts, web/src/lib/expr.ts, web/src/lib/localstate.ts, web/src/lib/xml.ts.
  • Control logic (server Go): internal/controllogic/ (model.go, engine.go, expr.go), editor web/src/ControlLogicEditor.tsx.

Build order: design both together; implement panel logic (TS) first, then port the same design to control logic (Go). The panel StateVar schema is the richer reference and doing TS first de-risks the expression-language change before the Go port.

In scope for v1: declaration + sizing policies, an array-aware expression language (shared by both engines), array mutation nodes, persistence, and widget binding (multi-LED/bitset, table, plot read array locals).

2. Data model — array local declaration

A new array kind on the existing StateVar (TS types.ts; mirrored as a Go struct in control logic, which gains state-var declarations for the first time):

StateVar {
  name: string
  type: 'number' | 'bool' | 'string' | 'array'   // 'array' is new
  initial: string            // arrays: JSON literal e.g. "[0,0,0]" / "[[1,2],[3,4]]", or "" = empty/zero-fill
  unit?, low?, high?         // existing scalar fields

  // present only when type === 'array':
  elem?: 'number' | 'bool' | 'array'  // element type; 'array' ⇒ nested (recursive, arbitrary depth, jagged allowed)
  sizing?: 'dynamic' | 'capped' | 'fixed'   // default 'dynamic'
  capacity?: number          // required for capped/fixed
}

Semantics:

  • dynamic — unbounded, guarded by a global safety cap (1e6 elements) to prevent runaway growth.
  • cappedcapacity max; pushing past full drops the oldest element (ring / FIFO).
  • fixed — exactly capacity slots. Initialised from the initial literal (truncated / zero-padded to capacity), else zero-filled. push is a no-op (write via index/set); clear zero-refills rather than emptying.
  • initial — non-empty JSON literal is parsed and used; empty ⇒ dynamic and capped start [], fixed starts zero-filled.
  • elem: 'bool' is display metadata only. Runtime leaves are numeric 1/0 (consistent with the engine's existing "booleans are 1/0" rule); widgets use the declaration to render on/off.

Declaration-time validation: capped/fixed require capacity >= 1; initial, if non-empty, must parse as JSON and match the declared element type / nesting. Errors surface inline in the editor.

3. Expression engine (expr.ts + expr.go)

Value model (tagged union — "Approach 1"):

  • TS: type EvalValue = number | EvalValue[]. The evaluator returns EvalValue; functions/indexing type-check at runtime and throw on misuse (caught → node error badge).
  • Go: a boxed value{ num float64; arr []value; isArr bool }. Resolver returns value; every *Node.eval returns value (a contained, mechanical refactor of expr.go, which today returns float64). The float64 leaf stays the fast path.

New syntax (both parsers):

  • Array literal: [a, b, c], nested [[1,2],[3,4]].
  • Indexing: postfix expr[expr], chainable a[i][j]. Index rounds to int; negative index counts from the end (a[-1] = last); out-of-range → node error.

Functions — added to the existing abs/min/max/sqrt/... table. All the read/transform functions are pure (return new values; never mutate a local):

Function Result Meaning / notes
len(a) number element count (top level)
sum(a), mean(a), min(a), max(a) number over a 1-D numeric array; error if elements are arrays
slice(a, s, e) array subrange, e exclusive, negative indices allowed
concat(a, b) array join
reverse(a) array
sort(a) array ascending numeric
scale(a, k) array element-wise a[i]*k
add(a, b), sub(a, b) array element-wise pairwise; length = min(len a, len b)
push(a, v) array copy with v appended
set(a, i, v) array copy with element i replaced (negative i ok)
insert(a, i, v) array copy with v inserted at i
remove(a, i) array copy without element i
pop(a) array copy without the last element (read it with a[-1])
shift(a) array copy without the first element (read with a[0])
indexOf(a, v) number first index of v, else -1
contains(a, v) number 1/0
fill(n, v) array new length-n array of v
  • min/max keep their existing scalar variadic form (min(x,y,z)) and gain a 1-arg array form (min(a)) — dispatch on arg count + type.
  • Resolution: a bare identifier naming an array local returns the whole array value; {ds:sig} waveform signals (EPICS float64[]) become first-class array values usable by every function above.

Purity & persistence interplay: the mutator-named functions (push/set/insert/remove/pop/shift) are immutable transforms — they return a new array and do not touch the local. You persist a result by writing it back; the sizing policy is enforced at store time in writeLocalState (TS) / setLocal (Go) whenever the write target is a typed array local (ring-drop for capped, clamp / no-op for fixed). The mutation nodes (§4) are convenient, visible sugar for "store with policy".

Errors (e.g. sum of nested array, indexing a scalar, add of non-arrays) throw in the evaluator and surface as the node's error reason via the existing checkExpr validation + runtime-catch / badge path.

Ref-collection (collectRefs / CollectRefs) walks the new literal/index AST so subscriptions still discover every {ds:sig} inside array expressions.

4. Mutation nodes + accumulate/export unification

New action nodes (panel LogicNodeKind, mirrored in Go control-logic kinds):

  • action.array.push{array, expr} — append eval(expr); sizing-policy aware (ring-drop if capped; no-op if fixed).
  • action.array.set{array, index, expr} — store at eval(index). Supports nested targets via an index path: index = "i, j"a[i][j]. Negative indices allowed; out-of-range → node error. (This is the imperative path-assignment style.)
  • action.array.remove{array, index} — remove element at index (in place).
  • action.array.pop{array} — remove last element (in place).
  • action.array.clear{array} — empty (dynamic/capped) or zero-refill (fixed).

These are the sizing-policy-aware, in-place counterparts to the pure expression functions.

Unification of the existing {t,v} array system (decision: unify):

  • action.accumulate{array, expr} → reframed as action.array.push (append, policy-enforced). Old kind kept as a compile alias so saved panels run.
  • action.clear{array}action.array.clear (alias retained).
  • action.export{columns, align, filename} → serializes array locals by column. Array locals are plain numeric (no per-sample t), so time-based alignment (common/any/interpolate) is dropped; columns are emitted side-by-side by index (ragged columns padded blank). The align param is ignored and hidden in the inspector. To keep a timestamp column, push {sys:time} into a parallel array local and add it as a column.
  • Custom column names: each export column keeps its label, surfaced as an editable header name in the inspector (default = array-local name); the CSV header row uses the chosen names.

Migration (non-destructive, at engine load()): any accumulate/clear/export node referencing an array name with no matching StateVar declaration triggers an auto-declared dynamic numeric array local of that name. No file rewrite.

5. Persistence

Panel logic (XML, xml.ts): <statevar> gains optional array attributes, written only for arrays:

<statevar name="hist" type="array" elem="number" sizing="capped" capacity="100" initial=""/>
<statevar name="grid" type="array" elem="array" sizing="fixed" capacity="4" initial="[[0,0],[0,0]]"/>

Round-trips through the existing verbatim-body store (no Go change for panels). New nodes serialize via the existing <node><param/></node> mechanism.

Control logic (Go, model.go): control logic has no state-var declarations today (locals is an untyped map[string]float64). Additions:

  • Graph gains StateVars []StateVar (Go struct mirroring the TS shape: Name, Type, Elem, Sizing string; Capacity int; Initial, Unit string; Low, High float64), serialized in controllogic.json.
  • compiledGraph.locals changes from map[string]float64 to map[string]value, initialised from StateVars (applying sizing/initial) at compile time.
  • getLocal/setLocal operate on value; setLocal enforces sizing policy. Config-apply / snapshot paths that read/write locals as float64 box/unbox.

Versioning: control-logic graphs are already git-style versioned; the new StateVars field rides along in each revision (no diff-engine change — just more JSON).

6. Editor UI + debug/live badges

Panel LogicEditor.tsx: the LocalVars palette subcomponent gains an array declaration form (type=array reveals element-type / sizing / capacity / initial JSON with inline validation). New array action nodes added to the Actions palette group with inspectors (array name + expr/index fields, reusing ExprField with array-aware checkExpr).

Control ControlLogicEditor.tsx: control logic has no local-var declaration UI today. Add a LocalVars panel mirroring the panel editor (same component, driven by Graph.StateVars) plus the array nodes in its palette. This brings control-logic locals to parity — scalars and arrays become declarable there for the first time.

Debug/live badges (flowDebug.ts + Go DebugObserver / synthetic trace): array node values render as a truncated literal, e.g. [1, 2, 3, …](n=100). The Go debug event payload (debugNode) and the synthetic trace already serialize a value — extended to carry array JSON; the badge formatter stringifies arrays compactly.

7. Widgets reading array locals

The ds:'local' plumbing already routes through stores.ts/ws.ts; the change is that a local's SignalValue.value can be an array (number / nested), held and initialised per panel instance by localstate.ts. No new widget types — new source modes on three existing widgets:

  • Plot — a 1-D numeric array local binds as a waveform sample (same path EPICS float64[] waveforms already use for multidimensional/FFT/waterfall). Each engine tick that rewrites the array updates the trace. Nested arrays show "unsupported shape".
  • Table widget — gains an array source mode: bound to one array local, renders one row per element (index + value, per-signal value-format applied). For an elem:'array' (2-D) local, rows are indices and the configured columns map to inner-array positions. Falls back to multi-signal mode for scalars.
  • Multi-LED / bitset — gains an array source mode: one LED per element, lit per element truthiness; when elem:'bool', on/off labels come from the declaration. Existing integer-bitset mode unchanged.

getLocalMetaStore carries elem/sizing/capacity so widgets self-configure (e.g. multi-LED LED count = array length, growing/shrinking live for dynamic arrays).

8. Testing

  • TS unit (expr): literals; indexing (negative, nested, out-of-range error); every new function incl. type-error cases; sizing-policy enforcement on store (ring drop-oldest, fixed no-op / zero-refill); accumulate→push migration; CSV export by-index with custom headers.
  • Go unit (internal/controllogic): port of the expr suite (boxed value, all functions/indexing/errors); StateVars init from declarations; setLocal policy enforcement; JSON round-trip of StateVars; config-apply/snapshot with boxed locals.
  • Cross-engine parity: a (expr, expected) table asserted identical in both expr.ts and expr.go to keep them in lockstep.
  • Widgets: light component tests for the new array source modes if widget tests exist; else manual verification.
  • Gates: gofmt, go vet, go test ./... -race, frontend typecheck/build.

9. Risks

  • Go expr.go refactor (float64 → boxed value): touches every node's eval and the Resolver. Mechanical but broad; the parity test guards behavioral drift from expr.ts.
  • Backward compatibility of accumulate/export: aliasing + auto-declared locals keep old panels running, but the dropped time-alignment in export is a behavioral change for any panel relying on interpolate/common align. Call this out in release notes.
  • Two engines staying in lockstep: the function set and semantics must match exactly across TS and Go; the cross-engine parity fixture is the safeguard.
  • Hot-path purity: array expression functions are evaluated repeatedly; they must remain allocation-light and side-effect-free (mutation only at store time).