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.
This commit is contained in:
Justin Visser 2026-06-10 12:37:57 +02:00
parent 45fdf7ffa4
commit e73db0bdd4

View file

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