engine: edge thresholds and the independent cascade
One random threshold per DIRECTED edge, drawn once per world: scenarios sharing thresholds differ only in who is educated, the prototype's 'same world, different lever' trick. The cascade is plain BFS in rounds; an educated student receives the fake but never forwards it, which is the entire effect of the lever. The result stores the first-reached round per node (exactly what a frontend animation needs). Tests hand-craft a 4-node line graph with exact thresholds, so every expectation is exact: spread, directional blocking, educated cutoff.
This commit is contained in:
parent
7f06ea0d6f
commit
736cd9070d
2 changed files with 187 additions and 0 deletions
85
internal/engine/cascade.go
Normal file
85
internal/engine/cascade.go
Normal file
|
|
@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
102
internal/engine/cascade_test.go
Normal file
102
internal/engine/cascade_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue