plum

#treesitter#compiler#wasm

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

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


279200c — Peter John 2026-11-04T10:05:30+05:30
docs: record the November 2026 language and scene decisions
docs/decisions/2026-11-language-and-scene-decisions.md ADDED
@@ -0,0 +1,97 @@
1
+ # Language and scene decisions, November 2026
2
+
3
+ A record of the design decisions taken in the 2026-11-03/04 sessions, with the reasoning and the options that were rejected. Each entry links the plan that carries it out. "User" marks a decision the user took; the rest are recommendations the user accepted.
4
+
5
+ ## 1. Direction: the most functional style, not the fastest
6
+
7
+ - **Decision (user):** prefer the most functional approach, where a frame is a recursive value built from state and walked recursively (`State -> view -> draw`), over matching the mutable scene's speed. "I don't want the fastest, otherwise I would just stick with mutate."
8
+ - **Consequence:** performance is measured and reported, not chased. The only hard bar for games is staying well inside the frame budget (state/view p95 2.5 ms against a 4 ms bar at 120 Hz).
9
+ - **Measured** (headed Chromium, M3 Pro, ms per frame): mutable scene 1.93, PureScene 1.65, state/view with Ui and Anim 2.07. With fusion, state/view draw is about 7% faster.
10
+
11
+ ## 2. Three scene styles, three folders
12
+
13
+ - **Decision (user):** each style lives in its own folder, because one shared folder was confusing.
14
+ - `plum-std/game/`: shared basics (`Game`, `Vec2`, `Color`, `Affine`) and the mutable `Scene`.
15
+ - `plum-std/game/pure/`: PureScene, SceneTree, SceneInput, Actions (pure updates, in place via `own` today).
16
+ - `plum-std/game/functional/`: Picture, Anim, Ui (state/view).
17
+ - `plum-examples/games/pure/` and `plum-examples/games/functional/` for their example games. `plum game` finds assets in the nearest parent `assets/` folder.
18
+
19
+ ## 3. Pure scene on enums
20
+
21
+ - **Decision (user):** the pure scene graph models sum types with enums, not a 14-field trait embedded by records.
22
+ - `own record Node` holds the shared fields and embeds `record Transform` (position, size, origin, scale, rotation, plus `localMatrix()` and the box hit test). Its `kind: Kind` is an enum: `Group(children: Slice[Node]) | Sprite | Label | Button | Custom(actor: Actor)`.
23
+ - `Custom(actor: Actor)` is Plum's `Custom(Box<dyn Actor>)`. `Actor` is a trait with no fields, only `act` and `draw`. The game's own kinds (Ship, Orb, Mine, Star, Panel) are records embedding it. Calls are resolved statically because Plum compiles the whole program.
24
+ - Fixed lists are built with variadic init, `Slice(a, b, c)` / `List(a, b)`; Plum has no `[a, b]` literal (dropped earlier by the user).
25
+ - Children stay a `Slice` (not the cons `List`) so updates can run in place with `mapInPlace`.
26
+ - **Rejected:** a generic `Node[K]` with game functions passed in, a union of the game's records with a `match`, and generic traits (`Actor[Msg]`). The user said no `Msg` parameter is needed: "Generics can be tried later".
27
+ - **Plan:** `docs/plans/2026-11-03-pure-scene-enums.md` (merged).
28
+
29
+ ## 4. State/view at feature parity
30
+
31
+ - **Decision (user):** give the functional style PureScene's features as pure functions.
32
+ - `Anim`: animations are functions of time, `Anim.at(anim, t) -> AnimPose`, with `MoveBy | RotateBy | ScaleTo | Delay | Seq | Par | Forever`. State stores only when an animation started; nothing is stepped, so nothing drifts.
33
+ - `Picture`: `Tagged(id, child)` plus `Picture.hitId`.
34
+ - `Ui`: `Ui.step(ui, input, hit)` returns the new UI state and `UiEvent`s, using PureScene's press/enter/exit/release/click and capture rules; `Ui.button` builds a button from the UI state.
35
+ - **Deferred (user):** static subtree reuse (build unchanging parts of a picture once, share them every frame). The user expects it isn't a big cost and can be optimised later.
36
+ - **Deferred (user):** structural `==` for records. The user chose deep structural equality (like Rust's `#[derive(PartialEq)]`, not cycle-safe), then deferred building it. Until then `==` on records is identity, and events are compared field by field.
37
+ - **Plan:** `docs/plans/2026-11-03-state-view-parity.md` (merged).
38
+
39
+ ## 5. Functional scene graphs: notes from the discussion
40
+
41
+ - Picture already follows the textbook functional design: a recursive ADT, the matrix passed down instead of parent pointers, top-down hit paths, assets referenced by handle.
42
+ - Structural sharing pays off only for parts that don't change (UI, terrain). In a game where everything moves, rebuilding from state costs about the same and is simpler.
43
+ - Folding a tree into a flat list of render commands is redundant here. The host already batches draws, and `Picture.draw` calling the host directly is the fused form; the supercompiler removes the tree as well.
44
+ - Zippers are not needed in state/view, because code edits `State` and rebuilds rather than editing the tree.
45
+
46
+ ## 6. The supercompiler
47
+
48
+ - **Goal:** remove data that is built only to be taken apart: List pipelines and `Picture.draw(view(s))`.
49
+ - **Design:**
50
+ - Passes in `plum-core`: purity, simplify (inlining, known-constructor `match`, case-of-case), whistle (homeomorphic embedding), drive (fold recursive producer/consumer pairs, chains, wrappers), uniqueness (in-place spreads), reuse (same-shape rebuilds reuse the old cell).
51
+ - They run after closure specialisation and before TRMC. Every rewrite is checked again, and the original is kept if that check fails.
52
+ - **Correction during the build:** only the producer must be pure and its result used once; the consumer may do I/O (`draw` calls the host). Interleaving a pure producer's evaluation with the consumer's effects can't be observed.
53
+ - Stops on growth (whistle, size budget, a cap of 64 new functions, nesting depth 6); no generalisation. Every refused site gets a reason note.
54
+ - **Switches:** `--no-fusion` and `--explain-fusion` compiler flags (user: flags, not environment variables).
55
+ - **Results:** `map -> filter -> fold` on the cons List fully fuses (1M elements: 4 ms against 15 ms); view->draw fuses into allocation-free functions, except the game-over overlay, whose `Filter` genuinely reads its child twice.
56
+ - **Plan:** `docs/plans/2026-11-03-supercompiler.md` (merged); reports `...benchmark.md`, `...own-report.md`.
57
+
58
+ ## 7. `own`
59
+
60
+ - **Decision (user):** keep `own` until the compiler can prove for itself that nobody else holds a value; then it becomes unnecessary.
61
+ - **Finding:** `own` mainly guards in-place field writes (`n.x = ...`). Uniqueness inference covered only spreads, so `own` could not be removed yet. Uniqueness did already give a plain record the same in-place spread `own` gives.
62
+ - **Measured earlier:** `own` barely moved scene speed. Copying was never the bottleneck; allocation-free actions and direct traversal were.
63
+ - **Path to removal:** the language restrictions below (immutable fields, uniqueness for method parameters and `self`), then delete `own`.
64
+
65
+ ## 8. Language restrictions for a simpler compiler
66
+
67
+ - **Decision (user):** the language may become stricter or lose features if that makes the supercompiler simpler and more reliable. All five restrictions were accepted:
68
+ 1. **Immutable fields; `mut` per field.** Fields are immutable unless declared `mut` (OCaml style), for example `record Slice[T](mut data: Array[T], start: Int, mut size: Int, mut cap: Int)`. Other updates go through spread, `T(..x, f: v)`. The user chose per-field `mut` over `mut record` because it is more precise: unchanging fields stay stable for the optimiser, and one counter doesn't make a whole type mutable.
69
+ 2. **Effects through an `Io` capability (Zig `std.Io` style).** Host I/O happens only through an `Io` value (`io.drawRect(..)`, `io.printLn(..)`). Entry points receive it, and a function is pure if no `Io` is reachable from its parameters or captures. The user rejected an `io fun` keyword and `fun!` markers ("I don't want function coloring") and rejected implicit inference as hidden. A fake or recording `Io` serves tests and replays.
70
+ 3. **Fusion runs before union lowering** (internal).
71
+ 4. **Only expression-bodied functions** (`= ...`) are fusion candidates.
72
+ 5. **Pattern casing is enforced:** Capitalised names are tags, lowercase names are binders. This removes the bug class behind a miscompile where an error name was read as a variable.
73
+ - **Plan:** `docs/plans/2026-11-03-language-restrictions.md` (in progress).
74
+
75
+ ## 9. Next: no heap mutation at all (Koka-style FBIP)
76
+
77
+ - **Decision (user):** after the restrictions, remove heap mutation entirely, in the spirit of Koka's FBIP ("functional but in-place").
78
+ - Persistent `Slice`/`Map`/`Array`/`Buffer`: `xs.set(i, v)` returns the new collection and is written in place when the old one is provably unique.
79
+ - Koka-style `fip` functions: a checked guarantee that every update is in place, a compile error otherwise.
80
+ - Game shells return the new world (`update(io, w, dt) -> World`). Local variable reassignment in loops stays.
81
+ - **Why not plain Koka:** Koka checks for a reference count of 1 at runtime (Perceus). Plum's heap is wasm-gc, which has no reference counts, so Plum proves uniqueness statically first. A small reference count used only as a uniqueness check (the GC still frees memory) is the fallback if static proof falls short on the games.
82
+ - **Risk:** an array write the analysis can't prove unique silently becomes a copy every frame; `fip` turns that into a compile error. The `mut` fields left after the restrictions plan are this plan's worklist.
83
+
84
+ ## 10. Next after that: deterministic games
85
+
86
+ - **Discussion:** pure updates make `step(state, input)` repeatable, but full determinism also needs a fixed timestep, a seeded random generator kept in game state, and recorded inputs. Plum's `Math.sin`/`cos` are written in Plum, and wasm `f64` gives the same results in every engine.
87
+ - **Decision (user):** a separate plan, ported from `kam_macroquad`'s design: a fixed-timestep accumulator, replays that record inputs rather than state, state checksums, and a seeded `KaMRandom`. The `Io` capability makes recording and replaying host input straightforward.
88
+
89
+ ## 11. Where Plum sits among languages
90
+
91
+ Gleam's data model (immutable records with `T(..x, f: v)` updates, custom types, enforced constructor/variable casing), Zig's effects and errors (`std.Io`-style capability, `!T`, error sets, `T?`, per-instantiation generics), Rust-style closed-world traits with static dispatch, an explicit `mut`-field escape hatch (to be removed by FBIP), and a whole-program wasm-gc compiler with a supercompiler. Unlike Gleam it tracks effects, keeps loops, and has traits.
92
+
93
+ ## 12. Smaller decisions
94
+
95
+ - **wasmtime:** the GC heap starts at 64 MiB in every host, and the dev profile compiles wasmtime at opt-level 3.
96
+ - **Tests:** superlite's per-file test mode now really runs the named files (`.superlite/run-test-files.sh`); it used to filter `cargo test` by path and run nothing.
97
+ - **Fixed during the builds** (each with a regression test): borrowed values passed to trait methods; trait forwarders keep `own`; field-less records (`Panel()`); a bare pattern on a variant with fields is a compile error; views into `own` values are checked after a move; TRMC handles `if`/`else` branches and methods; and several fusion and uniqueness soundness holes found in review.