Skip to content

Two engines, one spec: Dunky machines in Rust, next to TypeScript - #75

Open
ivanbanov wants to merge 28 commits into
mainfrom
rust-core
Open

ivanbanov wants to merge 28 commits into
mainfrom
rust-core

Conversation

@ivanbanov

@ivanbanov ivanbanov commented Oct 7, 2026 •

Copy link
Copy Markdown
Member

Why

Dunky's promise is define a behavior once, render it anywhere. "Anywhere" stopped at JavaScript: a dialog or a command palette could not run in a Rust program, a game, or natively on React Native.

This PR brings the same machines to Rust, as a second engine next to the TypeScript one. Both follow one behavior contract, packages/core/SPEC.md, with twin test suites. Neither runs on the other, so each stays tuned for its own runtime.

                 packages/core/SPEC.md
            (the one behavior contract)
               /                    \
              v                      v
TypeScript engine                Rust engine
@dunky.dev/state-machine         dunky-core
(as on main: pure TS)            Rust programs, native bindings,
                                 and JS through wasm

What is in it

  • crates/core (dunky-core): the Rust engine. It has the same model as the TS one (flat states, run-to-completion, guards, actions, effects, after timers, computed values, watchers, selections, connector, compose, store), with typed states, events and context (#[derive(State, Event, Context)]) and timers as commands (no clock inside).
  • Rust machines in TypeScript:
    • crates/wasm (export_machine!) turns a Rust machine into a JS class in your own wasm module.
    • The new @dunky.dev/state-machine-wasm wraps it with fromWasm, behind the same Machine interface. Its TS types are generated from the Rust types, and a type test pins the Rust palette's types to the TS palette's.
    • useMachine (React, Solid) now accepts a ready machine. Core exports toMachine / MachineSource.
  • The TS engine is unchanged: packages/core is main's engine. The only additions are exports: toMachine, MachineSource, and makeBroadcast / makeSelection, so a Rust machine notifies and dedupes exactly like a TS one.
  • The sandbox runs the Rust palette with ?machine=rust (built from crates/sandbox).
  • Benchmarks, one suite per engine: benchmark/ts (the TS suite and its demo, moved unchanged) and benchmark/rust (the Rust engine natively, plus a 60 fps frame-budget proof).
  • The website: a "Two engines, one spec" section on the home page, and a new Rust docs group (Two engines, Machines in Rust, Use in TypeScript).
  • Keeping the two in sync: AGENTS.md has a "Two engines, one spec" rule. A behavior change updates the spec, both engines and both test suites in the same PR. The Rust tests are twins of the TS tests, file by file.

Why two engines, not Rust everywhere

We tried one engine first: the TS package as a thin layer over the Rust engine, compiled to wasm. It worked, but it was not the win it looked like. Part of the TS side still had to mirror the Rust engine (the context, the computed cache and the transition steps stay in JS, next to the user code), and every event paid the JS↔wasm bridge, so it gave up a bit of speed. So we maintain both engines against one spec. That attempt is kept on a local branch for reference.

The Rust engine does not cost a frame

At 60 fps one frame is 16.67 ms. Each workload runs inside one frame, timed frame by frame over 600 frames (release build, Apple M5 Pro):

Workload per frame Median Worst frame Allocations
10,000 machines, 1 event each 0.151 ms (0.9%) 0.435 ms 0
1 machine, 10,000 events 0.105 ms (0.6%) 0.195 ms 0
1,000 transitions (exit, entry, effects) 0.017 ms (0.1%) 0.048 ms 0
64 observers, 1,000 field changes 0.044 ms (0.3%) 0.069 ms 0
  • No workload goes past 3% of a frame, even in its worst frame, and no frame allocates anything.
  • Headline numbers: ~96 M events/sec, ~344 M cached computed reads/sec, 23 ns to build and start a machine, 0.36 KB per machine (2-field context).
  • Engine work in this PR: zero allocations in the steady state, field selections that wake only on their field (select_field, the twin of TS select.context), and leaner notifies.

Details: benchmark/rust/README.md.

Known gaps (in TODO.md)

  • React Native: crates/uniffi exposes the Rust palette over JSI, but it still needs the TS facade that fromWasm gives wasm classes.
  • crates.io: the crates are not published yet; docs show a git dependency.

Changesets

Minor for @dunky.dev/state-machine, @dunky.dev/react-state-machine, @dunky.dev/solid-state-machine, and the new @dunky.dev/state-machine-wasm. No majors.

Testing

  • 390 TS tests: main's suite, plus fromWasm, the targets accepting a ready machine, and the Rust palette and dialog through real wasm.
  • 163 Rust tests (158 in release), twins of the TS tests plus Rust-only mechanism tests; the crates/uniffi tests.
  • Type-check (now including the sandbox, with the exact-type check), oxlint, oxfmt; cargo lint (clippy) for the host and for wasm32; cargo fmt.
  • Package build with publint; website build (with the benchmark demo); both benchmark suites run.

🤖 Generated with Claude Code

ivanbanov and others added 15 commits October 5, 2026 22:10
- toMachine(source) builds a stopped service from a config and returns a
  ready machine as is; MachineSource is the type both bridges accept.
- makeBroadcast and makeSelection are exported so other Machine
  implementations (the wasm adapter) share the same notify and
  value-dedup semantics.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The first argument may return a config (TS engine) or a ready machine,
e.g. a Rust machine compiled to wasm. A ready machine must come fresh from
the factory: the hook owns its lifecycle, as for a config-built one.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- crates/core (dunky-core): the engine of packages/core in Rust, same SPEC.
  Typed machines (#[derive(State, Event, Context)] in crates/macros), sans-IO
  `after` timers (the host runs timer commands), a change mask per call,
  and the handle protocol every binding shares (dunky_core::protocol).
  The tests port the 16 TS test files, plus Rust-only cases.
- crates/demo: the sandbox command palette, a dialog with an exit timer,
  and the benchmark machines, written against dunky-core.
- crates/wasm (dunky-wasm): export_machine! turns a machine type into a JS
  class (sendKind / sendPayload / change mask / timer commands).
- crates/demo-wasm + scripts/build-wasm.sh: the demo machines built to
  packages/demo-wasm/pkg (wasm-bindgen --target web, wasm-opt -Oz).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- @dunky.dev/state-machine-wasm: fromWasm(handle) returns the TS Machine
  interface over a Rust machine. It mirrors context into one plain object,
  re-reads only the fields the change mask names, caches computed values
  by version, and runs timer commands with setTimeout. Observers are
  notified once per call.
- @dunky.dev/demo-wasm (private): the Rust demo machines as TS Machines.
  Parity tests run the Rust palette next to the TS palette step by step.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- tests/wasm.ts: the same machines on both engines — one event, state
  churn, guard fallthrough, palette typing at 11 / 1,000 / 10,000 commands,
  construction, and the raw boundary cost. SPIKE_OUT=<file> writes JSON.
- The rendering bench gains wasm/selector and wasm/instance rows.
- Both are skipped with a warning when the wasm is not built, so the TS
  benchmarks still run without a Rust toolchain.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…tui)

The React and Solid sandboxes take ?engine=rust, opentui takes
--engine=rust (Bun runs the wasm). Same connect(), same markup; only the
machine source differs (CommandPaletteSource). The wasm is imported only
for the Rust engine, so the TS pages run without the wasm build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Hermes has no WebAssembly, so React Native runs the same Rust crate as
native code: crates/uniffi exports the palette machine with uniffi, and
uniffi-bindgen-react-native builds it into a Turbo Module
(packages/native-rust: xcframework for iOS, jniLibs for Android).
NativePaletteHandle speaks the same handle protocol as the wasm binding,
so fromWasm wraps it unchanged. The Expo sandbox gains an engine toggle;
without the native build it falls back to the TS palette.

Also: sandbox/native turns off Expo's tsconfig paths experiment (Expo 54
reads them through the TypeScript JS API, which TypeScript 7 does not
have) and dedupes react-native in Metro.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- CI: a rust job (fmt, clippy with warnings as errors, tests in debug and
  release, and the same for crates/uniffi); the test and typecheck jobs
  build the wasm first (.github/actions/wasm-setup reads the wasm-bindgen
  version from Cargo.lock).
