package synthetic import ( "errors" "fmt" "github.com/uopi/uopi/internal/broker" "github.com/uopi/uopi/internal/dsp" ) // runtimeGraph is the executable form of a synthetic signal's DAG. Nodes are // held in topological order so a single forward pass computes every value with // each node's inputs already resolved. Op-node state maps persist across // evaluations (for stateful nodes like moving_average / lua). type runtimeGraph struct { order []*rtNode // topological order (sources first, output last) sources []rtSource // source nodes, in topological order outputID string // id of the output node outType dsp.ValType // best-effort output type (scalar/array/unknown) } type rtNode struct { id string kind string // source | op | output op dsp.Node // set for kind==op state map[string]any // persistent per-node state (op only) inputs []string // upstream node ids, in input order } type rtSource struct { id string ref broker.SignalRef } // sourceRefs returns the broker references for every source node, in a stable // order matching rg.sources. func (rg *runtimeGraph) sourceRefs() []broker.SignalRef { refs := make([]broker.SignalRef, len(rg.sources)) for i, s := range rg.sources { refs[i] = s.ref } return refs } // evalSample computes the output Sample (scalar or array) given the latest // value for each source node (keyed by source node id). Nodes are visited in // topological order so every input is present by the time a node is processed. // // Op dispatch: // - ArrayNode ops (reductions/producers) run natively on Samples. // - stateless elementwise ops broadcast over array inputs. // - stateful ops (filters) and lua are scalar-only; an array input errors, // since their per-evaluation state cannot be split across array lanes. func (rg *runtimeGraph) evalSample(sourceVals map[string]dsp.Sample) (dsp.Sample, error) { vals, err := rg.evalSampleTrace(sourceVals) if err != nil { return dsp.Sample{}, err } return vals[rg.outputID], nil } // evalSampleTrace runs a full forward pass like evalSample but returns the value // computed for *every* node (sources, ops and the output), keyed by node id. It // is used by the editor's live/debug trace to show each node's current value. // On an op error it returns the values computed so far together with the error, // so partial results can still be displayed. func (rg *runtimeGraph) evalSampleTrace(sourceVals map[string]dsp.Sample) (map[string]dsp.Sample, error) { vals := make(map[string]dsp.Sample, len(rg.order)+len(sourceVals)) for id, v := range sourceVals { vals[id] = v } for _, n := range rg.order { switch n.kind { case "op": in := make([]dsp.Sample, len(n.inputs)) for i, id := range n.inputs { in[i] = vals[id] } r, err := evalOp(n, in) if err != nil { return vals, fmt.Errorf("node %s (%s): %w", n.id, n.op.Type(), err) } vals[n.id] = r case "output": if len(n.inputs) > 0 { vals[n.id] = vals[n.inputs[0]] } } } return vals, nil } // evalOp runs a single op node over its Sample inputs, choosing the right // execution path for the node type. func evalOp(n *rtNode, in []dsp.Sample) (dsp.Sample, error) { if an, ok := n.op.(dsp.ArrayNode); ok { return an.ProcessSample(in, n.state) } if dsp.StatelessElementwise(n.op.Type()) { return dsp.ApplyElementwise(n.op, in, n.state) } // Stateful / lua: scalar-only. row := make([]float64, len(in)) for i, s := range in { if s.IsArray { return dsp.Sample{}, fmt.Errorf("does not accept an array input") } row[i] = s.F } r, err := n.op.Process(row, n.state) if err != nil { return dsp.Sample{}, err } return dsp.Scalar(r), nil } // eval is the scalar wrapper around evalSample, kept so callers and tests that // deal purely in float64 (legacy linear graphs, scalar sources) are unchanged. func (rg *runtimeGraph) eval(sourceVals map[string]float64) (float64, error) { sv := make(map[string]dsp.Sample, len(sourceVals)) for id, v := range sourceVals { sv[id] = dsp.Scalar(v) } out, err := rg.evalSample(sv) if err != nil { return 0, err } return out.F, nil } // compileGraph converts a SignalDef into an executable runtimeGraph. When the // def carries an explicit Graph it is used directly; otherwise the legacy // Inputs+Pipeline form is converted to an equivalent linear graph (see toGraph). func compileGraph(def SignalDef) (*runtimeGraph, error) { g := toGraph(def) if g == nil || len(g.Nodes) == 0 { return &runtimeGraph{}, nil } order, err := topoOrder(g) if err != nil { return nil, err } rg := &runtimeGraph{outputID: g.Output} // nodeType tracks each node's best-effort output type for static // propagation. Sources are unknown at compile time (their real type is // only known once data flows), so type errors here are advisory; runtime // Sample typing is authoritative. nodeType := make(map[string]dsp.ValType, len(order)) for _, gn := range order { switch gn.Kind { case "source": rg.sources = append(rg.sources, rtSource{id: gn.ID, ref: broker.SignalRef{DS: gn.DS, Name: gn.Signal}}) nodeType[gn.ID] = dsp.ValUnknown case "op": node, err := buildNode(NodeDef{Type: gn.Op, Params: gn.Params}) if err != nil { return nil, fmt.Errorf("node %q: %w", gn.ID, err) } inTypes := make([]dsp.ValType, len(gn.Inputs)) for i, id := range gn.Inputs { inTypes[i] = nodeType[id] } ot, terr := dsp.OpOutputType(gn.Op, inTypes) if terr != nil { return nil, fmt.Errorf("node %q: %w", gn.ID, terr) } nodeType[gn.ID] = ot rg.order = append(rg.order, &rtNode{id: gn.ID, kind: "op", op: node, state: map[string]any{}, inputs: gn.Inputs}) case "output": rg.outputID = gn.ID if len(gn.Inputs) > 0 { nodeType[gn.ID] = nodeType[gn.Inputs[0]] } rg.order = append(rg.order, &rtNode{id: gn.ID, kind: "output", inputs: gn.Inputs}) default: return nil, fmt.Errorf("node %q: unknown kind %q", gn.ID, gn.Kind) } } rg.outType = nodeType[rg.outputID] return rg, nil } // topoOrder returns the graph's nodes in a topological (dependency-first) order, // treating each node's Inputs as its predecessors. It errors on dangling input // references or cycles. func topoOrder(g *Graph) ([]GraphNode, error) { byID := make(map[string]GraphNode, len(g.Nodes)) for _, n := range g.Nodes { byID[n.ID] = n } indeg := make(map[string]int, len(g.Nodes)) succ := make(map[string][]string, len(g.Nodes)) for _, n := range g.Nodes { if _, ok := indeg[n.ID]; !ok { indeg[n.ID] = 0 } for _, in := range n.Inputs { if _, ok := byID[in]; !ok { return nil, fmt.Errorf("node %q references unknown input %q", n.ID, in) } indeg[n.ID]++ succ[in] = append(succ[in], n.ID) } } // Seed the queue with roots, preserving the node slice order for determinism. queue := make([]string, 0, len(g.Nodes)) for _, n := range g.Nodes { if indeg[n.ID] == 0 { queue = append(queue, n.ID) } } order := make([]GraphNode, 0, len(g.Nodes)) for len(queue) > 0 { id := queue[0] queue = queue[1:] order = append(order, byID[id]) for _, s := range succ[id] { indeg[s]-- if indeg[s] == 0 { queue = append(queue, s) } } } if len(order) != len(g.Nodes) { return nil, errors.New("graph contains a cycle") } return order, nil } // toGraph returns the DAG for a SignalDef. If def.Graph is set it is returned // as-is. Otherwise the legacy linear form is converted: each input signal // becomes a source node, the pipeline becomes a chain of op nodes (the first op // receiving every source, each later op the previous op's output), terminated by // an output node. With no pipeline the output takes the first source directly, // matching the old runPipeline behaviour. func toGraph(def SignalDef) *Graph { if def.Graph != nil && len(def.Graph.Nodes) > 0 { return def.Graph } inputs := def.Inputs if len(inputs) == 0 && def.DS != "" && def.Signal != "" { inputs = []InputRef{{DS: def.DS, Signal: def.Signal}} } nodes := make([]GraphNode, 0, len(inputs)+len(def.Pipeline)+1) srcIDs := make([]string, 0, len(inputs)) for i, inp := range inputs { id := fmt.Sprintf("s%d", i) nodes = append(nodes, GraphNode{ID: id, Kind: "source", DS: inp.DS, Signal: inp.Signal}) srcIDs = append(srcIDs, id) } opIDs := make([]string, 0, len(def.Pipeline)) for i, nd := range def.Pipeline { id := fmt.Sprintf("p%d", i) var ins []string if i == 0 { ins = srcIDs } else { ins = []string{opIDs[i-1]} } nodes = append(nodes, GraphNode{ID: id, Kind: "op", Op: nd.Type, Params: nd.Params, Inputs: ins}) opIDs = append(opIDs, id) } var outInputs []string if len(opIDs) > 0 { outInputs = []string{opIDs[len(opIDs)-1]} } else if len(srcIDs) > 0 { outInputs = []string{srcIDs[0]} } nodes = append(nodes, GraphNode{ID: "out", Kind: "output", Inputs: outInputs}) return &Graph{Nodes: nodes, Output: "out"} }