Files
uopi/docs/superpowers/specs/2026-06-24-local-arrays-design.md
T
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

267 lines
13 KiB
Markdown

# 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.
- **capped** — `capacity` 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:
```xml
<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).