plum

#treesitter#compiler#wasm

git clone https://git.pyrossh.dev/plum

A statically typed, imperative programming language inspired by rust, python


8a00a46 — Peter John 2026-11-06T14:38:08+05:30
plans: a simpler language, approved, on its own branch
docs/plans/2026-11-06-simpler-language.md ADDED
@@ -0,0 +1,230 @@
1
+ # A simpler language
2
+
3
+ **Review plan:** docs/plans/2026-11-06-simpler-language.review.md, approved by the user 2026-11-06 ("Yes do everything to simplify all the language syntax and do it in a separate branch so that we can compare later on").
4
+ **Goal:** remove inheritance, two scene styles, `return`, `while`, `break`, `continue` and local reassignment; add modules, `.=` and paths in spreads; make the in-place proof work by type for structs and enums; compare the old imperative game with the new functional one.
5
+ **Architecture:** compiler/core, compiler/checker, compiler/driver, compiler/wasm-codegen, compiler/cli, compiler/lsp, compiler/host, compiler/game, compiler/tooling/tree-sitter-plum, compiler/tooling/vscode-plum, std/, examples/, tests/, docs/.
6
+ **Tech stack:** Rust 2021, Plum, tree-sitter.
7
+ **Detail:** contracts
8
+ **Target-min:** 480
9
+
10
+ ## Global constraints
11
+ - All work is on branch `simpler-language` in the main checkout (`/Users/pyrossh/Code/plum`), default shared `target/`. Never switch branch, never touch `master`, no worktrees, don't commit (the coordinator does). Exception: T1 only, see its notes. The user asked for this branch; it overrides the AGENTS.md rule for this run only.
12
+ - Never run `plum format` on real files. No `sed -i -E`. `../kam_plum` is out of scope.
13
+ - No `cargo test` (hook): `cargo build --workspace --tests`, then run every test executable, listed by `cargo test --no-run --workspace 2>&1 | grep Executable` (14 binaries; may shrink). Sweep `target/debug/plum test std examples tests`. `compiler/game` is a standalone crate with its own build.
14
+ - Tree-sitter regeneration needs `--disable-optimizations`.
15
+ - Tests of a removed feature are deleted in the task that removes it; add each to the task's reply so it reaches the results. Any other test that fails is a bug. Never weaken a check to make a test pass.
16
+ - Behaviour: a program that still compiles prints what it printed before the task. Compare against the T1 baseline tree where in doubt. Wasm bytes may change; behaviour may not.
17
+ - Differential check at each task's end: the sweep passes with the default, with `--no-fbip`, and with `--no-fusion`.
18
+ - Rewriting Plum sources by script is fine when the script is checked by the sweep. Put scripts in `.superlite/`. Hand-write anything subtle.
19
+ - User decisions (not to be reopened): methods stay indented under their type, no receiver syntax. `mut` fields stay. `=` stays only for `mut` fields. `?` and postfix `!` stay. `for` stays as side-effect sugar only. `Array` and `Slice` stay mutable and get no FBIP. No `consume`, `own`, `pure fun` or `|>`. No `pub`/private.
20
+
21
+ ## Task graph
22
+ ```mermaid
23
+ flowchart LR
24
+ T1[baseline on master]
25
+ T2[plain games, one scene]
26
+ T3[no inheritance]
27
+ T4[modules]
28
+ T5[std namespaces to modules]
29
+ T6[question rewrite and block if]
30
+ T7[update operator and spread paths]
31
+ T8[std off return and while]
32
+ T9[examples and tests off return and while]
33
+ T10[remove the old forms]
34
+ T11[proof by type]
35
+ T12[last-use moves]
36
+ T13[shared and unique callers]
37
+ T14[functional port and comparison]
38
+ T15[docs]
39
+ %% taskgraph
40
+ T1 --> T2
41
+ T2 --> T3
42
+ T3 --> T4
43
+ T4 --> T5
44
+ T5 --> T6
45
+ T6 --> T7
46
+ T7 --> T8
47
+ T8 --> T9
48
+ T9 --> T10
49
+ T10 --> T11
50
+ T11 --> T12
51
+ T12 --> T13
52
+ T13 --> T14
53
+ T14 --> T15
54
+ ```
55
+ Ruling: chain - every task rewrites the same std and compiler passes, so parallel writers would collide - cost: a long sequential run.
56
+
57
+ ## Package A: baseline and removal
58
+ **Probe entry:** run=plum test std examples tests
59
+
60
+ ### T1 baseline on master · Complexity 3 · Est. context 40k
61
+ **Owns:** docs/plans/2026-11-06-simpler-language.benchmark.md, .superlite/baseline-master/**
62
+ **Components:** 1
63
+ **LOC owned:** 100
64
+ **Tests:** fast
65
+ **Produces:** `.superlite/baseline-master/` holds an exported, release-built copy of `master` with its `plum` and `plum-web-test`; the benchmark doc records its game and loop numbers
66
+ e.g. `.superlite/baseline-master/target/release/plum test std` runs today's language
67
+ **Notes:** The export is `git archive master | tar -x -C .superlite/baseline-master`, not a worktree. Build it in release, including `compiler/game/web-test`. Record, with load average and 3 runs each: `stress_scene`, `stress_world` and the other stress fixtures through `plum-web-test` as in docs/plans/2026-11-05-game-engine.results.md, split into update, draw and render; compile time and `.wasm` size for one game; lines of `compiler/` Rust and of `std/`; the sweep count; the five loop probes from docs/plans/2026-11-06-simpler-language.review.md. Say which fixtures use the imperative scene and which state-and-view, and whether they draw the same scene. Don't change anything outside the owned paths.
68
+ **Test code:** none; the numbers are the deliverable.
69
+
70
+ ### T2 plain games, one scene · Complexity 6 · Est. context 80k
71
+ **Owns:** std/game/**, examples/games/**, compiler/game/**, compiler/cli/**, compiler/driver/**, compiler/checker/src/**, compiler/checker/tests/**, compiler/core/src/**, compiler/core/tests/**, compiler/wasm-codegen/**, tests/**, docs/plans/2026-11-06-simpler-language.benchmark.md
72
+ **Components:** 3
73
+ **LOC owned:** 800
74
+ **Tests:** fast
75
+ **Consumes:** `.superlite/baseline-master/` holds an exported, release-built copy of `master` with its `plum` and `plum-web-test`; the benchmark doc records its game and loop numbers (T1)
76
+ **Produces:** `main` returning any type with `update(self, io, dt) -> Self` and `draw(self, io)` runs as a game; `std/game/scene` is the only scene library
77
+ e.g. the `OrbRun` game in the review plan runs under `plum run`, with `import std/game/Scene` until T4 adds module names
78
+ **Notes:** The return type of `main` decides: such a type is a game; `Int`, `Result` or nothing is a CLI app. A type with only one of the two methods gets a clear error naming the missing one. Remove `Game`, `Engine.create` and the `self.canvas`, `self.input` wrapper fields: games call `io` functions. Move `std/game/functional/*` to `std/game/`, delete `std/game/imperative/` and `std/game/pure/`, and port their still useful examples to state-and-view. Keep `compiler/game/web-test/fixtures` covering the same scenes: keep the old imperative fixtures' intent by porting them, since T14 measures them against `.superlite/baseline-master`. Hot reload keeps working.
79
+ **Test code:** the cli `run`/`build` tests use a plain game; a test for the missing-method error; web-test still passes on a ported game.
80
+
81
+ ### T3 no inheritance · Complexity 7 · Est. context 90k
82
+ **Owns:** compiler/core/src/**, compiler/core/tests/**, compiler/checker/**, compiler/wasm-codegen/**, compiler/lsp/**, compiler/tooling/**, compiler/game/**, std/**, examples/**, tests/**
83
+ **Components:** 3
84
+ **LOC owned:** 1500
85
+ **Tests:** fast
86
+ **Consumes:** `main` returning any type with `update(self, io, dt) -> Self` and `draw(self, io)` runs as a game; `std/game/scene` is the only scene library (T2)
87
+ **Produces:** `abstract`, `override` and embedded struct fields are parse errors; `embeds.rs`, subtyping and override dispatch are gone
88
+ e.g. `struct A(B(x))` gives a parse error, and `examples/subtyping.plum` is deleted
89
+ **Notes:** Delete, don't disable. Every method call has one target. Replace remaining uses in std and examples with enums or plain structs. Purity, simplify, drive, reuse, uniqueness lose their override handling: remove it rather than leave dead branches. Report lines removed.
90
+ **Test code:** a parse-error test for each of the three removed forms.
91
+
92
+ ## Package B: modules
93
+
94
+ ### T4 modules · Complexity 8 · Est. context 110k
95
+ **Owns:** compiler/core/src/**, compiler/core/tests/**, compiler/checker/**, compiler/wasm-codegen/**, compiler/driver/**, compiler/lsp/**, compiler/host/**, compiler/tooling/**, tests/modules/**
96
+ **Components:** 3
97
+ **LOC owned:** 900
98
+ **Tests:** fast
99
+ **Consumes:** `abstract`, `override` and embedded struct fields are parse errors; `embeds.rs`, subtyping and override dispatch are gone (T3)
100
+ **Produces:** `import a/b` gives `b.f()` and `b.Const`; types and enum variants stay unqualified; two modules may reuse a function or constant name; module-level constants; `extern fun` at module level with `io: Io` first; `primitive` is rejected outside std
101
+ e.g. two imported modules each with `fun clamp` both work as `a.clamp(..)` and `b.clamp(..)`, and `ArenaW = 800.0` is a constant
102
+ **Notes:** Mangle module functions and constants while merging, so later passes keep seeing global names; show the source name in errors and the LSP. Two modules defining the same type name stay an error. Old unqualified access keeps working until T5 finishes; T5 removes that fallback. A local may shadow a module name; the local wins. Module constants accept any pure expression, evaluated once at start, and fusion may inline literals.
103
+ **Test code:** `tests/modules/`: same function name in two modules; constants; shadowing by a local; import cycles keep their current error; `primitive` outside std rejected; `extern` without `io` first rejected.
104
+
105
+ ### T5 std namespaces to modules · Complexity 7 · Est. context 110k
106
+ **Owns:** std/**, examples/**, tests/**, compiler/core/src/**, compiler/core/tests/**, compiler/checker/**, compiler/wasm-codegen/**, compiler/game/**, compiler/cli/**, compiler/lsp/**
107
+ **Components:** 3
108
+ **LOC owned:** 1500
109
+ **Tests:** fast
110
+ **Consumes:** `import a/b` gives `b.f()` and `b.Const`; types and enum variants stay unqualified; two modules may reuse a function or constant name; module-level constants; `extern fun` at module level with `io: Io` first; `primitive` is rejected outside std (T4)
111
+ **Produces:** std's namespaces are modules (`math.sqrt`, `gfx.rect(io, ..)`, `input.axis(io, ..)`); `primitive` only declares built-ins such as `Int` and `Array`; the unqualified fallback is gone
112
+ e.g. `import std/math` then `math.sqrt(2.0)`; `Math.sqrt` is an error
113
+ **Notes:** Host functions that today are `Io` methods used for games move to `gfx`, `input`, `audio` module functions taking `io` first. `io.printLn` and other general `Io` methods keep working. Static-style calls such as `Time.now(io)` become module functions where the type has no natural receiver. Use a script for call sites, checked by the sweep.
114
+ **Test code:** existing tests ported; a test that the old namespace form is an error.
115
+
116
+ ## Package C: syntax
117
+
118
+ ### T6 question rewrite and block if · Complexity 7 · Est. context 90k
119
+ **Owns:** compiler/core/src/**, compiler/core/tests/**, compiler/checker/**, compiler/wasm-codegen/**, tests/syntax/**
120
+ **Components:** 2
121
+ **LOC owned:** 500
122
+ **Tests:** fast
123
+ **Consumes:** std's namespaces are modules (`math.sqrt`, `gfx.rect(io, ..)`, `input.axis(io, ..)`); `primitive` only declares built-ins such as `Int` and `Array`; the unqualified fallback is gone (T5)
124
+ **Produces:** a lowering turns `x := e?` plus the rest of the block into a `match` with no `Expr::Question` left for later passes; block `if` and `match` are expressions anywhere an expression goes; functions using `?` fuse
125
+ e.g. `v := if a\n 1\nelse\n 2` compiles, and `--explain-fusion` reports a function containing `?` as eligible
126
+ **Notes:** Rewrite: `x := e?` then `rest` becomes `match e` with `Ok(x) => rest`, `Err(e) => Err(e)`, and the same for `Some`/`None`. A `?` nested in an argument is lifted to a temporary first, keeping evaluation order. Allowed: `?` at block level, including inside tail `if` and `match` branches, which recurse. Rejected with a clear error: `?` in a non-tail `if` branch, inside a closure whose return type doesn't fit, or inside a `for` body, with the message saying to move it into a function. The error mapping stays as it works today (`Err(error:)`). Remove the `Question` refusals in `drive.rs` and `closure_spec.rs`. Keep crash lines right for `!`.
127
+ **Test code:** `tests/syntax/question.plum`: chains; nested in arguments, with evaluation order observed; both enums; every rejected form; same answers with `--no-fusion`.
128
+
129
+ ### T7 update operator and spread paths · Complexity 5 · Est. context 60k
130
+ **Owns:** compiler/core/src/**, compiler/core/tests/**, compiler/checker/**, compiler/lsp/**, compiler/tooling/**, tests/syntax/**
131
+ **Components:** 2
132
+ **LOC owned:** 300
133
+ **Tests:** fast
134
+ **Consumes:** a lowering turns `x := e?` plus the rest of the block into a `match` with no `Expr::Question` left for later passes; block `if` and `match` are expressions anywhere an expression goes; functions using `?` fuse (T6)
135
+ **Produces:** `self.items .= push(x)` means `self.items = self.items.push(x)` on a `mut` field; `State(..s, ship.x: 1.0)` updates a nested field without a temporary
136
+ e.g. `State(..s, ship.x: 1.0)` is `State(..s, ship: Ship(..s.ship, x: 1.0))`
137
+ **Notes:** Both desugar at parse or lowering so later passes see plain forms. The path receiver in `.=` is evaluated once. Paths in spreads go through struct fields only; an enum or a generic parameter on the path is a type error. Two paths may share a prefix: `S(..s, a.x: 1, a.y: 2)` builds one `a`. Update tree-sitter, the formatter queries and vscode syntax.
138
+ **Test code:** `tests/syntax/`: shared prefixes, deep paths, a receiver with a side effect runs once, non-`mut` target rejected, path through an enum rejected.
139
+
140
+ ### T8 std off return and while · Complexity 8 · Est. context 130k
141
+ **Owns:** std/**
142
+ **Components:** 2
143
+ **LOC owned:** 3000
144
+ **Tests:** fast
145
+ **Consumes:** `self.items .= push(x)` means `self.items = self.items.push(x)` on a `mut` field; `State(..s, ship.x: 1.0)` updates a nested field without a temporary (T7)
146
+ **Produces:** std uses no `return`, `while`, `break`, `continue` or local `=`; std provides `fold`, `each`, `find`, `repeatUntil` over `range` and collections
147
+ e.g. `grep -rE '\breturn\b|^\s*while ' std` finds nothing in code
148
+ **Notes:** State goes through arguments, since closures copy captures. Use local recursive helper functions for hot paths in `Str`, `Buffer`, `Map`, `Slice`. A `for` with a side-effect body writing through `Array.set` is fine. Keep the same public behaviour and complexity: check `Map` and `Buffer` haven't gained copies. Compare the std probes in the benchmark doc after each large file, and report any more than 20% slower. Split the work by file and keep the sweep green throughout.
149
+ **Test code:** existing std tests stay unchanged and pass.
150
+
151
+ ### T9 examples and tests off return and while · Complexity 6 · Est. context 110k
152
+ **Owns:** examples/**, tests/**, compiler/core/tests/**, compiler/checker/tests/**, compiler/wasm-codegen/tests/**, compiler/cli/tests/**, compiler/game/**
153
+ **Components:** 2
154
+ **LOC owned:** 2500
155
+ **Tests:** fast
156
+ **Consumes:** std uses no `return`, `while`, `break`, `continue` or local `=`; std provides `fold`, `each`, `find`, `repeatUntil` over `range` and collections (T8)
157
+ **Produces:** no Plum source in examples, tests or Rust test strings uses the old forms, apart from tests of those forms themselves
158
+ e.g. `examples/control_flow.plum` teaches `if`, `match`, `for`, `fold` and recursion
159
+ **Notes:** Rust tests of `return`/`while` semantics that exist only for those forms are deleted in T10, not here. `tests/fbip` keeps checking the same aliasing cases in the new forms: never drop one. Kids' games use `for` and state structs and stay readable.
160
+ **Test code:** ported tests.
161
+
162
+ ### T10 remove the old forms · Complexity 8 · Est. context 120k
163
+ **Owns:** compiler/core/src/**, compiler/core/tests/**, compiler/checker/**, compiler/wasm-codegen/**, compiler/driver/**, compiler/lsp/**, compiler/tooling/**, std/Array.plum, tests/**
164
+ **Components:** 3
165
+ **LOC owned:** 2000
166
+ **Tests:** fast
167
+ **Consumes:** no Plum source in examples, tests or Rust test strings uses the old forms, apart from tests of those forms themselves (T9)
168
+ **Produces:** `return`, `while`, `break`, `continue` and `x = v` on a local are parse or type errors; `for` lowers to `each`; `Array.with`, `withUnique` and the pilot `Vec` are gone
169
+ e.g. `x = 1` on a local reports "locals can't be reassigned; use a new name with :="
170
+ **Notes:** Delete the AST nodes, checker and codegen arms, and the pass branches, and simplify the passes they complicated: this is the payoff. A `for` body that assigns an outer local is an error. A pass may keep a private loop node only if TRMC truly needs it, not parseable; say so if it does. Keep `fip fun`, `--no-fbip`, the fuzzer. Update the fuzzer's generator to the new forms. Report lines removed.
171
+ **Test code:** an error test for each removed form; `for` over a range, a list and a `Slice`.
172
+
173
+ ## Package D: the proof, for structs and enums
174
+
175
+ ### T11 proof by type · Complexity 8 · Est. context 110k
176
+ **Owns:** compiler/core/src/uniqueness.rs, compiler/core/src/purity.rs, compiler/core/src/reuse.rs, compiler/core/tests/**, compiler/driver/**, compiler/checker/src/**, tests/fbip/**
177
+ **Components:** 2
178
+ **LOC owned:** 800
179
+ **Tests:** fast
180
+ **Consumes:** `return`, `while`, `break`, `continue` and `x = v` on a local are parse or type errors; `for` lowers to `each`; `Array.with`, `withUnique` and the pilot `Vec` are gone (T10)
181
+ **Produces:** the proof and purity key methods by the checker's receiver type, so `Ship.update` and `Orb.update` are separate; struct and enum spreads and reuse prove in the shapes the pilot failed on
182
+ e.g. a struct with a method named `push` no longer blocks another type's `push`
183
+ **Notes:** With no overrides, each call has one target. Run after checking, or have the checker record the receiver type. Soundness first: anything unclear stays a copy.
184
+ **Test code:** every stage 1 aliasing test stays; new hostile programs for same-named methods on different types; the fuzzer, 200 seeds, compared with `--no-fbip`.
185
+
186
+ ### T12 last-use moves · Complexity 7 · Est. context 90k
187
+ **Owns:** compiler/core/src/uniqueness.rs, compiler/core/tests/**, tests/fbip/**
188
+ **Components:** 1
189
+ **LOC owned:** 400
190
+ **Tests:** fast
191
+ **Consumes:** the proof and purity key methods by the checker's receiver type, so `Ship.update` and `Orb.update` are separate; struct and enum spreads and reuse prove in the shapes the pilot failed on (T11)
192
+ **Produces:** a local moved into a field or returned counts as handed over, so `S(..s, ship: s.ship.update(dt))` reuses both when `s` is unique
193
+ e.g. `--explain-fusion` shows `S(..s, ship: s.ship.update(dt))` in place
194
+ **Notes:** Last use is flow-sensitive across `if` and `match` branches. A closure capture or a read after the move stays shared.
195
+ **Test code:** hostile programs: read after the move in one branch; move in a nested spread; capture before the move; the same value moved into two fields.
196
+
197
+ ### T13 shared and unique callers · Complexity 7 · Est. context 90k
198
+ **Owns:** compiler/core/src/uniqueness.rs, compiler/core/src/ctors.rs, compiler/core/tests/**, compiler/driver/**, tests/fbip/**
199
+ **Components:** 1
200
+ **LOC owned:** 400
201
+ **Tests:** fast
202
+ **Consumes:** a local moved into a field or returned counts as handed over, so `S(..s, ship: s.ship.update(dt))` reuses both when `s` is unique (T12)
203
+ **Produces:** a function called with both unique and shared values gets two compiled versions, so one shared call doesn't make every call copy
204
+ e.g. `--explain-fusion` names `update$unique` and `update` for such a function
205
+ **Notes:** Only when the unique version differs. Bound code growth: skip recursion cycles that would multiply versions, and report them. Report `.wasm` growth on one game.
206
+ **Test code:** a function called both ways gives the copying answer for the shared caller and reuses for the unique one; the fuzzer, 200 seeds.
207
+
208
+ ## Package E: the comparison and docs
209
+
210
+ ### T14 functional port and comparison · Complexity 6 · Est. context 80k
211
+ **Owns:** compiler/game/web-test/fixtures/**, docs/plans/2026-11-06-simpler-language.benchmark.md, docs/imperative-vs-functional.md
212
+ **Components:** 2
213
+ **LOC owned:** 500
214
+ **Tests:** fast
215
+ **Consumes:** a function called with both unique and shared values gets two compiled versions, so one shared call doesn't make every call copy (T13)
216
+ **Produces:** the benchmark doc compares `master`'s imperative stress games with the branch's functional ports on the same scene, and `docs/imperative-vs-functional.md` gives the result in plain words
217
+ e.g. a table of update, draw and total ms for old imperative, new functional, new `--no-fbip`, new `--no-fusion`
218
+ **Notes:** Rebuild nothing in the baseline; run old and new back to back, in alternation, 3 or more runs each, and record load. The scenes must match in sprite count and motion: state how that was checked. Also: compile time, `.wasm` size, lines of compiler and std, the sweep count, the five loop probes. Report what `--explain-fusion` says stayed copying. If the functional game loses, say so plainly and name the cause; don't tune the fixture to win. Say which conclusions the noise doesn't support.
219
+ **Test code:** none beyond fixtures running in the web-test harness.
220
+
221
+ ### T15 docs · Complexity 5 · Est. context 70k
222
+ **Owns:** README.md, docs/language.md, docs/std.md, docs/game.md, docs/game-scene-styles.md, docs/tools.md, docs/performance.md, docs/backlog.md, docs/plans/2026-11-06-simpler-language.results.md
223
+ **Components:** 1
224
+ **LOC owned:** 800
225
+ **Tests:** fast
226
+ **Consumes:** the benchmark doc compares `master`'s imperative stress games with the branch's functional ports on the same scene, and `docs/imperative-vs-functional.md` gives the result in plain words (T14)
227
+ **Produces:** docs describe only the new language, every code example in them compiles, and the results doc lists what was removed and every deleted test
228
+ e.g. README's sample is the `OrbRun` shape
229
+ **Notes:** Keep the plain, short style of the current docs. Delete `docs/game-scene-styles.md` if one style makes it pointless, and fix links. Say in the results doc that `master` keeps the old language. Don't decide the merge: that's the user's.
230
+ **Test code:** compile every doc example, as earlier doc passes did.
docs/plans/2026-11-06-simpler-language.review.md CHANGED
@@ -1,13 +1,12 @@
1
1
  # A simpler language: review plan
2
2
 
3
- **Status:** waiting for your decisions. Nothing here is built. This replaces the receiver-functions plan, whose `fun Orb.update(self)` syntax you rejected.
3
+ **Status:** approved by the user 2026-11-06 ("Yes do everything to simplify all the language syntax and do it in a separate branch"). Build plan: docs/plans/2026-11-06-simpler-language.md.
4
+ **Branch:** `simpler-language`, so it can be compared with `master`. `master` keeps today's language.
4
- **Evidence:** FBIP stage 1 results, docs/plans/2026-11-06-fbip-stage1.results.md.
5
+ **Evidence:** docs/plans/2026-11-06-fbip-stage1.results.md.
5
6
 
6
7
  ## Summary
7
8
 
8
- Remove inheritance, two of the three scene styles, and `primitive` namespaces from user code. Give Plum real modules. Make games plain structs. Then finish FBIP, which gets much simpler once inheritance is gone.
9
-
10
- The pilot showed that FBIP can't go ahead as planned: the in-place proof works by name across the whole program, so a collection with ordinary names like `push` and `data` is never updated in place. That makes the work here a prerequisite, not just a tidy-up.
9
+ Make Plum a small, functional language. Remove inheritance, two of the three scene styles, `return`, `while`, `break`, `continue` and reassigning locals. Give it real modules. Make games plain structs. Then make the optimiser reliable for structs and enums, and measure the old imperative game against the new functional one.
11
10
 
12
11
  ## What a game looks like
13
12
 
@@ -44,69 +43,68 @@ fun main(io: Io) -> OrbRun =
44
43
 
45
44
  A game is any type with `update` and `draw`, found by duck typing, as with Ebiten.
46
45
 
46
+ ## Decisions
47
+
48
+ | Question | Decision |
49
+ |---|---|
50
+ | Inheritance | Removed: `abstract`, `override`, struct embedding |
51
+ | Scene styles | One: state-and-view, as `std/game/scene`. Imperative and pure scenes removed |
52
+ | std namespaces | Modules: `Math.sqrt` becomes `math.sqrt`. `primitive` only declares std's built-ins |
53
+ | Imports | Types and enum variants unqualified; functions and constants use the module name |
54
+ | `return` | Removed. Block `if` is an expression |
55
+ | `?` | Stays. The compiler rewrites the rest of the block into a `match` arm |
56
+ | `while`, `break`, `continue` | Removed. Use `fold`, `find`, `repeatUntil` or a recursive function |
57
+ | `for` | Stays as side-effect sugar for `each`: no early exit, no updating outer variables |
58
+ | `=` on locals | Removed. `:=` is the only form. `=` stays only for `mut` fields |
59
+ | `mut` | Stays for now, for `Array`, `Slice` and other in-place shells |
60
+ | `.=` | Added: `self.items .= push(x)` |
61
+ | Paths in spreads | Added: `State(..s, ship.x: 1.0)` |
62
+ | FBIP for `Array`, `Slice` | Skipped: they stay mutable. `Array.with` and the pilot `Vec` go |
63
+ | FBIP for structs and enums | Built: proof by type, last-use moves, shared and unique callers |
64
+ | `consume` | Not added |
65
+
47
66
  ## What will be built
48
67
 
68
+ | Step | Part | Size |
69
+ |---|---|---|
70
+ | 1 | Record `master`'s benchmarks and keep a built copy for comparison | S |
71
+ | 2 | Plain games and one scene style | M |
72
+ | 3 | No inheritance | L, mostly deletion |
73
+ | 4 | Modules | L |
74
+ | 5 | std namespaces become modules | L |
75
+ | 6 | `?` as a rewrite, block `if` as an expression, `.=`, spread paths | M |
76
+ | 7 | Move std, examples and tests off `return`, `while` and local `=` | L |
77
+ | 8 | Remove those forms from the compiler, and `Array.with` | L, mostly deletion |
49
- In this order, because each step makes the next one easier.
78
+ | 9 | Proof by type, last-use moves, shared and unique callers | L |
79
+ | 10 | Port the imperative stress game to a functional one, and compare | M |
80
+ | 11 | Rewrite the docs | M |
50
81
 
51
- | Step | Part | What it does | Size |
52
- |---|---|---|---|
53
- | 1 | Plain games | Any type with `update` and `draw` is a game, and `update` returns the next one. Ports every game and fixture | M |
54
- | 1 | One scene style | Remove `std/game/imperative` and `std/game/pure` (about 2,250 lines) and their examples. State-and-view stays, as `std/game/scene` | M |
55
- | 2 | No inheritance | Remove `abstract`, `override` and struct embedding: `embeds.rs`, subtyping, most of `ctors.rs`, override dispatch | L, mostly deletion |
56
- | 3 | Modules | `import a/b` gives `b.f()` and `b.Const`. Two modules can reuse a name. Module-level constants. `extern` on module-level functions, with `io` first. `primitive` only for std's built-ins | L |
57
- | 4 | Syntax | The changes in decision 3 below | M |
58
- | 5 | Proof by type | The proof uses the checker's types, so `Vec.push` and `Slice.push` are separate. With no overrides, every call has exactly one target | L |
59
- | 6 | Last-use moves | A local moved into a field or returned counts as handed over: `S(..s, f: s.f.push(x))` through a method, and a returned unique local | M |
60
- | 7 | Shared and unique callers | One shared call no longer turns every call into a copy, by compiling a second version of the function | M |
61
- | 8 | FBIP stage 2 | Run the pilot again. If it passes, port `Slice`, `Map`, `Buffer`, `Deque` and the rest | L |
82
+ ## The comparison
62
83
 
63
- ## What the pilot measured
84
+ The same scene in both languages, on the same machine, back to back:
64
85
 
86
+ - **Old:** `master`'s imperative scene: a tree of objects changed in place.
65
- 20,000 items updated for 200 frames, through a state struct field, on a release build:
87
+ - **New:** the branch's functional game: state and view, with fusion and FBIP.
88
+ - **New, optimiser off:** the same game with `--no-fusion`, and with `--no-fbip`, to show what the optimiser earns.
66
89
 
67
- | Version | Time |
68
- |---|---|
69
- | Today's `Slice` with `mut` | 25 ms |
70
- | A persistent `List`, `l = l.map(..)` | 15 to 19 ms |
71
- | A pilot `Vec` in a local, proved in place | 31 ms |
72
- | A pilot `Vec` in a state struct field, copying because the proof fails | 22,000 ms |
73
-
74
- Two things follow:
75
- - **In-place works when it's proved,** close to `Slice`. When it isn't proved, it's about 900 times slower. That's the risk `fip fun` exists to catch.
76
- - **A persistent `List` is already fast, even faster than `Slice`.** The state-and-view style holds lists, so games using it are safe now, before any of steps 5 to 8.
77
-
78
- ## Decisions for you
79
-
80
- 1. **Remove inheritance?** Recommended. It costs little in features (enums and structs of functions cover it), and it removes most of the difficulty in the proof.
81
- 2. **Remove the imperative scene?** Recommended. If you'd rather keep a tree of objects you can change, the alternative is to keep it, written with `mut` fields and enums.
82
- 3. **Which syntax changes?**
83
- - **Block `if` as an expression, and no `return`.** Recommended, the most valuable and the most disruptive. Fusion skips any function with an early `return`, so this turns the optimiser on everywhere. `?` stays. Returning from inside a loop would need `fold` or a helper.
84
- - **`self.items .= push(x)`** for `self.items = self.items.push(x)`. Recommended.
85
- - **`State(..s, ship.x: 1.0)`**, paths in a spread. Recommended, because binding a field to a variable blocks the proof.
86
- - `consume`, a move marker: only if steps 5 to 7 leave a real gap.
87
- 4. **Should std's namespaces become modules?** `Math.sqrt` becomes `math.sqrt`, and so on. Recommended, so `primitive` means one thing: declaring a built-in.
88
- 5. **How are names imported?** Recommended: types and enum variants are unqualified, and functions and constants use the module name: `Texture`, `Some`, but `gfx.rect`.
89
- 6. **Remove `mut`?** Later. It becomes possible after step 8, if the shells no longer need it. Decide then, with numbers.
90
+ The report also covers compile time, `.wasm` size, and the size of the compiler and std.
90
91
 
91
92
  ## Risks
92
93
 
93
- - **Silent copies are the top risk.** A shape the proof misses is about 900 times slower, with no error. `--explain-fusion` shows it and `fip fun` makes it an error, but a game that doesn't use `fip` just gets slow.
94
- - **Steps 5 to 7 may not cover real game code.** The pilot re-run at step 8 decides that, and `List` remains a fast fallback either way.
95
- - **Wrong proofs are silent bugs.** Each proof change gets the same attack as stage 1: hostile programs and the fuzzer in `tests/fbip/fuzz`, compared against `--no-fbip`. That found four real miscompiles in stage 1.
96
- - **Losing the object model.** People from libGDX or Unity lose a familiar style, and very large worlds rely on fusion. Measure `stress_scene` and `stress_world` at step 1.
97
- - **Modules touch every pass,** since names are assumed global throughout. Mangling names per module while merging keeps later passes unchanged.
94
+ - **Slower loops.** 50 million simple iterations cost the same in every form today, but that wasn't boxed state or game code. A closure the compiler can't specialise stays an indirect call. The comparison will show it, and the report will say so if the functional game loses.
95
+ - **Silent copies.** A shape the proof misses is much slower, with no error. `--explain-fusion` shows it and `fip fun` makes it an error.
96
+ - **Wrong proofs are silent bugs.** Each proof change gets hostile programs and the fuzzer in `tests/fbip/fuzz`, compared against `--no-fbip`.
97
+ - **Harder for beginners.** Kids' games lose `while` and counters. `for` keeps the simple case readable.
98
+ - **Very large migration.** About 600 `return`s and 150 `while`s. It runs after each new form works, so tests stay green throughout.
99
+ - **Modules touch every pass,** since names are assumed global. Mangling names per module while merging keeps later passes unchanged.
98
100
 
99
101
  ## Out of scope
100
102
 
101
- Package management, visibility (`pub`/private), multi-touch, and splitting `Io`.
103
+ Package management, visibility (`pub`/private), multi-touch, and splitting `Io`. `kam_plum` isn't ported.
102
104
 
103
105
  ## How we will know it works
104
106
 
105
- - Each step keeps all tests green, compared against a recorded baseline.
107
+ - Each step keeps all tests green. Tests of removed features are deleted, and listed in the results.
106
- - The pilot's table passes with realistic names, in particular a state struct field.
108
+ - Examples behave as they do on `master`.
107
- - `stress_scene` and `stress_world` are no slower than today, on a quiet machine.
109
+ - The comparison is written down, whichever way it comes out.
108
- - The compiler is smaller at the end than now.
110
+ - The compiler and std are smaller at the end than on `master`.
109
-
110
- ## Rough time and cost
111
-
112
- Large, but steps 1 and 2 are mostly deletion. Each step is its own build and can be stopped at. Steps 1 to 4 are worth having even if FBIP stops there.