Files
functional-programming-snake/docs/contract.md
T
Lucas WintherandClaude Fable 5.2 5062838604 chore: keep the fixture generator scripts and the replay-sample bot
scripts/fixtures/ holds the scripts that produced the 2026-11-11
fixtures, with a README on running them safely into a copy. Run in
order they reproduce the content of every fixture they touch.

Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
2026-11-11 12:13:03 +01:00

23 KiB
Raw Blame History

Pure Snake — the contract

The canonical description of what the course code must do, as of 2026-11-11. It replaces the "contract" section of docs/superpowers/plans/2026-10-28-pure-snake-course.md, which is out of date.

What is normative, in order

  1. The shared fixtures in fixtures/*.json. All three tracks read the same files.
  2. The doc comments of the stubs in js/src, scala/src, fsharp/src. They state each function's behaviour; solutions and course text must agree with them.
  3. This document, for everything that crosses files: data shapes, orders, constants, pinned values, conventions.
  4. course/reference.md gives every name in all three languages side by side. It is a lookup table, not a specification.

If two of these disagree, that is a bug: fix the lower one, and say so in the build log.

Conventions

  • Tracks. JavaScript is the reference track. Scala 3 and F# are ports that must produce the same values for the same inputs, including every random draw.
  • Files. Student stubs in <track>/src, finished code in <track>/solutions, same file names. A stub compiles, carries the specifying doc comment and fails per test (todo('mNN'), ???, failwith "TODO mNN"). In F#, anything that would fail at module load is a unit function (main (), randomSeed (), loadBest (), pongMain (), lifeMain (), State.get ()).
  • Given code (present and complete in the stubs): types and constants in Scala/F#; todo; genActions, genState; CheckResult, Session, Recording, Viewer, the action types; the stream source clicks; canvasPoint; drive/answer in safeio; all main entry files and types.d.ts; the browser glue of Scala and F# (scala/web, fsharp/web).
  • Calling style. JS: data first, plain objects with a tag. Scala: methods and objects in package snake; Option, List; Long for seeds and time. F#: data last; bind for flatMap, choose for filterMap; PascalCase fields; uint32 seeds, int64 time.
  • Purity. Outside the edge files there is no reassignment, mutation, loop with a counter, randomness, clock or DOM. Edge files: JS runtime.js, browser.js, main.js, replay-main.js, */main.js; Scala Runtime.scala and scala/web; F# Runtime.fs and fsharp/web. Each track's purity test enforces this on src.
  • Module tags. M00–M13 plus the optional M07b. ./test.sh MNN in each track runs exactly that module's tests.
  • Coordinates. x grows right, y grows down. "Row-major" always means: by y, then by x.

Data

