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.
102 lines
3.9 KiB
Go
102 lines
3.9 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)
|
|
}
|
|
|
|
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
|
|
}
|