- Scripts: build:wasm, test:rust, lint:rust.
- Workspace: tsconfig paths and build references, tsdown entry for
  @dunky.dev/state-machine-wasm, knip ignore for the generated bindings.
- Docs: Rust setup in CONTRIBUTING, the Rust core in ARCHITECTURE.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The engine can now run a machine whose user code lives in another runtime
(cargo feature `host`): guards, actions, effects, delays and computed values
referenced by number, run by a `Host`, which also hears every change.

- A failing callback raises the machine's halt flag; the step stops at the
  next checkpoint, what is queued stays queued, and the binding rethrows.
- A run of host actions is one call, and so is a guard walk over a handler's
  candidates (`Action::ExternalList`, entry pick lists).
- A host may hold a state notification back and deliver it with its next
  callback, no later than `Host::settle` at the end of the transition.
- A host that owns the context reports its writes (`mark_changed`); the
  engine stamps them and runs the watchers, the host notifies.

Lighter machines for every binding: per-field stamps sized to the fields a
machine writes, broadcasts created on first subscribe, no effect or watcher
work for states and machines that have none, and an idle `send` that skips
the queue.

SPEC.md (new) covers what is specific to the crate: authoring in Rust,
timers as commands, change reporting and the binding API.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…mWasm

@dunky.dev/state-machine becomes a thin TS layer over the Rust engine,
compiled to wasm (crates/core-wasm) and inlined, so it starts synchronously
on the first machine() call. A TS config compiles once into numbers and JS
tables; Rust runs the graph and calls back into the user code. The 16 core
test files pass unchanged on it.

