# SX Language Improvements — roadmap Language-building improvements to the SX evaluator, compiler, and standard library. Ordered by impact and prerequisite chain. Each step is one loop commit. ## Roadmap complete (2026-05-07) All 14 steps shipped in 14 commits on the `architecture` branch. Phase 1 (bug fixes: JIT closures, letrec+resume), Phase 2 (E38 source info — subsumed by tokenizer fix), Phase 3 (native ADTs: AdtValue, define-type, match, exhaustiveness on both hosts), Phase 4 (parser/compiler plugin registry + worker), Phase 5 (perf: frame-records via prim_call fast path, buffer-based serializer, JIT inline opcodes). Cumulative performance wins on hot benchmarks: CEK fib -66% / loop -69% / reduce -86% (Step 12); inspect tree-d10 -80% / dict-1000 -61% (Step 13); VM JIT fib -69% / loop -62% / sum -50% / count-lt -38% / count-eq -58% (Step 14). Test suite: 4550/4550 OCaml. Branch: `architecture`. SX files via `sx-tree` MCP only. Never edit generated files. ## Current baseline (2026-05-06) - SX core spec: 2571 passing (595 non-HS pre-existing failures — bytecode-serialize, defcomp-render, etc.) - HyperScript behavioral: 1478/1496 (run via `node tests/hs-kernel-eval.js`) - Active bugs: JIT combinator bug (11 HS failures), letrec+resume (browser-only) - E38 sourceInfo: 2/4 tests passing (tokenizer missing `:end`/`:line`, some spans incomplete) --- ## Phase 1 — Bug fixes ### Step 1: Fix JIT closures-returning-closures **What:** `parse-bind`, `many`, `seq`, and other parser combinators that return closures miscompile under JIT. The compiled closure drops intermediate stack values when the callee itself returns a closure. 11 HyperScript tests fail under JIT, pass under CEK. **Root cause in `hosts/ocaml/lib/sx_vm.ml`:** When a JIT-compiled closure returns another closure (i.e. the callee is `VmClosure`), the frame restoration after the call incorrectly reuses the parent frame's locals slot, overwriting saved intermediate values. The `call_closure_reuse` path must snapshot `sp` before the inner call and restore it after, or bail to the non-reuse path for closures-returning-closures. **Verify:** `node tests/hs-kernel-eval.js 2>&1 | tail -3` — should go from 3116/3127 to 3127/3127. ### Step 2: Fix letrec + perform resume (browser) **What:** In browser JIT mode, `letrec` sibling bindings are nil after a `perform`/resume cycle. `call_closure_reuse` in `sx_browser.ml` intentionally ignores `_saved_sp`, which strips the frame locals that `sf_letrec` was waiting on. **Fix:** In `sx_browser.ml`, the `VmSuspension` resume path must restore frame locals from the suspension snapshot before calling the continuation. Mirror what `sx_vm.ml` does in the non-browser case. **Verify:** Write a test in `spec/tests/` that does `(letrec ((f (fn () (perform :io nil)))) (f))` with a resume, check bindings survive. Runs under OCaml: `dune exec -- bin/run_tests.exe`. --- ## Phase 2 — Source info (E38 completion) Design: `plans/designs/e38-sourceinfo.md`. Target: 4/4 sourceInfo tests. The API (`hs-parse-ast`, `hs-source-for`, `hs-line-for`, `hs-node-get`, `hs-src`, `hs-src-at`, `hs-line-at`) and parser span wrapping (`hs-ast-wrap`, `hs-span-mode`) are already in the codebase. Two tests are passing; two fail because: - Tokenizer tokens lack `:end` and `:line` (only `:pos` today). - Some statement-level spans and `:next` field navigation are incomplete. ### Step 3: Tokenizer — add `:end` and `:line` to tokens `lib/hyperscript/tokenizer.sx`: extend `hs-make-token` to `{:pos :end :value :type :line}`. Track a `current-line` counter (1-based, increments after `\n`). Update all ~20 emission sites. Mirror to `shared/static/wasm/sx/hs-tokenizer.sx` after edits. **Verify:** `(hs-make-token "NUMBER" "1" 0)` returns a dict with `:end` and `:line` keys. ### Step 4: Complete parser spans + :next field `lib/hyperscript/parser.sx`: ensure `hs-ast-wrap` populates `:next` on every command in a `CommandList` (i.e. the following sibling command). Check that statement-level productions (if, for) correctly populate `:true-branch`. Trace through the two failing tests (`get source works for expressions`, `get line works for statements`) to find the exact missing fields or off-by-one positions. Mirror to `shared/static/wasm/sx/hs-parser.sx`. **Verify:** All 4 `hs-upstream-core/sourceInfo` tests pass. **Outcome:** Subsumed by Step 3. Once tokens carried `:end` and `:line`, the existing parser plumbing (`link-next-cmds` for `:next`, `:true-branch` extraction in `parse-cmd`) worked end-to-end. All 4 `hs-upstream-core/sourceInfo` tests pass with no parser changes. --- ## Phase 3 — Native ADTs (`define-type` / `match`) Design: `plans/designs/sx-adt.md`. No existing implementation. Impact: every language implementation (Haskell, Prolog, Lua, Common Lisp, Erlang) currently fakes sum types with `{:tag "..." :field ...}` dicts. Native ADTs remove that everywhere. ### Step 5: OCaml — AdtValue type + `define-type` + basic `match` `hosts/ocaml/lib/sx_types.ml`: ```ocaml type adt_value = { av_type: string; av_ctor: string; av_fields: value array } | AdtValue of adt_value ``` `hosts/ocaml/lib/sx_runtime.ml` (or evaluator): - `step-sf-define-type`: parse `(Name (Ctor1 f1 f2) (Ctor2) ...)`, register constructor NativeFns, predicates (`Ctor1?`, `Name?`), field accessors (`Ctor1-f1`) via `env-bind!`. - `step-sf-match` + `MatchFrame`: linear scan of clauses; flat patterns only for 6a; bind pattern variables in child env; `else` clause; raise on no match. - `type-of` returns the type name (e.g. `"Maybe"`). Write tests in `spec/tests/test-adt.sx`: basic constructor, predicate, accessor, match, else, no-match raise. **Verify:** `dune exec -- bin/run_tests.exe` — new test file all green. ### Step 6: JS — AdtValue + `define-type` + `match` `hosts/javascript/platform.py`: add `AdtValue` as `{ _adt: true, _type, _ctor, _fields }`. Mirror `define-type` and `match` special forms in the JS evaluator. Retranspile: `python3 hosts/javascript/cli.py --output shared/static/scripts/sx-browser.js` **Verify:** `node hosts/javascript/run_tests.js` — adt tests pass on JS too. ### Step 7: Nested patterns (Phase 6b) Both OCaml and JS `MatchFrame`: replace linear binding with recursive `matchPattern(pattern, value, env)` that: - Recurses into constructor sub-patterns. - Returns `{matched: bool, bindings: map}`. - Handles wildcard `_`, literals (`42`, `"str"`, `true`, `nil`). Extend `spec/tests/test-adt.sx` with nested pattern tests. **Outcome:** No host-side changes needed. The spec-level `match-pattern` function in `spec/evaluator.sx` (≈line 2835) already recurses through constructor sub-patterns via the dict-shape shim (`(get value :_adt|:_ctor|:_fields)`), handles `_` wildcards, literals, and variable bindings. Step 7 added 8 new deftests to `spec/tests/test-adt.sx` covering: nested constructor sanity, nested constructor with field binding, nested wildcard, nested literal equality, nested literal-vs-var clause fall-through, deeply nested constructors, mixed bind+wildcard, and nested ctor fail-through. Both hosts: +8 tests pass, zero regressions (OCaml 4532→4540, JS 2578→2586). ### Step 8: Exhaustiveness warnings (Phase 6c) `_adt_registry: type_name → [ctor_names]` global populated by `define-type`. On first non-exhaustive `match` evaluation: `console.warn("[sx] match: non-exhaustive …")`. No error — warning only. **Outcome:** `host-warn` primitive added on both hosts (OCaml `prerr_endline`, JS `console.warn`). Spec-level helpers `match-clause-is-else?`, `match-clause-ctor-name`, `match-warn-non-exhaustive`, `match-check-exhaustiveness` added in `spec/evaluator.sx` and called from `step-sf-match`. `*adt-warned*` env-bound dict used to dedupe warnings per (type, missing-set). The OCaml `step_sf_match` in `hosts/ocaml/lib/sx_ref.ml` was hand-patched (not retranspiled) because `sx_ref.ml` retranspilation drops several preamble fixes; the spec changes still flow to JS via `sx_build target="js"`. Both hosts emit identical warnings (e.g. `[sx] match: non-exhaustive — Maybe: missing Nothing`). 5 new tests added. OCaml: 4540 → 4545. JS: 2586 → 2591. Zero regressions. --- ## Phase 4 — Plugin / extension system Design: `plans/designs/hs-plugin-system.md`. ### Step 9: Parser feature registry `lib/hyperscript/parser.sx`: replace `parse-feat` hardcoded `cond` with a dict lookup. `(hs-register-feature! name parse-fn)` adds to the registry. ### Step 10: Compiler command registry + `as` converter registry `lib/hyperscript/compiler.sx`: replace `hs-to-sx` hardcoded dispatch with dict. `(hs-register-command! name compile-fn)` and `(hs-register-converter! name convert-fn)`. ### Step 11: Migrate hs-prolog-hook + Worker plugin `lib/hyperscript/runtime.sx`: remove `hs-prolog-hook`/`hs-set-prolog-hook!` ad-hoc slots. Create `lib/hyperscript/plugins/prolog.sx` that calls `hs-register-feature!` and `hs-register-command!`. Create `lib/hyperscript/plugins/worker.sx` replacing the E39 stub. --- ## Phase 5 — Performance These are incremental and can interleave with other phases. ### Step 12: Frame records (CEK) `hosts/ocaml/lib/sx_runtime.ml`: represent CEK frames as OCaml records instead of tagged variant lists. Eliminates allocation pressure from list construction per frame. Profile before/after on a tight-loop benchmark. **Outcome:** Frames were already records (`cek_frame` in `sx_types.ml`) — the actual hot-path bottleneck was `prim_call "=" [...]` in `step_continue`/`step_eval` dispatch: each step did a Hashtbl lookup + 2x list cons + pattern match per comparison. Added a fast path in `prim_call` (sx_runtime.ml) for `=`, `<`, `>`, `<=`, `>=`, `empty?`, `first`, `rest`, `len` that skips the table lookup entirely. Also inlined `_fast_eq` for the common scalar-equality cases that dominate frame-type dispatch. Median improvements (bench_cek.exe, 7 runs): | Benchmark | Before | After | Change | |-----------|--------|-------|--------| | fib(18) | 2789ms | 941ms | -66% | | loop(5000) | 2018ms | 620ms | -69% | | map sq(1000) | 108ms | 48ms | -56% | | reduce + (2000) | 72ms | 10ms | -86% | | let-heavy(2000) | 491ms | 271ms | -45% | Tests: 4545 passing (unchanged baseline), 1339 failing (unchanged baseline). Benchmark binary: `bin/bench_cek.exe`. ### Step 13: Buffer primitive for string building Add `make-buffer`, `buffer-append!`, `buffer->string` primitives. Eliminates the `(str a b c d ...)` quadratic allocation pattern in serializers and renderers. Wire into `sx_primitives.ml` and the JS platform. **Outcome:** Short aliases `make-buffer`/`buffer?`/`buffer-append!`/`buffer->string`/ `buffer-length` added on both hosts, sharing the existing `StringBuffer` value type. `buffer-append!` accepts any value (auto-coerces non-strings via inspect), unlike `string-buffer-append!` which is strict. The hot path converted was the OCaml host-internal `inspect` function in `sx_types.ml`: rewrote from `(... ^ String.concat " " (List.map inspect items) ^ ...)` (which allocates O(n) intermediate strings per recursion level) to a single shared `Buffer.t` accumulator (`inspect_into buf v` walks the value tree appending into one buffer). `inspect` is called by `sx-serialize` on both spec and host paths, plus error-path formatting. Median improvements (`bin/bench_inspect.exe`, best of 3 runs of 9-run min): | Benchmark | Baseline (best min) | Buffer (best min) | Change | |-------------------|--------------------:|------------------:|-------:| | tree-d8 (75KB) | 5.31ms | 1.30ms | -76% | | tree-d10 (679KB) | 81.89ms | 16.02ms | -80% | | dict-1000 | 0.80ms | 0.31ms | -61% | | list-2000 | 0.74ms | 0.33ms | -55% | 5 new tests in `spec/tests/test-string-buffer.sx` covering the new aliases (incl non-string coercion and interop with the existing `string-buffer-*` API). OCaml: 4545 → 4550. JS: 2591 → 2596. Zero regressions. ### Step 14: Inline common primitives in JIT `hosts/ocaml/lib/sx_vm.ml`: add `OP_ADD`, `OP_SUB`, `OP_EQ`, `OP_APPEND` specialised opcodes that skip the primitive table lookup for the most common calls. Compiler emits these when operands are known numbers/lists. **Outcome:** The opcodes (`OP_ADD`=160, `OP_SUB`=161, `OP_MUL`=162, `OP_DIV`=163, `OP_EQ`=164, `OP_LT`=165, `OP_GT`=166, `OP_NOT`=167, `OP_LEN`=168, `OP_FIRST`=169, `OP_REST`=170, `OP_CONS`=172) already existed in `sx_vm.ml` but the compiler never emitted them — every primitive call went through `OP_CALL_PRIM` (52) with a Hashtbl lookup. Two changes: 1. **`lib/compiler.sx` `compile-call`**: when the primitive name + arity matches a specialized opcode, emit the 1-byte opcode (no name index, no argc operand) instead of the 4-byte CALL_PRIM. Bytecode for `fib` shrank from 50→38 bytes. 2. **`hosts/ocaml/lib/sx_vm.ml` opcode bodies**: extended `OP_ADD/SUB/MUL/DIV` to handle `Integer + Integer` (was `Number + Number` only — defaulted to Hashtbl for the common integer case). Inlined `OP_EQ` to call `Sx_runtime._fast_eq` directly. Inlined `OP_LT/GT` integer + mixed-numeric comparisons. Median improvements (`bin/bench_vm.exe`, best of 3 runs of 9-min): | Benchmark | Baseline (best min) | After (best min) | Change | |------------------|---------------------|------------------|-------:| | fib(22) | 107.87ms | 33.13ms | -69% | | loop(200000) | 429.64ms | 161.16ms | -62% | | sum-to(50000) | 72.85ms | 36.74ms | -50% | | count-lt(20000) | 28.44ms | 17.58ms | -38% | | count-eq(20000) | 37.23ms | 15.46ms | -58% | Tests: 4550/4550 passing (unchanged baseline). Zero regressions. Benchmark binary: `bin/bench_vm.exe` (loads `lib/compiler.sx` via CEK, JIT-compiles each test fn, measures `Sx_vm.call_closure` time on the compiled `vm_closure`). --- ## Progress log | Step | Status | Commit | |------|--------|--------| | 1 — JIT combinator bug | [x] | 882a4b76 | | 2 — letrec+resume | [x] | e80e655b | | 3 — tokenizer :end/:line | [x] | 023bc2d8 | | 4 — parser spans complete | [x] | b7ad5152 (subsumed by 023bc2d8) | | 5 — OCaml AdtValue + define-type + match | [x] | 1f49242a | | 6 — JS AdtValue + define-type + match | [x] | fc8a3916 | | 7 — nested patterns | [x] | 0679edf5 | | 8 — exhaustiveness warnings | [x] | 6d391119 | | 9 — parser feature registry | [x] | 986d6411 | | 10 — compiler + as converter registry | [x] | d22361e4 | | 11 — plugin migration + worker | [x] | 6328b810 | | 12 — frame records | [x] | a66c0f66 (fib -66%, loop -69%, reduce -86% via prim_call fast path) | | 13 — buffer primitive | [x] | 0e022ab6 (inspect rewrite: tree-d10 -80%, tree-d8 -76%, dict-1000 -61%, list-2000 -55%) | | 14 — inline primitives JIT | [x] | 6c171d49 (fib -69%, loop -62%, sum -50%, count-lt -38%, count-eq -58% via specialized opcode emission) | --- ## Rules - Branch: `architecture`. Never push to `main`. - SX files: `sx-tree` MCP tools only. `sx_validate` after every edit. - After every `.sx` edit to `lib/hyperscript/`, mirror to `shared/static/wasm/sx/hs-.sx`. - OCaml build: `sx_build target="ocaml"` MCP tool (never raw `dune`). - JS build: `sx_build target="js"` MCP tool. - One step per commit. Update progress log in this file. - No new planning docs. No comments in SX unless non-obvious. - Unicode in SX: raw UTF-8 only, never `\uXXXX`.