package synthetic import ( "math" "testing" ) // evalDef compiles a SignalDef and evaluates it against per-source values keyed // by source node id. func evalDef(t *testing.T, def SignalDef, srcVals map[string]float64) float64 { t.Helper() rg, err := compileGraph(def) if err != nil { t.Fatalf("compileGraph: %v", err) } out, err := rg.eval(srcVals) if err != nil { t.Fatalf("eval: %v", err) } return out } // TestGraphMultiInputDAG verifies that an intermediate op can take two // independently-wired sources — the capability the old linear pipeline lacked. func TestGraphMultiInputDAG(t *testing.T) { def := SignalDef{ Name: "diff", Graph: &Graph{ Output: "out", Nodes: []GraphNode{ {ID: "a", Kind: "source", DS: "x", Signal: "left"}, {ID: "b", Kind: "source", DS: "x", Signal: "right"}, {ID: "sub", Kind: "op", Op: "subtract", Inputs: []string{"a", "b"}}, {ID: "out", Kind: "output", Inputs: []string{"sub"}}, }, }, } got := evalDef(t, def, map[string]float64{"a": 10, "b": 3}) if got != 7 { t.Errorf("subtract DAG: want 7, got %v", got) } } // TestGraphExprNamedInputs verifies expr nodes bind arbitrary named inputs in // wired order. func TestGraphExprNamedInputs(t *testing.T) { def := SignalDef{ Name: "formula", Graph: &Graph{ Output: "out", Nodes: []GraphNode{ {ID: "a", Kind: "source", DS: "x", Signal: "p"}, {ID: "b", Kind: "source", DS: "x", Signal: "q"}, {ID: "e", Kind: "op", Op: "expr", Inputs: []string{"a", "b"}, Params: map[string]any{"expr": "price * qty", "vars": []any{"price", "qty"}}}, {ID: "out", Kind: "output", Inputs: []string{"e"}}, }, }, } got := evalDef(t, def, map[string]float64{"a": 4, "b": 2.5}) if math.Abs(got-10) > 1e-9 { t.Errorf("expr named inputs: want 10, got %v", got) } } // TestGraphFanInToExpr exercises a non-trivial DAG: two ops feeding one expr. func TestGraphFanInToExpr(t *testing.T) { def := SignalDef{ Name: "combo", Graph: &Graph{ Output: "out", Nodes: []GraphNode{ {ID: "a", Kind: "source", DS: "x", Signal: "p"}, {ID: "b", Kind: "source", DS: "x", Signal: "q"}, {ID: "g", Kind: "op", Op: "gain", Inputs: []string{"a"}, Params: map[string]any{"gain": 2.0}}, {ID: "o", Kind: "op", Op: "offset", Inputs: []string{"b"}, Params: map[string]any{"offset": 1.0}}, {ID: "e", Kind: "op", Op: "expr", Inputs: []string{"g", "o"}, Params: map[string]any{"expr": "a + b"}}, {ID: "out", Kind: "output", Inputs: []string{"e"}}, }, }, } // g = 5*2 = 10 ; o = 4+1 = 5 ; a+b = 15 got := evalDef(t, def, map[string]float64{"a": 5, "b": 4}) if math.Abs(got-15) > 1e-9 { t.Errorf("fan-in DAG: want 15, got %v", got) } } // TestGraphLegacyConversion verifies the linear Inputs+Pipeline form still // evaluates correctly via the graph runtime. func TestGraphLegacyConversion(t *testing.T) { def := SignalDef{ Name: "legacy", DS: "x", Signal: "p", Pipeline: []NodeDef{{Type: "gain", Params: map[string]any{"gain": 3.0}}}, } rg, err := compileGraph(def) if err != nil { t.Fatalf("compileGraph: %v", err) } if len(rg.sources) != 1 { t.Fatalf("want 1 source, got %d", len(rg.sources)) } got, err := rg.eval(map[string]float64{rg.sources[0].id: 4}) if err != nil { t.Fatalf("eval: %v", err) } if got != 12 { t.Errorf("legacy gain: want 12, got %v", got) } } // TestGraphCycleRejected ensures a cyclic graph is refused at compile time. func TestGraphCycleRejected(t *testing.T) { def := SignalDef{ Name: "cyclic", Graph: &Graph{ Output: "out", Nodes: []GraphNode{ {ID: "a", Kind: "op", Op: "gain", Inputs: []string{"b"}, Params: map[string]any{"gain": 1.0}}, {ID: "b", Kind: "op", Op: "gain", Inputs: []string{"a"}, Params: map[string]any{"gain": 1.0}}, {ID: "out", Kind: "output", Inputs: []string{"a"}}, }, }, } if _, err := compileGraph(def); err == nil { t.Error("expected cycle to be rejected") } }