One protocol (crates/wasm) for both kinds of machine: typed host calls that
return status codes, the facade passed with each call (Rust holds no JS
object between calls), a notify per change, timers on the host clock. JS
owns the errors. A TS machine announces its own context writes; Rust hears
about a write only when watchers or computed values need it, and a cached
computed value is served without calling Rust.

Machines written in Rust export with dunky_wasm::export_machine! and wrap
with fromWasm, now exported by the core package. The adapter package
(packages/wasm) and its exports for other engines (makeBroadcast,
makeSelection) are gone.

Restructure:
- packages/demo-wasm and crates/demo* removed: the palette and the dialog
  move to sandbox/shared/rust (with the real-wasm tests), the benchmark
  machines to benchmark/rust.
- packages/native-rust removed until the React Native engine over JSI lands;
  the native sandbox is back to the TS palette.
- Sandboxes switch with ?machine=rust (--machine=rust in the terminal).
- CI builds the wasm for build, test, typecheck, release and the benchmark
  demo; wasm-bindgen runs with reference types.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- AGENTS.md: the repo now runs one Rust engine for machines written in TS or
  Rust; crates/ is a scope; core is TypeScript over the engine.
- ARCHITECTURE.md: the engine section (protocol, one facade, few crossings)
  and the new project map.
- packages/core/SPEC.md: the engine, sources (config or ready machine),
  machine lifetime, machines written in Rust, writes only through setContext,
  the runaway guard in production too. README: a quick reference for Rust
  machines.
- packages/react/SPEC.md, packages/solid/SPEC.md (new): the lifecycle hook,
  the source it accepts, selection, component effects, edge cases.
- CONTRIBUTING.md, sandbox/README.md: the Rust toolchain is now required;
  ?machine=rust.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The memory section counts the engine's wasm heap for core (a JS heap
  sample does not see it; the shipped wasm counts its live bytes).
- benchmark/README.md section 8, and the website: the same machine written
  in TS and in Rust. In JS a TS machine is faster (its context lives in JS);
  Rust machines are for reuse across hosts.
- Refreshed tables (average of two runs) in the benchmark README, the
  website page and the root README.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
State and effect churn paid one call into JS per step. Now:

- A leaving transition of a TS machine without computed values runs whole
  in one host call: effect cleanups, exit actions, the transition's actions,
  the switch and its notification, entry actions, the target's effects
  (`Host::transition`, enabled per transition by a host id). The engine
  switches state after the call; a failure before the switch leaves the
  machine where it was.
- The host keeps the cleanups of the effects it starts; the engine signals
  one effects stop (`Host::stop_effects`), which rides on the next callback
  or is settled before the state switch. No boxed cleanup per effect, and
  the engine's cleanup list keeps its buffer.
- Leaner steps: an unguarded first candidate skips guard setup, one borrow
  around the host transition, no settle after it, and the engine-failure
  slot is read only when the halt flag is up.

Benchmark (tight loop): state churn 7.7 -> 8.2 M/s with the call removed and
up to 9.2 M/s in the suite; effect churn 4.4 -> 5.8 M/s in the suite.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ARCHITECTURE.md now defines every piece in one place: the layers (engine,
engine binding, core, shared, target), how a machine written in TS and one
written in Rust run on the engine, the facade and the protocol between them,
and a vocabulary grouped by the repo, the TS core and the Rust engine.
"host" now means only the engine's callback side (the old "host = the
agnostic core" meaning is retired), and "engine binding" names crates/wasm
and crates/uniffi apart from the shared/bindings vocabulary.

