diff --git a/cmd/spreadlab/main.go b/cmd/spreadlab/main.go index 201d0ea..1ed00e8 100644 --- a/cmd/spreadlab/main.go +++ b/cmd/spreadlab/main.go @@ -1,9 +1,35 @@ -// Command spreadlab will serve the spreadlab dashboard. For now it only -// proves the module builds; the HTTP server arrives in milestone 2. +// Command spreadlab will serve the spreadlab dashboard. Until the HTTP +// server lands (milestone 2), it runs the prototype's three scenarios in +// the default world and prints the comparison. package main -import "fmt" +import ( + "fmt" + "os" + + "github.com/JustinZeus/spreadlab/internal/engine" +) func main() { - fmt.Println("spreadlab: engine under construction (milestone 1)") + config := engine.DefaultConfig() + fmt.Printf("spreadlab: %d students, educate %d, forwarding probability %.2f\n", + config.NumStudents, config.NumEducated, config.ForwardProb) + fmt.Println("(illustrative, not validated)") + fmt.Println() + + strategies := []engine.Strategy{ + engine.StrategyNone, + engine.StrategyRandom, + engine.StrategyMostConnected, + } + for _, strategy := range strategies { + result, err := engine.RunScenario(config, strategy) + if err != nil { + fmt.Fprintln(os.Stderr, "spreadlab:", err) + os.Exit(1) + } + fmt.Printf("%-15s educated=%3d reached=%3d/%d (%2.0f%%) in %d rounds\n", + result.Strategy, len(result.Educated), result.NumReached, + config.NumStudents, result.ReachedPct, result.NumRounds) + } } diff --git a/internal/engine/scenario.go b/internal/engine/scenario.go new file mode 100644 index 0000000..2e29bd2 --- /dev/null +++ b/internal/engine/scenario.go @@ -0,0 +1,99 @@ +package engine + +import "fmt" + +// Strategy selects who gets educated. +type Strategy string + +const ( + StrategyNone Strategy = "none" + StrategyRandom Strategy = "random" + StrategyMostConnected Strategy = "most-connected" +) + +// Config fully describes one simulation world. It is the single source of +// truth for parameters: API and frontend types will be generated from the +// structs in this file, never redefined by hand. Identical configs produce +// identical results; all randomness flows from the three seeds. +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 + + GraphSeed uint64 `json:"graphSeed"` + ThresholdSeed uint64 `json:"thresholdSeed"` + EducationSeed uint64 `json:"educationSeed"` // used by the random strategy only +} + +// DefaultConfig mirrors the Python prototype: a school year of 120 +// students, educate 30% of them, forwarding probability 0.38. +func DefaultConfig() Config { + return Config{ + NumStudents: 120, + EdgesPerNode: 3, + TriangleProb: 0.45, + ForwardProb: 0.38, + NumEducated: 36, + Origin: 0, + GraphSeed: 17, + ThresholdSeed: 2, + EducationSeed: 1, + } +} + +// Result is the outcome of one scenario run. +type Result struct { + Strategy Strategy `json:"strategy"` + Educated []int `json:"educated"` + ReachedAtRound []int `json:"reachedAtRound"` // per node; -1 means never reached + NumReached int `json:"numReached"` + NumRounds int `json:"numRounds"` + ReachedPct float64 `json:"reachedPct"` +} + +// RunScenario builds the world the config describes (network plus edge +// thresholds), picks the educated students per strategy, and runs the +// cascade. Scenarios with the same config share the same world, so +// comparing strategies compares only the lever. +func RunScenario(config Config, strategy Strategy) (Result, error) { + if config.ForwardProb < 0 || config.ForwardProb > 1 { + return Result{}, fmt.Errorf("scenario: need 0 <= forwardProb <= 1, got %v", config.ForwardProb) + } + if config.Origin < 0 || config.Origin >= config.NumStudents { + return Result{}, fmt.Errorf("scenario: origin %d outside 0..%d", config.Origin, config.NumStudents-1) + } + if config.NumEducated < 0 { + return Result{}, fmt.Errorf("scenario: need numEducated >= 0, got %d", config.NumEducated) + } + + graph, err := HolmeKim(config.NumStudents, config.EdgesPerNode, config.TriangleProb, newRand(config.GraphSeed)) + if err != nil { + return Result{}, err + } + thresholds := NewEdgeThresholds(graph, newRand(config.ThresholdSeed)) + + var educated []int + switch strategy { + case StrategyNone: + // nobody educated; the baseline + case StrategyRandom: + educated = EducateRandom(graph, config.Origin, config.NumEducated, newRand(config.EducationSeed)) + case StrategyMostConnected: + educated = EducateMostConnected(graph, config.Origin, config.NumEducated) + default: + return Result{}, fmt.Errorf("scenario: unknown strategy %q", strategy) + } + + cascade := RunCascade(graph, config.Origin, config.ForwardProb, educated, thresholds) + return Result{ + Strategy: strategy, + Educated: educated, + ReachedAtRound: cascade.ReachedAtRound, + NumReached: cascade.NumReached, + NumRounds: cascade.NumRounds, + ReachedPct: 100 * float64(cascade.NumReached) / float64(config.NumStudents), + }, nil +} diff --git a/internal/engine/scenario_test.go b/internal/engine/scenario_test.go new file mode 100644 index 0000000..c6fd5ba --- /dev/null +++ b/internal/engine/scenario_test.go @@ -0,0 +1,87 @@ +package engine + +import "testing" + +// Golden regression tests, per the handoff: exact reach values are pinned +// for the default (fixed-seed) config so any change to engine behaviour +// shows up as a diff, and the qualitative ordering vs the prototype is +// asserted explicitly. The exact numbers intentionally differ from the +// Python figure (different RNG); the SHAPE of the result must match: +// no program >> random >> most-connected, with most-connected collapsing. + +func runAllStrategies(t *testing.T) map[Strategy]Result { + t.Helper() + results := make(map[Strategy]Result) + for _, strategy := range []Strategy{StrategyNone, StrategyRandom, StrategyMostConnected} { + result, err := RunScenario(DefaultConfig(), strategy) + if err != nil { + t.Fatalf("RunScenario(%q): %v", strategy, err) + } + results[strategy] = result + } + return results +} + +func TestRunScenarioGoldenReachValues(t *testing.T) { + // Pinned from the first verified run (2026-06-10). The prototype's + // figure showed 102/77/10 out of 120 (85%/64%/8%); ours is the same + // story with different dice. + wantReached := map[Strategy]int{ + StrategyNone: 99, // 82% + StrategyRandom: 70, // 58% + StrategyMostConnected: 7, // 6% + } + results := runAllStrategies(t) + for strategy, result := range results { + t.Logf("%-15s educated=%2d reached=%3d/120 (%.0f%%) rounds=%d", + strategy, len(result.Educated), result.NumReached, result.ReachedPct, result.NumRounds) + if want, pinned := wantReached[strategy]; pinned && result.NumReached != want { + t.Errorf("%s: NumReached = %d, want %d", strategy, result.NumReached, want) + } + } +} + +func TestRunScenarioQualitativeOrdering(t *testing.T) { + results := runAllStrategies(t) + noProgram := results[StrategyNone].NumReached + random := results[StrategyRandom].NumReached + mostConnected := results[StrategyMostConnected].NumReached + + if noProgram <= random || random <= mostConnected { + t.Errorf("want noProgram > random > mostConnected, got %d, %d, %d", + noProgram, random, mostConnected) + } + if noProgram < 60 { // an unchecked cascade must reach most of the school + t.Errorf("no program reached only %d/120, expected a majority", noProgram) + } + if mostConnected > 30 { // educating the hubs must collapse the spread + t.Errorf("most-connected still reached %d/120, expected a collapse", mostConnected) + } +} + +func TestRunScenarioRejectsInvalidInput(t *testing.T) { + tests := []struct { + name string + mutate func(*Config) + }{ + {name: "forward probability above one", mutate: func(c *Config) { c.ForwardProb = 1.5 }}, + {name: "origin out of range", mutate: func(c *Config) { c.Origin = 120 }}, + {name: "negative educated count", mutate: func(c *Config) { c.NumEducated = -1 }}, + {name: "edges per node too large", mutate: func(c *Config) { c.EdgesPerNode = 120 }}, + } + for _, testCase := range tests { + t.Run(testCase.name, func(t *testing.T) { + config := DefaultConfig() + testCase.mutate(&config) + if _, err := RunScenario(config, StrategyNone); err == nil { + t.Error("RunScenario accepted an invalid config") + } + }) + } + + t.Run("unknown strategy", func(t *testing.T) { + if _, err := RunScenario(DefaultConfig(), Strategy("telepathy")); err == nil { + t.Error("RunScenario accepted an unknown strategy") + } + }) +}