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:
parent
4c58e8a0f5
commit
a0635544d9
4 changed files with 148 additions and 21 deletions
|
|
@ -40,14 +40,12 @@ type CascadeResult struct {
|
||||||
|
|
||||||
// RunCascade spreads the fake from origin: in every round, each newly
|
// RunCascade spreads the fake from origin: in every round, each newly
|
||||||
// reached student forwards to each neighbour whose edge threshold falls
|
// reached student forwards to each neighbour whose edge threshold falls
|
||||||
// below forwardProb. Educated students receive the fake but never forward
|
// below that student's forwarding chance. forwardChance[student] already
|
||||||
// it; that is the entire effect of the education lever.
|
// encodes the education lever (an educated student's chance is scaled down,
|
||||||
func RunCascade(graph *Graph, origin int, forwardProb float64, educated []int, thresholds EdgeThresholds) CascadeResult {
|
// to zero under a full-strength program), so the cascade has no education
|
||||||
isEducated := make([]bool, graph.NumNodes())
|
// special case; an educated student receives the fake like anyone else and
|
||||||
for _, student := range educated {
|
// simply forwards it with a lower chance.
|
||||||
isEducated[student] = true
|
func RunCascade(graph *Graph, origin int, forwardChance []float64, thresholds EdgeThresholds) CascadeResult {
|
||||||
}
|
|
||||||
|
|
||||||
reachedAtRound := make([]int, graph.NumNodes())
|
reachedAtRound := make([]int, graph.NumNodes())
|
||||||
for node := range reachedAtRound {
|
for node := range reachedAtRound {
|
||||||
reachedAtRound[node] = NeverReached
|
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++ {
|
for round := 1; len(frontier) > 0; round++ {
|
||||||
var nextFrontier []int
|
var nextFrontier []int
|
||||||
for _, forwarder := range frontier {
|
for _, forwarder := range frontier {
|
||||||
if isEducated[forwarder] {
|
chance := forwardChance[forwarder]
|
||||||
continue // received the fake, refuses to pass it on
|
|
||||||
}
|
|
||||||
for _, receiver := range graph.Neighbors(forwarder) {
|
for _, receiver := range graph.Neighbors(forwarder) {
|
||||||
alreadyReached := reachedAtRound[receiver] != NeverReached
|
alreadyReached := reachedAtRound[receiver] != NeverReached
|
||||||
forwards := thresholds[[2]int{forwarder, receiver}] < forwardProb
|
forwards := thresholds[[2]int{forwarder, receiver}] < chance
|
||||||
if !alreadyReached && forwards {
|
if !alreadyReached && forwards {
|
||||||
reachedAtRound[receiver] = round
|
reachedAtRound[receiver] = round
|
||||||
numReached++
|
numReached++
|
||||||
|
|
|
||||||
|
|
@ -28,11 +28,20 @@ func uniformThresholds(graph *Graph, value float64) EdgeThresholds {
|
||||||
return thresholds
|
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) {
|
func TestRunCascadeSpreadsAlongOpenEdges(t *testing.T) {
|
||||||
graph := lineGraph(4)
|
graph := lineGraph(4)
|
||||||
thresholds := uniformThresholds(graph, 0.1) // 0.1 < 0.5: every edge forwards
|
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
|
wantRounds := []int{0, 1, 2, 3} // one hop further each round
|
||||||
if !slices.Equal(result.ReachedAtRound, wantRounds) {
|
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.
|
// matter: student 2 never gets the fake, so never forwards anything.
|
||||||
thresholds[[2]int{1, 2}] = 0.9
|
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}
|
wantRounds := []int{0, 1, NeverReached, NeverReached}
|
||||||
if !slices.Equal(result.ReachedAtRound, wantRounds) {
|
if !slices.Equal(result.ReachedAtRound, wantRounds) {
|
||||||
|
|
@ -68,10 +77,12 @@ func TestRunCascadeEducatedReceivesButDoesNotForward(t *testing.T) {
|
||||||
graph := lineGraph(4)
|
graph := lineGraph(4)
|
||||||
thresholds := uniformThresholds(graph, 0.1)
|
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
|
// Student 1 still receives the fake in round 1, but the chain stops there.
|
||||||
// chain stops there.
|
|
||||||
wantRounds := []int{0, 1, NeverReached, NeverReached}
|
wantRounds := []int{0, 1, NeverReached, NeverReached}
|
||||||
if !slices.Equal(result.ReachedAtRound, wantRounds) {
|
if !slices.Equal(result.ReachedAtRound, wantRounds) {
|
||||||
t.Errorf("ReachedAtRound = %v, want %v", result.ReachedAtRound, wantRounds)
|
t.Errorf("ReachedAtRound = %v, want %v", result.ReachedAtRound, wantRounds)
|
||||||
|
|
|
||||||
67
internal/engine/forwardchance_test.go
Normal file
67
internal/engine/forwardchance_test.go
Normal file
|
|
@ -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)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
@ -25,9 +25,17 @@ type Config struct {
|
||||||
NumStudents int `json:"numStudents"`
|
NumStudents int `json:"numStudents"`
|
||||||
EdgesPerNode int `json:"edgesPerNode"` // attachment edges per new student (network density)
|
EdgesPerNode int `json:"edgesPerNode"` // attachment edges per new student (network density)
|
||||||
TriangleProb float64 `json:"triangleProb"` // chance to close a friend-of-a-friend triangle
|
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
|
// Forwarding is an additive composite (see ForwardChance): a baseline
|
||||||
Origin int `json:"origin"` // student who first posts the fake
|
// 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"`
|
GraphSeed uint64 `json:"graphSeed"`
|
||||||
ThresholdSeed uint64 `json:"thresholdSeed"`
|
ThresholdSeed uint64 `json:"thresholdSeed"`
|
||||||
|
|
@ -42,7 +50,10 @@ func DefaultConfig() Config {
|
||||||
EdgesPerNode: 3,
|
EdgesPerNode: 3,
|
||||||
TriangleProb: 0.45,
|
TriangleProb: 0.45,
|
||||||
ForwardProb: 0.38,
|
ForwardProb: 0.38,
|
||||||
|
Novelty: 0, // behaviour-neutral until the model is tuned (slice 4)
|
||||||
|
HarmAwareness: 0, // "
|
||||||
NumEducated: 36,
|
NumEducated: 36,
|
||||||
|
ProgramEffect: 1.0, // a perfect program: today's hard block, softened in slice 4
|
||||||
Origin: 0,
|
Origin: 0,
|
||||||
GraphSeed: 17,
|
GraphSeed: 17,
|
||||||
ThresholdSeed: 2,
|
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.
|
// IntBounds is an inclusive allowed range for an integer Config field.
|
||||||
type IntBounds struct {
|
type IntBounds struct {
|
||||||
Min int `json:"min"`
|
Min int `json:"min"`
|
||||||
|
|
@ -169,7 +209,20 @@ func RunScenario(config Config, strategy Strategy) (Result, error) {
|
||||||
if educated == nil {
|
if educated == nil {
|
||||||
educated = []int{} // a nil slice marshals to JSON null, not []
|
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{
|
return Result{
|
||||||
Strategy: strategy,
|
Strategy: strategy,
|
||||||
Educated: educated,
|
Educated: educated,
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue