From b4e5c76692f8b8f089645e11d2715f1872132c1f Mon Sep 17 00:00:00 2001 From: hovsep Date: Sat, 12 Oct 2024 02:57:44 +0300 Subject: [PATCH 1/3] [WIP] Export with cycles --- component/activation_result.go | 21 ++++ export/dot.go | 170 ++++++++++++++++++++++++++++----- export/dot_test.go | 80 ++++++++++++++++ export/exporter.go | 9 +- 4 files changed, 256 insertions(+), 24 deletions(-) diff --git a/component/activation_result.go b/component/activation_result.go index 3b4982e..a69a24a 100644 --- a/component/activation_result.go +++ b/component/activation_result.go @@ -16,6 +16,27 @@ type ActivationResult struct { // ActivationResultCode denotes a specific info about how a component been activated or why not activated at all type ActivationResultCode int +func (a ActivationResultCode) String() string { + switch a { + case ActivationCodeOK: + return "OK" + case ActivationCodeNoInput: + return "No input" + case ActivationCodeNoFunction: + return "Activation function is missing" + case ActivationCodeReturnedError: + return "Returned error" + case ActivationCodePanicked: + return "Panicked" + case ActivationCodeWaitingForInputsClear: + return "Component is waiting for input" + case ActivationCodeWaitingForInputsKeep: + return "Component is waiting for input and wants to keep all inputs till next cycle" + default: + return "Unsupported code" + } +} + const ( // ActivationCodeOK : component is activated and did not return any errors ActivationCodeOK ActivationResultCode = iota diff --git a/export/dot.go b/export/dot.go index ce55f42..28f5d4a 100644 --- a/export/dot.go +++ b/export/dot.go @@ -4,7 +4,8 @@ import ( "bytes" "fmt" "github.com/hovsep/fmesh" - "github.com/hovsep/fmesh/component" + fmeshcomponent "github.com/hovsep/fmesh/component" + "github.com/hovsep/fmesh/cycle" "github.com/hovsep/fmesh/port" "github.com/lucasepe/dot" ) @@ -12,7 +13,11 @@ import ( type dotExporter struct { } -const nodeIDLabel = "export/dot/id" +const ( + nodeIDLabel = "export/dot/id" + portKindInput = "input" + portKindOutput = "output" +) func NewDotExporter() Exporter { return &dotExporter{} @@ -36,11 +41,52 @@ func (d *dotExporter) Export(fm *fmesh.FMesh) ([]byte, error) { return buf.Bytes(), nil } +// ExportWithCycles returns multiple graphs showing the state of the given f-mesh in each activation cycle +func (d *dotExporter) ExportWithCycles(fm *fmesh.FMesh, cycles cycle.Collection) ([][]byte, error) { + if len(fm.Components()) == 0 { + return nil, nil + } + + if len(cycles) == 0 { + return nil, nil + } + + results := make([][]byte, len(cycles)) + + for cycleNumber, c := range cycles { + graphForCycle, err := buildGraphForCycle(fm, c, cycleNumber) + if err != nil { + return nil, err + } + + buf := new(bytes.Buffer) + graphForCycle.Write(buf) + + results[cycleNumber] = buf.Bytes() + } + + return results, nil +} + // buildGraph returns a graph representing the given f-mesh func buildGraph(fm *fmesh.FMesh) (*dot.Graph, error) { mainGraph := getMainGraph(fm) - addComponents(mainGraph, fm.Components()) + addComponents(mainGraph, fm.Components(), nil) + + err := addPipes(mainGraph, fm.Components()) + if err != nil { + return nil, err + } + return mainGraph, nil +} + +func buildGraphForCycle(fm *fmesh.FMesh, activationCycle *cycle.Cycle, cycleNumber int) (*dot.Graph, error) { + mainGraph := getMainGraph(fm) + + addCycleInfo(mainGraph, activationCycle, cycleNumber) + + addComponents(mainGraph, fm.Components(), activationCycle.ActivationResults()) err := addPipes(mainGraph, fm.Components()) if err != nil { @@ -50,7 +96,7 @@ func buildGraph(fm *fmesh.FMesh) (*dot.Graph, error) { } // addPipes adds pipes representation to the graph -func addPipes(graph *dot.Graph, components component.Collection) error { +func addPipes(graph *dot.Graph, components fmeshcomponent.Collection) error { for _, c := range components { for _, srcPort := range c.Outputs() { for _, destPort := range srcPort.Pipes() { @@ -58,15 +104,15 @@ func addPipes(graph *dot.Graph, components component.Collection) error { // so we use the label we added earlier destPortID, err := destPort.Label(nodeIDLabel) if err != nil { - return fmt.Errorf("failed to add pipe: %w", err) + return fmt.Errorf("failed to add pipe to port: %s : %w", destPort.Name(), err) } - // Clean up and leave the f-mesh as it was before export + // Delete label, as it is not needed anymore destPort.DeleteLabel(nodeIDLabel) // Any source port in any pipe is always output port, so we can build its node ID - srcPortNode := graph.FindNodeByID(getPortID(c.Name(), "output", srcPort.Name())) + srcPortNode := graph.FindNodeByID(getPortID(c.Name(), portKindOutput, srcPort.Name())) destPortNode := graph.FindNodeByID(destPortID) - graph.Edge(srcPortNode, destPortNode).Attr("minlen", 3) + graph.Edge(srcPortNode, destPortNode).Attr("minlen", "3") } } } @@ -74,28 +120,32 @@ func addPipes(graph *dot.Graph, components component.Collection) error { } // addComponents adds components representation to the graph -func addComponents(graph *dot.Graph, components component.Collection) { +func addComponents(graph *dot.Graph, components fmeshcomponent.Collection, activationResults fmeshcomponent.ActivationResultCollection) { for _, c := range components { // Component - componentSubgraph := getComponentSubgraph(graph, c) - componentNode := getComponentNode(componentSubgraph, c) + var activationResult *fmeshcomponent.ActivationResult + if activationResults != nil { + activationResult = activationResults.ByComponentName(c.Name()) + } + componentSubgraph := getComponentSubgraph(graph, c, activationResult) + componentNode := getComponentNode(componentSubgraph, c, activationResult) // Input ports for _, p := range c.Inputs() { - portNode := getPortNode(c, p, "input", componentSubgraph) + portNode := getPortNode(c, p, portKindInput, componentSubgraph) componentSubgraph.Edge(portNode, componentNode) } // Output ports for _, p := range c.Outputs() { - portNode := getPortNode(c, p, "output", componentSubgraph) + portNode := getPortNode(c, p, portKindOutput, componentSubgraph) componentSubgraph.Edge(componentNode, portNode) } } } // getPortNode creates and returns a node representing one port -func getPortNode(c *component.Component, port *port.Port, portKind string, componentSubgraph *dot.Graph) *dot.Node { +func getPortNode(c *fmeshcomponent.Component, port *port.Port, portKind string, componentSubgraph *dot.Graph) *dot.Node { portID := getPortID(c.Name(), portKind, port.Name()) //Mark ports to be able to find their respective nodes later when adding pipes @@ -110,7 +160,7 @@ func getPortNode(c *component.Component, port *port.Port, portKind string, compo } // getComponentSubgraph creates component subgraph and returns it -func getComponentSubgraph(graph *dot.Graph, component *component.Component) *dot.Graph { +func getComponentSubgraph(graph *dot.Graph, component *fmeshcomponent.Component, activationResult *fmeshcomponent.ActivationResult) *dot.Graph { componentSubgraph := graph.NewSubgraph() componentSubgraph. NodeBaseAttrs(). @@ -123,17 +173,52 @@ func getComponentSubgraph(graph *dot.Graph, component *component.Component) *dot Attr("bgcolor", "lightgrey"). Attr("margin", "20") + // In cycle + if activationResult != nil { + switch activationResult.Code() { + case fmeshcomponent.ActivationCodeOK: + componentSubgraph.Attr("bgcolor", "green") + case fmeshcomponent.ActivationCodeNoInput: + componentSubgraph.Attr("bgcolor", "yellow") + case fmeshcomponent.ActivationCodeNoFunction: + componentSubgraph.Attr("bgcolor", "gray") + case fmeshcomponent.ActivationCodeReturnedError: + componentSubgraph.Attr("bgcolor", "red") + case fmeshcomponent.ActivationCodePanicked: + componentSubgraph.Attr("bgcolor", "pink") + case fmeshcomponent.ActivationCodeWaitingForInputsClear: + componentSubgraph.Attr("bgcolor", "blue") + case fmeshcomponent.ActivationCodeWaitingForInputsKeep: + componentSubgraph.Attr("bgcolor", "purple") + default: + } + } + return componentSubgraph } -// getComponentNodeCreate creates component node and returns it -func getComponentNode(componentSubgraph *dot.Graph, component *component.Component) *dot.Node { +// getComponentNode creates component node and returns it +func getComponentNode(componentSubgraph *dot.Graph, component *fmeshcomponent.Component, activationResult *fmeshcomponent.ActivationResult) *dot.Node { componentNode := componentSubgraph.Node() - componentNode.Attr("label", "𝑓") + label := "𝑓" + if component.Description() != "" { - componentNode.Attr("label", component.Description()) + label = component.Description() + } + + if activationResult != nil { + + if activationResult.Error() != nil { + errorNode := componentSubgraph.Node() + errorNode. + Attr("shape", "note"). + Attr("label", activationResult.Error().Error()) + componentSubgraph.Edge(componentNode, errorNode) + } } + componentNode. + Attr("label", label). Attr("color", "blue"). Attr("shape", "rect"). Attr("group", component.Name()) @@ -154,21 +239,60 @@ func getMainGraph(fm *fmesh.FMesh) *dot.Graph { return graph } +// addDescription adds f-mesh description to graph func addDescription(graph *dot.Graph, description string) { - descriptionSubgraph := graph.NewSubgraph() - descriptionSubgraph. + subgraph := graph.NewSubgraph() + subgraph. Attr("label", "Description:"). Attr("color", "green"). Attr("fontcolor", "green"). Attr("style", "dashed") - descriptionNode := descriptionSubgraph.Node() - descriptionNode. + node := subgraph.Node() + node. Attr("shape", "plaintext"). Attr("color", "green"). Attr("fontcolor", "green"). Attr("label", description) } +// addCycleInfo adds useful insights about current cycle +func addCycleInfo(graph *dot.Graph, activationCycle *cycle.Cycle, cycleNumber int) { + subgraph := graph.NewSubgraph() + subgraph. + Attr("label", "Cycle info:"). + Attr("style", "dashed") + subgraph.NodeBaseAttrs(). + Attr("shape", "plaintext") + + // Current cycle number + cycleNumberNode := subgraph.Node() + cycleNumberNode.Attr("label", fmt.Sprintf("Current cycle: %d", cycleNumber)) + + // Stats + stats := getCycleStats(activationCycle) + statNode := subgraph.Node() + tableRows := dot.HTML("") + for statName, statValue := range stats { + //@TODO: keep order + tableRows = tableRows + dot.HTML(fmt.Sprintf("", statName, statValue)) + } + tableRows = tableRows + "
%s : %d
" + statNode.Attr("label", tableRows) +} + +// getCycleStats returns basic cycle stats +func getCycleStats(activationCycle *cycle.Cycle) map[string]int { + stats := make(map[string]int) + for _, ar := range activationCycle.ActivationResults() { + if ar.Activated() { + stats["Activated"]++ + } + + stats[ar.Code().String()]++ + } + return stats +} + // getPortID returns unique ID used to locate ports while building pipe edges func getPortID(componentName string, portKind string, portName string) string { return fmt.Sprintf("component/%s/%s/%s", componentName, portKind, portName) diff --git a/export/dot_test.go b/export/dot_test.go index 2f1a682..3455c60 100644 --- a/export/dot_test.go +++ b/export/dot_test.go @@ -4,6 +4,7 @@ import ( "github.com/hovsep/fmesh" "github.com/hovsep/fmesh/component" "github.com/hovsep/fmesh/port" + "github.com/hovsep/fmesh/signal" "github.com/stretchr/testify/assert" "testing" ) @@ -74,3 +75,82 @@ func Test_dotExporter_Export(t *testing.T) { }) } } + +func Test_dotExporter_ExportWithCycles(t *testing.T) { + type args struct { + fm *fmesh.FMesh + } + tests := []struct { + name string + args args + assertions func(t *testing.T, data [][]byte, err error) + }{ + { + name: "empty f-mesh", + args: args{ + fm: fmesh.New("fm"), + }, + assertions: func(t *testing.T, data [][]byte, err error) { + assert.NoError(t, err) + assert.Empty(t, data) + }, + }, + { + name: "happy path", + args: args{ + fm: func() *fmesh.FMesh { + adder := component.New("adder"). + WithDescription("This component adds 2 numbers"). + WithInputs("num1", "num2"). + WithOutputs("result"). + WithActivationFunc(func(inputs port.Collection, outputs port.Collection) error { + num1 := inputs.ByName("num1").Signals().FirstPayload().(int) + num2 := inputs.ByName("num2").Signals().FirstPayload().(int) + + outputs.ByName("result").PutSignals(signal.New(num1 + num2)) + return nil + }) + + multiplier := component.New("multiplier"). + WithDescription("This component multiplies number by 3"). + WithInputs("num"). + WithOutputs("result"). + WithActivationFunc(func(inputs port.Collection, outputs port.Collection) error { + num := inputs.ByName("num").Signals().FirstPayload().(int) + outputs.ByName("result").PutSignals(signal.New(num * 3)) + return nil + }) + + adder.Outputs().ByName("result").PipeTo(multiplier.Inputs().ByName("num")) + + fm := fmesh.New("fm"). + WithDescription("This f-mesh has just one component"). + WithComponents(adder, multiplier) + + adder.Inputs().ByName("num1").PutSignals(signal.New(15)) + adder.Inputs().ByName("num2").PutSignals(signal.New(12)) + + return fm + }(), + }, + assertions: func(t *testing.T, data [][]byte, err error) { + assert.NoError(t, err) + assert.NotEmpty(t, data) + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + + cycles, err := tt.args.fm.Run() + assert.NoError(t, err) + + exporter := NewDotExporter() + + got, err := exporter.ExportWithCycles(tt.args.fm, cycles) + if tt.assertions != nil { + tt.assertions(t, got, err) + } + }) + } +} diff --git a/export/exporter.go b/export/exporter.go index 8888403..7877b37 100644 --- a/export/exporter.go +++ b/export/exporter.go @@ -1,8 +1,15 @@ package export -import "github.com/hovsep/fmesh" +import ( + "github.com/hovsep/fmesh" + "github.com/hovsep/fmesh/cycle" +) // Exporter is the common interface for all formats type Exporter interface { + // Export returns f-mesh representation in some format Export(fm *fmesh.FMesh) ([]byte, error) + + // ExportWithCycles returns representations of f-mesh during multiple cycles + ExportWithCycles(fm *fmesh.FMesh, cycles cycle.Collection) ([][]byte, error) } From 6475a4b2b4e9e9bfe512d447c95a965b212cc551 Mon Sep 17 00:00:00 2001 From: hovsep Date: Sat, 12 Oct 2024 15:52:15 +0300 Subject: [PATCH 2/3] Cycle refactored: add number field --- cycle/cycle.go | 12 ++++++++++++ fmesh.go | 10 ++++++---- fmesh_test.go | 18 ++++++------------ 3 files changed, 24 insertions(+), 16 deletions(-) diff --git a/cycle/cycle.go b/cycle/cycle.go index 72b8118..524d575 100644 --- a/cycle/cycle.go +++ b/cycle/cycle.go @@ -8,6 +8,7 @@ import ( // Cycle contains the info about given activation cycle type Cycle struct { sync.Mutex + number int activationResults component.ActivationResultCollection } @@ -43,3 +44,14 @@ func (cycle *Cycle) WithActivationResults(activationResults ...*component.Activa cycle.activationResults = cycle.ActivationResults().Add(activationResults...) return cycle } + +// Number returns sequence number +func (cycle *Cycle) Number() int { + return cycle.number +} + +// WithNumber sets the sequence number +func (cycle *Cycle) WithNumber(number int) *Cycle { + cycle.number = number + return cycle +} diff --git a/fmesh.go b/fmesh.go index 1ef3cb0..2bd4165 100644 --- a/fmesh.go +++ b/fmesh.go @@ -123,21 +123,23 @@ func (fm *FMesh) drainComponents(cycle *cycle.Cycle) { // Run starts the computation until there is no component which activates (mesh has no unprocessed inputs) func (fm *FMesh) Run() (cycle.Collection, error) { allCycles := cycle.NewCollection() + cycleNumber := 0 for { - cycleResult := fm.runCycle() + cycleResult := fm.runCycle().WithNumber(cycleNumber) allCycles = allCycles.With(cycleResult) - mustStop, err := fm.mustStop(cycleResult, len(allCycles)) + mustStop, err := fm.mustStop(cycleResult) if mustStop { return allCycles, err } fm.drainComponents(cycleResult) + cycleNumber++ } } -func (fm *FMesh) mustStop(cycleResult *cycle.Cycle, cycleNum int) (bool, error) { - if (fm.config.CyclesLimit > 0) && (cycleNum > fm.config.CyclesLimit) { +func (fm *FMesh) mustStop(cycleResult *cycle.Cycle) (bool, error) { + if (fm.config.CyclesLimit > 0) && (cycleResult.Number() > fm.config.CyclesLimit) { return true, ErrReachedMaxAllowedCycles } diff --git a/fmesh_test.go b/fmesh_test.go index 40bf0c9..02f6554 100644 --- a/fmesh_test.go +++ b/fmesh_test.go @@ -637,7 +637,6 @@ func TestFMesh_runCycle(t *testing.T) { func TestFMesh_mustStop(t *testing.T) { type args struct { cycleResult *cycle.Cycle - cycleNum int } tests := []struct { name string @@ -654,8 +653,7 @@ func TestFMesh_mustStop(t *testing.T) { component.NewActivationResult("c1"). SetActivated(true). WithActivationCode(component.ActivationCodeOK), - ), - cycleNum: 5, + ).WithNumber(5), }, want: false, wantErr: nil, @@ -668,8 +666,7 @@ func TestFMesh_mustStop(t *testing.T) { component.NewActivationResult("c1"). SetActivated(true). WithActivationCode(component.ActivationCodeOK), - ), - cycleNum: 1001, + ).WithNumber(1001), }, want: true, wantErr: ErrReachedMaxAllowedCycles, @@ -682,8 +679,7 @@ func TestFMesh_mustStop(t *testing.T) { component.NewActivationResult("c1"). SetActivated(false). WithActivationCode(component.ActivationCodeNoInput), - ), - cycleNum: 5, + ).WithNumber(5), }, want: true, wantErr: nil, @@ -700,8 +696,7 @@ func TestFMesh_mustStop(t *testing.T) { SetActivated(true). WithActivationCode(component.ActivationCodeReturnedError). WithError(errors.New("c1 activation finished with error")), - ), - cycleNum: 5, + ).WithNumber(5), }, want: true, wantErr: ErrHitAnErrorOrPanic, @@ -717,8 +712,7 @@ func TestFMesh_mustStop(t *testing.T) { SetActivated(true). WithActivationCode(component.ActivationCodePanicked). WithError(errors.New("c1 panicked")), - ), - cycleNum: 5, + ).WithNumber(5), }, want: true, wantErr: ErrHitAPanic, @@ -726,7 +720,7 @@ func TestFMesh_mustStop(t *testing.T) { } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { - got, err := tt.fmesh.mustStop(tt.args.cycleResult, tt.args.cycleNum) + got, err := tt.fmesh.mustStop(tt.args.cycleResult) if tt.wantErr != nil { assert.EqualError(t, err, tt.wantErr.Error()) } else { From cd0a5e13fc1ccb35161fdff9f85288d43db6e04b Mon Sep 17 00:00:00 2001 From: hovsep Date: Sat, 12 Oct 2024 17:05:23 +0300 Subject: [PATCH 3/3] Dot exporter: add config --- export/dot.go | 299 --------------------------------- export/dot/config.go | 137 +++++++++++++++ export/dot/dot.go | 315 +++++++++++++++++++++++++++++++++++ export/{ => dot}/dot_test.go | 2 +- export/exporter.go | 4 +- 5 files changed, 455 insertions(+), 302 deletions(-) delete mode 100644 export/dot.go create mode 100644 export/dot/config.go create mode 100644 export/dot/dot.go rename export/{ => dot}/dot_test.go (99%) diff --git a/export/dot.go b/export/dot.go deleted file mode 100644 index 28f5d4a..0000000 --- a/export/dot.go +++ /dev/null @@ -1,299 +0,0 @@ -package export - -import ( - "bytes" - "fmt" - "github.com/hovsep/fmesh" - fmeshcomponent "github.com/hovsep/fmesh/component" - "github.com/hovsep/fmesh/cycle" - "github.com/hovsep/fmesh/port" - "github.com/lucasepe/dot" -) - -type dotExporter struct { -} - -const ( - nodeIDLabel = "export/dot/id" - portKindInput = "input" - portKindOutput = "output" -) - -func NewDotExporter() Exporter { - return &dotExporter{} -} - -// Export returns the f-mesh represented as digraph in DOT language -func (d *dotExporter) Export(fm *fmesh.FMesh) ([]byte, error) { - if len(fm.Components()) == 0 { - return nil, nil - } - - graph, err := buildGraph(fm) - - if err != nil { - return nil, err - } - - buf := new(bytes.Buffer) - graph.Write(buf) - - return buf.Bytes(), nil -} - -// ExportWithCycles returns multiple graphs showing the state of the given f-mesh in each activation cycle -func (d *dotExporter) ExportWithCycles(fm *fmesh.FMesh, cycles cycle.Collection) ([][]byte, error) { - if len(fm.Components()) == 0 { - return nil, nil - } - - if len(cycles) == 0 { - return nil, nil - } - - results := make([][]byte, len(cycles)) - - for cycleNumber, c := range cycles { - graphForCycle, err := buildGraphForCycle(fm, c, cycleNumber) - if err != nil { - return nil, err - } - - buf := new(bytes.Buffer) - graphForCycle.Write(buf) - - results[cycleNumber] = buf.Bytes() - } - - return results, nil -} - -// buildGraph returns a graph representing the given f-mesh -func buildGraph(fm *fmesh.FMesh) (*dot.Graph, error) { - mainGraph := getMainGraph(fm) - - addComponents(mainGraph, fm.Components(), nil) - - err := addPipes(mainGraph, fm.Components()) - if err != nil { - return nil, err - } - return mainGraph, nil -} - -func buildGraphForCycle(fm *fmesh.FMesh, activationCycle *cycle.Cycle, cycleNumber int) (*dot.Graph, error) { - mainGraph := getMainGraph(fm) - - addCycleInfo(mainGraph, activationCycle, cycleNumber) - - addComponents(mainGraph, fm.Components(), activationCycle.ActivationResults()) - - err := addPipes(mainGraph, fm.Components()) - if err != nil { - return nil, err - } - return mainGraph, nil -} - -// addPipes adds pipes representation to the graph -func addPipes(graph *dot.Graph, components fmeshcomponent.Collection) error { - for _, c := range components { - for _, srcPort := range c.Outputs() { - for _, destPort := range srcPort.Pipes() { - // Any destination port in any pipe is input port, but we do not know in which component - // so we use the label we added earlier - destPortID, err := destPort.Label(nodeIDLabel) - if err != nil { - return fmt.Errorf("failed to add pipe to port: %s : %w", destPort.Name(), err) - } - // Delete label, as it is not needed anymore - destPort.DeleteLabel(nodeIDLabel) - - // Any source port in any pipe is always output port, so we can build its node ID - srcPortNode := graph.FindNodeByID(getPortID(c.Name(), portKindOutput, srcPort.Name())) - destPortNode := graph.FindNodeByID(destPortID) - graph.Edge(srcPortNode, destPortNode).Attr("minlen", "3") - } - } - } - return nil -} - -// addComponents adds components representation to the graph -func addComponents(graph *dot.Graph, components fmeshcomponent.Collection, activationResults fmeshcomponent.ActivationResultCollection) { - for _, c := range components { - // Component - var activationResult *fmeshcomponent.ActivationResult - if activationResults != nil { - activationResult = activationResults.ByComponentName(c.Name()) - } - componentSubgraph := getComponentSubgraph(graph, c, activationResult) - componentNode := getComponentNode(componentSubgraph, c, activationResult) - - // Input ports - for _, p := range c.Inputs() { - portNode := getPortNode(c, p, portKindInput, componentSubgraph) - componentSubgraph.Edge(portNode, componentNode) - } - - // Output ports - for _, p := range c.Outputs() { - portNode := getPortNode(c, p, portKindOutput, componentSubgraph) - componentSubgraph.Edge(componentNode, portNode) - } - } -} - -// getPortNode creates and returns a node representing one port -func getPortNode(c *fmeshcomponent.Component, port *port.Port, portKind string, componentSubgraph *dot.Graph) *dot.Node { - portID := getPortID(c.Name(), portKind, port.Name()) - - //Mark ports to be able to find their respective nodes later when adding pipes - port.AddLabel(nodeIDLabel, portID) - - portNode := componentSubgraph.NodeWithID(portID) - portNode. - Attr("label", port.Name()). - Attr("shape", "circle"). - Attr("group", c.Name()) - return portNode -} - -// getComponentSubgraph creates component subgraph and returns it -func getComponentSubgraph(graph *dot.Graph, component *fmeshcomponent.Component, activationResult *fmeshcomponent.ActivationResult) *dot.Graph { - componentSubgraph := graph.NewSubgraph() - componentSubgraph. - NodeBaseAttrs(). - Attr("width", "1.0").Attr("height", "1.0") - componentSubgraph. - Attr("label", component.Name()). - Attr("cluster", "true"). - Attr("style", "rounded"). - Attr("color", "black"). - Attr("bgcolor", "lightgrey"). - Attr("margin", "20") - - // In cycle - if activationResult != nil { - switch activationResult.Code() { - case fmeshcomponent.ActivationCodeOK: - componentSubgraph.Attr("bgcolor", "green") - case fmeshcomponent.ActivationCodeNoInput: - componentSubgraph.Attr("bgcolor", "yellow") - case fmeshcomponent.ActivationCodeNoFunction: - componentSubgraph.Attr("bgcolor", "gray") - case fmeshcomponent.ActivationCodeReturnedError: - componentSubgraph.Attr("bgcolor", "red") - case fmeshcomponent.ActivationCodePanicked: - componentSubgraph.Attr("bgcolor", "pink") - case fmeshcomponent.ActivationCodeWaitingForInputsClear: - componentSubgraph.Attr("bgcolor", "blue") - case fmeshcomponent.ActivationCodeWaitingForInputsKeep: - componentSubgraph.Attr("bgcolor", "purple") - default: - } - } - - return componentSubgraph -} - -// getComponentNode creates component node and returns it -func getComponentNode(componentSubgraph *dot.Graph, component *fmeshcomponent.Component, activationResult *fmeshcomponent.ActivationResult) *dot.Node { - componentNode := componentSubgraph.Node() - label := "𝑓" - - if component.Description() != "" { - label = component.Description() - } - - if activationResult != nil { - - if activationResult.Error() != nil { - errorNode := componentSubgraph.Node() - errorNode. - Attr("shape", "note"). - Attr("label", activationResult.Error().Error()) - componentSubgraph.Edge(componentNode, errorNode) - } - } - - componentNode. - Attr("label", label). - Attr("color", "blue"). - Attr("shape", "rect"). - Attr("group", component.Name()) - return componentNode -} - -// getMainGraph creates and returns the main (root) graph -func getMainGraph(fm *fmesh.FMesh) *dot.Graph { - graph := dot.NewGraph(dot.Directed) - graph. - Attr("layout", "dot"). - Attr("splines", "ortho") - - if fm.Description() != "" { - addDescription(graph, fm.Description()) - } - - return graph -} - -// addDescription adds f-mesh description to graph -func addDescription(graph *dot.Graph, description string) { - subgraph := graph.NewSubgraph() - subgraph. - Attr("label", "Description:"). - Attr("color", "green"). - Attr("fontcolor", "green"). - Attr("style", "dashed") - node := subgraph.Node() - node. - Attr("shape", "plaintext"). - Attr("color", "green"). - Attr("fontcolor", "green"). - Attr("label", description) -} - -// addCycleInfo adds useful insights about current cycle -func addCycleInfo(graph *dot.Graph, activationCycle *cycle.Cycle, cycleNumber int) { - subgraph := graph.NewSubgraph() - subgraph. - Attr("label", "Cycle info:"). - Attr("style", "dashed") - subgraph.NodeBaseAttrs(). - Attr("shape", "plaintext") - - // Current cycle number - cycleNumberNode := subgraph.Node() - cycleNumberNode.Attr("label", fmt.Sprintf("Current cycle: %d", cycleNumber)) - - // Stats - stats := getCycleStats(activationCycle) - statNode := subgraph.Node() - tableRows := dot.HTML("") - for statName, statValue := range stats { - //@TODO: keep order - tableRows = tableRows + dot.HTML(fmt.Sprintf("", statName, statValue)) - } - tableRows = tableRows + "
%s : %d
" - statNode.Attr("label", tableRows) -} - -// getCycleStats returns basic cycle stats -func getCycleStats(activationCycle *cycle.Cycle) map[string]int { - stats := make(map[string]int) - for _, ar := range activationCycle.ActivationResults() { - if ar.Activated() { - stats["Activated"]++ - } - - stats[ar.Code().String()]++ - } - return stats -} - -// getPortID returns unique ID used to locate ports while building pipe edges -func getPortID(componentName string, portKind string, portName string) string { - return fmt.Sprintf("component/%s/%s/%s", componentName, portKind, portName) -} diff --git a/export/dot/config.go b/export/dot/config.go new file mode 100644 index 0000000..c8589c6 --- /dev/null +++ b/export/dot/config.go @@ -0,0 +1,137 @@ +package dot + +import fmeshcomponent "github.com/hovsep/fmesh/component" + +type attributesMap map[string]string + +type ComponentConfig struct { + Subgraph attributesMap + SubgraphNodeBaseAttrs attributesMap + Node attributesMap + NodeDefaultLabel string + ErrorNode attributesMap + SubgraphAttributesByActivationResultCode map[fmeshcomponent.ActivationResultCode]attributesMap +} + +type PortConfig struct { + Node attributesMap +} + +type LegendConfig struct { + Subgraph attributesMap + Node attributesMap +} + +type PipeConfig struct { + Edge attributesMap +} + +type Config struct { + MainGraph attributesMap + Component ComponentConfig + Port PortConfig + Pipe PipeConfig + Legend LegendConfig +} + +var ( + defaultConfig = &Config{ + MainGraph: attributesMap{ + "layout": "dot", + "splines": "ortho", + }, + Component: ComponentConfig{ + Subgraph: attributesMap{ + "cluster": "true", + "style": "rounded", + "color": "black", + "margin": "20", + "penwidth": "5", + }, + SubgraphNodeBaseAttrs: attributesMap{ + "fontname": "Courier New", + "width": "1.0", + "height": "1.0", + "penwidth": "2.5", + "style": "filled", + }, + Node: attributesMap{ + "shape": "rect", + "color": "#9dddea", + "style": "filled", + }, + NodeDefaultLabel: "𝑓", + ErrorNode: nil, + SubgraphAttributesByActivationResultCode: map[fmeshcomponent.ActivationResultCode]attributesMap{ + fmeshcomponent.ActivationCodeOK: { + "color": "green", + }, + fmeshcomponent.ActivationCodeNoInput: { + "color": "yellow", + }, + fmeshcomponent.ActivationCodeNoFunction: { + "color": "gray", + }, + fmeshcomponent.ActivationCodeReturnedError: { + "color": "red", + }, + fmeshcomponent.ActivationCodePanicked: { + "color": "pink", + }, + fmeshcomponent.ActivationCodeWaitingForInputsClear: { + "color": "blue", + }, + fmeshcomponent.ActivationCodeWaitingForInputsKeep: { + "color": "purple", + }, + }, + }, + Port: PortConfig{ + Node: attributesMap{ + "shape": "circle", + }, + }, + Pipe: PipeConfig{ + Edge: attributesMap{ + "minlen": "3", + "penwidth": "2", + "color": "#e437ea", + }, + }, + Legend: LegendConfig{ + Subgraph: attributesMap{ + "style": "dashed,filled", + "fillcolor": "#e2c6fc", + }, + Node: attributesMap{ + "shape": "plaintext", + "color": "green", + "fontname": "Courier New", + }, + }, + } + + legendTemplate = ` + + {{ if .meshDescription }} + + + + {{ end }} + + {{ if .cycleNumber }} + + + + {{ end }} + + {{ if .stats }} + {{ range .stats }} + + + + {{ end }} + {{ end }} +
Description:{{ .meshDescription }}
Cycle:{{ .cycleNumber }}
{{ .Name }}:{{ .Value }}
+ ` +) diff --git a/export/dot/dot.go b/export/dot/dot.go new file mode 100644 index 0000000..270a1d8 --- /dev/null +++ b/export/dot/dot.go @@ -0,0 +1,315 @@ +package dot + +import ( + "bytes" + "fmt" + "github.com/hovsep/fmesh" + fmeshcomponent "github.com/hovsep/fmesh/component" + "github.com/hovsep/fmesh/cycle" + "github.com/hovsep/fmesh/export" + "github.com/hovsep/fmesh/port" + "github.com/lucasepe/dot" + "html/template" + "sort" +) + +type statEntry struct { + Name string + Value int +} + +type dotExporter struct { + config *Config +} + +const ( + nodeIDLabel = "export/dot/id" + portKindInput = "input" + portKindOutput = "output" +) + +// NewDotExporter returns exporter with default configuration +func NewDotExporter() export.Exporter { + return NewDotExporterWithConfig(defaultConfig) +} + +// NewDotExporterWithConfig returns exporter with custom configuration +func NewDotExporterWithConfig(config *Config) export.Exporter { + return &dotExporter{ + config: config, + } +} + +// Export returns the f-mesh as DOT-graph +func (d *dotExporter) Export(fm *fmesh.FMesh) ([]byte, error) { + if len(fm.Components()) == 0 { + return nil, nil + } + + graph, err := d.buildGraph(fm, nil) + + if err != nil { + return nil, err + } + + buf := new(bytes.Buffer) + graph.Write(buf) + + return buf.Bytes(), nil +} + +// ExportWithCycles returns multiple graphs showing the state of the given f-mesh in each activation cycle +func (d *dotExporter) ExportWithCycles(fm *fmesh.FMesh, activationCycles cycle.Collection) ([][]byte, error) { + if len(fm.Components()) == 0 { + return nil, nil + } + + if len(activationCycles) == 0 { + return nil, nil + } + + results := make([][]byte, len(activationCycles)) + + for _, activationCycle := range activationCycles { + graphForCycle, err := d.buildGraph(fm, activationCycle) + if err != nil { + return nil, err + } + + buf := new(bytes.Buffer) + graphForCycle.Write(buf) + + results[activationCycle.Number()] = buf.Bytes() + } + + return results, nil +} + +// buildGraph returns f-mesh as a graph +// activationCycle may be passed optionally to get a representation of f-mesh in a given activation cycle +func (d *dotExporter) buildGraph(fm *fmesh.FMesh, activationCycle *cycle.Cycle) (*dot.Graph, error) { + mainGraph, err := d.getMainGraph(fm, activationCycle) + if err != nil { + return nil, err + } + + d.addComponents(mainGraph, fm.Components(), activationCycle) + + err = d.addPipes(mainGraph, fm.Components()) + if err != nil { + return nil, err + } + return mainGraph, nil +} + +// getMainGraph creates and returns the main (root) graph +func (d *dotExporter) getMainGraph(fm *fmesh.FMesh, activationCycle *cycle.Cycle) (*dot.Graph, error) { + graph := dot.NewGraph(dot.Directed) + + setAttrMap(&graph.AttributesMap, d.config.MainGraph) + + err := d.addLegend(graph, fm, activationCycle) + if err != nil { + return nil, fmt.Errorf("failed to build main graph: %w", err) + } + + return graph, nil +} + +// addPipes adds pipes representation to the graph +func (d *dotExporter) addPipes(graph *dot.Graph, components fmeshcomponent.Collection) error { + for _, c := range components { + for _, srcPort := range c.Outputs() { + for _, destPort := range srcPort.Pipes() { + // Any destination port in any pipe is input port, but we do not know in which component + // so we use the label we added earlier + destPortID, err := destPort.Label(nodeIDLabel) + if err != nil { + return fmt.Errorf("failed to add pipe to port: %s : %w", destPort.Name(), err) + } + // Delete label, as it is not needed anymore + destPort.DeleteLabel(nodeIDLabel) + + // Any source port in any pipe is always output port, so we can build its node ID + srcPortNode := graph.FindNodeByID(getPortID(c.Name(), portKindOutput, srcPort.Name())) + destPortNode := graph.FindNodeByID(destPortID) + + graph.Edge(srcPortNode, destPortNode, func(a *dot.AttributesMap) { + setAttrMap(a, d.config.Pipe.Edge) + }) + } + } + } + return nil +} + +// addComponents adds components representation to the graph +func (d *dotExporter) addComponents(graph *dot.Graph, components fmeshcomponent.Collection, activationCycle *cycle.Cycle) { + for _, c := range components { + // Component + var activationResult *fmeshcomponent.ActivationResult + if activationCycle != nil { + activationResult = activationCycle.ActivationResults().ByComponentName(c.Name()) + } + componentSubgraph := d.getComponentSubgraph(graph, c, activationResult) + componentNode := d.getComponentNode(componentSubgraph, c, activationResult) + + // Input ports + for _, p := range c.Inputs() { + portNode := d.getPortNode(c, p, portKindInput, componentSubgraph) + componentSubgraph.Edge(portNode, componentNode) + } + + // Output ports + for _, p := range c.Outputs() { + portNode := d.getPortNode(c, p, portKindOutput, componentSubgraph) + componentSubgraph.Edge(componentNode, portNode) + } + } +} + +// getPortNode creates and returns a node representing one port +func (d *dotExporter) getPortNode(c *fmeshcomponent.Component, port *port.Port, portKind string, componentSubgraph *dot.Graph) *dot.Node { + portID := getPortID(c.Name(), portKind, port.Name()) + + //Mark ports to be able to find their respective nodes later when adding pipes + port.AddLabel(nodeIDLabel, portID) + + portNode := componentSubgraph.NodeWithID(portID, func(a *dot.AttributesMap) { + setAttrMap(a, d.config.Port.Node) + a.Attr("label", port.Name()).Attr("group", c.Name()) + }) + + return portNode +} + +// getComponentSubgraph creates component subgraph and returns it +func (d *dotExporter) getComponentSubgraph(graph *dot.Graph, component *fmeshcomponent.Component, activationResult *fmeshcomponent.ActivationResult) *dot.Graph { + componentSubgraph := graph.NewSubgraph() + + setAttrMap(componentSubgraph.NodeBaseAttrs(), d.config.Component.SubgraphNodeBaseAttrs) + setAttrMap(&componentSubgraph.AttributesMap, d.config.Component.Subgraph) + + // Set cycle specific attributes + if activationResult != nil { + if attributesByCode, ok := d.config.Component.SubgraphAttributesByActivationResultCode[activationResult.Code()]; ok { + setAttrMap(&componentSubgraph.AttributesMap, attributesByCode) + } + } + + componentSubgraph.Attr("label", component.Name()) + + return componentSubgraph +} + +// getComponentNode creates component node and returns it +func (d *dotExporter) getComponentNode(componentSubgraph *dot.Graph, component *fmeshcomponent.Component, activationResult *fmeshcomponent.ActivationResult) *dot.Node { + componentNode := componentSubgraph.Node(func(a *dot.AttributesMap) { + setAttrMap(a, d.config.Component.Node) + }) + + label := d.config.Component.NodeDefaultLabel + + if component.Description() != "" { + label = component.Description() + } + + if activationResult != nil { + if activationResult.Error() != nil { + errorNode := componentSubgraph.Node(func(a *dot.AttributesMap) { + setAttrMap(a, d.config.Component.ErrorNode) + }) + errorNode. + Attr("label", activationResult.Error().Error()) + componentSubgraph.Edge(componentNode, errorNode) + } + } + + componentNode. + Attr("label", label). + Attr("group", component.Name()) + return componentNode +} + +// addLegend adds useful information about f-mesh and (optionally) current activation cycle +func (d *dotExporter) addLegend(graph *dot.Graph, fm *fmesh.FMesh, activationCycle *cycle.Cycle) error { + subgraph := graph.NewSubgraph() + + setAttrMap(&subgraph.AttributesMap, d.config.Legend.Subgraph) + subgraph.Attr("label", "Legend:") + + legendData := make(map[string]any) + legendData["meshDescription"] = fmt.Sprintf("This mesh consist of %d components", len(fm.Components())) + if fm.Description() != "" { + legendData["meshDescription"] = fm.Description() + } + + if activationCycle != nil { + legendData["cycleNumber"] = activationCycle.Number() + legendData["stats"] = getCycleStats(activationCycle) + } + + legendHTML := new(bytes.Buffer) + err := template.Must( + template.New("legend"). + Parse(legendTemplate)). + Execute(legendHTML, legendData) + + if err != nil { + return fmt.Errorf("failed to render legend: %w", err) + } + + subgraph.Node(func(a *dot.AttributesMap) { + setAttrMap(a, d.config.Legend.Node) + a.Attr("label", dot.HTML(legendHTML.String())) + }) + + return nil +} + +// getCycleStats returns basic cycle stats +func getCycleStats(activationCycle *cycle.Cycle) []*statEntry { + statsMap := map[string]*statEntry{ + // Number of activated must be shown always + "activated": { + Name: "Activated", + Value: 0, + }, + } + for _, ar := range activationCycle.ActivationResults() { + if ar.Activated() { + statsMap["activated"].Value++ + } + + if entryByCode, ok := statsMap[ar.Code().String()]; ok { + entryByCode.Value++ + } else { + statsMap[ar.Code().String()] = &statEntry{ + Name: ar.Code().String(), + Value: 1, + } + } + } + // Convert to slice to preserve keys order + statsList := make([]*statEntry, 0) + for _, entry := range statsMap { + statsList = append(statsList, entry) + } + + sort.Slice(statsList, func(i, j int) bool { + return statsList[i].Name < statsList[j].Name + }) + return statsList +} + +// getPortID returns unique ID used to locate ports while building pipe edges +func getPortID(componentName string, portKind string, portName string) string { + return fmt.Sprintf("component/%s/%s/%s", componentName, portKind, portName) +} + +// setAttrMap sets all attributes to target +func setAttrMap(target *dot.AttributesMap, attributes attributesMap) { + for attrName, attrValue := range attributes { + target.Attr(attrName, attrValue) + } +} diff --git a/export/dot_test.go b/export/dot/dot_test.go similarity index 99% rename from export/dot_test.go rename to export/dot/dot_test.go index 3455c60..7726b47 100644 --- a/export/dot_test.go +++ b/export/dot/dot_test.go @@ -1,4 +1,4 @@ -package export +package dot import ( "github.com/hovsep/fmesh" diff --git a/export/exporter.go b/export/exporter.go index 7877b37..a70499b 100644 --- a/export/exporter.go +++ b/export/exporter.go @@ -10,6 +10,6 @@ type Exporter interface { // Export returns f-mesh representation in some format Export(fm *fmesh.FMesh) ([]byte, error) - // ExportWithCycles returns representations of f-mesh during multiple cycles - ExportWithCycles(fm *fmesh.FMesh, cycles cycle.Collection) ([][]byte, error) + // ExportWithCycles returns the f-mesh state representation in each activation cycle + ExportWithCycles(fm *fmesh.FMesh, activationCycles cycle.Collection) ([][]byte, error) }