spreadlab/internal/engine/graph.go
Justin Visser 9a392b1860 api: POST /api/scenario returns result plus graph topology (M3 slice 1)
One panel's whole world in one call: effective config in, echoed
config + cascade result + undirected edge list out. Edges are
[from, to] pairs with from < to in deterministic node order;
Graph.Edges() walks the adjacency once, GraphEdges(config) rebuilds
the seeded world (~25us) so Result stays lean and /api/comparison
stays untouched. This closes the topology gap the design brief
flagged; the frontend's seeded d3-force layout consumes these pairs.
Go bits: [][2]int is a slice of fixed-size arrays; [2]int is a value
type, comparable, and JSON-marshals to [a, b], exactly the wire
shape the spec asks for.
tygo regen includes a fix: engine.Strategy now maps to the generated
Strategy type instead of decaying to 'any' in ScenarioRequest.
Verified live through the dev stack: 7/120 reached, 351 edges.
2026-06-10 15:48:48 +02:00

65 lines
2.1 KiB
Go

package engine
import "slices"
// Graph is an undirected simple graph on nodes 0..n-1. Neighbours are kept
// in insertion order (a slice, not a map) because Go randomises map
// iteration order and the engine must be deterministic: every walk over the
// graph has to visit nodes in the same order on every run.
type Graph struct {
adj [][]int
edges int
}
// NewGraph returns an empty graph with n nodes and no edges.
func NewGraph(n int) *Graph {
return &Graph{adj: make([][]int, n)}
}
// NumNodes returns the number of nodes.
func (g *Graph) NumNodes() int { return len(g.adj) }
// NumEdges returns the number of undirected edges.
func (g *Graph) NumEdges() int { return g.edges }
// AddEdge connects u and v and reports whether the edge was added. Self
// loops and duplicate edges are ignored (reported as false), mirroring how
// networkx's Graph.add_edge treats duplicates as no-ops. Out-of-range nodes
// panic: that is a programmer error, not a runtime condition.
func (g *Graph) AddEdge(u, v int) bool {
if u == v || g.HasEdge(u, v) {
return false
}
g.adj[u] = append(g.adj[u], v)
g.adj[v] = append(g.adj[v], u)
g.edges++
return true
}
// HasEdge reports whether u and v are connected. Degrees in this model are
// small, so a linear scan beats the bookkeeping of a set per node.
func (g *Graph) HasEdge(u, v int) bool {
return slices.Contains(g.adj[u], v)
}
// Degree returns the number of neighbours of u.
func (g *Graph) Degree(u int) int { return len(g.adj[u]) }
// Neighbors returns u's neighbours in insertion order. The slice is the
// graph's own storage: callers must not modify it.
func (g *Graph) Neighbors(u int) []int { return g.adj[u] }
// Edges returns every undirected edge exactly once as a [from, to] pair
// with from < to, in deterministic node order. The slice is freshly
// allocated; callers may keep it.
func (g *Graph) Edges() [][2]int {
edges := make([][2]int, 0, g.edges)
for node := range g.NumNodes() {
for _, neighbor := range g.adj[node] {
if node < neighbor {
edges = append(edges, [2]int{node, neighbor})
}
}
}
return edges
}