diff --git a/internal/engine/cascade.go b/internal/engine/cascade.go new file mode 100644 index 0000000..4d752c5 --- /dev/null +++ b/internal/engine/cascade.go @@ -0,0 +1,85 @@ +package engine + +import "math/rand/v2" + +// EdgeThresholds holds one random draw in [0, 1) per directed edge. The +// cascade forwards across u -> v when the draw is below the forwarding +// probability. Drawing all thresholds once and reusing them recreates the +// prototype's "same world, different lever" comparison: scenarios that +// share thresholds differ only in who is educated. +type EdgeThresholds map[[2]int]float64 + +// NewEdgeThresholds draws a threshold for every directed edge. Both +// directions get independent draws (u forwarding to v is a different event +// than v forwarding to u). Node order makes the draws deterministic. +func NewEdgeThresholds(graph *Graph, rng *rand.Rand) EdgeThresholds { + thresholds := make(EdgeThresholds, 2*graph.NumEdges()) + for node := range graph.NumNodes() { + for _, neighbor := range graph.Neighbors(node) { + if node < neighbor { // visit each undirected edge exactly once + thresholds[[2]int{node, neighbor}] = rng.Float64() + thresholds[[2]int{neighbor, node}] = rng.Float64() + } + } + } + return thresholds +} + +// NeverReached marks a node the fake never got to. +const NeverReached = -1 + +// CascadeResult describes one finished spread. +type CascadeResult struct { + // ReachedAtRound[node] is the round in which node first received the + // fake (round 0 is the origin posting it), or NeverReached. This is + // exactly what an animation needs: the activation time per node. + ReachedAtRound []int + NumReached int + NumRounds int +} + +// RunCascade spreads the fake from origin: in every round, each newly +// reached student forwards to each neighbour whose edge threshold falls +// below forwardProb. Educated students receive the fake but never forward +// it; that is the entire effect of the education lever. +func RunCascade(graph *Graph, origin int, forwardProb float64, educated []int, thresholds EdgeThresholds) CascadeResult { + isEducated := make([]bool, graph.NumNodes()) + for _, student := range educated { + isEducated[student] = true + } + + reachedAtRound := make([]int, graph.NumNodes()) + for node := range reachedAtRound { + reachedAtRound[node] = NeverReached + } + reachedAtRound[origin] = 0 + + numReached := 1 + lastRound := 0 + frontier := []int{origin} + for round := 1; len(frontier) > 0; round++ { + var nextFrontier []int + for _, forwarder := range frontier { + if isEducated[forwarder] { + continue // received the fake, refuses to pass it on + } + for _, receiver := range graph.Neighbors(forwarder) { + alreadyReached := reachedAtRound[receiver] != NeverReached + forwards := thresholds[[2]int{forwarder, receiver}] < forwardProb + if !alreadyReached && forwards { + reachedAtRound[receiver] = round + numReached++ + lastRound = round + nextFrontier = append(nextFrontier, receiver) + } + } + } + frontier = nextFrontier + } + + return CascadeResult{ + ReachedAtRound: reachedAtRound, + NumReached: numReached, + NumRounds: lastRound + 1, + } +} diff --git a/internal/engine/cascade_test.go b/internal/engine/cascade_test.go new file mode 100644 index 0000000..d7cf017 --- /dev/null +++ b/internal/engine/cascade_test.go @@ -0,0 +1,102 @@ +package engine + +import ( + "slices" + "testing" +) + +// The cascade is deterministic once thresholds are fixed, so these tests +// hand-craft a tiny line graph (0-1-2-3) and exact thresholds: no +// randomness, every expectation is exact. + +func lineGraph(numNodes int) *Graph { + graph := NewGraph(numNodes) + for node := 0; node < numNodes-1; node++ { + graph.AddEdge(node, node+1) + } + return graph +} + +// uniformThresholds gives every directed edge the same threshold. +func uniformThresholds(graph *Graph, value float64) EdgeThresholds { + thresholds := make(EdgeThresholds) + for node := range graph.NumNodes() { + for _, neighbor := range graph.Neighbors(node) { + thresholds[[2]int{node, neighbor}] = value + } + } + return thresholds +} + +func TestRunCascadeSpreadsAlongOpenEdges(t *testing.T) { + graph := lineGraph(4) + thresholds := uniformThresholds(graph, 0.1) // 0.1 < 0.5: every edge forwards + + result := RunCascade(graph, 0, 0.5, nil, thresholds) + + wantRounds := []int{0, 1, 2, 3} // one hop further each round + if !slices.Equal(result.ReachedAtRound, wantRounds) { + t.Errorf("ReachedAtRound = %v, want %v", result.ReachedAtRound, wantRounds) + } + if result.NumReached != 4 { + t.Errorf("NumReached = %d, want 4", result.NumReached) + } + if result.NumRounds != 4 { + t.Errorf("NumRounds = %d, want 4", result.NumRounds) + } +} + +func TestRunCascadeThresholdBlocksOneDirection(t *testing.T) { + graph := lineGraph(4) + thresholds := uniformThresholds(graph, 0.1) + // Close the 1 -> 2 direction only. The open 2 -> 1 direction must not + // matter: student 2 never gets the fake, so never forwards anything. + thresholds[[2]int{1, 2}] = 0.9 + + result := RunCascade(graph, 0, 0.5, nil, thresholds) + + wantRounds := []int{0, 1, NeverReached, NeverReached} + if !slices.Equal(result.ReachedAtRound, wantRounds) { + t.Errorf("ReachedAtRound = %v, want %v", result.ReachedAtRound, wantRounds) + } + if result.NumReached != 2 { + t.Errorf("NumReached = %d, want 2", result.NumReached) + } +} + +func TestRunCascadeEducatedReceivesButDoesNotForward(t *testing.T) { + graph := lineGraph(4) + thresholds := uniformThresholds(graph, 0.1) + + result := RunCascade(graph, 0, 0.5, []int{1}, thresholds) + + // Student 1 is educated: still receives the fake in round 1, but the + // chain stops there. + wantRounds := []int{0, 1, NeverReached, NeverReached} + if !slices.Equal(result.ReachedAtRound, wantRounds) { + t.Errorf("ReachedAtRound = %v, want %v", result.ReachedAtRound, wantRounds) + } +} + +func TestNewEdgeThresholdsDeterministicAndComplete(t *testing.T) { + graph, err := HolmeKim(30, 3, 0.45, newRand(17)) + if err != nil { + t.Fatal(err) + } + + first := NewEdgeThresholds(graph, newRand(2)) + second := NewEdgeThresholds(graph, newRand(2)) + + if wantSize := 2 * graph.NumEdges(); len(first) != wantSize { + t.Errorf("len(thresholds) = %d, want %d (two directions per edge)", len(first), wantSize) + } + for directedEdge, firstDraw := range first { + if firstDraw < 0 || firstDraw >= 1 { + t.Errorf("threshold %v = %v, want in [0, 1)", directedEdge, firstDraw) + } + if secondDraw := second[directedEdge]; firstDraw != secondDraw { + t.Errorf("threshold %v differs across identical seeds: %v != %v", + directedEdge, firstDraw, secondDraw) + } + } +}