The engine and wasm SPECs describe the final protocol: host-kept effect
cleanups, the effects stop, whole transitions, and the `pre` channel. The
root README lists the engine and the core, with links to both SPECs.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Average of two runs on the final engine: one event 9.7 M ops/s (~6× XState),
state churn 7.5 M, effect churn 6.7 M, cached computed read 56.3 M,
construction 1.38 µs, memory 4.08 KB at 64 fields (~33× lighter than Zag).
Section 8 (TS vs Rust machines) and the website follow.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@vercel

vercel Bot commented Oct 7, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
dunky-state-machine Ready Ready Preview Oct 8, 2026 6:33pm UTC

ivanbanov and others added 4 commits October 7, 2026 14:47
A computed read after an unrelated write made two JS<->wasm crossings, and
a recompute made six. The old TS core made none. Most of them are gone:

- JS caches each computed value with what its last evaluation read. A
  write clears only the values that read a written field (and their
  readers), so an untouched value is served with no call.
- The value never crosses: the engine gets a status that says whether it
  changed (JS runs Object.is), so no Object.is import call, no value copy.
- What an evaluation read crosses only when it differs from last time.
- Without watchers, a write report is a plain call (stamp), and only for
  fields a computed value read; the engine runs no code for it.

The engine's Host::computed now returns an Evaluation (changed + reads)
instead of a value; computed_external no longer takes an equality.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Average of two runs. The computed rows the fix targets:

- recompute 3.4 M -> 4.5 M (old TS core: 4.9 M)
- re-read after an unrelated write 5.7 M -> 8.3 M (old: 10.7 M)
- 4-deep chain 982 K -> 2.3 M (old: 1.5 M)

Memory per machine drops ~0.1 KB: machines without computed values no
longer allocate cache arrays.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Without the host feature the computed match has one arm; the workspace
build hid it because dunky-wasm turns the feature on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
The TS types of a Rust machine were written by hand (fromWasm<State,
Context, Event, Computed>), so they could drift from the Rust ones.
Now they are generated:

- dunky-core: a TsType trait. #[derive(State, Event, Context)] write the
  TS types of states, events and context; #[derive(TsType)] covers the
  data types they hold. Names and shapes follow the serde attributes the
  values cross with (None is null, skipped-when-None is optional).
- dunky-wasm: export_machine! gives each class typescript(&config), and
  an optional `internal` block for computed values it serves but leaves
  out of its types.
- pnpm build:wasm appends them to the wasm-bindgen .d.ts as a __types
  member of the class; fromWasm infers Machine<...> from it (explicit
  generics still work for classes built without types).
- The sandbox type-checks the Rust palette's generated types against the
  TS palette's, exactly, and sandbox/shared joins `pnpm typecheck`.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ivanbanov and others added 2 commits October 7, 2026 21:45
Every call into the engine, and every host callback, passed the JS facade
as a reference: each one went into the wasm reference table and out
again. Rust only calls the host during a call into the machine, so JS now
keeps the facade of the call in progress itself (saved and restored as
calls nest), and the host functions run for it. Calls carry only numbers
and the JS event.

One event with an action: ~71 ns -> ~61 ns; a bare call into a machine:
29 ns -> 22 ns. The Rust-machine classes lose their optional facade
argument; once attached, call them through their fromWasm facade.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Average of two runs. One event 9.4 M -> 9.9 M; TS machine one event 9.8 M
-> 10.7 M; Rust machine one event 6.6 M -> 8.5 M, and its guard walk now
beats the TS twin (its guards never leave wasm).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
ivanbanov and others added 6 commits October 8, 2026 12:02
The TS engine and the Rust engine become two implementations of
packages/core/SPEC.md, each tuned for its own runtime. TS machines no
longer run on the Rust engine.

- Everything outside the Rust engine is back to main: packages/core is
  the pure TS engine again (no wasm), and the targets, sandbox,
  benchmark, website and CI are as on main.
- crates/core drops what existed only to run TS machines: the `host`
  feature (Host trait, External parts, halt flag, whole transitions) and
  TsType. The general wins stay (sized field stamps, lazy broadcasts, the
  idle send fast path).
- crates/wasm, crates/core-wasm and the Rust benchmark machines are gone
  for now; the full version is kept on the local branch
  rust-engine-under-ts.
- All Rust lives in crates/: the sandbox machines move to crates/sandbox
  (crates/uniffi runs the palette on React Native). Rust commands are
  plain cargo; `cargo lint` is a .cargo/config.toml alias, used by CI.
- AGENTS.md: a behavior change updates the spec, both engines and both
  test suites in the same PR; the test suites are twins.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