Point      {x, y}
Direction  Up | Down | Left | Right          delta: Up (0,-1), Down (0,1), Left (-1,0), Right (1,0)
Status     Playing | Paused | GameOver | Won
Action     Tick | Turn(dir) | TogglePause | Restart | Frame
GameState  {width, height, snake (head first), dir, nextDir, apple (or none), seed, score, status, frame, queued (or none)}
Shape      Rect {x, y, w, h, color} | Text {x, y, text, color, size, align}
Scene      list of Shape, drawn in order
Op         Draw(shape) | RandomSeed | LoadBest | SaveBest(score)
Input      Time(t ms) | KeyDown(key) | KeyUp(key) | Click(x, y)      (called Event in Scala and F#)
Session    {game, best, improved}
  • JSON field order of a game state is as listed; queued is last. "None" is null in JSON and JS.
  • dir is the direction the snake last moved; nextDir the direction of its next step; queued a second pending turn (Module 10); frame the number of frames since the snake last moved (Module 10). frame and queued are dormant (0 and none) until Module 10.
  • initialState(seed): 20×20, snake (10,10) (9,10) (8,10), dir = nextDir = Right, no apple, score 0, Playing, frame 0, nothing queued.

Modules

M01 util

range(n) = [0..n-1]; dropLast (total: empty stays empty); contains(xs, p, eq) calls eq(x, p). JS only: pipe, compose, deepFreeze (cycle-safe, descends into already-frozen parents, leaves null and primitives alone).

M02 model

point, pointEquals, delta, opposite, Action, initialState.

M03 rules

  • move(p, dir): no wrapping. inBounds(state, p): 0 <= x < width, 0 <= y < height.
  • turn(state, dir): unchanged unless Playing and dir != opposite(state.dir) (checked against dir, not nextDir); then nextDir = dir.
  • advance(state): unchanged unless Playing. head = move(snake[0], nextDir); eating = head is on the apple; body = the whole snake when eating, else the snake without its last cell. If head is off the board or in body: {dir: nextDir, status: GameOver}, snake unchanged. Otherwise snake = head + body, dir = nextDir, and when eating the apple is removed and score + 1. advance ignores queued and never touches seed or frame.

M04 random, spawn, optional state

  • nextUint32 is mulberry32: s = (seed + 0x6D2B79F5) mod 2^32; t = imul(s ^ (s >>> 15), s | 1); t = t ^ (t + imul(t ^ (t >>> 7), t | 61)); value = (t ^ (t >>> 14)) >>> 0; result (value, s). From seed 0: (1144304738, 1831565813). F# routes * and + through uint64 helpers because Fable does not wrap uint32.
  • Random<A> = seed -> (A, seed) with pure, map, flatMap, run. intBelow(n) = nextUint32 mapped with v % n (biased on purpose; the fixtures depend on it).
  • freeCells(state): board cells not in the snake, row-major; the apple's cell counts as free.
  • spawnApple(state): unchanged if there is an apple or the status is not Playing; Won if no cell is free (seed unchanged); otherwise apple = freeCells[i] with i from intBelow(count) on state.seed, and the seed advances.
  • tick = spawnApple ∘ advance. newGame(seed) = spawnApple(initialState(seed)).
  • state (optional Exercise 4.7): State<S, A> = s -> (A, s) with pure, map, flatMap, get, put, modify, run. Nothing in the game uses it. Its tests are under M04; a student who skips it copies the solution file.

M05 game (extended in M10)

  • movesEvery(score) = max(2, 8 - floor(score / 3)).
  • update(state, action), final form:
    • Tick: step(state) where step = takeQueued ∘ tick.
    • Turn(dir): steer(state, dir).
    • TogglePause: Playing <-> Paused; other statuses unchanged.
    • Restart: newGame(state.seed) from any status (the current seed).
    • Frame: unchanged unless Playing. n = frame + 1; if n >= movesEvery(score) then step of the state with frame: 0, else the state with frame: n. The threshold uses the score before the step.
  • steer(state, dir): not Playing → unchanged. No turn pending (state.nextDir == state.dir) → turn(state, dir). A turn pending → if dir is neither nextDir nor its opposite, queued = dir (a later key replaces it); otherwise unchanged.
  • takeQueued: after a step, if the result is Playing and something is queued: nextDir = queued, queued = none. A result that is not Playing keeps queued.
  • replay(state, actions) is the left fold of update; history is the scan (length = actions + 1, starts with state).
  • In Module 5 itself, update only handles Tick (→ tick) and Turn (→ turn); the Module 5 fixtures hold under both forms.

M06 view

CELL = 20. Scene, in order: background Rect(0, 0, w*20, h*20, '#111111'); the apple, if any, as a cell '#e53935'; each snake cell (head '#7cb342', body '#43a047'), where a cell is Rect(x*20+1, y*20+1, 18, 18); Text(6, 18, 'Score: <n>', '#ffffff', 16, 'left'); then for Paused / GameOver / Won two centred texts at (w*20/2, h*20/2) size 32 and 30 px lower size 16: Paused + Press Space to resume, Game Over + Press R to restart, You Win! + Press R to restart.

M07 io

  • IO<A> = Pure(value) | Effect(op, k). pure, effect(op) = Effect(op, pure), flatMap (on Pure it calls f at once; on Effect it extends k), map via flatMap, sequence (right-nested; left nesting overflows the 100 000-effect runtime test), draw, randomSeed, drawScene(scene) = sequence(scene.map(draw)).
  • Module 10 adds loadBest and saveBest(score).
  • trace(io, seed, best = 0) → {result, ops}: answers RandomSeed with seed, LoadBest with best, the others with unit. Scala: default argument. F#: traceWith seed best io, and trace seed io = traceWith seed 0 io. Scala/F# answers are OpResult: Done | Seed(n) | Best(n).

M07b safeio (optional)

A second, independent IO: Pure | Effect(op, k) | FlatMap(io, f). flatMap only builds a node and never calls f. step(io) does one constant-work rewrite: FlatMap(Pure(a), f) → f(a); FlatMap(Effect(op, k), f) → Effect(op, r => FlatMap(k(r), f)); FlatMap(FlatMap(m, g), f) → FlatMap(m, x => FlatMap(g(x), f)). trace(io, seed) must not grow the stack for 100 000 left- or right-nested binds, and must equal Module 7's trace on programs of Draw and RandomSeed. It answers only those two ops and throws for any other. JS gets a given trampoline drive; Scala and F# use a tail-recursive loop. F# erases the intermediate type (FlatMap of SafeIO<obj> * (obj -> SafeIO<'a>), given private erase).

