200 lines
6.0 KiB
Go
200 lines
6.0 KiB
Go
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
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
// eval computes the output value given the latest value for each source node
|
|
// (keyed by source node id). Nodes are visited in topological order so every
|
|
// input is already present in vals by the time a node is processed.
|
|
func (rg *runtimeGraph) eval(sourceVals map[string]float64) (float64, error) {
|
|
vals := make(map[string]float64, len(rg.order))
|
|
for id, v := range sourceVals {
|
|
vals[id] = v
|
|
}
|
|
for _, n := range rg.order {
|
|
switch n.kind {
|
|
case "op":
|
|
in := make([]float64, len(n.inputs))
|
|
for i, id := range n.inputs {
|
|
in[i] = vals[id]
|
|
}
|
|
r, err := n.op.Process(in, n.state)
|
|
if err != nil {
|
|
return 0, 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[rg.outputID], 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}
|
|
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}})
|
|
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)
|
|
}
|
|
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
|
|
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)
|
|
}
|
|
}
|
|
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"}
|
|
}
|