Scale: a larger program, two ways #
compare/ and hosts/ both work on a greeter: four waits, one actor, one conversation. Every claim in analysis/hosts-review.md about how the styles scale — more waits, more actors sharing them, helpers, fan-out, live state across waits — was made from that program and reasoned outward. This directory is the check: one larger program written twice, in the two styles at the ends of the spectrum, with one host each, and the numbers that fall out.
scale/
├── README.md this file
├── run.sh build both, run both hosts, require identical output
├── python/world.py the world and a deterministic simulation clock — shared
├── capabilities/
│ ├── rust/{core,wire,cdylib} the program against traits; its wire; the C ABI
│ ├── rust/{driver,host_ffi} the async mechanism, copied from hosts/capabilities (check-sync polices it)
│ └── python/{abi,ctypes} the Python/ctypes host
└── sansio/
├── rust/{core,wire,cdylib} the same program as hand-written state machines
├── rust/{driver,host_ffi} the sans-io mechanism, copied from hosts/sans-io
└── python/{abi,ctypes} its host
The scenario: a library desk #
Three actors share one vocabulary of eleven kinds of wait and three fire-and-forget effects. Replies are not all strings and integers: a patron record, a book record, an optional due day, and a list of loans cross the boundary as structs — encoded as bytes, since the ABI's menu is bytes, str, u32, unit.
| wait | reply | used by |
|---|---|---|
ReadLine |
String |
clerk |
LookupPatron(id) |
Patron { name, blocked, loans } |
clerk, reminder, auditor |
LookupBook(isbn) |
Book { title, available } |
clerk |
Now |
day (u64) |
clerk, reminder |
Sleep(days) |
() |
reminder |
StoreGet(key) |
Option<u64> |
clerk |
StorePut(key, due) |
() |
clerk |
StoreDelete(key) |
() |
clerk |
StoreList(prefix) |
Vec<Loan> |
clerk, reminder, auditor |
Count |
u32 |
clerk, reminder |
Extend(key, days) |
bool |
clerk (added in v2 — see below) |
Write, Log, Notify |
— | all |
Clerk — the interactive actor. One turn is one command from a script: checkout P ISBN (look up patron and book together, check standing and availability, Now, StorePut, Count, receipt), checkin P ISBN (StoreGet, Now, fine, StoreDelete), status P (a summary), renew P ISBN (v2), quit.
Reminder — the background actor. One turn is one sweep: Sleep(7), Now, StoreList, and for every overdue loan LookupPatron and a Notify; then Count. It carries a tick count across turns and stops after five sweeps.
Auditor — one shot: three patrons' summaries at once, one line.
Two things are shared, and they are where the styles part: fine(due, now) is a pure function, used by the clerk on checkin and the reminder on every overdue loan — both styles share it as a function. summary(patron) is two waits and a format!, used by the clerk's status and three times by the auditor — capabilities shares it as an async fn; sans-io cannot, and has a sub-machine that two parents embed and step.
Fan-out appears three times in the capabilities version (join in checkout, in summary, and across the auditor's three summaries). Sans-io has one witness outstanding by construction, so each is sequential there.
The world #
Both hosts run the same world.py: three patrons (one blocked), three books (one out), a loan store, a counter, and a virtual clock — a patron's visit takes five days, Sleep(d) wakes on day now + d, and machines run as events in day order. So the two hosts' outputs must be byte-identical, and run.sh checks that they are: 53 lines of transcript, one script, both styles.
Measurements #
Stripped lines (no comments, no blanks). v1 is the desk as described; v2 adds the renew command and the Extend wait it needs — one new kind of wait, one new reply type, one new command — to both.
Size #
| capabilities | sans-io | ratio | |
|---|---|---|---|
core — the program, v1 |
225 | 633 | 2.8× |
core, v2 |
250 | 690 | 2.8× |
wire |
289 | 259 | 0.9× |
cdylib |
46 | 44 | 1.0× |
| Python host (codec + driver + native + main) | 218 | 216 | 1.0× |
| shared world + simulation | 65 | 65 |
Of sans-io's 633: 44 state variants, 16 Answer impls, 18 witness_violated arms, and one sub-machine (Summary, 45 lines) standing in for a 5-line async fn. The capabilities core is three structs, seven traits, and two helpers.
The wire and the host are the same size in both — as with the greeter. The program is where the styles differ, and by more as it grows: 2.1× for the greeter's conversation logic, 2.8× here.
Adding a wait #
renew: a StoreGet, then a new wait Extend(key, days) → bool, then a line.
| capabilities | sans-io | |
|---|---|---|
| files touched | 5 | 4 |
| lines added | +45 | +78 |
of which core |
18 | 46 |
of which wire |
15 | 17 |
| of which host | 12 | 15 |
| bugs on the way | 0 | 1 |
The capabilities edit is a trait method, a Ctx arm, an Effect/Seen variant with its encoding, a match arm in the clerk, and the host's codec arm. The sans-io edit is all of that plus two new states, a new Answer impl, and a new arm in an existing Answer impl (Stored now arrives in two states) — and that last one is where the bug was: the arm was missed, the crate compiled, the host ran, and the clerk panicked at witness_violated on the first renew. Nothing in the type system knows that a state machine has a transition missing. The async fn version cannot have that bug, because the continuation is the transition.
Sharing a helper #
summary: capabilities, 5 lines, async fn summary<C: Directory + Store>(ctx: &C, patron: &str) -> String, called from two actors. Sans-io: a 45-line enum with three states and two methods, plus a Status(Summary) variant in the clerk, a current: Summary field in the auditor, and an arm in each parent's Patron and Loans impls to route replies into it — six integration points across two parents, for one helper.
Struct replies #
Both styles pay the menu: a Patron, a Book, an Option<u64>, and a Vec<Loan> each need a byte encoding on the host and a decoder on the Rust side. In capabilities the decoders live in wire::decode (40 lines) and the program never sees a byte. In sans-io they live in the wire too (35 lines) and the machine receives typed replies. Same cost, same place; the menu is indifferent to style.
What it says about the Scaling table #
The review's Scaling section was written from the greeter. This program confirms its rows for the two styles measured:
- more waits in one program — sans-io is worst, and superlinearly: every wait a variant, every live local a field on every variant between its birth and its use (
CheckoutAwaitingCount { patron, isbn, p, b, due }). - bigger programs — the ratio grew from 2.1× to 2.8× between the greeter and the desk.
- helpers — the one thing the greeter could not show. An
async fnover two waits is a sub-machine with six integration points. - fan-out — free in capabilities (
join), absent in sans-io. - the wire and the hosts do not care — same size, same shape, in both.
And one thing the table did not predict: the transition bug. Adding a feature to a state machine means adding arms to impls that already exist, and a missing arm is a runtime panic. This experiment produced one on the first try, in a 690-line crate written carefully by someone who knew the risk. That is the strongest argument in this directory.
Reproduce #
nix develop
scale/run.sh # builds both, runs both hosts, diffs, prints the transcript
hosts/check-sync.sh # the copied mechanism has not drifted