From e73db0bdd4a65a0d2d238b66ff5e7fd89831a396 Mon Sep 17 00:00:00 2001 From: Justin Visser Date: Wed, 10 Jun 2026 12:37:57 +0200 Subject: [PATCH] refactor engine: decompose Holme-Kim into builder methods The generator's working state (graph, attachment pool, rng) moves into a holmeKimBuilder struct so each algorithm step is a small named method: attachNewNode, link, pickMutualFriend, degreeProportionalSample. Max nesting drops from five levels to two. The RNG call order is untouched, and the golden test (99/70/7 reached) plus the fixed-seed determinism test prove behaviour is bit-for-bit identical. Go bits: pointer receivers ((b *holmeKimBuilder)) let methods mutate the builder; (value, ok) multiple returns are the idiom for 'may not exist', as in pickMutualFriend. --- internal/engine/holmekim.go | 147 +++++++++++++++++++++--------------- 1 file changed, 86 insertions(+), 61 deletions(-) diff --git a/internal/engine/holmekim.go b/internal/engine/holmekim.go index ec2f422..957a404 100644 --- a/internal/engine/holmekim.go +++ b/internal/engine/holmekim.go @@ -26,77 +26,102 @@ func HolmeKim(numNodes, edgesPerNode int, triangleProb float64, rng *rand.Rand) 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 + builder := holmeKimBuilder{graph: NewGraph(numNodes), rng: rng} + // Seed the pool with the first edgesPerNode nodes so the earliest + // arrivals have someone to connect to. + for node := range edgesPerNode { + builder.pool = append(builder.pool, 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) - } + builder.attachNewNode(newNode, edgesPerNode, triangleProb) } - return graph, nil + return builder.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 { +// holmeKimBuilder carries the generator's working state so each step of the +// algorithm reads as a small named method instead of one deeply nested loop. +type holmeKimBuilder struct { + graph *Graph + // pool holds one entry per edge endpoint, so sampling uniformly from it + // picks nodes proportionally to their degree: that is the whole + // "preferential attachment" trick. + pool []int + rng *rand.Rand +} + +// attachNewNode links newNode to edgesPerNode existing nodes: preferential +// attachment by default, a friend-of-a-friend triangle with probability +// triangleProb. +func (b *holmeKimBuilder) attachNewNode(newNode, edgesPerNode int, triangleProb float64) { + // Where this node could attach: edgesPerNode distinct existing nodes, + // drawn degree-proportionally, consumed from the end. + candidates := b.degreeProportionalSample(edgesPerNode) + + target := popLast(&candidates) + b.link(newNode, target) + + for edgesAdded := 1; edgesAdded < edgesPerNode; edgesAdded++ { + if b.rng.Float64() < triangleProb { + if mutualFriend, found := b.pickMutualFriend(newNode, target); found { + b.link(newNode, mutualFriend) + continue + } + } + // Plain preferential attachment. 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 = popLast(&candidates) + b.link(newNode, target) + } + + // The new node enters the pool once per edge slot, like networkx. + for range edgesPerNode { + b.pool = append(b.pool, newNode) + } +} + +// link adds the edge and records the endpoint in the attachment pool, +// raising target's future attachment odds. +func (b *holmeKimBuilder) link(newNode, target int) { + b.graph.AddEdge(newNode, target) + b.pool = append(b.pool, target) +} + +// pickMutualFriend draws a random neighbour of target that newNode is not +// already connected to; closing that link forms a triangle. found is false +// when no such neighbour exists. +func (b *holmeKimBuilder) pickMutualFriend(newNode, target int) (mutualFriend int, found bool) { + var candidates []int + for _, friendOfTarget := range b.graph.Neighbors(target) { + if friendOfTarget != newNode && !b.graph.HasEdge(newNode, friendOfTarget) { + candidates = append(candidates, friendOfTarget) + } + } + if len(candidates) == 0 { + return 0, false + } + return candidates[b.rng.IntN(len(candidates))], true +} + +// degreeProportionalSample draws sampleSize distinct nodes from the pool; +// nodes with more edges appear more often there, so they are +// proportionally more likely. networkx returns a Python set here; we keep +// a slice in draw order so the result is deterministic. +func (b *holmeKimBuilder) degreeProportionalSample(sampleSize int) []int { sample := make([]int, 0, sampleSize) for len(sample) < sampleSize { - drawn := pool[rng.IntN(len(pool))] + drawn := b.pool[b.rng.IntN(len(b.pool))] if !slices.Contains(sample, drawn) { sample = append(sample, drawn) } } return sample } + +// popLast removes and returns the last element of the slice. +func popLast(stack *[]int) int { + last := (*stack)[len(*stack)-1] + *stack = (*stack)[:len(*stack)-1] + return last +}