2170 lines
64 KiB
Markdown
2170 lines
64 KiB
Markdown
# 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 test** — `internal/controllogic/value_test.go`
|
|
|
|
```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 implementation** — `internal/controllogic/value.go`
|
|
|
|
```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**
|
|
|
|
```bash
|
|
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):
|
|
|
|
```go
|
|
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`):
|
|
|
|
```go
|
|
// 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**
|
|
|
|
```bash
|
|
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 test** — `internal/controllogic/expr_test.go`
|
|
|
|
```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`
|
|
|
|
```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**
|
|
|
|
```bash
|
|
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 test** — `internal/controllogic/engine_test.go`
|
|
|
|
```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:
|
|
|
|
```go
|
|
locals map[string]Value
|
|
decls map[string]StateVar
|
|
```
|
|
|
|
2. 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:
|
|
|
|
```go
|
|
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)
|
|
}
|
|
}
|
|
```
|
|
|
|
3. Add the scalar-initial helper (near `setLocal`):
|
|
|
|
```go
|
|
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
|
|
}
|
|
```
|
|
|
|
4. Replace `setLocal` / `getLocal`:
|
|
|
|
```go
|
|
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
|
|
}
|
|
```
|
|
|
|
5. 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:
|
|
|
|
```go
|
|
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.
|
|
|
|
6. Replace `write` to be value-aware:
|
|
|
|
```go
|
|
// 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)
|
|
}
|
|
```
|
|
|
|
7. 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):
|
|
|
|
```go
|
|
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.)
|
|
|
|
8. 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**
|
|
|
|
```bash
|
|
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`
|
|
|
|
```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`:
|
|
|
|
```go
|
|
// 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
|
|
}
|
|
```
|
|
|
|
2. Add the five cases to the `run()` switch (place them just before `action.dialog` or
|
|
after `action.delay`):
|
|
|
|
```go
|
|
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)
|
|
```
|
|
|
|
3. In `compile()`'s node loop, add ref-collection for the value-bearing array nodes so
|
|
their expressions subscribe to referenced signals/locals:
|
|
|
|
```go
|
|
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**
|
|
|
|
```bash
|
|
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`
|
|
|
|
```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:
|
|
|
|
```go
|
|
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**
|
|
|
|
```bash
|
|
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`:
|
|
|
|
```tsx
|
|
import { LocalVars } from './LogicEditor';
|
|
import type { StateVar } from './lib/types';
|
|
```
|
|
|
|
b. Extend `CLNodeKind` (after `'action.delay'` group) with:
|
|
|
|
```tsx
|
|
| '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):
|
|
|
|
```tsx
|
|
{ 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:
|
|
|
|
```tsx
|
|
'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`:
|
|
|
|
```tsx
|
|
<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:
|
|
|
|
```tsx
|
|
const arrayLocals = (graph.statevars ?? [])
|
|
.filter(v => v.type === 'array').map(v => v.name);
|
|
```
|
|
|
|
Then, for `selected.kind === 'action.array.push'` etc.:
|
|
|
|
```tsx
|
|
{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**
|
|
|
|
```bash
|
|
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**
|
|
|
|
```bash
|
|
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)"
|
|
```
|
|
|
|
---
|
|
|
|
## Recommended build order
|
|
|
|
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.
|