engine: Holme-Kim network generator (powerlaw_cluster_graph port)
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.
This commit is contained in:
parent
33a85cb720
commit
7f06ea0d6f
2 changed files with 218 additions and 0 deletions
102
internal/engine/holmekim.go
Normal file
102
internal/engine/holmekim.go
Normal file
|
|
@ -0,0 +1,102 @@
|
|||
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
|
||||
}
|
||||
116
internal/engine/holmekim_test.go
Normal file
116
internal/engine/holmekim_test.go
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
package engine
|
||||
|
||||
import (
|
||||
"slices"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// The generator is random, so these are property tests: instead of pinning
|
||||
// exact graphs we assert what must hold for ANY valid output (size, edge
|
||||
// bounds, connectivity, hubs) across several seeds, plus exact determinism
|
||||
// for a fixed seed. The prototype's values: 120 students, 3 edges per new
|
||||
// student, triangle probability 0.45.
|
||||
|
||||
const (
|
||||
testNumNodes = 120
|
||||
testEdgesPerNode = 3
|
||||
testTriangleProb = 0.45
|
||||
)
|
||||
|
||||
func TestHolmeKimRejectsInvalidParameters(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
numNodes int
|
||||
edgesPerNode int
|
||||
triangleProb float64
|
||||
}{
|
||||
{name: "zero edges per node", numNodes: 10, edgesPerNode: 0, triangleProb: 0.5},
|
||||
{name: "edges per node not below node count", numNodes: 3, edgesPerNode: 3, triangleProb: 0.5},
|
||||
{name: "negative triangle probability", numNodes: 10, edgesPerNode: 2, triangleProb: -0.1},
|
||||
{name: "triangle probability above one", numNodes: 10, edgesPerNode: 2, triangleProb: 1.1},
|
||||
}
|
||||
for _, testCase := range tests {
|
||||
t.Run(testCase.name, func(t *testing.T) {
|
||||
_, err := HolmeKim(testCase.numNodes, testCase.edgesPerNode, testCase.triangleProb, newRand(1))
|
||||
if err == nil {
|
||||
t.Errorf("HolmeKim(%d, %d, %v) accepted invalid parameters",
|
||||
testCase.numNodes, testCase.edgesPerNode, testCase.triangleProb)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestHolmeKimDeterministicForSameSeed(t *testing.T) {
|
||||
first, err := HolmeKim(testNumNodes, testEdgesPerNode, testTriangleProb, newRand(17))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
second, err := HolmeKim(testNumNodes, testEdgesPerNode, testTriangleProb, newRand(17))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for node := range testNumNodes {
|
||||
if !slices.Equal(first.Neighbors(node), second.Neighbors(node)) {
|
||||
t.Fatalf("node %d: neighbour lists differ for identical seeds: %v vs %v",
|
||||
node, first.Neighbors(node), second.Neighbors(node))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestHolmeKimProperties(t *testing.T) {
|
||||
for _, seed := range []uint64{1, 2, 17} {
|
||||
graph, err := HolmeKim(testNumNodes, testEdgesPerNode, testTriangleProb, newRand(seed))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if got := graph.NumNodes(); got != testNumNodes {
|
||||
t.Errorf("seed %d: NumNodes() = %d, want %d", seed, got, testNumNodes)
|
||||
}
|
||||
|
||||
// Every new node attempts exactly edgesPerNode attachments; some
|
||||
// may collide with an edge a triangle step already added, so the
|
||||
// count is bounded, not exact.
|
||||
grownNodes := testNumNodes - testEdgesPerNode
|
||||
minEdges, maxEdges := grownNodes, grownNodes*testEdgesPerNode
|
||||
if got := graph.NumEdges(); got < minEdges || got > maxEdges {
|
||||
t.Errorf("seed %d: NumEdges() = %d, want within [%d, %d]", seed, got, minEdges, maxEdges)
|
||||
}
|
||||
|
||||
if !isConnected(graph) {
|
||||
t.Errorf("seed %d: graph is not connected", seed)
|
||||
}
|
||||
|
||||
// Preferential attachment must produce hubs: some node far better
|
||||
// connected than the attachment minimum.
|
||||
maxDegree := 0
|
||||
for node := range graph.NumNodes() {
|
||||
maxDegree = max(maxDegree, graph.Degree(node))
|
||||
}
|
||||
if maxDegree < 3*testEdgesPerNode {
|
||||
t.Errorf("seed %d: max degree %d, want at least %d (no hubs formed)",
|
||||
seed, maxDegree, 3*testEdgesPerNode)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// isConnected reports whether every node is reachable from node 0,
|
||||
// via breadth-first search.
|
||||
func isConnected(graph *Graph) bool {
|
||||
visited := make([]bool, graph.NumNodes())
|
||||
visited[0] = true
|
||||
frontier := []int{0}
|
||||
visitedCount := 1
|
||||
for len(frontier) > 0 {
|
||||
current := frontier[0]
|
||||
frontier = frontier[1:]
|
||||
for _, neighbor := range graph.Neighbors(current) {
|
||||
if !visited[neighbor] {
|
||||
visited[neighbor] = true
|
||||
visitedCount++
|
||||
frontier = append(frontier, neighbor)
|
||||
}
|
||||
}
|
||||
}
|
||||
return visitedCount == graph.NumNodes()
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue