engine: Config, scenario runner, golden regression tests; demo CLI
Config is the single source of truth for parameters (TS types will be generated from these structs in milestone 2); all randomness flows from its three seeds, so identical configs give identical results. Golden test pins the default world: none=99/120 (82%), random=70/120 (58%), most-connected=7/120 (6%). Same story as the prototype's 85/64/8 with different dice; the ordering and the collapse are asserted explicitly, exact Python numbers are out of scope by design. 'go run ./cmd/spreadlab' prints the three-scenario comparison. This completes milestone 1 (engine ported, parameterised, tested).
This commit is contained in:
parent
c40e483ee6
commit
45fdf7ffa4
3 changed files with 216 additions and 4 deletions
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
99
internal/engine/scenario.go
Normal file
99
internal/engine/scenario.go
Normal file
|
|
@ -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
|
||||
}
|
||||
87
internal/engine/scenario_test.go
Normal file
87
internal/engine/scenario_test.go
Normal file
|
|
@ -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")
|
||||
}
|
||||
})
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue