spreadlab/internal/engine/holmekim.go
Justin Visser e73db0bdd4 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.
2026-06-10 12:37:57 +02:00

127 lines
4.6 KiB
Go

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)
}
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++ {
builder.attachNewNode(newNode, edgesPerNode, triangleProb)
}
return builder.graph, nil
}
// 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 := 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
}