feat(course): audit fixes and twelve extensions (Modules 7b, 12, 13)
Audit pass: - Frame rule counts frames since the last move, so every step lasts exactly movesEvery(score) frames, also right after a speed-up - clampTime + MAX_GAP_MS: a hidden tab no longer replays the whole gap - Pong Restart keeps held keys - Fact corrections (mulberry32 output coverage, Haskell IO, uniform) - New fixture cases from a mutation analysis Extensions (all three tracks, shared fixtures, course text): - Turn queue (Exercise 10.6), persisted high score (10.7), replay viewer (10.8), play() end-to-end helper (9.5) - Optional State (4.7) and optional Module 7b, stack-safe IO - Module 12, property-based testing with generators, shrinking, invariants and a model-based oracle - Module 13, Conway's Life with a Click event - JS type checking via jsconfig, JSDoc and types.d.ts - Maintainer tools: scripts/mutate.mjs, scripts/typecheck-js.sh, Playwright end-to-end suite (e2e/, scripts/e2e.sh) Docs: docs/contract.md is the canonical contract; specs and build log under docs/superpowers. Verified: JS 728, Scala 689, F# 691 unit tests; stubs and progression M01-M13 and M07b; 176 end-to-end tests on Chromium and WebKit. Co-Authored-By: Claude Fable 5.2 <noreply@anthropic.com>
This commit is contained in:
1 parent
876f5aabb1
commit
edd57f0148
253 files changed
+21753
-503
No files matched your search
@@ -1,6 +1,6 @@
|
||||
# Module 4 — Randomness without side effects
|
||||
|
||||
> **Time:** ~4 h · **You'll edit:** JS `js/src/random.js`, `js/src/spawn.js` · Scala `scala/src/Random.scala`, `scala/src/Spawn.scala` · F# `fsharp/src/Random.fs`, `fsharp/src/Spawn.fs`
|
||||
> **Time:** ~4 h (+1 h for the optional Exercise 4.7) · **You'll edit:** JS `js/src/random.js`, `js/src/spawn.js` (optional: `js/src/state.js`) · Scala `scala/src/Random.scala`, `scala/src/Spawn.scala` (optional: `scala/src/State.scala`) · F# `fsharp/src/Random.fs`, `fsharp/src/Spawn.fs` (optional: `fsharp/src/State.fs`)
|
||||
> **Tests:** JS `cd js && node --test "test/m04-*.test.js"` · Scala `cd scala && ./test.sh M04` · F# `cd fsharp && ./test.sh M04`
|
||||
> **Prerequisites:** Modules 1–3
|
||||
|
||||
@@ -30,7 +30,8 @@ After this module you can:
|
||||
- **describe** at a high level how mulberry32 turns a seed into a number and a new seed, and why 32-bit arithmetic must be handled carefully in each language;
|
||||
- **implement** `Random<A> = seed → (A, seed)` with `pure`, `map` and `flatMap`, and recognise it as the State monad;
|
||||
- **use** Scala's for-comprehension and F#'s computation expressions as syntax for the same chaining;
|
||||
- **implement** apple spawning, including the full-board edge case, and explain why "same seed ⇒ same game".
|
||||
- **implement** apple spawning, including the full-board edge case, and explain why "same seed ⇒ same game";
|
||||
- *(optional)* **generalise** `Random` to `State<S, A>` with `get`, `put` and `modify`, and use it for state other than a seed.
|
||||
|
||||
## Concepts
|
||||
|
||||
@@ -71,7 +72,9 @@ value = t xor (t >>> 14) -- as an unsigned 32-bit number
|
||||
return (value, s)
|
||||
```
|
||||
|
||||
(`imul` is 32-bit multiplication, keeping only the low 32 bits; `>>>` is an unsigned right shift.) mulberry32 is fine for games and simulations, and **not** suitable for anything security-related: given one output, the next ones can be predicted.
|
||||
(`imul` is 32-bit multiplication, keeping only the low 32 bits; `>>>` is an unsigned right shift.) mulberry32 is fine for games, and **not** suitable for anything security-related: given one output, the next ones can be predicted.
|
||||
|
||||
One more honest detail. The counter visits every 32-bit value once, but the scramble is not one-to-one: different counters can give the same output. Run the generator through its whole period and count (it takes a minute in any of the three languages): only 1 893 145 848 of the 4 294 967 296 possible values ever come out, about 44 %, and those that do come out are not equally frequent. For placing apples this is irrelevant. For a simulation whose results you intend to publish, pick a generator whose output step is a bijection, such as SplitMix or PCG (see "Further reading").
|
||||
|
||||
For example, from seed `0` the first draw gives the value `1144304738` and the new seed `1831565813` (which is `0x6D2B79F5`: zero plus the constant). The next draw, from seed `1831565813`, gives `1416247` and seed `3663131626`. These are the first two cases of `fixtures/m04-random.json`.
|
||||
|
||||
@@ -260,11 +263,41 @@ JavaScript has no such syntax for user-defined types, so you write the nested `f
|
||||
|
||||
> **From everyday programming:** `async`/`await` in JavaScript, C# or Python is the same idea for promises and tasks. `const res = await fetch(url)` reads like an ordinary assignment, but behind the scenes it is a `then` whose callback is the rest of the function. A for-comprehension or a computation expression gives `Random` (or any monad) that same "looks like sequential code" syntax.
|
||||
|
||||
### Going further: `State` for any kind of state (optional)
|
||||
|
||||
This section and Exercise 4.7 are optional: nothing in the game depends on them. If you want to get to the apples, skip to "Drawing a number below `n`" and come back after Exercise 4.6.
|
||||
|
||||
The previous section claimed that `Random<A>` is `State<Seed, A>`. A claim like that is worth checking, and the way to check it is to write `State` itself and then rebuild the dice with it.
|
||||
|
||||
**Why bother, if `Random` already works?** Because the seed is not the only thing a program threads through by hand. A counter that hands out fresh labels (`L0`, `L1`, `L2`, ...) has the same problem as the seed: each step needs the number the previous step left behind, and reusing an old number is a bug that nothing catches. So does a parser that reads a string piece by piece, where each step returns a value and "the rest of the input". Every one of these is `S -> (A, S)` for some `S`. Write the plumbing once, for every `S`, and each of them gets `map` and `flatMap` for free.
|
||||
|
||||
The code of `pure`, `map`, `flatMap` and `run` does not change at all: look at your `random.js` and rename "seed" to "s". What is new are three small building blocks that talk about the state itself. `Random` never needed them, because only `nextUint32` ever touched the seed directly; for a general state, you need a way to read it and to change it:
|
||||
|
||||
```
|
||||
get : State<S, S> s gives [s, s] the value is the state; the state is unchanged
|
||||
put(s2) : State<S, unit> s gives [nothing, s2] throw the old state away, store s2
|
||||
modify(f) : State<S, unit> s gives [nothing, f(s)] store f of the old state
|
||||
```
|
||||
|
||||
("unit" is the type with a single value that carries no information: the value a step gives when only its effect on the state matters. JavaScript uses `undefined` for it, Scala `()` of type `Unit`, F# `()` of type `unit`.)
|
||||
|
||||
With these, the label counter is one line, and three labels in a row are a chain of `flatMap`s with no counter in sight:
|
||||
|
||||
```js
|
||||
const fresh = flatMap(get, (n) => map(put(n + 1), () => `L${n}`)) // read n, store n + 1, give "Ln"
|
||||
const three = flatMap(fresh, (a) => flatMap(fresh, (b) => map(fresh, (c) => [a, b, c])))
|
||||
run(three, 0) // [['L0', 'L1', 'L2'], 3]
|
||||
```
|
||||
|
||||
And a single draw of the generator can be written with nothing but `get` and `put`: read the seed, compute the value and the next seed, store the next seed. Build the dice of the previous section on top of that draw, and you get exactly the values and seeds that `Random` gives. That is the precise meaning of "`Random` is `State` with the seed as `S`", and it is what the last test of Exercise 4.7 checks.
|
||||
|
||||
> **From everyday programming:** the usual way to hand out fresh ids is a field and an increment: `this.nextId++` in a class, or a module-level counter. It works until two parts of the program share the counter by accident, or a test needs it to start at 0 again. `State` makes the counter an explicit input and output of every step, so it can never be shared by accident and every run starts where you say. Redux reducers are the same idea in a different shape: `(state, action) => newState` returns the next state instead of changing the current one.
|
||||
|
||||
### Drawing a number below `n`
|
||||
|
||||
To pick one of `n` free cells, you need a number in `[0, n)`. `intBelow(n)` is `nextUint32` with its value taken modulo `n`. That is a `map`.
|
||||
|
||||
(Strictly, `v % n` is slightly biased when 2^32 is not a multiple of `n`: small results are a tiny bit more likely. For `n` up to 400 the bias is below one in ten million, far below anything a player could notice. Libraries that need exact uniformity reject and redraw; we keep it simple and identical across tracks.)
|
||||
(Strictly, `v % n` is slightly biased when 2^32 is not a multiple of `n`: even for a perfectly uniform `v`, small results would be a tiny bit more likely. For `n` up to 400 that effect is below one in ten million, much smaller than the unevenness of mulberry32 itself, and both are far below anything a player could notice. Libraries that need exact uniformity reject and redraw, see question 9; we keep it simple and identical across tracks.)
|
||||
|
||||
### Spawning an apple
|
||||
|
||||
@@ -317,7 +350,7 @@ And now the payoff: `newGame(42)` is the same game every time you call it. Any s
|
||||
|
||||
## Exercises
|
||||
|
||||
> **About test names.** The test titles quoted under "Test"/"Tests" are the JavaScript ones; the JS runner prints them with the module in front (`m04 intBelow always lands in [0, n)`). The Scala and F# suites test the same things one-to-one with the same titles, except that F# says `bind` where JS and Scala say `flatMap` (`bind threads the seed through both steps`). Failing tests are reported by name when you run the module's tests; to read all titles, open `scala/test/M04Random.test.scala` and `M04Spawn.test.scala`, or `fsharp/tests/M04Random.fs` and `M04Spawn.fs`. Fixture-based tests have the same case names in every track (JS `m04 nextUint32: seed-0-step-1`, Scala/F# `nextUint32: seed-0-step-1`).
|
||||
> **About test names.** The test titles quoted under "Test"/"Tests" are the JavaScript ones; the JS runner prints them with the module in front (`m04 intBelow always lands in [0, n)`). The Scala and F# suites test the same things one-to-one with the same titles, except that F# says `bind` where JS and Scala say `flatMap` (`bind threads the seed through both steps`). Failing tests are reported by name when you run the module's tests; to read all titles, open `scala/test/M04Random.test.scala`, `M04Spawn.test.scala` and `M04State.test.scala`, or `fsharp/tests/M04Random.fs`, `M04Spawn.fs` and `M04State.fs`. Fixture-based tests have the same case names in every track (JS `m04 nextUint32: seed-0-step-1`, Scala/F# `nextUint32: seed-0-step-1`).
|
||||
|
||||
### Exercise 4.1 — `nextUint32`
|
||||
|
||||
@@ -448,9 +481,52 @@ let newGame (seed: uint32) : GameState
|
||||
|
||||
Fixtures: `fixtures/m04-tick.json` (`eat-then-respawn`, `plain-move-no-respawn`, `wall-hit-is-game-over-no-spawn`, `game-over-state-unchanged`) and `fixtures/m04-new-game.json` (`seed-0`, `seed-42`). Tests: `tick is spawnApple after advance`, `newGame is reproducible: same seed, same game`.
|
||||
|
||||
### Exercise 4.7 (optional) — `State`
|
||||
|
||||
Read "Going further: `State` for any kind of state" first. The file is new: JS `js/src/state.js`, Scala `scala/src/State.scala`, F# `fsharp/src/State.fs`. Nothing else in the course imports it.
|
||||
|
||||
**JavaScript**
|
||||
```js
|
||||
export const pure = (a) => ... // State<S, A>: s => [a, s]
|
||||
export const map = (st, f) => ... // State<S, A>, (A -> B) -> State<S, B>
|
||||
export const flatMap = (st, f) => ... // State<S, A>, (A -> State<S, B>) -> State<S, B>
|
||||
export const get = ... // State<S, S> (a value, like nextUint32)
|
||||
export const put = (s) => ... // State<S, undefined>
|
||||
export const modify = (f) => ... // State<S, undefined>, f : S -> S
|
||||
export const run = (st, s) => ... // [A, S]
|
||||
```
|
||||
**Scala**
|
||||
```scala
|
||||
final case class State[S, A](run: S => (A, S)):
|
||||
def map[B](f: A => B): State[S, B]
|
||||
def flatMap[B](f: A => State[S, B]): State[S, B]
|
||||
object State:
|
||||
def pure[S, A](a: A): State[S, A]
|
||||
def get[S]: State[S, S]
|
||||
def put[S](s: S): State[S, Unit]
|
||||
def modify[S](f: S => S): State[S, Unit]
|
||||
```
|
||||
**F#** (in `module Snake.State`, which also defines the type)
|
||||
```fsharp
|
||||
type State<'s, 'a> = State of ('s -> 'a * 's)
|
||||
let run (s: 's) (State f: State<'s, 'a>) : 'a * 's
|
||||
let pure (a: 'a) : State<'s, 'a>
|
||||
let map (f: 'a -> 'b) (st: State<'s, 'a>) : State<'s, 'b>
|
||||
let bind (f: 'a -> State<'s, 'b>) (st: State<'s, 'a>) : State<'s, 'b>
|
||||
let get () : State<'s, 's> // a function of unit, so it can be generic
|
||||
let put (s: 's) : State<'s, unit>
|
||||
let modify (f: 's -> 's) : State<'s, unit>
|
||||
```
|
||||
|
||||
Scala's `get[S]` needs the state type at the call site when nothing else fixes it: `State.get[Int]`. In the tests, the Random comparison turns a `Random` step into a `State` by hand: Scala `State[Long, Long](seed => Random.nextUint32.run(seed))`, F# `State(fun seed -> Random.run seed Random.nextUint32)`. In JS there is nothing to turn: a `Random` already *is* a function `seed => [value, seed]`.
|
||||
|
||||
Tests (in `m04-state.test.js` / `M04State`): `State pure gives its value and leaves the state alone`, `State map applies f to the value and keeps the state from the underlying step`, `State flatMap threads the state through both steps`, `State get gives the state as the value and leaves it alone`, `State put replaces the state and gives undefined` (Scala/F#: "gives unit"), `State modify applies f to the state and gives undefined` (Scala/F#: "gives unit"), the three laws `State law: left identity, ...`, `State law: right identity, ...`, `State law: associativity, ...`, `State a counter hands out fresh labels in order`, and `State Random is State with the seed as S: the dice give the same values and seeds`.
|
||||
|
||||
Watch out: like `Random`, every function here returns a State (a function, or a value wrapping one) and runs nothing. `put(s)` ignores the incoming state, but it must still *be* a function of it.
|
||||
|
||||
### Run
|
||||
|
||||
- JS: `cd js && node --test "test/m04-*.test.js"` (runs both `m04-random` and `m04-spawn`)
|
||||
- JS: `cd js && node --test "test/m04-*.test.js"` (runs `m04-random`, `m04-spawn` and the optional `m04-state`; until you do Exercise 4.7, the `m04 State ...` tests fail; if you decide to skip it, copy the finished file instead, `cp js/solutions/state.js js/src/state.js`, and the same for `State.scala` / `State.fs`, so that your suite stays green)
|
||||
- Scala: `cd scala && ./test.sh M04`
|
||||
- F#: `cd fsharp && ./test.sh M04`
|
||||
|
||||
@@ -659,6 +735,42 @@ let newGame (seed: uint32) : GameState = seed |> ___ |> ___
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Exercise 4.7 (State, optional) — nudge</summary>
|
||||
|
||||
Four of the seven functions are already written: they are in your `random.js` (`Random.scala`, `Random.fs`) under the same names, with "seed" where you now write "s". For the other three, write down what each must return when run with a state `s`, as a pair, and the code follows from the pair.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Exercise 4.7 (State, optional) — stronger hint</summary>
|
||||
|
||||
- `get` run with `s` gives `[s, s]`. In JS, `get` is itself that function (not a function that returns one), just as `nextUint32` is a `Random`.
|
||||
- `put(s2)` run with any `s` gives `[undefined, s2]`: it ignores `s`, but it is still a function of it.
|
||||
- `modify(f)` run with `s` gives `[undefined, f(s)]`. (You can also write it with `flatMap`, `get` and `put`: read, then store `f` of what you read.)
|
||||
- Scala: `get`, `put` and `modify` live in the companion object and wrap their function with `State(...)`; in `put` and `modify` the value is `()`. F#: same shape with `State(fun s -> ...)`; `get` takes `()` so that it can stay generic.
|
||||
|
||||
</details>
|
||||
|
||||
<details><summary>Exercise 4.7 (State, optional) — near-solution (try the others first)</summary>
|
||||
|
||||
```js
|
||||
export const get = (s) => [___, ___]
|
||||
export const put = (s) => () => [undefined, ___]
|
||||
export const modify = (f) => (s) => [___, ___]
|
||||
```
|
||||
```scala
|
||||
def get[S]: State[S, S] = State(s => (___, ___))
|
||||
def put[S](s: S): State[S, Unit] = State(_ => ((), ___))
|
||||
def modify[S](f: S => S): State[S, Unit] = State(s => (___, ___))
|
||||
```
|
||||
```fsharp
|
||||
let get () : State<'s, 's> = State(fun s -> (___, ___))
|
||||
let put (s: 's) : State<'s, unit> = State(fun _ -> ((), ___))
|
||||
let modify (f: 's -> 's) : State<'s, unit> = State(fun s -> (___, ___))
|
||||
```
|
||||
`pure`, `map`, `flatMap`/`bind` and `run` are the near-solution of Exercise 4.2 with `Random` renamed to `State` and the seed renamed to `s`.
|
||||
|
||||
</details>
|
||||
|
||||
## Check your understanding
|
||||
|
||||
1. Why is `const roll = () => Math.floor(Math.random() * 6)` impure, while `d6 = map(nextUint32, v => 1 + v % 6)` is pure? Where did the "randomness" go?
|
||||
@@ -669,10 +781,14 @@ let newGame (seed: uint32) : GameState = seed |> ___ |> ___
|
||||
6. What would go wrong, across tracks, if `freeCells` listed cells column-major in JS but row-major in Scala?
|
||||
7. What does `spawnApple` do on a full board, and why is "status becomes `Won`" better than letting `intBelow(0)` decide?
|
||||
8. Why does `tick` leave the seed unchanged when the snake did not eat? What would break in replays if every tick consumed a random number anyway? (Hint: would it break anything? Think about determinism versus "which apple appears".)
|
||||
9. **Challenge: a draw without bias.** `intBelow(n)` is very slightly biased (see "Drawing a number below `n`"). The standard cure is *rejection*: let `limit` be the largest multiple of `n` that is at most 2^32; draw a value; if it is below `limit`, use `v % n`; otherwise draw again. Write `intBelowUniform(n)` on paper with the functions of this module. Why do you need `flatMap` here, where `intBelow` only needed `map`? What does that tell you about the difference between the two? (Do not put it in the course code: the fixtures expect the simple version.)
|
||||
10. Two generators that start from seeds `s` and `s + 0x6D2B79F5` produce the same sequence, shifted by one draw. Explain why from the structure of mulberry32. Why does that make "seed the second player's generator with `seed + 1`" a reasonable idea and "seed it with the first player's *next seed*" a bad one?
|
||||
11. *(Exercise 4.7)* **A label counter with `State`.** A game log numbers its lines: `1: ate an apple`, `2: turned left`, ... Write `label(text)` as a `State<number, string>` (Scala `State[Int, String]`, F# `State<int, string>`) using `get` and `put` (or `modify`), so that running the chain of `label('ate an apple')` and then `label('turned left')` from `1` gives both lines and leaves the counter at `3`. What is `S` here, and what bug can no longer happen that `this.line++` would allow? (When you reach Module 11, try the same with the Pong serve: write the choice of `dy` as a `State` over the seed, with `get` and `put`, and compare it with the `Random` version.)
|
||||
12. *(Exercise 4.7)* In JavaScript, `Random` and `State` are the same plain functions, so `random.js` could import `pure`, `map` and `flatMap` from `state.js`. In Scala and F#, `Random[A]` and `State[Long, A]` are different types, and you had to convert one into the other by hand in the tests. What does the separate `Random` type buy you there, and what does it cost? Which of `get`, `put`, `modify` would you *not* want to offer on `Random`, and why? (Think about what `put(0)` would do to a game's apples.)
|
||||
|
||||
## In production
|
||||
|
||||
- **Haskell's** [`random`](https://hackage.haskell.org/package/random) package uses pure generators: `uniformR :: g -> (a, g)`, exactly our shape. The **State monad** pattern is described on the [Haskell wiki](https://wiki.haskell.org/State_Monad).
|
||||
- **Haskell's** [`random`](https://hackage.haskell.org/package/random) package uses pure generators: `uniform :: g -> (a, g)` (leaving out the type-class constraints), exactly our shape. The **State monad** pattern is described on the [Haskell wiki](https://wiki.haskell.org/State_Monad).
|
||||
- **cats** (Scala) has a general [`State`](https://typelevel.org/cats/datatypes/state.html) data type: `State[S, A]` with `map`, `flatMap`, `get`, `set`. *Functional Programming in Scala* builds a `RNG`/`State` very much like this module in its chapter on purely functional state.
|
||||
- **Property-based testing** libraries such as [ScalaCheck](https://scalacheck.org/) and [fast-check](https://fast-check.dev/) generate random test inputs from a seed, so that a failing case can be reproduced by re-running with the same seed. Their generators are monads like `Random`.
|
||||
- **Java's** [`SplittableRandom`](https://docs.oracle.com/en/java/javase/21/docs/api/java.base/java/util/SplittableRandom.html) is a mutable object, but its algorithm (a counter plus a mixing function) has the same structure as mulberry32.
|
||||
@@ -688,3 +804,6 @@ let newGame (seed: uint32) : GameState = seed |> ___ |> ___
|
||||
- [Functional Programming in Scala, 2nd edition](https://www.manning.com/books/functional-programming-in-scala-second-edition) — chapter 6, "Purely functional state", is this module in book form.
|
||||
- [Scala: for-comprehensions](https://docs.scala-lang.org/tour/for-comprehensions.html) and [F# computation expressions](https://learn.microsoft.com/en-us/dotnet/fsharp/language-reference/computation-expressions) — the official docs for the syntax you saw.
|
||||
- [Mostly Adequate Guide](https://mostly-adequate.gitbook.io/mostly-adequate-guide/) — chapters 8 and 9 build `map` and `chain` (flatMap) for containers in JavaScript.
|
||||
- [Philip Wadler, "Monads for functional programming"](https://homepages.inf.ed.ac.uk/wadler/papers/marktoberdorf/baastad.pdf) — the classic tutorial paper; its section 2 threads a state through an evaluator by hand and then removes the plumbing, exactly the path of this module.
|
||||
- [Guy Steele, Doug Lea and Christine Flood, "Fast Splittable Pseudorandom Number Generators"](https://gee.cs.oswego.edu/dl/papers/oopsla14.pdf) — the design behind "a counter plus a mixing function" (SplitMix), the family mulberry32 belongs to.
|
||||
- [Daniel Lemire, "Fast Random Integer Generation in an Interval"](https://arxiv.org/abs/1805.10941) — how libraries draw a number below `n` without the modulo bias, and without a division.
|
||||
Reference in new issue
Block a user