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:
parent
45fdf7ffa4
commit
e73db0bdd4
1 changed files with 86 additions and 61 deletions
|
|
@ -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)
|
||||
builder.attachNewNode(newNode, edgesPerNode, triangleProb)
|
||||
}
|
||||
return builder.graph, nil
|
||||
}
|
||||
if len(mutualCandidates) > 0 {
|
||||
mutualFriend := mutualCandidates[rng.IntN(len(mutualCandidates))]
|
||||
graph.AddEdge(newNode, mutualFriend)
|
||||
attachmentPool = append(attachmentPool, mutualFriend)
|
||||
edgesAdded++
|
||||
|
||||
// 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
|
||||
}
|
||||
}
|
||||
// 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++
|
||||
// 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 {
|
||||
attachmentPool = append(attachmentPool, newNode)
|
||||
b.pool = append(b.pool, 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 {
|
||||
// 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
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue