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 } }