From 519c1f2df48745ea076911a1ebb8a37652729d41 Mon Sep 17 00:00:00 2001 From: Martino Ferrari Date: Wed, 24 Jun 2026 14:53:40 +0200 Subject: [PATCH] controllogic: make expr evaluator value-polymorphic (arrays, indexing, array funcs) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Rewrites internal/controllogic/expr.go to evaluate to Value (scalar float64 or []Value array): adds array literals [a,b], postfix indexing arr[i], and array functions (len, sum, mean, push, pop, slice, concat, sort, …). Resolver type changes from func(...) float64 to func(...) Value; EvalValue added; EvalExpr/EvalBool retain scalar float64/bool returns. Three temporary shims added in engine.go, lua.go, and expr_test.go pending Tasks 4 and 6. Co-Authored-By: Claude Sonnet 4.6 --- internal/controllogic/engine.go | 2 +- internal/controllogic/expr.go | 430 +++++++++++++++++++++++++---- internal/controllogic/expr_test.go | 74 ++++- internal/controllogic/lua.go | 6 +- 4 files changed, 446 insertions(+), 66 deletions(-) diff --git a/internal/controllogic/engine.go b/internal/controllogic/engine.go index f971d96..a8e63c3 100644 --- a/internal/controllogic/engine.go +++ b/internal/controllogic/engine.go @@ -897,7 +897,7 @@ func (cg *compiledGraph) activate(triggerID string) { dt = float64(now-last) / 1e9 } - resolve := func(ds, name string) float64 { + resolve := func(ds, name string) Value { // shim: was float64 (Task 4 removes) switch ds { case "sys": if name == "dt" { diff --git a/internal/controllogic/expr.go b/internal/controllogic/expr.go index d669758..367f53f 100644 --- a/internal/controllogic/expr.go +++ b/internal/controllogic/expr.go @@ -2,16 +2,15 @@ // // Supports numbers, booleans (true/false → 1/0), arithmetic (+ - * / %), // comparison (< <= > >= == !=), boolean (&& || !), ternary (a ? b : c), -// parentheses, and a handful of math functions. Two kinds of variable -// reference are resolved live at evaluation time: +// 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 (the brace content is split on the -// FIRST ':' so EPICS PV names like "MY:PV:NAME" work). +// {ds:name} a data-source signal value (brace content split on FIRST ':'). // bareIdent a graph-local state variable (data source "local"). // -// Booleans are represented as numbers: comparisons / logical ops yield 1 or 0, -// and any nonzero value is truthy. The evaluator never uses reflection or eval; -// it walks a parsed AST against a caller-supplied Resolver. +// 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 ( @@ -22,8 +21,8 @@ import ( "sync" ) -// Resolver returns the current numeric value of a signal/local reference. -type Resolver func(ds, name string) float64 +// 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 { @@ -33,11 +32,13 @@ type RefLite struct { // ── AST ────────────────────────────────────────────────────────────────────── -type exprNode interface{ eval(R Resolver) float64 } +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 @@ -52,33 +53,84 @@ type callNode struct { args []exprNode } -func (n numNode) eval(R Resolver) float64 { return n.v } -func (n sigNode) eval(R Resolver) float64 { return R(n.ds, n.name) } -func (n varNode) eval(R Resolver) float64 { return R("local", n.name) } -func (n unNode) eval(R Resolver) float64 { - if n.op == "-" { - return -n.a.eval(R) +func mustNum(v Value) float64 { + f, err := asNum(v) + if err != nil { + panic(err) } - if n.a.eval(R) == 0 { - return 1 - } - return 0 + return f } -func (n ternNode) eval(R Resolver) float64 { - if n.c.eval(R) != 0 { +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) float64 { - args := make([]float64, len(n.args)) +func (n callNode) eval(R Resolver) Value { + args := make([]Value, len(n.args)) for i, a := range n.args { args[i] = a.eval(R) } - return funcs[n.fn](args) + // 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) float64 { - a, b := n.a.eval(R), n.b.eval(R) +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 @@ -107,7 +159,7 @@ func (n binNode) eval(R Resolver) float64 { case "||": return boolf(a != 0 || b != 0) } - return math.NaN() + panic(fmt.Errorf("unknown operator %q", n.op)) } func boolf(b bool) float64 { @@ -117,15 +169,34 @@ func boolf(b bool) float64 { return 0 } -var funcs = map[string]func([]float64) float64{ +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]) }, - "min": func(a []float64) float64 { return minSlice(a) }, - "max": func(a []float64) float64 { return maxSlice(a) }, "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(sign(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]) }, @@ -133,27 +204,145 @@ var funcs = map[string]func([]float64) float64{ "cos": func(a []float64) float64 { return math.Cos(a[0]) }, } -func minSlice(a []float64) float64 { - if len(a) == 0 { - return math.Inf(1) - } - m := a[0] - for _, x := range a[1:] { - m = math.Min(m, x) - } - return m +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 maxSlice(a []float64) float64 { - if len(a) == 0 { - return math.Inf(-1) - } - m := a[0] - for _, x := range a[1:] { - m = math.Max(m, x) - } - return m -} -func sign(x float64) int { + +func signOf(x float64) int { switch { case x > 0: return 1 @@ -164,6 +353,49 @@ func sign(x float64) int { } } +// sign is an alias for signOf, kept for backward compatibility with existing tests. +func sign(x float64) int { return signOf(x) } +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 { @@ -223,7 +455,7 @@ func tokenize(src string) ([]tok, error) { continue } } - if strings.ContainsRune("+-*/%<>!()?:,", c) { + if strings.ContainsRune("+-*/%<>!()?:,[]", c) { toks = append(toks, tok{k: string(c)}) i++ continue @@ -279,7 +511,7 @@ func parse(src string) (exprNode, error) { return root, nil } -func (ps *parser) primary() (exprNode, error) { +func (ps *parser) atom() (exprNode, error) { t, ok := ps.peek() if !ok { return nil, fmt.Errorf("unexpected end of expression") @@ -292,6 +524,32 @@ func (ps *parser) primary() (exprNode, error) { 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, ':') @@ -333,7 +591,7 @@ func (ps *parser) primary() (exprNode, error) { if _, err := ps.eat(")"); err != nil { return nil, err } - if _, ok := funcs[id]; !ok { + if !knownFunc(id) { return nil, fmt.Errorf("unknown function %q", id) } return callNode{fn: id, args: args}, nil @@ -353,6 +611,39 @@ func (ps *parser) primary() (exprNode, error) { 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("") @@ -449,8 +740,9 @@ func parseCached(src string) (exprNode, error) { return n, err } -// EvalExpr evaluates an expression string, returning NaN on parse/eval failure. -func EvalExpr(src string, resolve Resolver) float64 { +// 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() @@ -458,7 +750,7 @@ func EvalExpr(src string, resolve Resolver) float64 { return safeEval(n, resolve) } -func safeEval(n exprNode, resolve Resolver) (out float64) { +func safeEval(n exprNode, resolve Resolver) (out Value) { defer func() { if recover() != nil { out = math.NaN() @@ -467,14 +759,23 @@ func safeEval(n exprNode, resolve Resolver) (out float64) { return n.eval(resolve) } -// EvalBool reports whether the expression evaluates to a nonzero, non-NaN value. +// 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, for -// subscription. Returns nil for an unparseable expression. +// CollectRefs returns every signal/local reference an expression reads. func CollectRefs(src string) []RefLite { root, err := parseCached(src) if err != nil { @@ -496,6 +797,13 @@ func CollectRefs(src string) []RefLite { 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: diff --git a/internal/controllogic/expr_test.go b/internal/controllogic/expr_test.go index 17d0eea..2018d1f 100644 --- a/internal/controllogic/expr_test.go +++ b/internal/controllogic/expr_test.go @@ -2,11 +2,12 @@ package controllogic import ( "math" + "reflect" "testing" ) func TestEvalExpr(t *testing.T) { - resolve := func(ds, name string) float64 { + resolve := func(ds, name string) Value { // shim: was float64 (Task 4 removes) switch { case ds == "stub" && name == "x": return 10 @@ -45,7 +46,7 @@ func TestEvalExpr(t *testing.T) { } func TestEvalExprErrors(t *testing.T) { - r := func(ds, name string) float64 { return 0 } + r := func(ds, name string) Value { return 0 } // shim: was float64 (Task 4 removes) for _, bad := range []string{"1 +", "(1", "1 2", "{unterminated"} { if v := EvalExpr(bad, r); !math.IsNaN(v) { t.Errorf("EvalExpr(%q) = %v, want NaN", bad, v) @@ -84,3 +85,72 @@ func TestEpicsRefSplitFirstColon(t *testing.T) { t.Errorf("got %+v, want epics / SR:BPM:01:X", refs) } } + +// ── New tests for value-polymorphic evaluator ───────────────────────────────── + +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) + } +} diff --git a/internal/controllogic/lua.go b/internal/controllogic/lua.go index 013e369..dad6673 100644 --- a/internal/controllogic/lua.go +++ b/internal/controllogic/lua.go @@ -61,8 +61,10 @@ func (lr *luaRuntime) ensure() error { target := s.CheckString(1) ds, name, ok := parseRef(target) var v float64 - if ok && lr.curResolve != nil { - v = lr.curResolve(ds, name) + if ok && lr.curResolve != nil { // shim: Task 6 finalizes lua Value handling + if f, fok := lr.curResolve(ds, name).(float64); fok { + v = f + } } s.Push(lua.LNumber(v)) return 1