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>
This commit is contained in:
@@ -0,0 +1,266 @@
|
||||
# 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).
|
||||
Reference in New Issue
Block a user