A machine written in Rust now plugs into TS code next to the TS engine's
machines, with no change to either engine:

- crates/wasm (dunky-wasm) is back as Rust machines' JS binding only
  (export_machine!), without the TS runtime. It hears changes through the
  engine's own subscribe() / take_changes() and runs timers from
  take_commands(), so the engine keeps no JS hook. Freeing a class undoes
  its subscription, so the machine is freed with it.
- @dunky.dev/state-machine-wasm (packages/wasm) holds fromWasm: the TS
  Machine interface over a wasm class, typed from the Rust types (TsType
  is back in dunky-core and the derives).
- The TS core exports toMachine / MachineSource, and makeBroadcast /
  makeSelection so a Rust machine notifies and dedupes like a TS one;
  React and Solid useMachine accept a ready machine.
- The sandbox runs the Rust palette with ?machine=rust again (built from
  crates/sandbox by pnpm build:wasm); a type test pins its types to the
  TS palette's, and sandbox/shared joins pnpm typecheck.
- TODO.md keeps the parked work: the React Native facade over uniffi, and
  benchmarks split into benchmark/ts and benchmark/rust.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- Home: a "Two engines, one spec" section: the same machine in TypeScript
  and in Rust, both following one contract, and Rust machines in JS
  through wasm.
- Docs, new "Rust" group: Two engines (which to use, the crates, how they
  stay in sync), Machines in Rust (dunky-core: types, config, timers as
  commands, computed), and Use in TypeScript (export_machine!, generated
  types, fromWasm, useMachine).
- Install: how to depend on dunky-core.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- The TS suite (and its live demo) moves to benchmark/ts, unchanged;
  pnpm benchmark, the demo build and its deploy follow it.
- benchmark/rust (dunky-benchmark) measures the Rust engine natively on
  the same scenarios: throughput, unobserved writes, 64 observers,
  computed, guards, state and effect churn, construction and memory.
  A plain `cargo bench -p dunky-benchmark` target, no extra dependency.
- benchmark/README.md indexes both; the Rust README shows the TS
  engine's row next to each Rust row, for reference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
- A selection reuses its stored value in place when nothing else holds
  it, instead of a fresh Rc per change; a computed value's definition
  evaluates into its slot, reusing the memory the same way (it also says
  whether the value changed, so the type-erased equality goes). One event
  with an observer: ~52 M -> ~93 M events/sec; recompute ~42 M -> ~68 M.
- benchmark/rust gains a frame-budget section: six per-frame workloads
  (up to 10,000 machines with an event each) timed frame by frame over
  600 frames, with allocations counted. The worst frame of the heaviest
  workload uses 2% of 16.67 ms, with zero allocations per frame.
- The Rust README presents the Rust engine on its own (no TS column).
- A Rust-only test: a computed value a reader still holds keeps its
  contents after a recompute.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Same behavior as the spec (and the TS twin tests); faster where it counts:

- select_field(Field): the Rust twin of TS select.context(key). Field
  handles are now typed (Field<Ctx, V>, with a getter), so the TS test
  "select.context(key) selects one field" has its Rust twin at last.
- A listener carries the change bits it can react to: a field selection
  wakes only on its field, select_state only on a state change. Others
  skip it with one bit test; order stays subscription order. A selection
  created mid-action wakes on everything until seeded, so it never misses
  a change (new Rust-only test, which fails without the rule).
- One borrow per action instead of two, no second state read between
  process and apply, a counter instead of a RefCell borrow per listener in
  notify, and an idle send skips the empty flush.

64 observers, 1 change: 3.6 M -> 22.6 M/sec (6.3x); unobserved write over
5,000 cells: 69 M -> 98 M/sec. The frame budget's heaviest workload now
peaks at 0.4% of a frame (it was 2%).

Docs: the crate SPEC (typed fields, wake bits), the website's Rust pages,
and a short note in the wasm READMEs and the website on why the TS engine
does not run on Rust: tried, the JS<->wasm bridge made it slower.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Reframe the note in the wasm READMEs and the website: one Rust engine
behind the TS package worked, but part of the TS side still mirrored
the Rust engine and every event paid the bridge, so both engines are
maintained against one spec.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@ivanbanov ivanbanov changed the title One Rust engine for machines written in TS or Rust Two engines, one spec: Dunky machines in Rust, next to TypeScript Oct 8, 2026

This branch was successfully deployed

1 active deployment
Preview — ef3da071 Deployed Oct 8, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant