From 33a85cb72039d3daf268b011095526dbbd490c85 Mon Sep 17 00:00:00 2001 From: Justin Visser Date: Wed, 10 Jun 2026 11:58:45 +0200 Subject: [PATCH] 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. --- internal/engine/graph.go | 50 +++++++++++++++++++++++ internal/engine/graph_test.go | 76 +++++++++++++++++++++++++++++++++++ internal/engine/rng_test.go | 13 +++--- 3 files changed, 133 insertions(+), 6 deletions(-) create mode 100644 internal/engine/graph.go create mode 100644 internal/engine/graph_test.go diff --git a/internal/engine/graph.go b/internal/engine/graph.go new file mode 100644 index 0000000..e6b57e7 --- /dev/null +++ b/internal/engine/graph.go @@ -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] } diff --git a/internal/engine/graph_test.go b/internal/engine/graph_test.go new file mode 100644 index 0000000..56124b3 --- /dev/null +++ b/internal/engine/graph_test.go @@ -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") + } +} diff --git a/internal/engine/rng_test.go b/internal/engine/rng_test.go index 0ba8de4..fe09dbe 100644 --- a/internal/engine/rng_test.go +++ b/internal/engine/rng_test.go @@ -7,18 +7,19 @@ import "testing" // these assertions are exact, not probabilistic. func TestNewRandSameSeedSameStream(t *testing.T) { - a, b := newRand(17), newRand(17) - for i := range 100 { - if got, want := a.Float64(), b.Float64(); got != want { - t.Fatalf("draw %d: streams diverged: %v != %v", i, got, want) + firstStream, secondStream := newRand(17), newRand(17) + for draw := range 100 { + first, second := firstStream.Float64(), secondStream.Float64() + if first != second { + t.Fatalf("draw %d: streams diverged: %v != %v", draw, first, second) } } } func TestNewRandDifferentSeedsDiffer(t *testing.T) { - a, b := newRand(17), newRand(18) + seededWith17, seededWith18 := newRand(17), newRand(18) for range 100 { - if a.Float64() != b.Float64() { + if seededWith17.Float64() != seededWith18.Float64() { return // diverged, as expected } }