engine: undirected simple graph with deterministic neighbour order

Adjacency lives in slices, not maps, on purpose: Go randomises map
iteration order between runs, and the engine's determinism guarantee
needs every graph walk to visit neighbours in the same order. AddEdge
mirrors networkx semantics (duplicates and self loops are no-ops) so
the Holme-Kim port can lean on the same behaviour.
Receiver names stay short per Go convention (g *Graph); everything
else uses descriptive names.
This commit is contained in:
Justin Visser 2026-06-10 11:58:45 +02:00
parent 33d40a18ea
commit 33a85cb720
3 changed files with 133 additions and 6 deletions

50
internal/engine/graph.go Normal file
View file

@ -0,0 +1,50 @@
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] }

View file

@ -0,0 +1,76 @@
package engine
import (
"slices"
"testing"
)
// Table-driven tests are the standard Go shape: a slice of cases, one
// t.Run per case so failures name the case that broke.
func TestGraphAddEdge(t *testing.T) {
tests := []struct {
name string
edges [][2]int // applied in order
wantAdded []bool // expected AddEdge result per edge
wantEdges int // expected NumEdges afterwards
}{
{
name: "simple edges",
edges: [][2]int{{0, 1}, {1, 2}},
wantAdded: []bool{true, true},
wantEdges: 2,
},
{
name: "duplicate ignored both directions",
edges: [][2]int{{0, 1}, {0, 1}, {1, 0}},
wantAdded: []bool{true, false, false},
wantEdges: 1,
},
{
name: "self loop ignored",
edges: [][2]int{{2, 2}},
wantAdded: []bool{false},
wantEdges: 0,
},
}
for _, testCase := range tests {
t.Run(testCase.name, func(t *testing.T) {
graph := NewGraph(4)
for edgeIndex, edge := range testCase.edges {
added := graph.AddEdge(edge[0], edge[1])
if added != testCase.wantAdded[edgeIndex] {
t.Errorf("AddEdge(%d, %d) = %v, want %v",
edge[0], edge[1], added, testCase.wantAdded[edgeIndex])
}
}
if got := graph.NumEdges(); got != testCase.wantEdges {
t.Errorf("NumEdges() = %d, want %d", got, testCase.wantEdges)
}
})
}
}
func TestGraphNeighborsAndDegree(t *testing.T) {
graph := NewGraph(4)
graph.AddEdge(0, 1)
graph.AddEdge(0, 2)
graph.AddEdge(0, 3)
if got := graph.Degree(0); got != 3 {
t.Errorf("Degree(0) = %d, want 3", got)
}
if got := graph.Degree(3); got != 1 {
t.Errorf("Degree(3) = %d, want 1", got)
}
// Insertion order is part of the contract (determinism).
if got, want := graph.Neighbors(0), []int{1, 2, 3}; !slices.Equal(got, want) {
t.Errorf("Neighbors(0) = %v, want %v", got, want)
}
if !graph.HasEdge(2, 0) {
t.Error("HasEdge(2, 0) = false, want true (undirected)")
}
if graph.HasEdge(1, 2) {
t.Error("HasEdge(1, 2) = true, want false")
}
}

View file

@ -7,18 +7,19 @@ import "testing"
// these assertions are exact, not probabilistic. // these assertions are exact, not probabilistic.
func TestNewRandSameSeedSameStream(t *testing.T) { func TestNewRandSameSeedSameStream(t *testing.T) {
a, b := newRand(17), newRand(17) firstStream, secondStream := newRand(17), newRand(17)
for i := range 100 { for draw := range 100 {
if got, want := a.Float64(), b.Float64(); got != want { first, second := firstStream.Float64(), secondStream.Float64()
t.Fatalf("draw %d: streams diverged: %v != %v", i, got, want) if first != second {
t.Fatalf("draw %d: streams diverged: %v != %v", draw, first, second)
} }
} }
} }
func TestNewRandDifferentSeedsDiffer(t *testing.T) { func TestNewRandDifferentSeedsDiffer(t *testing.T) {
a, b := newRand(17), newRand(18) seededWith17, seededWith18 := newRand(17), newRand(18)
for range 100 { for range 100 {
if a.Float64() != b.Float64() { if seededWith17.Float64() != seededWith18.Float64() {
return // diverged, as expected return // diverged, as expected
} }
} }