From 7f06ea0d6f51e5f4f575bbda2fb512d208363600 Mon Sep 17 00:00:00 2001 From: Justin Visser Date: Wed, 10 Jun 2026 12:05:43 +0200 Subject: [PATCH] engine: Holme-Kim network generator (powerlaw_cluster_graph port) Preferential attachment via an attachment pool that holds one entry per edge endpoint, so uniform draws are degree-proportional: that is the whole hub-forming mechanism. A triangle step closes friend-of-a-friend links with probability triangleProb, giving friend-group clustering. Semantics ported from networkx, NOT its RNG stream (per the handoff, no cross-language number matching). Tests are property-based: size, edge bounds, connectivity, hub formation across seeds, plus exact determinism for a fixed seed. 'go test ./...', gofmt and golangci-lint all clean. --- internal/engine/holmekim.go | 102 +++++++++++++++++++++++++++ internal/engine/holmekim_test.go | 116 +++++++++++++++++++++++++++++++ 2 files changed, 218 insertions(+) create mode 100644 internal/engine/holmekim.go create mode 100644 internal/engine/holmekim_test.go diff --git a/internal/engine/holmekim.go b/internal/engine/holmekim.go new file mode 100644 index 0000000..ec2f422 --- /dev/null +++ b/internal/engine/holmekim.go @@ -0,0 +1,102 @@ +package engine + +import ( + "fmt" + "math/rand/v2" + "slices" +) + +// HolmeKim generates a random social network of numNodes nodes using the +// Holme-Kim "powerlaw cluster" model, a port of networkx's +// powerlaw_cluster_graph. Each new node attaches to edgesPerNode existing +// nodes by preferential attachment (popular nodes attract more links, which +// produces hubs), and after each attachment a triangle is closed with +// probability triangleProb (a friend of a friend becomes a friend, which +// produces the clustering of real friend groups). +// +// The port preserves the model's semantics, not networkx's random number +// stream: the same seed gives the same graph here, but not the same graph +// as Python. +func HolmeKim(numNodes, edgesPerNode int, triangleProb float64, rng *rand.Rand) (*Graph, error) { + if edgesPerNode < 1 || edgesPerNode >= numNodes { + return nil, fmt.Errorf("holme-kim: need 1 <= edgesPerNode < numNodes, got edgesPerNode=%d numNodes=%d", + edgesPerNode, numNodes) + } + if triangleProb < 0 || triangleProb > 1 { + return nil, fmt.Errorf("holme-kim: need 0 <= triangleProb <= 1, got %v", triangleProb) + } + + graph := NewGraph(numNodes) + + // One entry per edge endpoint, so sampling uniformly from this list is + // sampling nodes proportionally to their degree: that is the whole + // "preferential attachment" trick. Seeded with the first edgesPerNode + // nodes so the earliest arrivals have someone to connect to. + attachmentPool := make([]int, edgesPerNode) + for node := range attachmentPool { + attachmentPool[node] = node + } + + for newNode := edgesPerNode; newNode < numNodes; newNode++ { + // Where this node could attach: edgesPerNode distinct existing + // nodes, drawn degree-proportionally. Consumed from the end. + candidates := degreeProportionalSample(attachmentPool, edgesPerNode, rng) + + target := candidates[len(candidates)-1] + candidates = candidates[:len(candidates)-1] + graph.AddEdge(newNode, target) + attachmentPool = append(attachmentPool, target) + + for edgesAdded := 1; edgesAdded < edgesPerNode; { + // Triangle step: with probability triangleProb, also link to a + // friend of the node we just attached to. + if rng.Float64() < triangleProb { + var mutualCandidates []int + for _, friendOfTarget := range graph.Neighbors(target) { + if friendOfTarget != newNode && !graph.HasEdge(newNode, friendOfTarget) { + mutualCandidates = append(mutualCandidates, friendOfTarget) + } + } + if len(mutualCandidates) > 0 { + mutualFriend := mutualCandidates[rng.IntN(len(mutualCandidates))] + graph.AddEdge(newNode, mutualFriend) + attachmentPool = append(attachmentPool, mutualFriend) + edgesAdded++ + continue + } + } + // Otherwise (or if no triangle was possible): plain + // preferential attachment to the next candidate. Mirrors + // networkx, including the quirk that a candidate already linked + // via a triangle step counts as an attempt without adding an + // edge, so a node can end up with slightly fewer than + // edgesPerNode edges. + target = candidates[len(candidates)-1] + candidates = candidates[:len(candidates)-1] + graph.AddEdge(newNode, target) + attachmentPool = append(attachmentPool, target) + edgesAdded++ + } + + // The new node enters the pool once per edge slot, like networkx. + for range edgesPerNode { + attachmentPool = append(attachmentPool, newNode) + } + } + return graph, nil +} + +// degreeProportionalSample draws sampleSize distinct nodes from the pool. +// The pool holds one entry per edge endpoint, so nodes with more edges are +// proportionally more likely to be drawn. networkx returns a Python set +// here; we keep a slice in draw order so the result is deterministic. +func degreeProportionalSample(pool []int, sampleSize int, rng *rand.Rand) []int { + sample := make([]int, 0, sampleSize) + for len(sample) < sampleSize { + drawn := pool[rng.IntN(len(pool))] + if !slices.Contains(sample, drawn) { + sample = append(sample, drawn) + } + } + return sample +} diff --git a/internal/engine/holmekim_test.go b/internal/engine/holmekim_test.go new file mode 100644 index 0000000..ae679fc --- /dev/null +++ b/internal/engine/holmekim_test.go @@ -0,0 +1,116 @@ +package engine + +import ( + "slices" + "testing" +) + +// The generator is random, so these are property tests: instead of pinning +// exact graphs we assert what must hold for ANY valid output (size, edge +// bounds, connectivity, hubs) across several seeds, plus exact determinism +// for a fixed seed. The prototype's values: 120 students, 3 edges per new +// student, triangle probability 0.45. + +const ( + testNumNodes = 120 + testEdgesPerNode = 3 + testTriangleProb = 0.45 +) + +func TestHolmeKimRejectsInvalidParameters(t *testing.T) { + tests := []struct { + name string + numNodes int + edgesPerNode int + triangleProb float64 + }{ + {name: "zero edges per node", numNodes: 10, edgesPerNode: 0, triangleProb: 0.5}, + {name: "edges per node not below node count", numNodes: 3, edgesPerNode: 3, triangleProb: 0.5}, + {name: "negative triangle probability", numNodes: 10, edgesPerNode: 2, triangleProb: -0.1}, + {name: "triangle probability above one", numNodes: 10, edgesPerNode: 2, triangleProb: 1.1}, + } + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + _, err := HolmeKim(testCase.numNodes, testCase.edgesPerNode, testCase.triangleProb, newRand(1)) + if err == nil { + t.Errorf("HolmeKim(%d, %d, %v) accepted invalid parameters", + testCase.numNodes, testCase.edgesPerNode, testCase.triangleProb) + } + }) + } +} + +func TestHolmeKimDeterministicForSameSeed(t *testing.T) { + first, err := HolmeKim(testNumNodes, testEdgesPerNode, testTriangleProb, newRand(17)) + if err != nil { + t.Fatal(err) + } + second, err := HolmeKim(testNumNodes, testEdgesPerNode, testTriangleProb, newRand(17)) + if err != nil { + t.Fatal(err) + } + for node := range testNumNodes { + if !slices.Equal(first.Neighbors(node), second.Neighbors(node)) { + t.Fatalf("node %d: neighbour lists differ for identical seeds: %v vs %v", + node, first.Neighbors(node), second.Neighbors(node)) + } + } +} + +func TestHolmeKimProperties(t *testing.T) { + for _, seed := range []uint64{1, 2, 17} { + graph, err := HolmeKim(testNumNodes, testEdgesPerNode, testTriangleProb, newRand(seed)) + if err != nil { + t.Fatal(err) + } + + if got := graph.NumNodes(); got != testNumNodes { + t.Errorf("seed %d: NumNodes() = %d, want %d", seed, got, testNumNodes) + } + + // Every new node attempts exactly edgesPerNode attachments; some + // may collide with an edge a triangle step already added, so the + // count is bounded, not exact. + grownNodes := testNumNodes - testEdgesPerNode + minEdges, maxEdges := grownNodes, grownNodes*testEdgesPerNode + if got := graph.NumEdges(); got < minEdges || got > maxEdges { + t.Errorf("seed %d: NumEdges() = %d, want within [%d, %d]", seed, got, minEdges, maxEdges) + } + + if !isConnected(graph) { + t.Errorf("seed %d: graph is not connected", seed) + } + + // Preferential attachment must produce hubs: some node far better + // connected than the attachment minimum. + maxDegree := 0 + for node := range graph.NumNodes() { + maxDegree = max(maxDegree, graph.Degree(node)) + } + if maxDegree < 3*testEdgesPerNode { + t.Errorf("seed %d: max degree %d, want at least %d (no hubs formed)", + seed, maxDegree, 3*testEdgesPerNode) + } + } +} + +// isConnected reports whether every node is reachable from node 0, +// via breadth-first search. +func isConnected(graph *Graph) bool { + visited := make([]bool, graph.NumNodes()) + visited[0] = true + frontier := []int{0} + visitedCount := 1 + for len(frontier) > 0 { + current := frontier[0] + frontier = frontier[1:] + for _, neighbor := range graph.Neighbors(current) { + if !visited[neighbor] { + visited[neighbor] = true + visitedCount++ + frontier = append(frontier, neighbor) + } + } + } + return visitedCount == graph.NumNodes() +}