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.
This commit is contained in:
Justin Visser 2026-06-18 20:03:04 +02:00
parent 4c58e8a0f5
commit a0635544d9
4 changed files with 148 additions and 21 deletions

View file

@ -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)