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>
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), editorweb/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.
- capped —
capacitymax; pushing past full drops the oldest element (ring / FIFO). - fixed — exactly
capacityslots. Initialised from theinitialliteral (truncated / zero-padded tocapacity), else zero-filled.pushis a no-op (write via index/set);clearzero-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 numeric1/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 returnsEvalValue; functions/indexing type-check at runtime and throw on misuse (caught → node error badge). - Go: a boxed
value{ num float64; arr []value; isArr bool }.Resolverreturnsvalue; every*Node.evalreturnsvalue(a contained, mechanical refactor ofexpr.go, which today returnsfloat64). Thefloat64leaf stays the fast path.
New syntax (both parsers):
- Array literal:
[a, b, c], nested[[1,2],[3,4]]. - Indexing: postfix
expr[expr], chainablea[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/maxkeep 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 (EPICSfloat64[]) 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}— appendeval(expr); sizing-policy aware (ring-drop if capped; no-op if fixed).action.array.set{array, index, expr}— store ateval(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 asaction.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-samplet), so time-based alignment (common/any/interpolate) is dropped; columns are emitted side-by-side by index (ragged columns padded blank). Thealignparam 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:
GraphgainsStateVars []StateVar(Go struct mirroring the TS shape:Name, Type, Elem, Sizing string; Capacity int; Initial, Unit string; Low, High float64), serialized incontrollogic.json.compiledGraph.localschanges frommap[string]float64tomap[string]value, initialised fromStateVars(applying sizing/initial) at compile time.getLocal/setLocaloperate onvalue;setLocalenforces sizing policy. Config-apply / snapshot paths that read/write locals asfloat64box/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 (boxedvalue, all functions/indexing/errors);StateVarsinit from declarations;setLocalpolicy enforcement; JSON round-trip ofStateVars; config-apply/snapshot with boxed locals. - Cross-engine parity: a
(expr, expected)table asserted identical in bothexpr.tsandexpr.goto 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.gorefactor (float64→ boxedvalue): touches every node'sevaland theResolver. Mechanical but broad; the parity test guards behavioral drift fromexpr.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 oninterpolate/commonalign. 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).