From a0635544d91e09d068e730cf69f67a2199303536 Mon Sep 17 00:00:00 2001 From: Justin Visser Date: Thu, 18 Jun 2026 20:03:04 +0200 Subject: [PATCH] engine: additive forward probability, behaviour-preserving Forwarding was a single global ForwardProb; make it a per-student composite (Config.ForwardChance): baseline propensity raised by the fake's Novelty, lowered by ambient HarmAwareness, and scaled down for an educated student by ProgramEffect (1 = today's hard block). RunCascade now takes a precomputed per-student []float64 chance and has no education special case. Defaults are behaviour-neutral, so the 82/58/6 golden tests are unchanged; the model is tuned in a later slice. --- internal/engine/cascade.go | 20 ++++---- internal/engine/cascade_test.go | 21 +++++++-- internal/engine/forwardchance_test.go | 67 +++++++++++++++++++++++++++ internal/engine/scenario.go | 61 ++++++++++++++++++++++-- 4 files changed, 148 insertions(+), 21 deletions(-) create mode 100644 internal/engine/forwardchance_test.go diff --git a/internal/engine/cascade.go b/internal/engine/cascade.go index 4d752c5..47190e1 100644 --- a/internal/engine/cascade.go +++ b/internal/engine/cascade.go @@ -40,14 +40,12 @@ type CascadeResult struct { // 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 - } - +// below that student's forwarding chance. forwardChance[student] already +// encodes the education lever (an educated student's chance is scaled down, +// to zero under a full-strength program), so the cascade has no education +// special case; an educated student receives the fake like anyone else and +// simply forwards it with a lower chance. +func RunCascade(graph *Graph, origin int, forwardChance []float64, thresholds EdgeThresholds) CascadeResult { reachedAtRound := make([]int, graph.NumNodes()) for node := range reachedAtRound { reachedAtRound[node] = NeverReached @@ -60,12 +58,10 @@ func RunCascade(graph *Graph, origin int, forwardProb float64, educated []int, t 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 - } + chance := forwardChance[forwarder] for _, receiver := range graph.Neighbors(forwarder) { alreadyReached := reachedAtRound[receiver] != NeverReached - forwards := thresholds[[2]int{forwarder, receiver}] < forwardProb + forwards := thresholds[[2]int{forwarder, receiver}] < chance if !alreadyReached && forwards { reachedAtRound[receiver] = round numReached++ diff --git a/internal/engine/cascade_test.go b/internal/engine/cascade_test.go index d7cf017..7d9eed4 100644 --- a/internal/engine/cascade_test.go +++ b/internal/engine/cascade_test.go @@ -28,11 +28,20 @@ func uniformThresholds(graph *Graph, value float64) EdgeThresholds { return thresholds } +// uniformChance gives every student the same forwarding chance. +func uniformChance(numNodes int, value float64) []float64 { + chances := make([]float64, numNodes) + for node := range chances { + chances[node] = value + } + return chances +} + 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) + result := RunCascade(graph, 0, uniformChance(4, 0.5), thresholds) wantRounds := []int{0, 1, 2, 3} // one hop further each round if !slices.Equal(result.ReachedAtRound, wantRounds) { @@ -53,7 +62,7 @@ func TestRunCascadeThresholdBlocksOneDirection(t *testing.T) { // 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) + result := RunCascade(graph, 0, uniformChance(4, 0.5), thresholds) wantRounds := []int{0, 1, NeverReached, NeverReached} if !slices.Equal(result.ReachedAtRound, wantRounds) { @@ -68,10 +77,12 @@ 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 by a full-strength program: forwarding chance 0. + chance := uniformChance(4, 0.5) + chance[1] = 0 + result := RunCascade(graph, 0, chance, thresholds) - // Student 1 is educated: still receives the fake in round 1, but the - // chain stops there. + // Student 1 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) diff --git a/internal/engine/forwardchance_test.go b/internal/engine/forwardchance_test.go new file mode 100644 index 0000000..46cfd06 --- /dev/null +++ b/internal/engine/forwardchance_test.go @@ -0,0 +1,67 @@ +package engine + +import "testing" + +// ForwardChance is the additive composite the whole model rests on. These +// pin the formula's shape (which lever pushes which way, the clamp, and how +// the program scales an educated student) in terms of the weight constants, +// so tuning the weights later does not silently break the relationships. +func TestForwardChance(t *testing.T) { + base := DefaultConfig() // ForwardProb 0.38, novelty/harm 0, programEffect 1 + + t.Run("default not educated is the baseline", func(t *testing.T) { + if got := base.ForwardChance(false); got != base.ForwardProb { + t.Errorf("ForwardChance(false) = %v, want baseline %v", got, base.ForwardProb) + } + }) + + t.Run("default educated never forwards under a full program", func(t *testing.T) { + if got := base.ForwardChance(true); got != 0 { + t.Errorf("ForwardChance(true) = %v, want 0 (programEffect 1)", got) + } + }) + + t.Run("novelty raises forwarding", func(t *testing.T) { + config := base + config.Novelty = 1 + want := base.ForwardProb + noveltyWeight + if got := config.ForwardChance(false); got != want { + t.Errorf("ForwardChance = %v, want %v", got, want) + } + }) + + t.Run("harm awareness lowers forwarding", func(t *testing.T) { + config := base + config.HarmAwareness = 0.5 + want := base.ForwardProb - harmAwarenessWeight*0.5 + if got := config.ForwardChance(false); got != want { + t.Errorf("ForwardChance = %v, want %v", got, want) + } + }) + + t.Run("clamps to zero", func(t *testing.T) { + config := base + config.HarmAwareness = 1 // 0.38 - 0.40 < 0 + if got := config.ForwardChance(false); got != 0 { + t.Errorf("ForwardChance = %v, want clamped 0", got) + } + }) + + t.Run("clamps to the ceiling", func(t *testing.T) { + config := base + config.ForwardProb = 0.9 + config.Novelty = 1 // 0.9 + 0.30 > 0.95 + if got := config.ForwardChance(false); got != maxForwardChance { + t.Errorf("ForwardChance = %v, want clamped %v", got, maxForwardChance) + } + }) + + t.Run("a softer program leaves some forwarding", func(t *testing.T) { + config := base + config.ProgramEffect = 0.5 + want := base.ForwardProb * 0.5 + if got := config.ForwardChance(true); got != want { + t.Errorf("ForwardChance(true) = %v, want %v", got, want) + } + }) +} diff --git a/internal/engine/scenario.go b/internal/engine/scenario.go index a210703..27e3a98 100644 --- a/internal/engine/scenario.go +++ b/internal/engine/scenario.go @@ -25,9 +25,17 @@ type Config struct { NumStudents int `json:"numStudents"` EdgesPerNode int `json:"edgesPerNode"` // attachment edges per new student (network density) TriangleProb float64 `json:"triangleProb"` // chance to close a friend-of-a-friend triangle - ForwardProb float64 `json:"forwardProb"` // chance a student forwards the fake along an edge - NumEducated int `json:"numEducated"` // students the education program reaches - Origin int `json:"origin"` // student who first posts the fake + + // Forwarding is an additive composite (see ForwardChance): a baseline + // propensity, raised by how novel/shocking the fake is, lowered by the + // year group's ambient harm awareness. + ForwardProb float64 `json:"forwardProb"` // baseline chance a student forwards the fake along an edge + Novelty float64 `json:"novelty"` // how novel/shocking the fake is (0..1); raises forwarding + HarmAwareness float64 `json:"harmAwareness"` // ambient AI-literacy / harm awareness in the year group (0..1); lowers forwarding + + NumEducated int `json:"numEducated"` // students the education program reaches + ProgramEffect float64 `json:"programEffect"` // how strongly the program suppresses an educated student's forwarding (0..1; 1 = never forwards) + Origin int `json:"origin"` // student who first posts the fake GraphSeed uint64 `json:"graphSeed"` ThresholdSeed uint64 `json:"thresholdSeed"` @@ -42,7 +50,10 @@ func DefaultConfig() Config { EdgesPerNode: 3, TriangleProb: 0.45, ForwardProb: 0.38, + Novelty: 0, // behaviour-neutral until the model is tuned (slice 4) + HarmAwareness: 0, // " NumEducated: 36, + ProgramEffect: 1.0, // a perfect program: today's hard block, softened in slice 4 Origin: 0, GraphSeed: 17, ThresholdSeed: 2, @@ -50,6 +61,35 @@ func DefaultConfig() Config { } } +// Forwarding-composite weights: how far each lever can move the baseline +// forwarding probability. These are provisional, illustrative values, not +// fitted to data; they are tuned for legible behaviour in slice 4. +const ( + noveltyWeight = 0.30 // a maximally novel fake adds up to +0.30 + harmAwarenessWeight = 0.40 // a maximally aware year group subtracts up to -0.40 +) + +// maxForwardChance caps the composite: even the most novel fake in the most +// permissive world is never forwarded with certainty. +const maxForwardChance = 0.95 + +// ForwardChance is the probability that a student forwards the fake along one +// friendship in one round: the additive composite at the heart of the model. +// A baseline propensity is raised by the fake's novelty and lowered by the +// year group's ambient harm awareness; a student the program reached then has +// that propensity scaled down by the program's effect. educated reports +// whether the education program reached this student. +func (config Config) ForwardChance(educated bool) float64 { + propensity := config.ForwardProb + + noveltyWeight*config.Novelty - + harmAwarenessWeight*config.HarmAwareness + propensity = min(max(propensity, 0), maxForwardChance) + if educated { + propensity *= 1 - config.ProgramEffect + } + return propensity +} + // IntBounds is an inclusive allowed range for an integer Config field. type IntBounds struct { Min int `json:"min"` @@ -169,7 +209,20 @@ func RunScenario(config Config, strategy Strategy) (Result, error) { if educated == nil { educated = []int{} // a nil slice marshals to JSON null, not [] } - cascade := RunCascade(graph, config.Origin, config.ForwardProb, educated, thresholds) + + // Fold the education lever into a per-student forwarding chance: an + // educated student's composite is scaled down by the program effect, so + // the cascade itself needs no special case for education. + isEducated := make([]bool, config.NumStudents) + for _, student := range educated { + isEducated[student] = true + } + forwardChance := make([]float64, config.NumStudents) + for student := range forwardChance { + forwardChance[student] = config.ForwardChance(isEducated[student]) + } + + cascade := RunCascade(graph, config.Origin, forwardChance, thresholds) return Result{ Strategy: strategy, Educated: educated,