README rewritten for a public audience: what the tool is and is not
(the not-validated disclaimer up front), the model with literature
references (Holme & Kim 2002; Kempe, Kleinberg & Tardos 2003), honest
status checklist, quick start, layout, the generated-types rule, and
the prevention-only framing of the subject.
CI mirrors the local checks (go test, gofmt, golangci-lint, vue
type-check/lint/test/build) and adds a drift guard: go generate must
leave web/src/types/ unchanged, so the Go structs stay the single
source of truth in fact, not just in principle.
Starts the Go API and the Vite dev server as background jobs; 'wait -n'
returns when the first one exits and the EXIT trap kills the rest, so
one Ctrl-C (or either server crashing) stops everything. Installs
web/node_modules on first run. Verified: both ports respond, SIGINT
leaves no orphan processes.
Two-terminal dev loop (go run + vite proxy), the full check matrix,
and the regenerate-types rule: web/src/types/ is generated from the
Go structs and must never be edited by hand.
App.vue fetches the default config, posts it to /api/comparison, and
renders ComparisonTable; the Vite dev server proxies /api to the Go
server so the browser sees one origin. src/lib/api.ts is the only
fetch code and uses exclusively generated types: no shape is defined
on the frontend. Scaffold example components removed.
Engine fix surfaced by the smoke test: a nil Go slice marshals to
JSON null, violating the generated 'educated: number[]' contract;
RunScenario now returns an empty slice instead.
Verified end to end: curl through the Vite proxy returns the golden
99/70/7. Vitest covers the table rendering; type-check, oxlint,
eslint clean. This completes the milestone 2 parity check.
internal/api stays a translation layer: decode (with
DisallowUnknownFields so typos 400 instead of silently defaulting),
run the engine, encode; all validation stays in the engine. Routes use
Go 1.22 mux patterns ('POST /api/comparison'), so wrong methods get
405 from the stdlib for free. httptest runs handlers fully in memory;
the API test re-pins the 99/70/7 golden values end to end.
ComparisonResponse is added to tygo.yaml with a type mapping so the
generated web/src/types/api.ts reuses the engine's TS types.
cmd/spreadlab now serves on -addr (default localhost:8080); -table
keeps the CLI comparison as a sanity check.
rng.go is a construction point, not an abstraction wall; components
take *rand.Rand directly (idiomatic Go dependency injection) and only
RunScenario creates streams, from config seeds. Decided 2026-06-10
over the alternatives (interface wall, folding newRand into
scenario.go).
tygo is pinned via go.mod's tool directive (Go 1.24+): the tool's
version is locked like any dependency and runs as 'go tool tygo', no
global install. 'go generate ./...' regenerates web/src/types/engine.ts
from Config/Result and their json tags; the generated file is committed
so the frontend builds without Go installed. The go:generate directive
anchors in a root generate.go because generate runs commands from the
declaring file's directory and tygo reads tygo.yaml from the cwd.
go.mod's ignore directive (Go 1.25+) keeps ./... from crawling into
web/node_modules, where some npm packages ship stray .go files.
Verbatim output of 'npm create vue@latest web -- --ts --vitest
--eslint --prettier' so this commit shows exactly what the official
scaffold generates; our own changes come separately. Lean per the
agreed choices: no Router/Pinia until a second page or shared state
exists. node_modules is ignored via the scaffold's own web/.gitignore.
Manual baseline, not a CI gate: go test -bench=. -benchmem.
First numbers on a Ryzen 7 5800X: HolmeKim ~25us/op (48KB, 671
allocs), full RunScenario ~45us/op (78KB, 681 allocs). Plenty fast
until milestone 5 runs thousands of cascades per request; this is the
'before' picture for that work.
b.Loop() (Go 1.24+) replaces the old 'for i := 0; i < b.N; i++'
pattern and prevents the compiler optimising the loop body away.
The strategy list moves into the engine (AllStrategies), where the API
and frontend will read it too: one source of truth, per the handoff.
main shrinks to the standard Go shell pattern: all work happens in
run(out io.Writer) error; main only maps the error to stderr and the
exit code. Writing to an interface instead of stdout is what lets
main_test.go capture output in a bytes.Buffer.
errcheck flagged every unchecked Fprintf, so formatting became pure
Sprintf string building with one checked write at the end: nicer than
discarding four errors with '_, _ ='.
The generator's working state (graph, attachment pool, rng) moves into
a holmeKimBuilder struct so each algorithm step is a small named
method: attachNewNode, link, pickMutualFriend, degreeProportionalSample.
Max nesting drops from five levels to two. The RNG call order is
untouched, and the golden test (99/70/7 reached) plus the fixed-seed
determinism test prove behaviour is bit-for-bit identical.
Go bits: pointer receivers ((b *holmeKimBuilder)) let methods mutate
the builder; (value, ok) multiple returns are the idiom for 'may not
exist', as in pickMutualFriend.
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).
Two ways to spend the same education budget: a uniform random sample
(spray and pray) vs the highest-degree hubs, ties broken stably towards
lower node numbers so the pick is deterministic. The origin is never
educated; they post the fake. rng.Shuffle does a seeded Fisher-Yates,
so the random pick is reproducible too. min() is a builtin since
Go 1.21.
One random threshold per DIRECTED edge, drawn once per world: scenarios
sharing thresholds differ only in who is educated, the prototype's
'same world, different lever' trick. The cascade is plain BFS in rounds;
an educated student receives the fake but never forwards it, which is
the entire effect of the lever. The result stores the first-reached
round per node (exactly what a frontend animation needs).
Tests hand-craft a 4-node line graph with exact thresholds, so every
expectation is exact: spread, directional blocking, educated cutoff.
Preferential attachment via an attachment pool that holds one entry per
edge endpoint, so uniform draws are degree-proportional: that is the
whole hub-forming mechanism. A triangle step closes friend-of-a-friend
links with probability triangleProb, giving friend-group clustering.
Semantics ported from networkx, NOT its RNG stream (per the handoff,
no cross-language number matching).
Tests are property-based: size, edge bounds, connectivity, hub
formation across seeds, plus exact determinism for a fixed seed.
'go test ./...', gofmt and golangci-lint all clean.
Adjacency lives in slices, not maps, on purpose: Go randomises map
iteration order between runs, and the engine's determinism guarantee
needs every graph walk to visit neighbours in the same order. AddEdge
mirrors networkx semantics (duplicates and self loops are no-ops) so
the Holme-Kim port can lean on the same behaviour.
Receiver names stay short per Go convention (g *Graph); everything
else uses descriptive names.
math/rand/v2 (Go 1.22+) replaces the old math/rand: PCG generator,
no global Seed(), and rand.New(rand.NewPCG(seed, 0)) gives an isolated
deterministic stream. Each randomness consumer (graph, thresholds,
education sampling) will get its own stream so levers vary independently.
Tests live next to the code as *_test.go; 'go test ./...' runs them all.
'for i := range 100' is Go 1.22 range-over-int.
go.mod declares the module path (github.com/JustinZeus/spreadlab); every
import inside the repo is spelled relative to it. Layout follows the
standard Go shape: cmd/<binary>/main.go per executable, internal/ for
packages other modules may not import (the compiler enforces this).
No engine code yet, just a placeholder main that proves 'go build' works.