M08 stream (extended in M10 and M13)

  • Stream<A> = {step: input -> (outputs, nextStream)}; no hidden state.
  • Sources: keyDowns, keyUps (emit the key), clicks (emit (x, y), given, used from M13), interval(ms): on Time t emits every multiple k*ms with last < k*ms <= t, ascending; last starts at 0 and becomes t.
  • Combinators: map, filter, filterMap/choose (drops none), merge(a, b) (both step on every input; a's outputs first), scan(s, f, z) (emits each new accumulator, not z), startWith(s, v) (emits v before the outputs of the first step, whatever the first input is), run(s, inputs).
  • clampTime(s, maxGap) (M10): gives s a clock of its own starting at 0. On Time t the clock advances by min(max(t - last, 0), maxGap) and s is stepped with Time(clock); last (initially 0) becomes t. Every other input is passed through unchanged.

M09 input, app, runtime, browser (extended in M10 and M13)

  • keyToAction(key): ArrowUp/w/W → Turn Up, likewise Down (s), Left (a), Right (d); from M10 ' ' → TogglePause, r/R → Restart; anything else none (including constructor, toString).
  • Constants: TICK_MS = 150, FRAME_MS = 25, MAX_GAP_MS = 250.
  • classicActions = merge(map(interval(TICK_MS), Tick), filterMap(keyDowns, keyToAction)); speedActions the same with Frame every FRAME_MS.
  • states(seed, actions) = startWith(scan(actions, update, newGame(seed)), newGame(seed)); render(state) = drawScene(view(state)).
  • Session (M10): sessionUpdate(s, a): game' = update(game, a), best' = max(best, game'.score), improved = best' > best. sessions(seed, best, actions) = startWith(scan(actions, sessionUpdate, s0), s0), s0 = {newGame(seed), best, false}. sessionView(s) = view(game) + Text(width*20 - 6, 18, 'Best: <best>', '#ffffff', 16, 'right'). renderSession(s) = drawScene(sessionView(s)), then saveBest(best) exactly when improved.
  • main : IO<Stream<IO<unit>>>. Final form: randomSeed, then loadBest, then clampTime(map(sessions(seed, best, speedActions), renderSession), MAX_GAP_MS). Its ops when traced: [RandomSeed, LoadBest]. In Module 9 it is map(randomSeed, seed => map(states(seed, classicActions), render)); the tests keep that program as classicMain.
  • play(main, seed, events, best = 0): the list of ops of each drawing main emits when fed events. F#: play seed events main and playWith seed best events main.
  • Platform: fillRect(x, y, w, h, color), fillText(x, y, text, color, size, align), randomSeed(), startClock(onTime), onKeyDown(cb), onKeyUp(cb), loadBest(), saveBest(n) (M10), onClick(cb) (M13).
  • runIO(io, platform): a loop (or tail recursion); performs each op on the platform. runApp(main, platform): runs main, keeps the current stream in the program's one mutable variable, and feeds KeyDown, KeyUp, Time (and from M13 Click) inputs: step, store the next stream, run each emitted IO in order. Nothing is drawn before the first input.
  • Browser platform: the clock reports milliseconds since the first animation frame; the seed is one Uint32 from crypto.getRandomValues; text is <size>px monospace. Storage key pure-snake-best: only a string of 1–10 decimal digits with a value of at most 2147483647 counts; anything else, a missing entry, or storage that throws reads as 0; saving never throws. Click position: floor((clientX - rect.left - canvas.clientLeft) * canvas.width / canvas.clientWidth), same for y; the listener is registered only when onClick is called.

M10 additions not listed above: replay

  • Recording {seed, actions}; frames(rec) = history(newGame(seed), actions).
  • Viewer {index, last, playing}; viewerStart(last) = {0, last, false}. Actions Next | Prev | First | Last | TogglePlay | Beat.
  • viewerUpdate: Next/Prev move one frame, clamped to [0, last]; First/Last jump; TogglePlay flips playing, and starting to play at the last frame goes back to frame 0; Beat advances one frame when playing; after every action a viewer on the last frame is not playing.
  • viewerKey: ArrowRight/d/D Next, ArrowLeft/a/A Prev, Home First, End Last, ' ' TogglePlay.
  • viewerView(frames, viewer) = view(frames[index]) + Text(w - 6, 18, 'step <index> / <last>', '#ffffff', 16, 'right') + Text(w/2, h*20 - 8, hint, '#aaaaaa', 12, 'center'), hint 'Space: pause' when playing, else 'Left/Right: step Home/End: jump Space: play'.
  • REPLAY_MS = 100; replayActions = beats merged with keys (beats first); viewerStates(last, actions); replayMain(rec) = pure of the clamped stream of drawings. No seed and no stored best are asked for.
  • fixtures/replay-sample.json: seed 1, 226 actions, 19 apples, ends in game over. All three tracks' given glue loads it with fetch("../fixtures/replay-sample.json").

M11 pong

  • Constants: W = 80, H = 50, PCELL = 8, PADDLE = 8, PLAYER_X = 2, CPU_X = 77, WIN = 5, FRAME_MS_PONG = 30.
  • State {ball, vel {dx, dy}, player, cpu, held {up, down}, score {player, cpu}, status Playing | Over, seed, frame}; initialPong(seed): ball (40, 25), vel (1, 1), both paddles at 21, nothing held, 0–0.
  • Actions Press(key) | Release(key) | Frame | Restart with key Up | Down. Press/Release set held even when the match is over. Restart = initialPong(state.seed) with held kept.
  • Frame while Playing, in order: frame + 1; player moves by held (both cancel), clamped to [0, H - PADDLE]; on an even new frame the cpu moves one cell toward ball.y - PADDLE/2, clamped, looking at the ball before it moves; ny = y + dy, and if ny < 0 || ny >= H then dy flips and ny = y + dy; nx = x + dx, and the ball bounces (dx flips, nx = x) when nx == PLAYER_X, dx < 0 and player <= ny < player + PADDLE (player's new position), or nx == CPU_X, dx > 0 and cpu <= ny < cpu + PADDLE; nx < 0 → cpu scores, nx >= W → player scores; a point serves from (40, 25) with dx toward the side that conceded and dy = 1 if nextUint32(seed) is even else -1, and the seed advances; a score of WIN sets Over.
  • pongKey(isDown, key): arrows and w/s (either case) press/release; r/R restart on press only. pongMain is clamped like main and has no stored best.
  • pongView: background Rect(0, 0, 640, 400, '#111111'); paddles Rect(X*8, y*8, 8, 64, '#ffffff') (player, then cpu); ball Rect(x*8, y*8, 8, 8, '#ffeb3b'); Text(320, 30, '<player> <cpu>', 24, 'center'); when over Text(320, 200, 'You Win!' | 'CPU Wins', 32) and Text(320, 230, 'Press R to restart', 16).

M12 check (bonus)

  • A generator is a Random<A>. elementOf(xs) = element at intBelow(length). listOf(gen, n): n draws threaded left to right; n <= 0 → pure([]).
  • genActions(n) = listOf(elementOf(pool), n) with pool exactly [Tick, Tick, Tick, Turn Up, Turn Down, Turn Left, Turn Right].
  • forAll(gen, prop, runs, seed): draws from seed, each draw continuing from the previous one's seed; stops at the first failure. Result: passed with runs, or failed with run (0-based), the seed that draw started from, and the value. Stack-safe in Scala and F#.
  • removals(xs): every list with one element removed, by index. minimize(xs, fails): repeatedly continue with the first failing removal; the result is 1-minimal.
  • violations(state): the names of broken invariants in this order: snake-not-empty, snake-in-bounds, snake-no-overlap, snake-connected (consecutive cells exactly one step apart), apple-in-bounds, apple-not-on-snake, turn-not-reversed (nextDir != opposite(dir)), queued-not-reversed (nothing queued, or queued != opposite(nextDir)).
  • moveGaps(states): differences between consecutive indices i >= 1 at which the head differs from the head at i - 1; total on empty snakes.
  • genSnake(width, height, maxLen): draw x = intBelow(width), then y = intBelow(height); then, until the snake has maxLen cells, one elementOf among the in-bounds neighbours of the last cell that are not in the snake, listed in the order Up, Down, Left, Right; stop early when there is none (stopping uses no draw). The first cell is the head.
  • genState(width, height) (given), draws in order: n = intBelow(width*height); the snake genSnake(width, height, n + 1); nextDir = elementOf of [Up, Down, Left, Right] without opposite(dir); a coin intBelow(2): 0 = no apple, 1 = elementOf(freeCells) (none if no cell is free). dir is the direction from the second cell to the head (Right for one cell); seed, score, frame are 0; nothing queued; Playing.
  • advanceSpec(state): builds the next snake naively, asks violations for snake-in-bounds / snake-no-overlap, and otherwise behaves as advance. It must equal advance on every sane state and must not call it.

M13 life

  • Constants: LW = 40, LH = 30, LCELL = 16, STRIP = 24, GEN_MS = 150. Canvas 640×504.
  • State {cells, running, seed, generation}; cells is always canonical: on the board, no duplicates, row-major. initialLife(seed): no cells, paused, generation 0.
  • lifeStep(cells): B3/S23 judged by the old board only; edges are dead, nothing wraps; the result is canonical.
  • lifeUpdate: Frame → next generation and generation + 1 when running; Step → the same when paused; TogglePause flips running; Toggle(cell) flips a cell whether running or not (off the board: unchanged; generation unchanged); Clear → no cells, generation 0, running and seed kept; Randomize → one intBelow(4) per board cell in row-major order from state.seed, alive when the draw is 0, seed after the last draw, generation 0, running kept.
  • lifeKey: ' ' TogglePause, n/N Step, c/C Clear, r/R Randomize. lifeClick(x, y): Toggle of (floor(x/16), floor(y/16)), or none when the pixel is not on the board (test x >= 0 && y >= 0 before dividing).
  • lifeActions = frames, then keys, then clicks (merge order). lifeMain: seed, then the clamped stream of drawings; no stored best.
  • lifeView: Rect(0, 0, 640, 480, '#111111'); Rect(x*16+1, y*16+1, 14, 14, '#7cb342') per live cell in order; Rect(0, 480, 640, 24, '#222222'); Text(6, 497, 'Generation <n>', '#ffffff', 14, 'left'); Text(634, 497, 'Running: Space pauses' | 'Paused: Space runs, N steps', '#ffffff', 14, 'right').
  • Clicks reach a student only here: Module 13 has them add onClick to their runtime (and, in JS, to browser.js). Modules 9–12 must pass with a runtime that does not register clicks.

Pinned values that all tracks must reproduce

What Value
nextUint32(0) (1144304738, 1831565813)
listOf(nextUint32, 3) from 0 [1144304738, 1416247, 958946056], seed 1199730143
newGame(1) apple at (12, 8), seed 1831565814
genSnake(5, 5, 6) from seed 0 (3,2) (3,1) (3,0) (2,0) (1,0) (0,0), next seed 4231026099
Double-turn mutant, forAll(genActions(40), …, 300, 2024) shrinks to [Turn Up or Down, Turn Left]
No-walls mutant, same call shrinks to ten Ticks
"Tick forgets the queue" mutant shrinks to [Turn Up, Turn Right, Tick, Turn Left]
"Queue accepts a reversal" mutant shrinks to [Turn Up, Turn Down]
Puzzle 1 mutant vs advanceSpec, 500 states of 5×5, seed 2024 caught at run 21

Fixtures

Format: {function, cases: [{name, args, expected}]}; names are kebab-case sentences. Generated from the JS solutions with small scripts (those of 2026-11-11 are kept in scripts/fixtures/, with a README) and then read by hand; formatting is JSON.stringify(x, null, 2) plus a newline.

File Function Cases
m00-sanity sanity 2
m02-delta, m02-opposite, m02-point-equals, m02-initial-state as named 4, 4, 4, 2
m03-move, m03-in-bounds, m03-turn, m03-advance as named 5, 7, 8, 12
m04-random, m04-int-below nextUint32, intBelow 30, 7
m04-free-cells, m04-spawn, m04-tick, m04-new-game as named 5, 8, 5, 2
m05-update, m05-replay, m05-history as named 7, 2, 2
m06-view view 5
m09-input, m10-input keyToAction 17, 7
m10-moves-every, m10-update movesEvery, update 6, 40
m10-session-update, m10-session-view as named 11, 3
m10-golden play on main; args [seed, events, best], expected = the last drawing's shapes 3
m10-viewer-update, m10-viewer-key, m10-viewer-view as named 18, 14, 3
m11-pong-update, m11-pong-key, m11-pong-view as named 49, 21, 4
m12-violations violations 21
m13-life-step, m13-life-update, m13-life-key, m13-life-click, m13-life-view as named 15, 21, 12, 10, 3
replay-sample data, not cases —

Changing an existing case is a contract change: say which and why in the build log.

Checks a change must pass

Run from the repository root; see the README maintainer table for details.

Command Must show
scripts/verify.sh <js|scala|fsharp> whole suite green against the solutions
scripts/check-stubs.sh <track> every module M01–M13 and M07b fails against the stubs, no build errors
scripts/check-progression.sh <track> every checkpoint passes with solutions up to module N
scripts/typecheck-js.sh JS stubs and solutions type-check (needs network for npx)
node scripts/mutate.mjs no surviving mutant outside scripts/mutate-allow.txt
scripts/e2e.sh every page of every track draws exactly what the pure JS model says (Playwright; Chromium and WebKit)
node scripts/smoke.mjs <url> quick dependency-free check that a page paints
node scripts/check-links.mjs every link in the Markdown resolves

Test counts on 2026-11-11: JS 728, Scala 689, F# 691, end-to-end 176. They differ between tracks because a few tests are track-specific (JS browser-glue and constructor tests, one F# stack test, one JS-only play default test).