Compare commits

..

1 commit

Author SHA1 Message Date
acamilo
d18bc043b3 docs(design): plan MaleCNS, modular sessions and Melee integration
Some checks failed
ci / node 22 (test + typecheck) (push) Has been cancelled
ci / rust stable (cargo test --workspace --release) (push) Has been cancelled
ci / infra/tests/lint.sh (push) Has been cancelled
ci / playwright apps/stage (allowed to fail) (push) Has been cancelled
(cherry picked from commit 83090a9391aac4044cfc48958fef54c0869a0e7b)
2026-09-21 15:29:12 +00:00
248 changed files with 1587 additions and 101910 deletions

View file

@ -47,5 +47,5 @@ export const DESCRIBE_CARD: DescribeCard = {
'The screen is its eye. Its motor neurons press the buttons. Each scene offers a few actions, ' + 'The screen is its eye. Its motor neurons press the buttons. Each scene offers a few actions, ' +
'walk to a door, talk, attack; the fly picks one. When the game rewards it, a few thousand ' + 'walk to a door, talk, attack; the fly picks one. When the game rewards it, a few thousand ' +
'synapses shift, and what worked gets likelier. !sugar sends it a small reward pulse, no ' + 'synapses shift, and what worked gets likelier. !sugar sends it a small reward pulse, no ' +
'buttons. FlyWire connectome. github.com/acamilo/flybrain', 'buttons. FlyWire connectome.',
}; };

View file

@ -89,9 +89,7 @@ decoder preset to use.
disassembly at commit 0cd19d3 (`symbols.rs`), gates rewards on a playable state, baselines already disassembly at commit 0cd19d3 (`symbols.rs`), gates rewards on a playable state, baselines already
achieved flags on the first sample so a restore never replays them, and pays only positive achieved flags on the first sample so a restore never replays them, and pays only positive
rewards: story flags, exploration coverage (capped per map), new areas, Pokédex entries, trainer rewards: story flags, exploration coverage (capped per map), new areas, Pokédex entries, trainer
flags, decaying wild wins, badges, exits found outdoors, catches, conversations indoors and items flags, decaying wild wins, badges. Version `pokered-unique8-v5`.
picked up (`docs/rewards-learning.md`). Version `pokered-unique8-v7`; a deploy that names the
previous version in `FLY_ACCEPT_ADAPTERS` migrates its checkpoints instead of refusing them.
- Ratchet (`ratchet.rs`): a 38-rung ladder (boot, bedroom, Pallet Town, Oak's lab, starter, parcel, - Ratchet (`ratchet.rs`): a 38-rung ladder (boot, bedroom, Pallet Town, Oak's lab, starter, parcel,
Pokédex, each town, each badge, the Elite Four, Champion). On first reaching a higher rung in a Pokédex, each town, each badge, the Elite Four, Champion). On first reaching a higher rung in a
safe state it archives the emulator snapshot; on a stall (120 s without new exploration) or a game safe state it archives the emulator snapshot; on a stall (120 s without new exploration) or a game
@ -133,9 +131,6 @@ publish. Pacing uses absolute deadlines at 1.0x by default; it never skips frame
snapshot at 30 Hz: a JSON header (status, rates, learning stats, game mode, milestone rank and snapshot at 30 Hz: a JSON header (status, rates, learning stats, game mode, milestone rank and
total, sugar state, events, chat ring) followed by attachments: RGBA frame, f32 stereo 48 kHz total, sugar state, events, chat ring) followed by attachments: RGBA frame, f32 stereo 48 kHz
audio (binjgb's unipolar u8 converted and DC-blocked), and a 17,407-byte spike bitset. audio (binjgb's unipolar u8 converted and DC-blocked), and a 17,407-byte spike bitset.
flysim serves it itself by default; with `FLY_FEED_VIA=bus` it publishes each snapshot on an
embedded flybus router and the `fly-edge` process serves the same bytes
(`docs/design/flybus.md`, "Feed over the bus").
- Control API (`docs/control-api.md`): loopback HTTP :7401. `POST /stimulate` (sugar: a timed PAM - Control API (`docs/control-api.md`): loopback HTTP :7401. `POST /stimulate` (sugar: a timed PAM
pulse, rate-limited server side), `POST /reward` (present, disabled by config), `POST /chat` pulse, rate-limited server side), `POST /reward` (present, disabled by config), `POST /chat`
(sanitized, deny-listed, ring of 12), `/status`, `/checkpoint`, `/pause`, `/resume`, (sanitized, deny-listed, ring of 12), `/status`, `/checkpoint`, `/pause`, `/resume`,
@ -164,8 +159,8 @@ sequenceDiagram
S->>S: every 5 s hot copy, every 300 s durable checkpoint S->>S: every 5 s hot copy, every 300 s durable checkpoint
``` ```
Where: `services/flysim/crates/flysim/src/{main,config,simloop,pacing,snapshot,feed,feedbus,api,chat,store,eventlog,metrics}.rs`, Where: `services/flysim/crates/flysim/src/{main,config,simloop,pacing,snapshot,feed,api,chat,store,eventlog,metrics}.rs`,
`services/flysim/crates/fly-edge`, `docs/design/flysim.md`. `docs/design/flysim.md`.
## 4. Stage page ## 4. Stage page

View file

@ -100,22 +100,6 @@ mode = "raw" # "raw" or "macros"; FLY_MACRO_MODE overr
- Refusals are counted as `fly_chat_rejected_total{reason}`, one series per rule: `control`, - Refusals are counted as `fly_chat_rejected_total{reason}`, one series per rule: `control`,
`charset`, `empty`, `too_long`, `url`, `name`, `deny_list`, `rate_limited`, `malformed`. `charset`, `empty`, `too_long`, `url`, `name`, `deny_list`, `rate_limited`, `malformed`.
Acceptances are `fly_chat_accepted_total`, and the ring depth is `fly_chat_ring_lines`. Acceptances are `fly_chat_accepted_total`, and the ring depth is `fly_chat_ring_lines`.
- **The ring survives a restart (2026-09-22).** Every accepted line rewrites a sidecar,
`<hot_dir>/chat-ring.json` (`[paths] hot_dir`, the tmpfs the hot checkpoints use), by the same
atomic sequence a checkpoint commit uses: tmp file, fsync, rename over. At startup, before the
first publish, the file is read back; lines older than 24 hours are dropped, only the newest
`ring` of them are kept, and a missing file is silence. An unreadable, unparseable or
unknown-version file is ignored with a logged warning and an empty panel — which is what a
restart gave before this existed — never a startup failure. Writing it is best-effort too: a
failure is a warning, and the line is still accepted and still on screen.
The sidecar is **not** part of the checkpoint: it is session state, it adds no chunk to the
`FLYSIM01` envelope and nothing about it enters the compatibility string, so `--print-compatibility`
is unchanged and a build that refuses every checkpoint in a directory still restores the panel.
It lives beside the hot checkpoints because it has their lifetime — a reboot clears the tmpfs —
and `FLY_RESET_STATE=1` clears it along with them (`infra/05-deploy.sh`). The bridge resends
nothing on reconnect: the lines the page shows after a restart are the ones the service already
accepted, with their original event ids and timestamps.
`POST /chat` status codes: `202 { eventId }` accepted, `400` malformed body, `403` chat disabled, `POST /chat` status codes: `202 { eventId }` accepted, `400` malformed body, `403` chat disabled,
`422 { error }` a rule refused the line (the error names the rule), `429 { retryAfterMs }` a rate `422 { error }` a rule refused the line (the error names the rule), `429 { retryAfterMs }` a rate

View file

@ -13,7 +13,7 @@ come from the dataset at runtime; nothing else is dynamic.
This is a real fly's brain, 139,255 mapped neurons and 2.7 million synapses, running live. The This is a real fly's brain, 139,255 mapped neurons and 2.7 million synapses, running live. The
screen is its eye. Its motor neurons press the buttons. Each scene offers a few actions, walk to screen is its eye. Its motor neurons press the buttons. Each scene offers a few actions, walk to
a door, talk, attack; the fly picks one. When the game rewards it, a few thousand synapses shift, a door, talk, attack; the fly picks one. When the game rewards it, a few thousand synapses shift,
and what worked gets likelier. !sugar sends it a small reward pulse, no buttons. FlyWire connectome. github.com/acamilo/flybrain and what worked gets likelier. !sugar sends it a small reward pulse, no buttons. FlyWire connectome.
## The new-chatter switch (the operator, 2026-09-17) ## The new-chatter switch (the operator, 2026-09-17)

View file

@ -1,190 +0,0 @@
# flybus: the communications bus
Status: **crate landed; the feed rides it behind `FLY_FEED_VIA=bus`, off by default**.
Written 2026-09-22, amended 2026-09-23 (EDGE-01, below). Index only; the
authority for the API and the wire format is the crate's own
[README](../../services/flysim/crates/flybus/README.md), and the audit of the crate against
the draft is the [conformance report](session-framework/bus-conformance.md).
## What it is
`flybus` is a local RPC and pub/sub bus for Tokio processes on one host, with immutable
file-backed artifacts for large payloads. One library, one router, one wire protocol:
messages are small strict-JSON envelopes carrying metadata and artifact references, while
bulk bytes (frames, audio, spike bitsets) live in a store directory the router owns.
Ownership follows deliveries and explicit holds; the router moves messages and tracks
ownership and does not interpret them. It implements the Flybus v1 draft (`bus-v1`, draft 1
of 2026-09-18) over the `ipc-v1` scalar encodings.
Transports are an in-memory pair, an accepted `AsyncRead + AsyncWrite` stream, or a Unix
socket. Identity is bound by the launcher before Hello and checked against a `Policy` of
per-client grants; open/unbound mode is for trusted tests only and is not authentication.
Nothing in the crate is specific to a game, a brain or a stream.
## What it is meant to replace
Today the three processes talk over two ad-hoc loopback surfaces:
| Today | Under the bus |
| --- | --- |
| Feed: WebSocket `127.0.0.1:7400/feed`, one binary message per snapshot at 30 Hz, header plus RGBA frame, audio and spike attachments, re-serialized per consumer | One `latest`-mode topic per stream, with the frame as a sealed artifact shared by fan-out instead of copied per subscriber |
| Control: loopback HTTP `127.0.0.1:7401` (`/stimulate`, `/chat`, `/checkpoint`, `/pause`, `/status`, ...) | RPC services with per-client grants, FIFO dispatch, explicit cancel states and backpressure |
| Per-surface limits, rate limits and timeouts written twice | `Limits` and `Policy` in one place, negotiated at Hello |
The feed and control contracts in [feed-protocol.md](../feed-protocol.md) and
[control-api.md](../control-api.md) stay binding until a migration replaces them. The bus
does not change any published contract by existing.
## Crate layout
`services/flysim/crates/flybus`, a workspace member of the flysim workspace. `flysim` depends
on it for the feed publisher (`src/feedbus.rs`) and `fly-edge` for the subscriber.
| Module | Contents |
| --- | --- |
| `wire` | Scalars, strict JSON, `Envelope`, `ArtifactRef`, `Attachment`, `Location`, framing, `CONTRACT` and `contract_digest()` |
| `error` | `ErrorCode`, `Dispatch`, `BusError` |
| `limits` | `Limits` and its hello encoding |
| `policy` | `Policy`, `Grants`, `Pattern` |
| `router` | `Router`, `RouterConfig`, `RouterStats`, `UnixListenerHandle`; the state machine in `router/state.rs` |
| `client` | `Client` and the handle types |
| `store` | The router-side file store and client-side location resolution |
| `transport` | `Transport` and the `Stream` trait |
Dependencies are already in the workspace lockfile: `tokio`, `serde`, `serde_json`, `sha2`,
`libc`. Tests run every integration case twice, once in memory and once over a Unix socket:
wire negotiation, RPC authority and cancellation, pub/sub credits and retention, artifact
allocate/seal/read with quotas and router restarts, plus the conformance suites and an
`--ignored` perf measurement.
## Wiring still pending
- ~~**flysim publisher.**~~ Done 2026-09-23 behind `FLY_FEED_VIA=bus`: see "Feed over the bus".
- **flysim control services.** The control endpoints as RPC services with grants, so the
"no button endpoint" structural guarantee is expressed as a grant table.
- **Stage and bridge clients.** Both are TypeScript/Node; the crate is Rust only, so either
a binding or a thin translating edge process is required before they leave the WebSocket
and HTTP surfaces. The operator chose the edge process (port decisions, 2026-09-23); for
the feed it exists (`fly-edge`), and they keep the WebSocket contract unchanged. The
control API (:7401) is the next slice and stays in flysim until then.
- ~~**Sizing.**~~ Decided 2026-09-23: amendment "Feed sizing" below.
- ~~**Lifecycle.**~~ Decided 2026-09-23: amendment "Feed store lifecycle" below.
- **Migration order.** The feed is the cheaper first move; control should follow only once
the bus carries the feed in production for a full session.
## Feed over the bus (2026-09-23, EDGE-01)
`feed.via` (`FLY_FEED_VIA`) picks who serves `ws://127.0.0.1:7400/feed`. `direct` is the
default and is the behaviour that predates the bus. With `bus`:
```text
sim thread --watch<Snapshot>--> publisher task --flybus (in memory)--> Router
(unchanged) (flysim-bus runtime) | <bus_dir>/edge.sock
v (bound to "fly-edge")
fly-edge: Subscription -> watch -> flysim::feed :7400
```
- flysim does not bind `feed.bind`. It starts a `Router` on a runtime of its own (two
threads, `flysim-bus`), store root `<bus_dir>/store`, closed policy: `flysim` may declare
and publish `fly.feed.snapshots`, `fly-edge` may only subscribe to it, and the Unix socket
`<bus_dir>/edge.sock` is launcher-bound to `fly-edge`.
- The topic is `retained: latest`. Each publication is one snapshot: the attachments the
header lists as sealed artifacts named `frame` (`image/x-rgba`), `audio`
(`audio/x-f32le`), `spikes` (`application/x-spike-bitset`), and the header as the payload
`{"header": {...}}`. A header over 48 KiB of JSON goes as a `header` artifact instead, so
the 65,536-byte envelope limit can never make a snapshot unpublishable.
- The sim thread is untouched. The publisher reads the same `watch` slot the direct server
reads, so a slow bus skips snapshots the way a slow WebSocket client does, and nothing on
the bus can hold the loop's publish. `fly_bus_published_total` and
`fly_bus_publish_failures_total` count it.
- `fly-edge` subscribes `latest`, one in flight, with replay, rebuilds each `Snapshot` with
`feedbus::receive` and serves it with flysim's own `feed::router`. `hello`, `wants`,
drop-oldest, the idle header and the framing are therefore the same code, and the bytes are
the same bytes: `crates/fly-edge/tests/parity.rs` replays the committed stage fixtures
through both paths at once and requires byte-equal messages per client flavour.
- `fly_frames_sent_total` and `fly_feed_clients` move to the edge with the clients; it exports
them under the same names on `FLY_EDGE_METRICS_ADDR` (`127.0.0.1:9102` in
`infra/units/flyedge.service`), and watchdog check 2 follows `FLY_FEED_VIA` in `fly.env` to
them. flysim's own copies read 0 in bus mode; `/status` is otherwise unchanged. The edge also
exports `fly_edge_bus_connected`, `fly_edge_bus_lost_total`, `fly_edge_bind_failures_total`
(the bus answered but :7400 was taken, most likely by a flysim still in direct mode) and
`fly_edge_decode_failures_total`.
- Nothing about the fly changes: the readout, the reward catalog, the adapter version and the
compatibility string are byte-identical in both modes (`--print-compatibility`).
### Amendment 2026-09-23: feed sizing
Measured on the live fly (release build, the real cartridge): a running snapshot is a
**92,160-byte** frame (160x144 RGBA; not the 640x480 "1.2 MB" the pending list assumed), a
**17,407-byte** spike bitset (139,255 neurons), about **12,800 bytes** of audio at realtime
(1,600 stereo f32 frames per 30 Hz snapshot at 48 kHz) and a 2 to 3 KB header: **122,367
bytes** of artifacts, about 3.7 MB/s at 30 Hz. `flysim::feedbus::limits()`:
| Limit | Value | Why |
| --- | --- | --- |
| `max_clients` | 8 | connections, pending handshakes included: the in-process publisher and the edge's one socket seat |
| `max_subscriptions_per_client` | 4 | the edge needs 1; this is what bounds the worst case |
| `max_latest_in_flight` | 2 | the default; the edge asks for 1 |
| `max_artifact_bytes` | 4 MiB | ten seconds of audio that piled up behind a late publish |
| `max_store_bytes` | 32 MiB | tmpfs, so RAM; ten times the worst case below |
| `max_retained_bytes` | 8 MiB | one retained snapshot, plus a large header artifact |
| `max_owners_per_client` / reserved | 64 / 8 | three artifacts per delivery, a few deliveries |
| others | small counts | one topic, no services |
A `latest` subscriber that never consumes pins at most its queued slot plus its in-flight
credits (3 snapshots); the topic pins one retained value; the publisher holds one snapshot of
staging plus the sealed copy while sealing. Only one client can subscribe at all: the publisher
is in process, and `edge.sock` is launcher-bound to `fly-edge`, which the router admits once at
a time (a second connection is refused as already connected). The worst case is therefore that
one client holding all 4 subscriptions it may open, none consuming: 4 x 3 + 1 + 2 = **15
snapshots, about 1.8 MB**, and publication never waits on any of them (a latest subscriber is
never a reason to refuse a publication, bus-v1 section 9). `crates/fly-edge/tests/stall.rs`
measures exactly that seat: four hoarding subscriptions, a fifth refused, a second connection
refused, pacer lag 0, no publication refused. The 15 is an upper bound; the measured store
was 472,061 bytes (under 4 snapshots), because fan-out adds roots and never copies, so four
subscriptions stuck on the same publications pin the same artifacts.
### Amendment 2026-09-23: feed store lifecycle
- **Location.** `feed.bus_dir` (`FLY_BUS_DIR`), `/run/fly/bus` on the containers: tmpfs,
0700, owned by `fly`, created by tmpfiles and again by flysim. The store root is
`<bus_dir>/store`, the socket `<bus_dir>/edge.sock`. A reboot empties it.
- **Owner.** The router lives in flysim; its lifetime is flysim's. flysim removes a stale
socket file at start, and `Router::new` removes any store directory whose `flock` is free,
i.e. one a crashed flysim left behind. A clean stop removes its own directory. The edge owns
nothing on disk.
- **Order.** flysim first, the edge after it: `flyedge.service` is `After=` and
`Requires=flysim.service`, so an explicit stop or restart of flysim (the unstick rule's
restart included) takes the edge with it. A crash-restart of flysim needs nothing: the edge
sees the connection close, drops every WebSocket client, unbinds :7400 and reconnects every
500 ms, binding :7400 again only when the first snapshot of the new router arrives. To the
stage that is exactly a flysim restart in direct mode: refused, then back.
- **Default.** `flyedge.service` is in no target and `07-enable.sh` does not enable it;
`05-deploy.sh` writes `FLY_FEED_VIA=direct` unless the env file says otherwise, and refuses
anything but `direct` or `bus` (any case, written lowercased). The switch and the way back
are in the unit's header. The edge gets a cpuset drop-in on the page's CPUs with the other
units, so once enabled it never runs on flysim's.
- **Paths.** `feed.bus_dir` must be absolute and non-empty (checked in both modes), since
flysim and the edge each resolve it.
- **Migration order** is unchanged: the feed first; control only after the bus has carried
the feed in production for a full session.
### Known limits (review round 1, 2026-09-23)
Accepted for now and written down rather than fixed:
- **Feed counters off the container.** In bus mode flysim's `:9101` reports
`fly_feed_clients` and `fly_frames_sent_total` as 0, and the edge's copies are on loopback
`:9102` only. The watchdog follows `FLY_FEED_VIA`; anything that scrapes `:9101` from off
the container (the metrics dashboard) goes blind to the feed until it also scrapes the edge.
- **Store quota is per router, not per client.** Any client on `edge.sock` may allocate
artifacts up to the store cap; a hostile process running as the same user could fill the
store and make flysim's publications fail. The loop is unaffected (a refusal is counted, never
waited on), but the feed would stall. Same-user processes are inside the trust boundary
(crate README, "Limitations").
- **Rollback while in bus mode.** Rolling back to a release without `fly-edge` while `fly.env`
still says `bus` leaves no one on :7400, and check 2 then reads the edge's absent `:9102` and
escalates. Switch back to `direct` first (the unit header's way back), then roll back.
- **Old fixtures.** `cold-open`, `steady` and `big-moment` predate `game.scene` and cannot be a
Rust `FeedHeader`, so fixture parity covers `macros`, `shop`, `center` and `bigpad`.

View file

@ -355,61 +355,6 @@ on-screen ticker cannot disagree with what the sim did.
against the prototype's WASM size and diffs a known save. If they match, prototype checkpoints against the prototype's WASM size and diffs a known save. If they match, prototype checkpoints
import and the segment records the shared tag; if not, milestone saves must be re-earned and that import and the segment records the shared tag; if not, milestone saves must be re-earned and that
is a stated M3 finding. is a stated M3 finding.
- **Restoring across an adapter version** (2026-09-22). The compatibility string is compared
whole, so bumping the reward adapter refuses every checkpoint the previous one wrote -- which is
the right default and was, until now, the only behaviour. It is the wrong default for a change
that only *adds* a rule: `pokered-unique8-v6` adds the catch reward and one counter,
`catchCounts`, and means the same thing as `v5` for every other field, so a `v5` run is
resumable and throwing it away would be a choice nobody made deliberately.
So there is one narrow, opt-in migration, `flybrain_gb::compatibility::decide`, and it requires
**all three** of:
1. the two compatibility strings differ in the adapter segment (segment 1) and **nowhere else**.
A dataset, kernel, plasticity, emulator-revision, symbol-provenance or state-format
difference is still a refusal: none of those has a migration, and a fly restored across one
is a different fly;
2. the running adapter's `migrates_from()` lists the checkpoint's adapter, so the code that will
read that state says out loud that it can. Pokémon Red's list is `["pokered-unique8-v5"]` and
nothing else -- `v4` is excluded because its ledger holds no `boundary:` keys and resuming it
would pay a second time for every exit already found, and `v3` because its stored rank is a
rung on a different ladder;
3. the deploy names the same adapter id in **`FLY_ACCEPT_ADAPTERS`** (comma- or
space-separated). Unset or empty migrates nothing, which is what every deploy before this one
did.
Condition 2 without 3 would make the migration silent; condition 3 without 2 would let an
operator wave through a pair nobody wrote a migration for. `infra/05-deploy.sh`'s compatibility
gate applies the same rule before it flips the `current` symlink, and writes the variable into
`/etc/fly/fly.env` so flysim applies it at restore -- the two must agree, or a deploy would pass
a gate that flysim then fails, which is the black stream the gate exists to prevent. The
migration itself is `PokemonRedReward::import_state` doing what it already did: `catchCounts` is
absent from a `v5` state and restores empty, which is the truth about a run that was never paid
for a catch. `STATE_VERSION` does not move, because the schema did not.
**`v6` -> `v7`** (2026-09-23, the engagement rewards) is the same migration for the next pair,
and `pokered-unique8-v7`'s `migrates_from()` is `["pokered-unique8-v6"]` and nothing else -- `v5`
is no longer migrated, because the live run is `v6`. The deploy that ships it sets
`FLY_ACCEPT_ADAPTERS=pokered-unique8-v6`. No field is added this time: `talk` and `item` key
their ledgers into the existing `seen` array, as `boundary` did, so a `v6` state restores
unchanged with no `talk:` keys. The one step is at the first sample after the restore, not in
`import_state`: a ledger without the `items:seeded` key writes an `item:`/`hidden:` key for every
item the cartridge already shows as taken, pays for none, and marks the seed, so a rollback to a
slot from before a `v6`-era pickup cannot pay for it (`docs/rewards-learning.md`, "The seed").
`STATE_VERSION` stays 4.
- **Restarting a run from an earlier rung** (2026-09-22). `FLY_RESET_STATE=1` throws the run away;
`infra/bin/fly-reset-to-milestone <N>` keeps it and rewinds it. It archives both stores to a
dated directory, rewrites `milestone-<N>.checkpoint` with the ratchet's `attempts` and
`recoveries` at zero (so the restarted run does not begin with its recovery budget already
spent), installs it as the newest generation of the hot and durable stores, removes the
milestone archives above N, and clears the event log -- whose id sequence the restored
checkpoint's `lastEventId` rewinds. `best` is not touched: the archive's own `best` is the rung
it was taken at, and the rank the stream shows is recomputed by the adapter from the restored
game state. The implementation is `flysim::reset` (`flysim --reset-to-milestone N`) rather than
the shell script, because two of those steps are inside the envelope. The sequence around it is
in `infra/docs/runbook.md`.
- **A running macro is not checkpointed** (2026-09-16, `docs/design/macros.md`). Palette mode's - **A running macro is not checkpointed** (2026-09-16, `docs/design/macros.md`). Palette mode's
state — the scene, the palette, the running macro, its plan and its frame count — is transient, state — the scene, the palette, the running macro, its plan and its frame count — is transient,
like the readout's blocked-direction cooldown and for the same reason: a restore that resumed a like the readout's blocked-direction cooldown and for the same reason: a restore that resumed a

View file

@ -125,18 +125,7 @@ condition the plan wanted, at the cost of no new state.
## Recovery budgets ## Recovery budgets
Attempts per rung stay 3; lifetime budget scales with the ladder (36 instead of 12). Stall window Attempts per rung stay 3; lifetime budget scales with the ladder (36 instead of 12). Stall window
unchanged (120 s without new exploration, 180 s since last recovery). unchanged (120 s without new exploration, 180 s since last recovery). The ratchet's rank bound is
**Amended 2026-09-22 (rung 10).** "New exploration" is the adapter's lifetime tile ledger, so a map
entered for the first time resets the window -- a new map is a map's worth of tiles nobody has stood
on -- and a map *re-entered* does not. Two "Stuck" rollbacks fired inside half an hour on a fly that
was walking a town it had already covered toward the rung's own door, and both were this rule
working. The window now takes a second signal beside the tile count: **the fly being nearer its
objective, in map hops, than this run has ever been** (`docs/design/macros.md` section 12.15). The
ratchet treats it exactly as it treats new exploration -- it restarts the window and does nothing
else -- it can fire at most once per step of the road, and the ratchet itself knows no more about
what it means than it knows what a tile is. Nothing else about the budgets, the triggers or the
checkpointed state changes. The ratchet's rank bound is
now the running adapter's ladder length, passed in rather than a constant, because the bound belongs now the running adapter's ladder length, passed in rather than a constant, because the bound belongs
to the adapter: Pokémon's ladder is 38 rungs and the platformer's is 16 to the adapter: Pokémon's ladder is 38 rungs and the platformer's is 16
(`docs/design/platformer.md` §3). Only the *budgets* are per-game, from the adapter's (`docs/design/platformer.md` §3). Only the *budgets* are per-game, from the adapter's

View file

@ -118,42 +118,6 @@ Addresses are at the pinned commit. "Verified" is one of:
| which slot is out | `wPlayerMonNumber` | `$cc2f` | 0-based party slot | ROM, trace | | which slot is out | `wPlayerMonNumber` | `$cc2f` | 0-based party slot | ROM, trace |
| the enemy | `wEnemyMonSpecies`, `wEnemyMonHP`, `wEnemyMonLevel`, `wEnemyMonMaxHP` | `$cfe5`, `$cfe6`, `$cff3`, `$cff4` | HP big-endian. Not written on the frame a battle starts — the reward adapter's own comment says the same — so the enemy is `None` for the first few hundred frames of a battle. | ROM (the rival's Squirtle, level 5, 20/20, and `None` on the first frame), trace | | the enemy | `wEnemyMonSpecies`, `wEnemyMonHP`, `wEnemyMonLevel`, `wEnemyMonMaxHP` | `$cfe5`, `$cfe6`, `$cff3`, `$cff4` | HP big-endian. Not written on the frame a battle starts — the reward adapter's own comment says the same — so the enemy is `None` for the first few hundred frames of a battle. | ROM (the rival's Squirtle, level 5, 20/20, and `None` on the first frame), trace |
| how many moves | `wNumMovesMinusOne` | `$cd6c` | the move count minus one, valid in a battle | trace | | how many moves | `wNumMovesMinusOne` | `$cd6c` | the move count minus one, valid in a battle | trace |
| **a ball kept this one** | `wCapturedMonSpecies` | `$d11c` | **new 2026-09-22** (the catch reward, `docs/rewards-learning.md`). `ram/wram.asm`'s own comment is "0 if no mon was captured". `ItemUseBall` zeroes it before every throw (`.canUseBall`) and writes `wEnemyMonSpecies` into it only on the branch that keeps the Pokémon; `UseBagItem`'s `.returnAfterCapturingMon` zeroes it again and sets `wBattleResult` to 2 on the way out of the battle. It is therefore non-zero for the hundreds of frames the catch's text and Pokédex screen take, and zero everywhere else. The value is the **internal** species index, like `wEnemyMonSpecies` and unlike `wPokedexOwned`'s bit index. Address resolved by `services/flysim/tools/resolve_wram.py`, bracketed by `wFontLoaded` and `wForcePlayerToChooseMon`. | survey (`tests/rom_catch.rs`: a real wild battle from a rung-9 checkpoint, balls thrown by the `THROW BALL` macro, the byte read out of the running game), trace (`pokemon_red/tests.rs`) |
`wBattleResult` (`$cf0b`) is the second half of that row and is worth its own sentence: it is 0
for a win, 1 for a loss, and 2 on exactly two paths in the whole game -- `.returnAfterCapturingMon`
and a *link* battle whose opponent ran (`engine/battle/core.asm`), which this cartridge never has.
So "the captured-species byte was non-zero during the battle **and** the result is 2" is a catch
and nothing else. `InitBattleVariables`, `ResetStatusAndHalveMoneyOnBlackout` and
`HandleFlyWarpOrDungeonWarp` all clear it, so a stale 2 cannot survive into the next battle.
Not used for the catch, and why: `wPartyCount` (`$d163`) rises on a catch **only** when the party
has room -- a full party sends the Pokémon to `wBoxCount` instead -- and it also rises for a gift,
a trade and a Pokémon withdrawn from the PC. Reading a catch off it would need a second rule to
tell those apart. The cartridge's own flag needs none, which is why the row above is the one the
adapter reads.
### The engagement rewards' reads (2026-09-23)
**New 2026-09-23** (`talk` and `item`, `docs/rewards-learning.md`, "Engagement rewards"). All six
were resolved by `services/flysim/tools/resolve_wram.py` from `ram/wram.asm` at the pinned commit
and are bracketed by addresses `symbols.rs` already carried; two of the brackets needed the tool
to count `NUM_STATS` and `NUM_CITY_MAPS`, which the decomp defines as `const_value` over an
enumeration.
| what | symbol | address | notes | verified |
| --- | --- | --- | --- | --- |
| the text's subject | `wSpriteIndex` | `$cf13` | `DisplayTextID` copies its argument here: a sprite slot up to `wNumSprites`, else a text id. It arrives **about twenty frames after** `wFontLoaded` bit 0 rises, because `DisplayTextIDInit` loads the font's tiles first; until then it still holds the previous text's subject. | survey (`tests/rom_engage.rs`: the Viridian Forest north gate, the old man at slot 2, font bit on frame 820 and the argument on frame 840) |
| mid-step | `wWalkCounter` | `$cfc5` | non-zero for the frames of a step; the overworld only reads A at zero. Right after `wFontLoaded` in `ram/wram.asm`. | ROM, trace |
| an item ball's item | `wMapSpriteExtraData` | `$d504` | two bytes per sprite slot (slot 1 first): `(item id, 0)` for an `ITEM` `object_event`, `(trainer class, trainer number)` for a `TRAINER` one, zeroes otherwise -- `LoadMapHeader`'s `.itemBallSprite` / `.trainerSprite` / `.regularSprite` | survey (the forest's Antidote ball read `(11, 0)`) |
| taken or hidden, per object | `wToggleableObjectFlags` | `$d5a6` | `flag_array $100`, one bit per global toggleable index (`constants/toggle_constants.asm`); `PickUpItem`'s `HideObject` sets an item ball's bit after `GiveItem` succeeded | survey (the forest's Antidote ball's bit rose on the pickup frame) |
| this map's toggleables | `wToggleableObjectList` | `$d5ce` | up to sixteen `(sprite slot, global index)` pairs, `$ff`-terminated, written by `MarkTownVisitedAndLoadToggleableObjects` | survey |
| hidden items found | `wObtainedHiddenItemsFlags` | `$d6f0` | `flag_array MAX_HIDDEN_ITEMS` (112); `FoundHiddenItemText` sets the bit after `GiveItem` succeeded, and nothing else writes it | ROM (disassembly), trace |
Not used, and why: `hJoyPressed`/`hJoyHeld` would say "A was pressed" directly, but they are HRAM,
which neither `gen_symbols.py` nor `resolve_wram.py` resolves, and a hand-written address is the one
thing those tools exist to refuse. "The fly had the joypad and was standing still on the frame
before the box opened, and the box is about the thing it faces" is the same fact read out of WRAM.
### Battle menu and cursor, own turn against forced switch ### Battle menu and cursor, own turn against forced switch
@ -164,7 +128,7 @@ parked its cursor. All five bytes are contiguous: `wTopMenuItemY` `$cc24`, `wTop
| menu | signature | cursor | verified | | menu | signature | cursor | verified |
| --- | --- | --- | --- | | --- | --- | --- | --- |
| the top-level battle menu | `wTextBoxID` = `$0b`, `wTopMenuItemY` = 14, `wTopMenuItemX` = 9 with watched keys `PAD_RIGHT\|PAD_A` (left column) or 15 with `PAD_LEFT\|PAD_A` (right), `wMaxMenuItem` = 1 (`DisplayBattleMenu`, `engine/battle/core.asm:2081` and `:2114`) | reported 0 FIGHT, 1 PKMN, 2 ITEM, 3 RUN: the game keeps the index *within* the column and `.rightColumn` adds two on selection | ROM (a fresh menu is FIGHT; RIGHT is ITEM; DOWN from there is RUN), trace | | the top-level battle menu | `wTextBoxID` = `$0b`, `wTopMenuItemY` = 14, `wTopMenuItemX` = 9 with watched keys `PAD_RIGHT\|PAD_A` (left column) or 15 with `PAD_LEFT\|PAD_A` (right), `wMaxMenuItem` = 1 (`DisplayBattleMenu`, `engine/battle/core.asm:2081` and `:2114`) | reported 0 FIGHT, 1 PKMN, 2 ITEM, 3 RUN: the game keeps the index *within* the column and `.rightColumn` adds two on selection | ROM (a fresh menu is FIGHT; RIGHT is ITEM; DOWN from there is RUN), trace |
| the move list | `wTopMenuItemY` = 12, `wTopMenuItemX` = 5 (`MoveSelectionMenu`'s regular menu, `:2492`) **and the box it draws** — section 10, because nothing clears the cursor bytes and `SelectMenuItem` decrements `wCurrentMenuItem` back into range on its way out | the game's list is **one-based** — `wCurrentMenuItem` is `wPlayerMoveListIndex + 1` and `wMaxMenuItem` is the move count plus one — so the accessor reports the 0-based slot, and `None` for an index that names no move | trace, and the press survey of section 10 | | the move list | `wTopMenuItemY` = 12, `wTopMenuItemX` = 5 (`MoveSelectionMenu`'s regular menu, `:2492`) | the game's list is **one-based** — `wCurrentMenuItem` is `wPlayerMoveListIndex + 1` and `wMaxMenuItem` is the move count plus one — so the accessor reports the 0-based slot, and `None` for an index that names no move | trace |
| the party list | `wTopMenuItemY` = 1, `wTopMenuItemX` = 0, `wMaxMenuItem` = `wPartyCount - 1`, watched keys `PAD_A\|PAD_B` or `PAD_A` alone (`PartyMenuInit`, `home/pokemon.asm:201`) | 0-based party slot | trace | | the party list | `wTopMenuItemY` = 1, `wTopMenuItemX` = 0, `wMaxMenuItem` = `wPartyCount - 1`, watched keys `PAD_A\|PAD_B` or `PAD_A` alone (`PartyMenuInit`, `home/pokemon.asm:201`) | 0-based party slot | trace |
| **a forced switch** | the party list, in a battle, with `wPartyMenuTypeOrMessageID` = `BATTLE_PARTY_MENU` (`$02`) at `$d07d`. `ChooseNextMon` is the battle path that sets it (`engine/battle/core.asm:1088`, and `:1389` for the "use next mon?" branch); choosing PKMN from the menu sets `NORMAL_PARTY_MENU` (`$00`, `:2316`), which is why the two are distinguishable. `wForcePlayerToChooseMon` (`$d11f`) is the byte `PartyMenuInit` turns into "A only, no way out". | — | trace | | **a forced switch** | the party list, in a battle, with `wPartyMenuTypeOrMessageID` = `BATTLE_PARTY_MENU` (`$02`) at `$d07d`. `ChooseNextMon` is the battle path that sets it (`engine/battle/core.asm:1088`, and `:1389` for the "use next mon?" branch); choosing PKMN from the menu sets `NORMAL_PARTY_MENU` (`$00`, `:2316`), which is why the two are distinguishable. `wForcePlayerToChooseMon` (`$d11f`) is the byte `PartyMenuInit` turns into "A only, no way out". | — | trace |
@ -181,7 +145,6 @@ forces. A battle that is neither — text, an animation, the turn resolving —
| a submenu | the weakest rule here, and the reason `Unknown` exists: `wListMenuID` is the bag or an elevator list, or the party list geometry outside a battle. A submenu none of those catch reads as `Unknown`, never as `Overworld`. | trace | | a submenu | the weakest rule here, and the reason `Unknown` exists: `wListMenuID` is the bag or an elevator list, or the party list geometry outside a battle. A submenu none of those catch reads as `Unknown`, never as `Overworld`. | trace |
| mart | `wTextBoxID` = `BUY_SELL_QUIT_MENU` (`$15`) for the BUY / SELL / QUIT choice, and `engine/events/pokemart.asm:17` is its only user in the game; the buy list is `wListMenuID` = `$02` and the sell list is the bag's own `$03`, recognised only while the mart's template is still the last one drawn. | trace | | mart | `wTextBoxID` = `BUY_SELL_QUIT_MENU` (`$15`) for the BUY / SELL / QUIT choice, and `engine/events/pokemart.asm:17` is its only user in the game; the buy list is `wListMenuID` = `$02` and the sell list is the bag's own `$03`, recognised only while the mart's template is still the last one drawn. | trace |
| PC | `wMiscFlags` bit 3, above. | trace | | PC | `wMiscFlags` bit 3, above. | trace |
| a two-option YES/NO box | **new 2026-09-22, generalised 2026-09-23** (`docs/design/macros.md` sections 12.12 and 12.20). `wFontLoaded` bit 0, plus a two-option cursor (`wMaxMenuItem` 1, `wMenuWatchedKeys` = A\|B), plus the border `DisplayTwoOptionMenu` drew **around the cursor it parked** -- see section 11. **Both halves are load-bearing**: the cursor bytes survive the box closing, so all forty-six frames of a nurse's conversation and all fifty-two of a gym guide's carry that geometry while the box is drawn on a handful of them. The border is no longer pinned to one rectangle, because Red places the menu where the script asking for it says and two of those places are surveyed. | ROM (the rung-10 Pokemon Center and the rung-10 Pewter Gym checkpoints, each surveyed one raw pulse at a time: `examples/scene_probe.rs`, `FLY_PROBE_CATCH=nurse` and `=dialog`) |
### Money and bag ### Money and bag
@ -242,9 +205,6 @@ current tileset's list of passable tiles, walking it until it matches or hits `$
is not followed and the answer is `Unknown`, because banks 1 and up are whatever the last bank is not followed and the answer is `Unknown`, because banks 1 and up are whatever the last bank
switch left mapped. This is the only ROM read in the module and the reason it is allowed. switch left mapped. This is the only ROM read in the module and the reason it is allowed.
**Section 9 is the same predicate over the whole map** (2026-09-22): the window below is what the
walks fall back to on a frame the map cannot be decoded, and no longer what they plan over.
Three things bound it, all reported as `Unknown` rather than guessed: Three things bound it, all reported as `Unknown` rather than guessed:
1. **The window is ten tiles by nine and it follows the player**: `x - 4 ..= x + 5` and 1. **The window is ten tiles by nine and it follows the player**: `x - 4 ..= x + 5` and
@ -511,13 +471,6 @@ Two facts about the emulator came out of building these and are worth keeping:
which is two battles away from anything a fixed-seed walk reaches quickly. The trace is built which is two battles away from anything a fixed-seed walk reaches quickly. The trace is built
from `ChooseNextMon` and `PartyMenuInit`, and the distinguishing byte from `ChooseNextMon` and `PartyMenuInit`, and the distinguishing byte
(`wPartyMenuTypeOrMessageID` = `BATTLE_PARTY_MENU`) is asserted both ways. (`wPartyMenuTypeOrMessageID` = `BATTLE_PARTY_MENU`) is asserted both ways.
- **`yes_no_prompt` is "*this* two-option box is drawn", not "a choice is open".** The claim in
earlier revisions of this file — that pokered has no observable for a choice — stands for the
general question and is now narrowed rather than withdrawn: the box the Pokémon Center nurse's
offer is drawn in has been surveyed on the cartridge and is readable, and every other
two-option menu in the game is not, because `DisplayTwoOptionMenu` takes its coordinates from
the script that calls it. A prompt this reading misses is a plain dialog, which is the pad it
had before the reading existed.
- **`text_box().waiting` is "the dialogue box is drawn", not "the game wants a button".** Pokered - **`text_box().waiting` is "the dialogue box is drawn", not "the game wants a button".** Pokered
has no flag for the second thing; A is the right press either way, so the distinction has no has no flag for the second thing; A is the right press either way, so the distinction has no
consequence for the palette, but it is not what the field's name might suggest. consequence for the palette, but it is not what the field's name might suggest.
@ -550,46 +503,9 @@ change, 648 bytes, checked on the WSL box.
| state | symbol | address | encoding | verified | | state | symbol | address | encoding | verified |
| --- | --- | ---: | --- | --- | | --- | --- | ---: | --- | --- |
| the open mart's stock | `wItemList` | `$cf7b` | `ds 16`. `LoadItemList` (`home/text_script.asm:156`) copies the clerk's `script_mart` list out of its text script the moment the counter opens: **a count byte** (the macro's `_NARG`), then the item ids, then `$ff`. `DisplayPokemartDialogue_` points the buy list's `wListPointer` at the same buffer for `PRICEDITEMLISTMENU`, so an item's position in this list is its cursor index in the buy menu **for the first three entries only** — see the correction below. | ROM (Viridian's counter reads POKE BALL, ANTIDOTE, PARLYZ HEAL, BURN HEAL, in that order, and no Potion), trace | | the open mart's stock | `wItemList` | `$cf7b` | `ds 16`. `LoadItemList` (`home/text_script.asm:156`) copies the clerk's `script_mart` list out of its text script the moment the counter opens: **a count byte** (the macro's `_NARG`), then the item ids, then `$ff`. `DisplayPokemartDialogue_` points the buy list's `wListPointer` at the same buffer for `PRICEDITEMLISTMENU`, so **an item's position in this list is its cursor index** in the buy menu — which is what lets a purchase be navigated by reading the cursor rather than by counting presses. The terminator wins over the count, as it does for the bag, and the sixteen-byte buffer bounds both. | ROM (Viridian's counter reads POKE BALL, ANTIDOTE, PARLYZ HEAL, BURN HEAL, in that order, and no Potion), trace |
| the tileset's counter tiles | `wTilesetTalkingOverTiles` | `$d532` | three tile ids from the tileset header (`data/tilesets/tileset_headers.asm`), `$ff` for a tileset with fewer. Mart and Pokecenter are both `$18 $19 $1e`; the overworld and an ordinary house have none. `IsSpriteOrSignInFrontOfPlayer`'s `.extendRangeOverCounter` branch (`home/overworld.asm:1115`) walks exactly this list and doubles the talking range from `$10` to `$20` pixels — one tile to two — when the tile in front of the player is one of them. | ROM (the Viridian mart's column 1 and the centre's (3, 2) read as counters and the floor either side does not), trace | | the tileset's counter tiles | `wTilesetTalkingOverTiles` | `$d532` | three tile ids from the tileset header (`data/tilesets/tileset_headers.asm`), `$ff` for a tileset with fewer. Mart and Pokecenter are both `$18 $19 $1e`; the overworld and an ordinary house have none. `IsSpriteOrSignInFrontOfPlayer`'s `.extendRangeOverCounter` branch (`home/overworld.asm:1115`) walks exactly this list and doubles the talking range from `$10` to `$20` pixels — one tile to two — when the tile in front of the player is one of them. | ROM (the Viridian mart's column 1 and the centre's (3, 2) read as counters and the floor either side does not), trace |
### 7.1 Correction, 2026-09-22 (row 55): the list byte and the cursor index both say less than this
Two claims in the table above were surveyed again from the live checkpoint the stream looped in
(`examples/scene_probe.rs`, `FLY_PROBE_CATCH=shop`, in the Pewter mart), and both are narrower
than they were written.
**`wListMenuID` says the counter is open, not which of its screens is up.** Section 2's row for it
says it is "zeroed by `DisplayTextIDInit` at the start of every text display, so a stale value
cannot outlive one". That holds for text the overworld displays and not for the mart's own: the
clerk's "Here you are! Thank you!" is printed from inside `DisplayPokemartDialogue_`, which never
calls the routine that clears it. Measured: **every frame of a mart visit read `PRICEDITEMLISTMENU`
`$02`** — the counter menu and each of the clerk's text boxes included — and on the counter menu
`wTextBoxID` reads `MONEY_BOX` `$0d` rather than `BUY_SELL_QUIT_MENU` `$15`, because the money box
is the last template drawn. So `ShopScreen::BuySellQuit` and `Selling` were unreachable in a mart
that had ever drawn a buy list, and the frame the stream sat on — a dialogue box waiting for a
press, with a two-option box's leftover cursor bytes (`wTopMenuItemY` 8, `wTopMenuItemX` 15,
`wMaxMenuItem` 1, `wMenuWatchedKeys` `$03`) — read as "the priced buy list is open".
The screen is now read from the figure the game draws, the construction [`text_box`]'s `waiting`
test and [`yes_no_prompt`] already use: the full-width box drawn and waiting is the clerk
(`ShopScreen::Talking`, new), the item window drawn is the buy list, and the item window blank is
the counter menu. The item window's own figure is its first name cell, screen (6, 4), which held
`$7f` on the counter menu and `P` of `POKE BALL` on every frame the list was drawn.
**The buy list scrolls, so a position in `wItemList` is a cursor index only for the first three
entries.** Walked one DOWN pulse at a time on the live list: the cursor went `0, 1, 2` and then
**stopped moving while the window scrolled under it** — the fourth drawn row is a look-ahead the
cursor never occupies. The absolute position of the item under the cursor is that index plus
`wListScrollOffset`, which is **not** in the reviewed address list and cannot be pinned without the
disassembly `gen_symbols.py` reads (the same refusal `$cfc5` met in section 9). Viridian's
four-item counter hid it: POKE BALL is 0 and ANTIDOTE is 1, both in reach. Pewter's seven-item
counter did not: its ANTIDOTE is **3**.
So the macros carry `MART_CURSOR_ROWS = 3` and a purchase past it is not on the pad
(`infra/docs/macros-traps.md` row 55). Pinning `wListScrollOffset` would widen that, and it is a
residual rather than a guess.
**Why the second one is load-bearing.** A mart clerk is `object_event 0, 5, SPRITE_CLERK` behind a **Why the second one is load-bearing.** A mart clerk is `object_event 0, 5, SPRITE_CLERK` behind a
counter running down column 1; a Pokémon Center nurse is `object_event 3, 1, SPRITE_NURSE` behind counter running down column 1; a Pokémon Center nurse is `object_event 3, 1, SPRITE_NURSE` behind
the counter tile at (3, 2). **None of the four tiles around either of them is standable** — they are the counter tile at (3, 2). **None of the four tiles around either of them is standable** — they are
@ -657,341 +573,3 @@ PP, type effectiveness applied from the ROM's type chart", and section 14 replac
one button per move slot: which move is used is the fly's choice and the mushroom body's to learn. one button per move slot: which move is used is the fly's choice and the mushroom body's to learn.
Knowledge that nothing reads is not narrowed, it is deleted — `MacroState` is four methods Knowledge that nothing reads is not narrowed, it is deleted — `MacroState` is four methods
shorter and `pokemon_red/state.rs` never needed them. shorter and `pokemon_red/state.rs` never needed them.
## 9. The whole map, not the window (2026-09-22, `docs/design/macros.md` section 15)
The walkable predicate of section 2 answers about ten tiles by nine because that is how much map
the screen buffer holds. Every tile of the loaded map follows the same rule, decoded from the
tables the cartridge has loaded.
**Four more names through the same door.** `services/flysim/tools/gen_symbols.py`'s `EXTRA_RAM`
takes the table from 63 addresses to **67**, and nothing else in it moves — no event flag, no
milestone, no existing address. `flysim --print-compatibility` is byte-identical across the change:
648 bytes, `0d9bfde7…707fa`.
The prototype checkout `gen_symbols.py` reads is not on this box, so the four addresses were
resolved the way it would have resolved them, by a second tool that reads the disassembly directly:
`services/flysim/tools/resolve_wram.py` walks `ram/wram.asm` at the pinned commit with a byte
cursor that is **only ever live while it is anchored on an address `symbols.rs` already pins**, and
emits an address only when a pinned address *after* it agrees as well. It re-derives 40 of the 63
addresses the table already carries with no disagreement, and each of the four new ones is
bracketed by two of them. A declaration form it cannot size exactly kills the cursor rather than
being guessed at, so an unanchored region cannot produce a number at all.
| state | symbol | address | encoding | verified |
| --- | --- | ---: | --- | --- |
| the loaded map's blocks | `wOverworldMap` | `$c6e8` | one byte per 4x4-tile block. `LoadTileBlockMap` (`home/overworld.asm`) fills it from the map's own ROM bank as rows of `wCurMapWidth + MAP_BORDER * 2` bytes with the map itself `MAP_BORDER` = 3 rows and columns in, so the border can hold strips of the connected maps. The map's own blocks are therefore a WRAM read. | survey + ROM (Pallet Town and Viridian Forest, below), trace |
| which tileset | `wCurMapTileset` | `$d367` | tileset id (`constants/tileset_constants.asm`, `OVERWORLD` 0 … `FOREST` 3 … `CAVERN` 17). Keys the tile-pair lists, which is the only thing this work reads it for. | ROM, trace |
| the blockset's bank | `wTilesetBank` | `$d52b` | the tileset header's `db BANK(\1)` (`data/tilesets/tileset_headers.asm`). Not bank 0, which is the whole reason the seam grew a bank-aware read. | ROM (the overworld tileset's blockset reads from bank `$19`), trace |
| blocks to tiles | `wTilesetBlocksPtr` | `$d52c` | little-endian pointer, 16 bytes per block id, four rows of four screen tile ids. `DrawTileBlock` (`home/overworld.asm`) indexes it as `block * $10` and walks four rows of four, which pins the layout exactly. | ROM, trace |
### The one ROM read that needed a bank
`MemoryReader` gains `read_rom(bank, address) -> Option<u8>`, defaulted to `None`. The bus read
cannot reach the blockset — banks 1 and up are whatever the cartridge's last switch left mapped, and
the only way to change that would be to *write* the mapper's bank register, which the doctrine
forbids (`docs/design/macros.md` section 12: the joypad register is the only write). So the
emulator implements it over **the cartridge image the process already holds**: below `$4000` it is
bank 0 whatever the bank says, `$4000..$8000` is the banked window, and an offset past the end of
the image is `None`. Nothing is written, no bank is switched, and the emulator's state does not
move. Every other reader — the synthetic WRAM of the tests, the sim loop's stubs — keeps the
default, and `None` there means the grid narrows to the window predicate rather than decoding a map
out of whatever bytes were to hand.
### The corner, which was measured
A map tile is 2x2 screen tiles and `CheckTilePassable` matches **one** id, so a decode has to pick
the same one the cartridge picks. It is the **lower left** of the four. The upper left is the
plausible guess: the view is centred so that the player's own 2x2 begins at screen row 8 and
`_GetTileAndCoordsInFrontOfPlayer` reads `(8, 9)`, which is its lower half. Measured on the
cartridge rather than argued: Viridian Forest's (4, 32) reads `$23` on the screen, which is the
second row of its block, where the first row holds `$04`. On a town most quadrants hold one tile id
four times over, so an upper-left decode reads correctly there and falls apart in a forest — which
is exactly the shape of mistake the cross-check below exists for.
### Two gates, because a wrong decode answers plausibly
- **Against the screen, before the grid is trusted.** The decoded ids are compared with
`map_tile_id` over the fly's own tile and its four neighbours; a frame where the window can answer
for none of them is refused. `wOverworldMap` shares its bytes with the picture buffer
(`ram/wram.asm`'s own `UNION`), so a battle is precisely when the blocks under it are somebody
else's.
- **Against the screen again, whenever a cached grid is served.** A warp writes `wCurMap` before the
header and the blocks: measured on Oak's lab's doormat, where `wCurMap` reads `PALLET_TOWN` while
the header still reads the lab's ten-by-twelve. The decode and the screen agree on such a frame —
both are the old map — so only the *id* is wrong, and the check that catches it is one byte: does
the cached grid still agree with the screen about the tile the fly is standing on.
### The frame mid-step, which the check refused (2026-09-22, row 54)
The cross-check above refused on **every frame the fly was moving**, and the reason is a fact about
when the cartridge writes the coordinates. `FLY_PROBE_CATCH=step` in
`services/flysim/crates/flysim/examples/scene_probe.rs` holds one direction from a checkpoint and
prints, per frame, the coordinates, the grid's verdict, the tiles the two readings disagree on, and
every plausible candidate for "a step is in progress". Holding UP out of the Pewter museum:
| frame | `wYCoord` | the grid | the disagreement |
| ---: | ---: | --- | --- |
| 0 | 7 | ok | -- |
| 1 | 7 | ok | -- |
| 2 .. 15 | **7** | screen disagrees | (10, 7) decoded `$20`, screen `$01`; (11, 7) `$20` against `$01` |
| 16 | **6** | ok | -- |
| 19 .. 32 | 6 | screen disagrees | (10, 7) `$20`/`$01`; (11, 6) `$01`/`$50` |
| 33 | 5 | ok | -- |
Three readings come out of it, and the first is the one everything else follows from:
1. **The coordinates change at the *end* of a step.** A step is sixteen frames; `wYCoord` reads the
tile it began on for all of them but the last. The background scrolls throughout, so from the
second frame the screen buffer is already centred one tile ahead.
2. **`map_tile_id(x, y)` therefore answers for `(x + dx, y + dy)` mid-step**, where `(dx, dy)` is
the step. Verified on both arms of the trace: at frame 19 the screen's reading for (10, 7) is
the decode of (10, 6) and its reading for (11, 6) is the decode of (11, 5), exactly.
3. **No pinned address says a step is in flight.** The player sprite's Y and X step deltas
(`wSpriteStateData1 + 3` and `+ 5`) keep their last value after the step ends -- `$ff, $00` on
every frame of the trace after the first -- so they cannot tell a step from the one before it.
`wStatusFlags5` stayed `$00`, `wMovementFlags` tracked the warp tile the fly was standing on and
not the step, and `rSCY` lags the coordinates by a frame of its own. The one byte that does
track it exactly -- counting `$07 $07 $06 $06 … $01 $01` down to `$00` on the frame the
coordinates catch up -- is **`$cfc5`**, and `gen_symbols.py` refuses a hand-written address while
the checkout `resolve_wram.py` reads is not on this box. So it is recorded here and **not used**.
What the reader does instead is measure the anchor: the screen is centred on the fly's own tile or
on one of its four neighbours, and the anchor it is centred on is the one whose **whole**
neighbourhood agrees with the decode. `(0, 0)` is tried first, so a standing frame costs exactly
what it did before. The refusals the check exists for all survive, because a wrong stride, a wrong
quadrant, a half-loaded map and the mid-warp tear each disagree under every one of the five: the
neighbourhood has to agree as a unit rather than tile by tile.
### The survey, on two maps
`services/flysim/crates/flysim/tests/rom_map_grid.rs`, the method of
`docs/design/room-escape.md` section 3: walk the map with real button presses on throwaway
emulators, 120 frames of held direction per step and twenty released frames before each state is
kept, and compare the grid against what the cartridge did.
| map | size | walkable | reachable | unknown | window tiles compared | tiles surveyed | refused presses | a sprite was in the way | a battle or a script answered |
| --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | ---: |
| Pallet Town `$00` | 20x18 | 221 | 207 | 0 | 90, no disagreement | 120 | 58, all explained | 4 | 0 |
| Viridian Forest `$33` | 34x48 | 719 | 719 | 0 | 90, no disagreement | 120 | 74, all explained | 2 | 10 |
"All explained" is the assertion that matters: every press the cartridge refused is a tile the grid
calls a wall, a directed wall out of that tile, or a tile a sprite was standing on **in the frame
the press was made in** — Pallet Town's two villagers walk, so reading the sprite list from the
state the survey started in would not do. And every step the cartridge made is one the grid would
have planned. Both halves are needed: the first catches a decode that is too permissive, the second
one that is too strict.
Three things are still not modelled, and none of them is new: a sprite in the way (the sprite list
answers that, and the executor's per-step moved check covers the rest), a warp that fires on the
step onto it, and a script that pushes the fly off a tile (a session ledger answers that). The
water half of the tile-pair lists is deliberately absent: it is the list
`CheckForJumpingAndTilePairCollisions` uses while surfing, and the palette cannot surf.
## 10. A menu that is accepting input, against one that is only remembered (2026-09-22, row 50)
`HandleMenuInput` is shared by every menu in the game (section 2) and so are the five bytes it
parks a cursor in. Section 2's table reads those bytes to say *which* menu is up; it does not say
whether anybody is reading them. The difference is the whole of row 50: `MOVE n` reported `blocked`
**890 times in 1,431 macros** on the cartridge, every one of them on a frame the seam called an
open move list with a placeable cursor.
**Nothing in the game clears the cursor bytes.** `MoveSelectionMenu` writes `wTopMenuItemY` 12 and
`wTopMenuItemX` 5 once, and the whole of the turn that follows — the text, the animation, the
damage, the enemy's reply — reads them back unchanged. It is the same fact section 7's YES/NO box
rests on ("the cursor bytes survive the box closing"), and the reason the battle's *top-level* menu
never had this problem is that it carries `wTextBoxID` = `$0b` beside its geometry.
`SelectMenuItem` makes it worse rather than better: on its way out of `HandleMenuInput` it does
`ld a, [wCurrentMenuItem] / dec a / ld [wCurrentMenuItem], a`, turning the menu's one-based index
back into a 0-based move slot. That lands straight back inside the range the accessor reads as a
valid one-based slot, so a turn spent on move 2, 3 or 4 leaves a *placeable* cursor behind it.
### The accessor
| state | how | verified |
| --- | --- | --- |
| the move list is **accepting input** | the cursor at `wTopMenuItemY` 12 / `wTopMenuItemX` 5 **and** the figure `MoveSelectionMenu` draws: a `TextBoxBorder` at (4, 12) fourteen wide and four tall, with a horizontal run written over its top-left corner and the `┘` junction written over (10, 12) (`engine/battle/core.asm`, `.regularmenu`). Read whole — both verticals, both horizontal runs, all four corners — because a single frame tile id is an ordinary character. The mimic and relearn menus draw at row 7 and never reach a battle's own turn. | survey (below) |
`Scene::Battle { own_turn }` follows it: a frame whose move list is not on screen reads
`BattleMenu::None`, which is nobody's turn, which is the between-turns row and its one `NEXT`
(`docs/design/macros.md` 12.10). Nothing else moves — the top-level menu, the party list and the
bag keep the readings they had.
### The survey
`examples/scene_probe.rs`, `FLY_PROBE_CATCH=accept`, from the rung-9 forest checkpoint. The
question "is this menu accepting input" is answered by **pressing at it**, not by nominating a
flag: on every battle frame the emulator exports its state, one directional pulse is issued,
`wCurrentMenuItem` is read, and the state goes straight back — `HandleMenuInput` moves the cursor
on UP and DOWN before it even looks at `wMenuWatchedKeys`, so a cursor that moves is a menu running
its input loop. The pulse *releases* the buttons first, because `JoypadLowSensitivity` acts on a
key's edge and a direction the fly is already holding would read as refused for the measurement's
reason rather than the cartridge's.
| the reading | press refused | press honoured |
| --- | ---: | ---: |
| the cursor bytes alone (what the seam read before row 50) | 2,838 | 264 |
| the cursor bytes **and** the box on screen | **0** | **231** |
| the cursor bytes with no box drawn | 2,838 | 33 |
So 91.5% of the frames the old reading called an open move list were frames no press reached, and
the reading that survives is exact on the 231 it keeps. (The 33 are frames where the pulse's own
thirty frames were long enough for the cartridge to open something by itself; the pulse is a
measurement and not a claim about one frame.)
Beside the press, the probe asks **every byte of WRAM and HRAM** whether its values on accepting
frames are disjoint from its values on refusing ones, so a reading is found rather than guessed.
Over the move list, once the box is in the reading, no byte separates the two classes at all —
there is nothing left to separate. Over the whole class before the fix, the only separators were
the HRAM joypad bytes, which is the measurement seeing its own held button.
### What the same survey found and this section did not fix
- **The top-level battle menu is already exact**: 413 frames, 0 refused. `wTextBoxID` is why.
- **The bag is the same trap, unfixed and named.** `wListMenuID` = `ITEMLISTMENU` outlives the bag
exactly as the cursor bytes outlive the move list: 449 refused against 36 honoured over the
frames the seam calls an open battle bag. The bag list is drawn in the top half of the screen and
the survey has not yet found the figure that tells it from the frame after it closes, so it is
reported rather than guessed — `docs/design/ladder.md`'s rule. `ITEM` and `THROW BALL` are the
two macros it costs.
- **The party list, likewise**: `PartyMenuInit`'s geometry outlives its list.
## 11. A two-option box is the one the cartridge drew (2026-09-23, `docs/design/macros.md` 12.20)
Section 10 read one menu by the figure it draws; row 41 read the YES/NO box the same way but at a
**pinned** rectangle, (11, 6)-(19, 11), and named the limit in its own residual: Red places a
two-option menu where the script asking for it says, so a prompt drawn elsewhere read `false`.
Row 56 is that residual, live: the Pewter Gym guide's "Let me take you to the top!" draws the same
menu at **(14, 7)-(19, 11)** with the shared cursor at row 8, column **15**. Over 260 surveyed
presses of his conversation the box was drawn on **10** frames and `yes_no_prompt` answered `false`
on **all 260** -- so the dialog pad was `NEXT, YES, NO` on a box that was a choice, and the whole of
12.12 (no `NEXT` on a prompt, the nurse's one bound answer, the reopened-prompt exclusion) was
inert wherever the box was not the centre's.
### The accessor
Nothing in the reviewed symbol list says "a choice is open" and nothing can be added by hand
(`gen_symbols.py` refuses a hand-written address, and the disassembly is not built on this box), so
the reading is the construction `text_box`'s `waiting` already makes -- a WRAM flag plus the figure
-- with the figure **found** rather than pinned:
1. `wFontLoaded` bit 0, as for every text display;
2. the cursor is a two-option menu's: `wMaxMenuItem` 1 and `wMenuWatchedKeys` = A\|B;
3. the border's **left edge is one column to the left of `wTopMenuItemX`**, because
`DisplayTwoOptionMenu` writes the cursor into the box's first interior column. This holds in
both surveyed boxes -- left 11 with the cursor at 12, left 14 with the cursor at 15 -- and it is
the only geometric relation that does;
4. the **top** is looked up from the cursor's row for the border's own top-left corner, at most
three rows, because the nurse's box begins two rows above the first item and the guide's one:
one menu carries a caption line and the other does not;
5. and the rest of the figure is then read **whole** by `border_drawn` -- both verticals, both
horizontal runs and all four corners -- because a single frame tile id is an ordinary character.
### The survey
`examples/scene_probe.rs`, `FLY_PROBE_CATCH=dialog`, which walks a conversation one raw pulse at a
time and prints, per frame, the seam's reading beside **every complete `TextBoxBorder` on screen**
(`state::drawn_boxes`, a diagnostic that tries every rectangle rather than one). Two checkpoints,
400 frames:
| checkpoint | a box drawn above the dialogue box | `wTextBoxID` = `TWO_OPTION_MENU` (`$14`) | the new reading | frames |
| --- | --- | --- | --- | ---: |
| rung-10 Pewter Gym | false | false | false | 250 |
| rung-10 Pewter Gym | **true** | **true** | **true** | 10 |
| rung-10 Pokémon Center | false | false | false | 136 |
| rung-10 Pokémon Center | **true** | **true** | **true** | 4 |
The three agree exactly on all 400 frames. **`wTextBoxID` is recorded and not used**: it would be a
tighter reading still, and row 41's own note says the nurse's *plain* boxes read `$01` — which is
confirmed here — but no survey on this branch covers Red's other two-option menus, and a reading
this crate has not verified does not go in (`docs/design/ladder.md`). It is the named strengthening.
### What it does not claim
A frame whose two-option cursor bytes have outlived their box reads `false`, which is the whole
point of reading the figure; a border drawn somewhere the cursor is not parked is not the cursor's
box; and a menu of more than two options is not this menu. What the pad makes of a readable prompt
is `pokemon_red::macros::palette`'s business (`docs/design/macros.md` 12.12 and 12.20), not this
accessor's.
## 12. The people off the screen, and a battle decided (2026-09-23, `docs/design/macros.md` 12.22)
Two readings row 58 added, both out of bytes the seam already had or a byte bracketed by two it had.
### `state::offscreen_npcs`
`CheckSpriteAvailability` (`engine/overworld/movement.asm`) writes `$ff` into
`SPRITESTATEDATA1_IMAGEINDEX` (offset 2) for a sprite that is a toggleable object switched off, that
is outside its window, or that stands on a text box's tiles. The window, for a sprite whose
`SPRITESTATEDATA2_MOVEMENTBYTE1` (offset 6) is `WALK` (`$fe`) or `STAY` (`$ff`), compares the
sprite's biased `MAPY` / `MAPX` (offsets 4 and 5) with `wYCoord` / `wXCoord`: drawn when equal, or
when the sprite's is greater by at most `SCREEN_HEIGHT / 2 - 1` (8) rows and
`SCREEN_WIDTH / 2 - 1` (9) columns. A scripted mover (movement byte below `WALK`) skips the test.
So a `$ff` sprite of the loaded map whose coordinates fall **outside** the window, and whose
movement byte is `WALK` or above, is reported with those coordinates: the cartridge would hide it
for being off the screen whatever else were true, and a sprite it is not updating does not move.
Everything else `$ff` is not reported. Measured from the row-58 checkpoint at the Pewter Gym's
doormat: BROCK at (4, 1) and the Jr. Trainer at (3, 6) reported, the guide at (7, 10) drawn.
What it cannot tell is a toggleable object switched off from one out of sight, because both are
`$ff` outside the window. Its one reader is the rung's own list of people; the ladder's person
places are Oak's lab and the gyms, and only the lab and Viridian Gym carry toggleable people
(`data/maps/toggleable_objects.asm`).
### `poke::CUR_OPPONENT`, `wCurOpponent`
Written when a battle is decided (`home/trainers.asm` for a trainer, the encounter check for a wild
one), cleared by `EndOfBattle` in the same block that clears `wIsInBattle`. Not in the generated
table: `ram/wram.asm` at the pinned commit declares `wIsInBattle:: db`,
`wPartyGainExpFlags:: flag_array PARTY_LENGTH` (one byte), `wCurOpponent:: db`,
`wBattleType:: db`, `wDamageMultipliers:: db`, `wGymLeaderNo:: db`, `wTrainerNo:: db`, and the
table's `wIsInBattle` (`$d057`), `wBattleType` (`$d05a`) and `wTrainerNo` (`$d05d`) are exactly
where that layout puts them, so the byte is `wBattleType - 1` = `$d059`, asserted against both
neighbours in `scene/tests.rs`. Measured on the cartridge in the Pewter Gym: `$00` in the
overworld, `$cd` (`OPP_JR_TRAINER_M`) from the last box of the trainer's challenge through the
219-frame transition and the battle. `controllable` reads it as zero.
## 13. What a move will do: the move table and the bytes its effect reads (2026-09-23, `docs/design/macros.md` 12.23)
Row 60. Seven addresses, resolved by `tools/resolve_wram.py` and emitted into `symbols.rs`; the
tool now follows the decomp's `const` and `_RS` counters and a struct macro's field labels, which
is what reaches the `battle_struct` fields, and it re-derives 77 of 81 pinned addresses with no
disagreement.
| symbol | address | what reads it |
| --- | --- | --- |
| `wPlayerMonStatMods` | `$cd1a` | six stages, ATTACK DEFENSE SPEED SPECIAL ACCURACY EVASION; 1 is -6, 7 normal, 13 is +6 |
| `wEnemyMonStatMods` | `$cd2e` | the same for the enemy |
| `wEnemyMonStatus` | `$cfe9` | the enemy's status byte (`battle_struct` +4) |
| `wEnemyMonType1` | `$cfea` | and `Type2` after it |
| `wEnemyMonAttack` | `$cff6` | the enemy's modified ATTACK DEFENSE SPEED SPECIAL, big-endian words |
| `wBattleMonAttack` | `$d025` | the same for the fly's Pokémon |
| `wEnemyBattleStatus2` | `$d068` | bit 1 Mist, 4 substitute, 5 must recharge |
**The move table is ROM.** `Moves` opens `SECTION "Battle Engine 7"`, which `layout.link` places
first in bank `$0E`, so it is `$0E:$4000`, six bytes a row in move-id order from `POUND`:
animation (the move id itself), effect, power, type, accuracy, PP. `state::move_data` reads a row
through `MemoryReader::read_rom`, the cartridge image, and refuses a row whose first byte is not the
id asked for. Measured from the row-60 checkpoint: TACKLE (`$21`) effect `$00` power 35, TAIL
WHIP (`$27`) effect `$13` (`DEFENSE_DOWN1_EFFECT`) power 0.
**`state::move_without_effect`** answers only refusals decided before the roll, for a move with no
power, from `engine/battle/effects.asm` and `MoveHitTest`:
- `*_UP1` / `*_UP2`: the user's stage is 13, or (ATTACK..SPECIAL) the stat is 999;
- `*_DOWN1` / `*_DOWN2`: the target has Mist or a substitute, its stage is 1, or (ATTACK..SPECIAL)
the stat is 1 -- `StatModifierDownEffect` restores the stage and prints "Nothing happened!"
then, so a low-level target reaches it before -6;
- `SLEEP_EFFECT`: any status, unless the target must recharge;
- `POISON_EFFECT`: a substitute, any status, or a Poison type;
- `PARALYZE_EFFECT`: any status, or an Electric move against a Ground type.
`None` outside a battle, without a cartridge, or with a stage byte outside 1..13.
**Not covered** (the reading would be the same kind, and nothing early in the game reaches it):
Confuse Ray and Supersonic on a confused target, Leech Seed on a seeded or Grass target, Focus
Energy, Mist, Reflect and Light Screen already up, Disable on a disabled target, and a damaging
move the type chart makes "doesn't affect" (the chart is another ROM table).

File diff suppressed because it is too large Load diff

View file

@ -11,22 +11,11 @@ Concrete second-game audit: [Melee framework and emulator plan](melee-framework-
Its MELEE-01/02 spikes specialize EMULATOR-01 below and can proceed alongside framework Its MELEE-01/02 spikes specialize EMULATOR-01 below and can proceed alongside framework
extraction; they do not depend on importing MaleCNS first. extraction; they do not depend on importing MaleCNS first.
The [session framework contract set](session-framework/README.md) now specifies the new
multi-process architecture. Its [implementation guide](session-framework/implementation.md)
breaks RUNTIME/WIRE/STATE work into concrete schema, transport, coordinator and worker slices.
**Communications decision:** build [Flybus](session-framework/bus-v1.md), one Rust RPC/pub-sub
router with immutable external artifacts, delivery guards and last-owner GC. It serves workers,
application supervision, presentation and storage. No second direct-RPC system or NATS broker.
Application-specific orchestration and frontend are developed together; native frames leave
the environment, while resizing/compositing/streaming belongs to presentation.
## 1. Delivery strategy ## 1. Delivery strategy
Deliver working vertical slices; keep the existing FAFB/Game Boy composition usable throughout. Deliver working vertical slices; keep the existing FAFB/Game Boy composition usable throughout.
1. Establish a behavior baseline and explicit dataset/profile identities. 1. Establish a behavior baseline and explicit dataset/profile identities.
Independently build the generic Flybus example; it needs neither dataset nor emulator.
2. In parallel workstreams, characterize MaleCNS and extract the single-agent session runtime. 2. In parallel workstreams, characterize MaleCNS and extract the single-agent session runtime.
3. Demonstrate two isolated brains driving one ROM-free shared environment. 3. Demonstrate two isolated brains driving one ROM-free shared environment.
4. Expose that session through versioned feed/control contracts and a multi-agent broadcast. 4. Expose that session through versioned feed/control contracts and a multi-agent broadcast.
@ -68,15 +57,6 @@ already created. Builders use separate worktrees; the coordinator reviews contra
- **Done:** empty required populations, malformed CSR and incorrect profile restores fail; - **Done:** empty required populations, malformed CSR and incorrect profile restores fail;
legacy FAFB artifacts and default numerical version strings remain unchanged. legacy FAFB artifacts and default numerical version strings remain unchanged.
**2026-09-23: split by operator decision.** PROF-02a, the legacy profile, is done as a contract:
[legacy Game Boy composition v1](session-framework/legacy-gameboy-v1.md) defines
`gameboy-legacy-fafb-v783-v1` (today's schema-1 fingerprint embedded, `lif-1ms-f64-v2` and
`fly-kc-mbon-rstdp-v2` unchanged, the macro-role exception declared), the readout context
`gameboy-readout-context-v1`, the decision `gameboy-channels-v1`, and a composition declaration
whose digest carries the decoder and macro-channel configuration instead of the legacy
compatibility string. PROF-02b -- the bundle manifests, role mapping, strict graph validation and
profile-mismatch fixtures above -- is later and gates DATA-01, not the session port.
### DATA-01 — Acquire and normalize MaleCNS ### DATA-01 — Acquire and normalize MaleCNS
- **Branch:** `feat/malecns-import` - **Branch:** `feat/malecns-import`
@ -111,33 +91,19 @@ profile-mismatch fixtures above -- is later and gates DATA-01, not the session p
observation ownership and backend capabilities. observation ownership and backend capabilities.
- Wrap binjgb as the first environment; retain Game Boy FFI/cache/state behavior. - Wrap binjgb as the first environment; retain Game Boy FFI/cache/state behavior.
- Keep Pokémon memory inspection, objective routing, macros and reward rules in its task. - Keep Pokémon memory inspection, objective routing, macros and reward rules in its task.
- The executor receives coherent current game state, progress/objective view and clock on
every step. This richer context is not implicitly passed to the neural sensory encoder.
- Preserve existing imports through a facade; avoid simultaneous directory moves. - Preserve existing imports through a facade; avoid simultaneous directory moves.
- **Done:** existing single-agent action/reward traces match and a fake environment can be - **Done:** existing single-agent action/reward traces match and a fake environment can be
driven through the same boundary without importing binjgb or task-specific addresses. driven through the same boundary without importing binjgb or task-specific addresses.
**2026-09-23: contract written (RT-01a), implementation pending.** The operator decided the
boundary: macros run in the coordinator's action executor over a per-boundary 64-KiB memory
image carried as an inspection artifact plus the ROM as an `AssetRef`; the emulator shim gains
one read-only bulk read and the joypad stays its only write; the Pokémon task and executor are
one object (`pokered-macros-v1`); the ratchet is the `legacy-ratchet-rollback-v1` episode policy
over the environment extension `gameboy-slots-v1`. The dated amendments are in
[workers-v1](session-framework/workers-v1.md), [step-v1](session-framework/step-v1.md) and
[state-media-v1](session-framework/state-media-v1.md); the session implementation guide's
ENV-01 carries the build.
### RUNTIME-02 — Extract the single-agent session ### RUNTIME-02 — Extract the single-agent session
- **Branch:** `refactor/session-runtime` - **Branch:** `refactor/session-runtime`
- **Depends on:** RUNTIME-01 and BUS-01..03 from the contract implementation guide. - **Depends on:** RUNTIME-01.
- Move deterministic agent/environment/task orchestration out of `Sim` into a library. - Move deterministic agent/environment/task orchestration out of `Sim` into a library.
- Keep HTTP, WebSocket serialization, wall-clock publication and process supervision in flysim. - Keep HTTP, WebSocket serialization, wall-clock publication and process supervision in flysim.
- Route internal worker calls and observations through Flybus; keep public compatibility
adapters at the application edge. Domain caches own artifact handles for replay.
- Give session clock, action executor, task ledger and recovery state explicit owners. - Give session clock, action executor, task ledger and recovery state explicit owners.
- Wrap the existing composition with legacy ordering, checkpoint and reset semantics. - Wrap the existing composition with legacy ordering, checkpoint and reset semantics.
- **Done:** headless bus client runs a session without Twitch/browser; the legacy composition - **Done:** headless consumer runs a session without Twitch/browser; the legacy composition
passes its traces and restore tests; a slow snapshot consumer cannot stall simulation. passes its traces and restore tests; a slow snapshot consumer cannot stall simulation.
### RUNTIME-03 — Add synchronized multi-agent sessions ### RUNTIME-03 — Add synchronized multi-agent sessions
@ -180,8 +146,6 @@ ENV-01 carries the build.
- **Branch:** `feat/multi-agent-broadcast` - **Branch:** `feat/multi-agent-broadcast`
- **Depends on:** WIRE-01, RUNTIME-03. - **Depends on:** WIRE-01, RUNTIME-03.
- Replace stage store/scaler singletons with session/agent instances and one paint scheduler. - Replace stage store/scaler singletons with session/agent instances and one paint scheduler.
- Combine framework descriptors/measurements with application-owned state/cues over the bus.
Rendering may retain an artifact after message drop; last-use release returns delivery credit.
- Resolve geometry from hashed descriptors, preserve the Game Boy presentation, and add a - Resolve geometry from hashed descriptors, preserve the Game Boy presentation, and add a
shared-match layout with explicit audio ownership. shared-match layout with explicit audio ownership.
- Route bridge commands/redemptions to persistent session/agent identities; test lost responses, - Route bridge commands/redemptions to persistent session/agent identities; test lost responses,
@ -207,8 +171,7 @@ ENV-01 carries the build.
- Choose the exact game/version and emulator; Melee/Dolphin is a candidate, not a commitment. - Choose the exact game/version and emulator; Melee/Dolphin is a candidate, not a commitment.
- Prove pause/step, simultaneous ports, analog input, frame/audio capture, state inspection, - Prove pause/step, simultaneous ports, analog input, frame/audio capture, state inspection,
save/restore, process lifecycle and achievable cadence with synthetic controller traces. save/restore, process lifecycle and achievable cadence with synthetic controller traces.
- Prefer a bus-connected helper if embedding would leak emulator internals into the session - Prefer private IPC if embedding would leak emulator internals into the session library.
library. Its native emulator protocol is an implementation detail, not a second framework API.
- **Done:** capability report includes pinned backend/content identity and reproducible results. - **Done:** capability report includes pinned backend/content identity and reproducible results.
If bounded stepping or coherent restore fails, stop and revise the backend/requirements If bounded stepping or coherent restore fails, stop and revise the backend/requirements
before writing neural game logic. No game content enters repository fixtures. before writing neural game logic. No game content enters repository fixtures.
@ -249,13 +212,10 @@ FOUNDATION-01 → FOUNDATION-02 ┬→ DATA-01 → DATA-02 ───────
second backend + presentation → PACKAGE-01 second backend + presentation → PACKAGE-01
``` ```
The item dependency lists are authoritative; the diagram is a reading aid. The detailed The item dependency lists are authoritative; the diagram is a reading aid.
contract guide adds BUS-01 (RPC), BUS-02 (pub/sub) and BUS-03 (artifacts/GC) before the
distributed RUNTIME-02/03 slices. These can proceed independently of MaleCNS import.
Start the preservation track at FOUNDATION-01; the generic bus track can start with the When implementation begins, take **FOUNDATION-01 only** as the first build task. Then review
contract guide's small RPC/pub-sub/artifact example. Then review FOUNDATION-02's contract FOUNDATION-02's contract before assigning DATA-01 and RUNTIME-01 to independent worktrees.
before assigning DATA-01 and RUNTIME-01 to independent worktrees.
Contract/schema authorship is serialized to avoid conflicting definitions. Deployment host Contract/schema authorship is serialized to avoid conflicting definitions. Deployment host
work remains serialized under the repository's claim protocol. work remains serialized under the repository's claim protocol.
@ -284,4 +244,4 @@ work remains serialized under the repository's claim protocol.
| Package publication versus monorepo reuse | PACKAGE-01 | Monorepo libraries/examples first; public package publishing later | | Package publication versus monorepo reuse | PACKAGE-01 | Monorepo libraries/examples first; public package publishing later |
There is no need to resolve these now to plan another feature. This backlog is ready for There is no need to resolve these now to plan another feature. This backlog is ready for
resumption at FOUNDATION-01 and the independent BUS-01..03 track. resumption at FOUNDATION-01.

View file

@ -21,12 +21,6 @@ deliverables, dependencies and completion criteria. Start at FOUNDATION-01 when
Concrete follow-up: [Melee emulator and multi-fly framework audit](melee-framework-audit.md), Concrete follow-up: [Melee emulator and multi-fly framework audit](melee-framework-audit.md),
including source-checked Dolphin/libmelee integration options and full-stack performance gates. including source-checked Dolphin/libmelee integration options and full-stack performance gates.
Implementation contracts: [session framework](session-framework/README.md), defining private
Flybus RPC/pub-sub, lockstep phases, worker methods, artifact ownership, recovery and publication.
The [bus specification](session-framework/bus-v1.md) is the selected communications design:
one small Rust router, external immutable artifacts and delivery-scoped GC. Application
orchestration/presentation are developed together; tournaments are examples, not framework types.
## 1. Recommendation ## 1. Recommendation
1. **Add MaleCNS as a dataset/profile combination, not a replacement neural model.** 1. **Add MaleCNS as a dataset/profile combination, not a replacement neural model.**
@ -354,9 +348,6 @@ optional backend requiring its own new-dataset equivalence/capacity gate, not as
- **Task:** interpretation of environment state: rewards, progress, episode endings, allowed - **Task:** interpretation of environment state: rewards, progress, episode endings, allowed
macro actions and recovery policy. Pokémon is a task, not an environment API. macro actions and recovery policy. Pokémon is a task, not an environment API.
- **Session:** one environment plus agents, port assignments, scheduler, task state and clocks. - **Session:** one environment plus agents, port assignments, scheduler, task state and clocks.
- **Application:** composes sessions/components and their presentation; owns supervision,
persistent identities/history, run/intervention rules and application-specific schemas.
- **Bus:** generic RPC/pub-sub routing and artifact ownership, with no game/simulation semantics.
- **Broadcast:** presentation of one or several sessions, plus chat and audience interactions. - **Broadcast:** presentation of one or several sessions, plus chat and audience interactions.
An agent ID is not a Twitch username, controller port, array position or dataset ID. Session, An agent ID is not a Twitch username, controller port, array position or dataset ID. Session,
@ -396,7 +387,6 @@ root, updating CI/build/golden paths in one dedicated change.
| `packages/brain` | Reference numerical behavior and legacy public facade | Existing package; keep imports compatible | | `packages/brain` | Reference numerical behavior and legacy public facade | Existing package; keep imports compatible |
| `crates/flybrain-core` | Rust numerical kernel, plasticity, generic population decoder | Existing `core/`; leave compatibility re-exports for presets | | `crates/flybrain-core` | Rust numerical kernel, plasticity, generic population decoder | Existing `core/`; leave compatibility re-exports for presets |
| `crates/fly-dataset` | Manifest validation, artifact loading, source-ID/index mapping | `core/src/dataset.rs`; retain legacy fingerprint implementation | | `crates/fly-dataset` | Manifest validation, artifact loading, source-ID/index mapping | `core/src/dataset.rs`; retain legacy fingerprint implementation |
| `crates/flybus` | One Rust RPC/pub-sub client/router, immutable artifact store, delivery guards and GC | New generic library; embedded router or small executable, no separate worker transport |
| `tools/datasets/{fafb,malecns}` | Source-specific conversion to common bundles | Existing Python builder plus new importer; existing CLI wrapper remains | | `tools/datasets/{fafb,malecns}` | Source-specific conversion to common bundles | Existing Python builder plus new importer; existing CLI wrapper remains |
| `crates/fly-session` | Agent ownership, clock coordination, action commit, event/reward routing | Orchestration extracted from `sim/src/simloop.rs` | | `crates/fly-session` | Agent ownership, clock coordination, action commit, event/reward routing | Orchestration extracted from `sim/src/simloop.rs` |
| `crates/fly-environment` | Backend capabilities, ports, observations, media, save-state interfaces | New small contract proven with binjgb and synthetic arena | | `crates/fly-environment` | Backend capabilities, ports, observations, media, save-state interfaces | New small contract proven with binjgb and synthetic arena |
@ -412,9 +402,7 @@ root, updating CI/build/golden paths in one dedicated change.
Use static Rust composition or a small closed registry initially, with trait boundaries at Use static Rust composition or a small closed registry initially, with trait boundaries at
backend/task seams. Do not require stable native dynamic-plugin ABI. An out-of-process backend/task seams. Do not require stable native dynamic-plugin ABI. An out-of-process
emulator helper implements the backend through Flybus RPC, using the same bus as application emulator can implement the backend through private IPC; it is not a new public action API.
events and publication. Native emulator protocols stay inside its adapter. This is not a
new public action API or a reason to maintain a second framework transport.
Keep backend-specific memory access private to its task implementation instead of widening Keep backend-specific memory access private to its task implementation instead of widening
`read8(u16)` into a supposedly universal game-state abstraction. `read8(u16)` into a supposedly universal game-state abstraction.
@ -548,14 +536,6 @@ clock may lead environment time by warm-up; persist that offset instead of prete
clocks start at zero. Rendering, physics and decision cadence may differ, but the backend clocks start at zero. Rendering, physics and decision cadence may differ, but the backend
must define their relationship. must define their relationship.
**Amendment, 2026-09-23 (operator decision of 2026-09-23).** "Preserve the detailed legacy
ordering inside the legacy single-agent composition" now means inside a legacy composition that
runs on the session framework's `lockstep-v1`, not beside it: the operator decided on a full port.
The detailed ordering is preserved because it is already the lockstep transaction order with one
agent ([legacy-gameboy-v1](session-framework/legacy-gameboy-v1.md) section 4), and "keep the
legacy floating remainder arithmetic" costs nothing, because the legacy `f64` frame constant is
exactly the rational Game Boy frame and the two accumulators are identical (section 3 there).
Start with sequential agent evaluation for reproducibility. Then compare parallel agent Start with sequential agent evaluation for reproducibility. Then compare parallel agent
evaluation against the same action trace. Cap total worker budget: `agents × brain_threads` evaluation against the same action trace. Cap total worker budget: `agents × brain_threads`
can otherwise oversubscribe the machine. Use private pools for concurrent agents or serialize can otherwise oversubscribe the machine. Use private pools for concurrent agents or serialize
@ -613,9 +593,7 @@ jobs so repeated copies cannot exhaust memory under slow storage.
Exact replay requires action-executor and admission state, not just the neural envelope. Exact replay requires action-executor and admission state, not just the neural envelope.
Legacy macros intentionally discard transient execution on restart; preserve that behavior Legacy macros intentionally discard transient execution on restart; preserve that behavior
for v1 and label it as legacy continuation semantics, not exact session replay. for v1 and label it as legacy continuation semantics, not exact session replay. New sessions
(*2026-09-23:* the label is `restore: legacy-transient-reset`, declared by the legacy
composition, [legacy-gameboy-v1](session-framework/legacy-gameboy-v1.md) section 14.) New sessions
persist all behavior-affecting state or explicitly restart an episode under a documented rule. persist all behavior-affecting state or explicitly restart an episode under a documented rule.
Keep `FLYSIM01` readable through a legacy adapter. Never silently rewrite a checkpoint on Keep `FLYSIM01` readable through a legacy adapter. Never silently rewrite a checkpoint on
@ -634,8 +612,6 @@ Extending those conventions to fighting games would preserve syntax while losing
Keep v1 stable for the legacy session. Introduce a negotiated v2 or a separate `/v2/feed` Keep v1 stable for the legacy session. Introduce a negotiated v2 or a separate `/v2/feed`
endpoint, with a descriptor delivered before dependent snapshots and available on reconnect. endpoint, with a descriptor delivered before dependent snapshots and available on reconnect.
This is the application's browser/presentation gateway over Flybus, not another internal
bus. Native participants use the same RPC/pub-sub protocol for all framework communication.
The contract change must update Rust, TypeScript, schema, fake service, fixture player, The contract change must update Rust, TypeScript, schema, fake service, fixture player,
stage and bridge together. Proposed shape: stage and bridge together. Proposed shape:
@ -667,12 +643,11 @@ on reset, reconnect and lag. Latest-value video/telemetry may drop, while audio
bounded timestamped buffer and explicit gap handling. Durable event IDs permit recovering bounded timestamped buffer and explicit gap handling. Durable event IDs permit recovering
missed events; a drop-oldest snapshot feed is not an exactly-once event log. missed events; a drop-oldest snapshot feed is not an exactly-once event log.
At 640×480 RGBA, native frame production is 36.864 MB/s at 30 Hz or 73.728 MB/s at 60 Hz. Bandwidth becomes an architectural concern: 640×480 RGBA at 30 Hz is ~36.9 MB/s before
Publish one immutable artifact and pass owned references through Flybus to all consumers. framing; 1920×1080 is ~248.8 MB/s. Do not copy full-resolution raw frames per fly across
Bytes stay outside router messages; producer/read/copy costs still need measurement. A several sockets. Start with one bounded broadcast-resolution local stream and separate
renderer retains its handle past message drop; cached RPC results and latest retention also agent input resolution; choose a compressed media transport only after measuring latency,
own data until release. Last-owner GC replaces coordinator-managed slots/reader acknowledgments. CPU/GPU cost and synchronization. A telemetry protocol need not become a video codec.
Resizing, overlays, codecs and streaming are presentation-layer choices, not bus requirements.
### 6.2 Stage composition ### 6.2 Stage composition
@ -688,11 +663,6 @@ audio source. A generic fallback shows status, media, controls and task labels w
inventing Pokémon counters. Unknown optional extensions can be omitted; unknown required inventing Pokémon counters. Unknown optional extensions can be omitted; unknown required
capabilities or mismatched descriptors must be visible rather than silently showing Pokémon. capabilities or mismatched descriptors must be visible rather than silently showing Pokémon.
Combine common framework measurements with application-owned state/events. Describe values
by owner, type, units, range and timestamp; distinguish zero, unknown, unsupported and stale.
Game-specific progress/collections remain validated schema extensions. The application defines
its supervisory/story behavior alongside its UI, not inside a mandatory generic Director service.
Keep UI runtime dependencies out of the numerical package. The current `@flybrain/brain` Keep UI runtime dependencies out of the numerical package. The current `@flybrain/brain`
view exports and optional Three.js peer can remain compatibility re-exports when viewer view exports and optional Three.js peer can remain compatibility re-exports when viewer
geometry/helpers move to a dedicated view module. Controller labels belong in controller geometry/helpers move to a dedicated view module. Controller labels belong in controller
@ -704,7 +674,7 @@ copy or a replacement for visual sign-off.
### 6.3 Audience interaction ### 6.3 Audience interaction
Keep Twitch authentication/EventSub and template-only replies in the application bridge. Extract a Keep Twitch authentication/EventSub and template-only replies in the bridge. Extract a
session-targeted interaction client with explicit `sessionId`, optional `agentId`, interaction session-targeted interaction client with explicit `sessionId`, optional `agentId`, interaction
kind and idempotency key. A multi-agent request with no target is rejected unless a fixed, kind and idempotency key. A multi-agent request with no target is rejected unless a fixed,
declared target policy exists; presentation focus must never choose the recipient. declared target policy exists; presentation focus must never choose the recipient.
@ -712,13 +682,10 @@ declared target policy exists; presentation focus must never choose the recipien
Service admission owns per-session, per-agent and global limits. A profile advertises its Service admission owns per-session, per-agent and global limits. A profile advertises its
supported stimulation capability; an agent without PAM support returns unsupported rather supported stimulation capability; an agent without PAM support returns unsupported rather
than pretending to accept “sugar.” `!stuck` becomes task-aware (ladder time versus round than pretending to accept “sugar.” `!stuck` becomes task-aware (ladder time versus round
time), while chat remains broadcast-scoped and independent of neural state. Future boons time), while chat remains broadcast-scoped and independent of neural state.
use task/backend-declared capabilities with explicit target, timing and outcome; gifts do
not automatically become earned learning rewards. These need separate capability/admission
contracts before enabling them, carried over the same bus rather than an arbitrary write API.
For redemptions, persist the resolved target and request identity before retrying. Distinguish For redemptions, persist the resolved target and request identity before retrying. Distinguish
an RPC timeout (HTTP on legacy v1) from a definite refusal: it may occur after the sim accepted the an HTTP timeout from a definite refusal: a timeout may occur after the sim accepted the
effect. V2 needs a durable or explicitly recoverable deduplication/status contract so bridge effect. V2 needs a durable or explicitly recoverable deduplication/status contract so bridge
restart cannot apply the same pulse twice or retarget a redemption to a new match. Do not restart cannot apply the same pulse twice or retarget a redemption to a new match. Do not
promise exactly-once behavior from the bridge intent log alone. Existing v1 remains as-is. promise exactly-once behavior from the bridge intent log alone. Existing v1 remains as-is.
@ -732,11 +699,9 @@ values outside this repository. Package viewer artifacts separately from the ful
graph so the browser does not need every edge. graph so the browser does not need every edge.
Preserve existing process isolation: sim, bridge, browser, capture, local relay and Twitch Preserve existing process isolation: sim, bridge, browser, capture, local relay and Twitch
push can restart independently. One coordinator/session with separate worker processes is push can restart independently. One process/session remains the default for fault isolation;
the default composition; a shared match remains one logical failure/recovery group across a multi-agent match stays one coordinated session, not one independently supervised service
those workers. A worker restart cannot silently rejoin. Router failure also invalidates its per port. Multi-session resource placement and broadcast composition are deployment concerns.
ephemeral handles/routes. Multi-session placement and presentation composition are application
deployment concerns; router scope sets an explicit shared-failure boundary.
Metrics distinguish session lag, environment step cost, per-agent step cost, barrier wait, Metrics distinguish session lag, environment step cost, per-agent step cost, barrier wait,
snapshot drops, audio discontinuities, checkpoint queue age and resource budget. Bound agent snapshot drops, audio discontinuities, checkpoint queue age and resource budget. Bound agent
@ -791,16 +756,15 @@ consumers. No estimate here assumes an emulator backend or biological mapping al
| --- | --- | --- | --- | | --- | --- | --- | --- |
| P0 — baseline | Trace fixtures and boundary tests around `Sim::step_frame`, restore, dataset loader, stage singleton behavior; current-state documentation inventory | None; reconcile open branches first | Current TS/Rust goldens and legacy API/feed fixtures pinned; behavior ledger distinguishes intentional transient reset from exact replay | | P0 — baseline | Trace fixtures and boundary tests around `Sim::step_frame`, restore, dataset loader, stage singleton behavior; current-state documentation inventory | None; reconcile open branches first | Current TS/Rust goldens and legacy API/feed fixtures pinned; behavior ledger distinguishes intentional transient reset from exact replay |
| P1 — identities | Dataset/profile manifest, role resolver, behavior hash, synthetic fixtures; new strict validators in both languages | P0 | Missing/ambiguous roles fail; wrong profile refuses restore; FAFB legacy fingerprints/version strings unchanged | | P1 — identities | Dataset/profile manifest, role resolver, behavior hash, synthetic fixtures; new strict validators in both languages | P0 | Missing/ambiguous roles fail; wrong profile refuses restore; FAFB legacy fingerprints/version strings unchanged |
| B1 — Flybus | Small Rust router/client with RPC, pub/sub, owned artifacts and GC; see BUS-01..03 in the contract implementation guide | Can start alongside P0/P1; no dataset/emulator dependency | Same semantics in-memory/Unix sockets; cache/retention holds safe; 480p three-reader test; no raw binary in message envelopes |
| M1 — MaleCNS import | `tools/datasets/malecns`, source lock, inventory/exclusion report, schema-compatible baseline bundle, attribution | P1 | Counts reconcile to selected inventory; reproducible output hashes; graph invariants and both loaders agree; no runtime downloads | | M1 — MaleCNS import | `tools/datasets/malecns`, source lock, inventory/exclusion report, schema-compatible baseline bundle, attribution | P1 | Counts reconcile to selected inventory; reproducible output hashes; graph invariants and both loaders agree; no runtime downloads |
| M2 — headless characterization | Profile-specific L1/sensor binding, role/readout audit, learning-off/on benches and new goldens | M1 | Stable/finite activity measured over repeated seeds; no silent empty populations; exact TS/Rust state agreement; unsupported inputs remain marked unsupported | | M2 — headless characterization | Profile-specific L1/sensor binding, role/readout audit, learning-off/on benches and new goldens | M1 | Stable/finite activity measured over repeated seeds; no silent empty populations; exact TS/Rust state agreement; unsupported inputs remain marked unsupported |
| M3 — task integration | Explicit MaleCNS profile selected by service config and matching stage assets, fresh state namespace | M2 and minimal descriptor/asset support from P5 | Fresh brain on audited game state; sugar capability verified; stage index/geometry identity correct; one-hour local soak plus restore drill; no automatic promotion over FAFB | | M3 — task integration | Explicit MaleCNS profile selected by service config and matching stage assets, fresh state namespace | M2 and minimal descriptor/asset support from P5 | Fresh brain on audited game state; sugar capability verified; stage index/geometry identity correct; one-hour local soak plus restore drill; no automatic promotion over FAFB |
| P2 — environment/task split | Environment contract, binjgb wrapper, task-specific inspector; facade for existing `flybrain-gb` imports | P1 | Legacy action/reward traces identical; ROM-free tests pass; optional ROM-backed sample confirms stepping/audio/state behavior | | P2 — environment/task split | Environment contract, binjgb wrapper, task-specific inspector; facade for existing `flybrain-gb` imports | P1 | Legacy action/reward traces identical; ROM-free tests pass; optional ROM-backed sample confirms stepping/audio/state behavior |
| P3 — session library | Single-agent session owns clocks, agent/task/executor state; service composes bus-connected workers | P2, B1 | Same legacy order and compatibility; renderer/bridge restart leaves run intact; fake backend runs without binjgb/ROM/Twitch | | P3 — session library | Single-agent session owns clocks, agent/task/executor state; service becomes transport/composition wrapper | P2 | Same legacy order and compatibility; renderer/bridge restart leaves run intact; fake backend runs without binjgb/ROM/Twitch |
| P4 — multi-agent + persistence | Two-agent synthetic arena, action barrier, isolated state, session envelope/recovery and failure behavior | P3 | One world step per batch; no port/order advantage; resume/parallel-vs-sequential equivalence; failure cannot partly commit a match | | P4 — multi-agent + persistence | Two-agent synthetic arena, action barrier, isolated state, session envelope/recovery and failure behavior | P3 | One world step per batch; no port/order advantage; resume/parallel-vs-sequential equivalence; failure cannot partly commit a match |
| P5 — feed/control v2 | Descriptor, scoped snapshots/events/media, targeted stimulation and idempotency, TS/Rust schema fixtures/fake server | P1, P3; validate with P4 fixture | V1 still works for legacy; multi-agent attachments cannot collide; unknown target/profile rejected; retry/reconnect tests pass | | P5 — feed/control v2 | Descriptor, scoped snapshots/events/media, targeted stimulation and idempotency, TS/Rust schema fixtures/fake server | P1, P3; validate with P4 fixture | V1 still works for legacy; multi-agent attachments cannot collide; unknown target/profile rejected; retry/reconnect tests pass |
| P6 — modular stage/bridge | Instantiable stores, dataset assets, shared/independent views, target-aware commands/redemptions | P4–P5 | PNG review for two-agent layout; browser legibility/fixture tests; correct audio ownership and no agent cross-talk | | P6 — modular stage/bridge | Instantiable stores, dataset assets, shared/independent views, target-aware commands/redemptions | P4–P5 | PNG review for two-agent layout; browser legibility/fixture tests; correct audio ownership and no agent cross-talk |
| E1 — new emulator spike | Select title/backend; bus-connected helper or native wrapper; record native frame, ports, inspection and restore capabilities | P2, can run beside P4–P6; framework integration uses B1 | Reliable bounded step + simultaneous controls, pinned content/backend identity, reproducible state round trip; stop before task implementation if unavailable | | E1 — new emulator spike | Select title/backend; private IPC or native wrapper; record frame, ports, media, inspection and restore capability matrix | P2, can run beside P4–P6 | Reliable bounded step + simultaneous controls, pinned content/backend identity, reproducible state round trip; stop before task implementation if unavailable |
| E2 — fighting-game vertical slice | Two flies, chosen game task, analog/digital readout, episode logic, attributed rewards, match view | E1, P4–P6 | Repeated local matches and side swaps; measured compute headroom; documented scaffold and interventions; win-rate claims require controls | | E2 — fighting-game vertical slice | Two flies, chosen game task, analog/digital readout, episode logic, attributed rewards, match view | E1, P4–P6 | Repeated local matches and side swaps; measured compute headroom; documented scaffold and interventions; win-rate claims require controls |
| P7 — packaging/reorg | Optional root Rust workspace move, library consumers, profile-based release/asset manifests, updated infra and docs | Useful second backend + P6 | Four merge suites and affected browser gates pass; old composition deploys locally; preflight rejects incompatible multi-agent state | | P7 — packaging/reorg | Optional root Rust workspace move, library consumers, profile-based release/asset manifests, updated infra and docs | Useful second backend + P6 | Four merge suites and affected browser gates pass; old composition deploys locally; preflight rejects incompatible multi-agent state |

View file

@ -6,12 +6,6 @@ Status: **research and proposed implementation plan**. Written 2026-09-18 agains
Existing [feed](../feed-protocol.md) and [control](../control-api.md) contracts still win. Existing [feed](../feed-protocol.md) and [control](../control-api.md) contracts still win.
No emulator, game image, deployment host or live broadcast was run for this audit. No emulator, game image, deployment host or live broadcast was run for this audit.
Follow-on [session framework contracts](session-framework/README.md) define the concrete
multi-process synchronization and worker interfaces this backend will implement.
The subsequent [Flybus decision](session-framework/bus-v1.md) selects one small Rust router
for RPC and pub/sub, with immutable artifacts outside messages and delivery-scoped GC.
The application owns supervisory/presentation behavior; the bus owns no game or stream logic.
## 1. Executive decision ## 1. Executive decision
**Use Dolphin, initially a pinned mainline-based Slippi Dolphin build, as a separate backend **Use Dolphin, initially a pinned mainline-based Slippi Dolphin build, as a separate backend
@ -211,29 +205,26 @@ does not inspect the host or establish that it can run two brains plus Dolphin i
### 5.1 Four runtime components, not one enormous adapter ### 5.1 Four runtime components, not one enormous adapter
```text ```text
Application / supervisory logic Presentation / recording private backend protocol
\ / Rust session process <----------------------------------------------> backend helper
Flybus RPC + pub/sub session clock / port ownership Python + libmelee initially
/ | \ N agent states owns Dolphin process/user dir
Session coordinator Agent workers Backend helper sensory encoders │
clock / barrier brain / encoder Python + libmelee initially fixed readouts ├─ all controller pipes
task + executors fixed readout owns Dolphin process/user dir task events / reward router ├─ telemetry/parser
episode policy native pipes, parser, media/state hooks episode policy / checkpoints └─ media + state hooks
\ | / │ │
owned artifact references ├─ feed/control adapters Dolphin
| └─ durable event/checkpoint store one local match
immutable local store │
stage/compositor → capture → local relay → optional Twitch push
Presentation owns stage/compositor → capture → local relay → optional Twitch push.
``` ```
The helper is an **internal backend implementation**, not an audience-accessible controller The helper is an **internal backend implementation**, not an audience-accessible controller
service. The session remains the sole authority assigning actions to ports. Python never service. The session remains the sole authority assigning actions to ports. Python never
simulates the neurons or selects actions. Keep it if measurements say its overhead is small; simulates the neurons or selects actions. Keep it if measurements say its overhead is small;
replace its internals with Rust only when an actual bottleneck or maintenance replace its internals with Rust/native IPC only when an actual bottleneck or maintenance
requirement justifies it. requirement justifies it.
Its Flybus binding uses the same protocol as every component. Dolphin-specific input pipes
stay behind the environment adapter, not a second framework communications system.
An external emulator is a normal `Environment` implementation, not a special `if melee` An external emulator is a normal `Environment` implementation, not a special `if melee`
branch sprinkled throughout the session. For Game Boy, the same interface has an in-process branch sprinkled throughout the session. For Game Boy, the same interface has an in-process
@ -247,12 +238,11 @@ need to know which one owns the world.
| `Brain` / numerical core | Tick semantics, state, spikes, rates, learning updates | Game, process, controller labels or viewer | | `Brain` / numerical core | Tick semantics, state, spikes, rates, learning updates | Game, process, controller labels or viewer |
| `SensorEncoder` | Declared observation→neural drive transform | Reward inspector state not declared as input | | `SensorEncoder` | Declared observation→neural drive transform | Reward inspector state not declared as input |
| `Readout` | Fixed rate/signal→control-channel mapping | Opponent strategy, game addresses or pathfinding | | `Readout` | Fixed rate/signal→control-channel mapping | Opponent strategy, game addresses or pathfinding |
| `ActionExecutor` | Selected action + coherent game state + task progress + clock → controller state | Authority to invent a winning action when brain is silent | | `ActionExecutor` | Selected action→controller state; optional declared macro lifetime | Authority to invent a winning action when brain is silent |
| `Environment` | Native clock, port schema, action commit, observations/media, snapshot capabilities | Neural roles, Twitch or task reward weights | | `Environment` | Native clock, port schema, action commit, observations/media, snapshot capabilities | Neural roles, Twitch or task reward weights |
| `Task` | Typed state interpretation, rewards, progress and episode outcomes | Direct neural mutation or direct controller writes | | `Task` | Typed state interpretation, rewards, progress and episode outcomes | Direct neural mutation or direct controller writes |
| `EpisodePolicy` | Start/end/reset/recovery semantics | Hidden per-player rewind in a shared world | | `EpisodePolicy` | Start/end/reset/recovery semantics | Hidden per-player rewind in a shared world |
| `Session` | Barrier, identity, agent isolation, routing, state capture and supervision | Melee memory offsets or Pokémon map IDs | | `Session` | Barrier, identity, agent isolation, routing, state capture and supervision | Melee memory offsets or Pokémon map IDs |
| `Flybus` | RPC/pub-sub routing, bounded deliveries, artifact ownership and GC | Game timing, neural roles, presentation/stream semantics |
| `Presentation` | Descriptor-driven layout, media/audio and task panels | Emulator stepping or access to controller pipes | | `Presentation` | Descriptor-driven layout, media/audio and task panels | Emulator stepping or access to controller pipes |
Use modules first, then crates/packages as second consumers appear. The existing monorepo Use modules first, then crates/packages as second consumers appear. The existing monorepo
@ -264,11 +254,9 @@ implementation, a task/profile and a composition manifest, not edits to the sess
neural core, protocol enums or generic stage store. A game-specific presentation plugin is neural core, protocol enums or generic stage store. A game-specific presentation plugin is
allowed. Wire extensions must be namespaced/schema-validated, not hardcoded into every panel. allowed. Wire extensions must be namespaced/schema-validated, not hardcoded into every panel.
### 5.3 Backend RPCs over the common bus ### 5.3 Minimal backend IPC
Use Flybus for these conceptual capabilities; do not implement another socket/message Specify and test a small private protocol before implementing a network-shaped abstraction:
envelope. Exact method names/bodies are in the session-framework contracts. Pause/Resume
are session lifecycle intents; an environment adapter holds its boundary between Advances.
```text ```text
Hello → backend build/content/patch identity, capabilities, cadence, ports, views Hello → backend build/content/patch identity, capabilities, cadence, ports, views
@ -286,12 +274,11 @@ Only advertise `Capture/Restore` if implemented and tested. Otherwise expose an
Port batches use canonical buttons plus sticks in [-1,1] and triggers in [0,1], converted Port batches use canonical buttons plus sticks in [-1,1] and triggers in [0,1], converted
once by the backend. Descriptor/schema versions pin conversions and active ports. once by the backend. Descriptor/schema versions pin conversions and active ports.
Commands and pub/sub envelopes contain metadata and ArtifactRefs; native media/checkpoint Use a local framed socket for commands/small observations; use a bounded shared-memory ring
bytes live in Flybus's managed immutable store. Include producing epoch/frame/sample identity or equivalent for large media. Include epoch, frame/sample identity, dimensions, format and
and format in domain descriptors. DeliveryGuard keeps bytes alive beyond message drop if an generation on media references. Release/acquire ownership and slot lifetime prevent the
encoder/renderer retains a handle. Cached RPC replies own handles for retry; last-owner GC backend overwriting a sensory buffer while an encoder reads it. Reconnection invalidates old
reclaims data. Start file-backed; pooled shared-memory reuse is an optional later optimization. handles; a stale frame must not be silently accepted because its byte length matches.
Router restart invalidates routes/handles and requires coherent session recovery.
One request in flight, explicit timeouts, bounded queues. After an uncertain `Advance` One request in flight, explicit timeouts, bounded queues. After an uncertain `Advance`
response, do **not** resend blindly: the world may already have advanced. Resolve batch ID/ response, do **not** resend blindly: the world may already have advanced. Resolve batch ID/
@ -310,8 +297,7 @@ both the simulation and pending input consumption have known state.
Two flies in Melee normally means two controller ports in **one Dolphin instance**. Four Two flies in Melee normally means two controller ports in **one Dolphin instance**. Four
flies means four ports, not four copies of Melee joined through netplay. Several independent flies means four ports, not four copies of Melee joined through netplay. Several independent
matches are separate sessions/process trees, orchestrated by application code developed with matches are separate sessions/process trees and can share one broadcast director.
its presentation. A tournament director is one example, not a mandatory framework service.
For each committed boundary: For each committed boundary:
@ -451,7 +437,7 @@ rounds is a run policy. Competitive success is not guaranteed by increased model
For a lockstep pixel-fed match, approximate the critical wall-time interval as: For a lockstep pixel-fed match, approximate the critical wall-time interval as:
```text ```text
T_step = T_brains + T_readout/task + T_bus_RPC T_step = T_brains + T_readout/task + T_controller_IPC
+ T_emulation_to_observation + T_required_render_readback + T_boundary_overhead + T_emulation_to_observation + T_required_render_readback + T_boundary_overhead
T_brains ≈ sum(T_agent_i) [sequential evaluation] T_brains ≈ sum(T_agent_i) [sequential evaluation]
@ -519,18 +505,17 @@ advertises actual dimensions/format/aspect. One shared camera is delivered once
match; two flies can sample one immutable image without duplicating its transport. If their match; two flies can sample one immutable image without duplicating its transport. If their
sensor transforms differ, encode separately against the same source frame. sensor transforms differ, encode separately against the same source frame.
Start with native frames as immutable artifacts: 480p is not an automatic reason for a codec Prefer reducing/downsampling the sensory copy close to the renderer, ideally before GPU
or second transport. Flybus carries handles only; multiple readers map the same object. Measure readback, while preserving a separately timestamped spectator view. Benchmark against an
renderer readback, optional seal copy and consumer reads separately from router overhead. ordinary CPU path before adding device-buffer interop. A shared-memory ring removes socket
Sensor downsampling remains a declared profile operation (optionally optimized near capture copies, not the GPU fence/readback itself. Slow viewers may drop frames; required sensory
after measurement). Viewer scaling/composition/encoding belongs to presentation. Last-use frames may not disappear silently from the neural run.
handles, not receipt acknowledgments, govern GC; required sensory input cannot be coalesced.
### 8.4 Benchmark ladder and decision records ### 8.4 Benchmark ladder and decision records
| Run | Configuration | Question / recorded output | | Run | Configuration | Question / recorded output |
| --- | --- | --- | | --- | --- | --- |
| B0 | Synthetic two-port backend plus common Flybus, no neurons | Routed RPC latency, pub/sub fairness, step barrier, timeout and artifact-GC behavior | | B0 | Synthetic two-port backend, no neurons | IPC latency, one-step semantics, barriers, timeouts, media-buffer ownership |
| B1 | Dolphin with fixed input traces, rendering/audio on, no brains | Cold/warm emulator cost, step timing, port alignment, render-to-state latency | | B1 | Dolphin with fixed input traces, rendering/audio on, no brains | Cold/warm emulator cost, step timing, port alignment, render-to-state latency |
| B2 | Same run + helper/media extraction | Incremental parsing, copying, downsampling and audio cost | | B2 | Same run + helper/media extraction | Incremental parsing, copying, downsampling and audio cost |
| B3 | One FAFB brain | End-to-end reference and per-phase costs | | B3 | One FAFB brain | End-to-end reference and per-phase costs |
@ -549,15 +534,12 @@ and non-identifying results. No measurements were performed by this document-wri
### 9.1 Two viable routes ### 9.1 Two viable routes
Both routes are **application/presentation implementations**, not additional framework buses. **Route A — stage receives game media.** Closest to the current architecture: backend emits
The environment emits native frame/audio artifacts through Flybus in either case. pixels/audio, stage composites game and overlays, ffmpeg captures the page. Start the local
prototype with bounded lower-resolution raw frames to validate semantics. If bandwidth and
**Route A — stage receives game media.** A presentation gateway subscribes to native artifacts, copying dominate, add a compressed local media track (for example WebRTC) while telemetry
delivers them to the browser, and the stage composites game/overlays for capture. Start with remains on the feed. Avoid encode→decode→encode unless its measured simplicity/latency tradeoff
native uncompressed artifacts internally. Browser-edge delivery can remain raw or use a codec is acceptable. Browser frame presentation timestamps must align overlays with displayed video.
after measurement; avoid encode→decode→encode unless its tradeoff is justified. The gateway
holds artifacts through conversion/use and then releases them; browser backpressure cannot
pin required sensory state without a bound. Overlay timestamps track displayed video.
**Route B — compositor combines native game output and stage overlay.** Dolphin supplies its **Route B — compositor combines native game output and stage overlay.** Dolphin supplies its
rendered output to a compositor; the browser supplies a separate overlay surface. This can rendered output to a compositor; the browser supplies a separate overlay surface. This can
@ -569,8 +551,7 @@ which image a brain used at a given game boundary.
**Recommendation:** prototype Route A for the two-player local slice; benchmark Route B in **Recommendation:** prototype Route A for the two-player local slice; benchmark Route B in
the media spike before committing to the long-run high-resolution pipeline. Preserve media the media spike before committing to the long-run high-resolution pipeline. Preserve media
as a capability behind the environment interface so the choice does not change brain/task code. as a capability behind the environment interface so the choice does not change brain/task code.
The browser-facing v2 contract can reference media streams; it does not dictate the internal The v2 protocol should be able to reference media streams, not mandate all video as WS RGBA.
bus, native sensor format or artifact-store implementation.
### 9.2 Audio and clock policy ### 9.2 Audio and clock policy
@ -662,7 +643,7 @@ Melee-specific spikes can start before the full framework reorganization is fini
| **MELEE-01: backend selection spike** | Specializes EMULATOR-01. Pin mainline Slippi + maintained libmelee, content and Gecko codes; use isolated user config and two synthetic controllers | Boot/render/audio; block one then both ports; one batch/frame mapping; menu→match→results lifecycle; cleanup/restart. Choose this build or stock Dolphin + narrow hook based on results | | **MELEE-01: backend selection spike** | Specializes EMULATOR-01. Pin mainline Slippi + maintained libmelee, content and Gecko codes; use isolated user config and two synthetic controllers | Boot/render/audio; block one then both ports; one batch/frame mapping; menu→match→results lifecycle; cleanup/restart. Choose this build or stock Dolphin + narrow hook based on results |
| **MELEE-02: capture/restore spike** | Alongside MELEE-01; prove sensory-frame identity, media export and save/load acknowledgment independently | Fixed pixel↔telemetry latency, bounded media storage, correct input after restore, parser/cache recovery. Explicit decision: exact resume or prototype-only episode restart | | **MELEE-02: capture/restore spike** | Alongside MELEE-01; prove sensory-frame identity, media export and save/load acknowledgment independently | Fixed pixel↔telemetry latency, bounded media storage, correct input after restore, parser/cache recovery. Explicit decision: exact resume or prototype-only episode restart |
| **MELEE-03: task observation audit** | Pin decomp; build field catalog, typed parser/inspector, lifecycle and synthetic event fixtures | Verified port/player/sub-fighter mapping; stocks/results; no guessed rewards; content/patch mismatches visibly disable unsupported semantic interpretation | | **MELEE-03: task observation audit** | Pin decomp; build field catalog, typed parser/inspector, lifecycle and synthetic event fixtures | Verified port/player/sub-fighter mapping; stocks/results; no guessed rewards; content/patch mismatches visibly disable unsupported semantic interpretation |
| **FRAMEWORK-01: generic backend + session** | Existing FOUNDATION-01/02, BUS-01..03 and RUNTIME-01/02; all workers/application events use Flybus | Same legacy Game Boy traces; synthetic environment uses identical session API; artifact-backed requests/results and no Melee logic in router/core | | **FRAMEWORK-01: generic backend + session** | Existing FOUNDATION-01/02 and RUNTIME-01/02; add the private IPC implementation behind `Environment` | Same legacy Game Boy traces; headless synthetic environment uses identical session API; no Melee branches in core loop |
| **FRAMEWORK-02: multi-agent and state** | Existing RUNTIME-03/STATE-01; integrate complete action batches, worker budget and chosen backend recovery capability | No cross-agent state leakage; one world step; changed evaluation order invariant; failed restore cannot partly install a match | | **FRAMEWORK-02: multi-agent and state** | Existing RUNTIME-03/STATE-01; integrate complete action batches, worker budget and chosen backend recovery capability | No cross-agent state leakage; one world step; changed evaluation order invariant; failed restore cannot partly install a match |
| **MELEE-04: fixed readout and sensory profile** | MELEE-01/02 + framework boundary; TS specification then Rust implementation for any new decoder semantics | Neutral/release, analog conversion, tap/hold/direction combinations, aspect-preserved neural input and recorded latency; no hidden combo/aim policy | | **MELEE-04: fixed readout and sensory profile** | MELEE-01/02 + framework boundary; TS specification then Rust implementation for any new decoder semantics | Neutral/release, analog conversion, tap/hold/direction combinations, aspect-preserved neural input and recorded latency; no hidden combo/aim policy |
| **MELEE-05: first two-fly match** | MELEE-03/04 + FRAMEWORK-02; learning off, then audited positive rewards | Recorded action/observation timelines; paired side/seed trials; match terminal deduplication and visible reset/failure semantics | | **MELEE-05: first two-fly match** | MELEE-03/04 + FRAMEWORK-02; learning off, then audited positive rewards | Recorded action/observation timelines; paired side/seed trials; match terminal deduplication and visible reset/failure semantics |
@ -706,7 +687,7 @@ synthetic schemas and independently authored tests in the repository.
character/stage changes, wrong game revision, changed patch/parser normalization. character/stage changes, wrong game revision, changed patch/parser normalization.
- **Events:** multi-hit, trade, self-damage, projectile ownership, stock reset, simultaneous - **Events:** multi-hit, trade, self-damage, projectile ownership, stock reset, simultaneous
KO, timeout, sudden death, results re-entry, disconnect, restart after accepted reward. KO, timeout, sudden death, results re-entry, disconnect, restart after accepted reward.
- **Clocks/media:** game-frame reset, renderer lag, stale artifact/store identity, last-use GC, dropped - **Clocks/media:** game-frame reset, renderer lag, stale shared-memory generation, dropped
spectator frame versus required sensory frame, paused audio, mismatched overlay timestamps. spectator frame versus required sensory frame, paused audio, mismatched overlay timestamps.
- **Recovery:** all-agent atomic validation, one corrupt state chunk, backend import failure, - **Recovery:** all-agent atomic validation, one corrupt state chunk, backend import failure,
helper parser not reinitialized, asynchronous save completion, hot-store coalescing and helper parser not reinitialized, asynchronous save completion, hot-store coalescing and

View file

@ -1,156 +0,0 @@
# Application and session framework: architecture and contracts
Status: **implementation specification, draft 2**, 2026-09-18. This is a guide for future
agents; none of the new runtime is implemented yet. Baseline code is `83090a9` on
`docs/improvement-suggestions`. MUST/SHOULD requirements apply to the proposed new path, not
retroactively to existing [public feed](../../feed-protocol.md),
[control API](../../control-api.md), or legacy numerical/checkpoint behavior.
## Decisions from the architecture discussion
1. **Applications orchestrate components and develop their presentation alongside them.**
“Director” is application code, not a mandatory framework service. A tournament is an
example application, not the system's organizing data model.
2. **One lightweight Rust bus supports RPC and pub/sub everywhere internally.** Flybus replaces
separate direct worker transports and an application broker. No NATS dependency.
3. **Messages stay small; large artifacts live in managed storage.** Delivery guards and
explicit cache/retention owners keep data alive until its last actual use, then GC reclaims it.
4. **The router moves messages and tracks generic ownership.** It never schedules game frames,
understands macro actions, composites video or operates a stream.
5. **Sessions synchronize worlds; agent workers compute in parallel.** One logical clock is
not one execution thread. The coordinator alone commits complete world-control batches.
6. **Game-aware executors receive current game state and task progress.** Rich inspection data
does not become undeclared neural input.
7. **Native observations are framework outputs.** Resizing, overlays, browser delivery, audio
mixing, encoding, narration and streaming belong to the application/presentation layer.
## Read in this order
1. [Flybus v1](bus-v1.md) — authoritative wire/routing/RPC/pub-sub/artifact lifecycle contract.
2. [Session RPCs](ipc-v1.md) — domain payloads, worker capability negotiation and safe retries.
3. [Step protocol](step-v1.md) — session state machine, ordering and clocks.
4. [Worker/task interfaces](workers-v1.md) — exact method bodies and game-aware executor boundary.
5. [Session media/state](state-media-v1.md) — observation timing and coherent recovery.
6. [Application/presentation boundary](publishing-v1.md) — snapshots, flexible data and effects.
7. [Implementation guide](implementation.md) — sequenced build tasks and acceptance tests.
8. [Flybus conformance report](bus-conformance.md) — the `flybus` crate audited sentence by
sentence against bus-v1, with the test that proves each row, the measurements and the
draft's own contradictions. A review artifact, not a contract.
Two derived specifications, written by CONTRACT-01 because the slices that need them cannot
be built without them:
- [Seed derivation v1](seed-derivation-v1.md) — independent per-agent seeds from one recorded
master seed and stable agent ids, with test vectors in both languages.
- [Checkpoint envelope v1](checkpoint-envelope-v1.md) — the exact bytes of the new `FLYSESS1`
envelope and the durable commit sequence. `FLYSIM01` is unchanged and stays separately
readable.
One contract added by the 2026-09-23 amendments (PROF-02a, RT-01a), after the operator decided
to port the live fly onto this framework:
- [Legacy Game Boy composition v1](legacy-gameboy-v1.md) — the legacy profile
`gameboy-legacy-fafb-v783-v1`, its readout context and decision schemas, the memory-image
inspection, the `pokered-macros-v1` executor, the `gameboy-slots-v1` environment extension,
the `legacy-ratchet-rollback-v1` episode policy, `legacy-transient-reset` restore semantics and
the composition digest. MaleCNS bundles (PROF-02b) come later.
For context: [modular-session analysis](../malecns-modular-sessions.md) and
[Melee audit](../melee-framework-audit.md). Each contract owns its named subject; step ordering
wins over an informal diagram, and Flybus owns transport/resource rules. Resolve contradictions
before implementation. The [existing HTML report](report.html) is an overview, not a contract.
## 1. Composition and processes
```text
Application / supervisor Application presentation
run policies, identity, history UI, media composition, audience, stream
\ /
Flybus: RPC + pub/sub
/ | \
Session Agent workers Environment worker
coordinator brain + encoder emulator/world + native observations
task/executors + fixed readout
|
artifact store (same bus API)
```
This diagram is connectivity, not execution order. The step contract defines causal order.
One router can host several independent sessions and application consumers; a deployment may
choose one router per application for fault isolation. A router crash affects all its clients,
so choose that boundary deliberately. In-process mode still exercises routing and ownership.
Defaults: coordinator per session, worker per fly, environment worker per world. Task and
per-agent executors begin as coordinator-local libraries. An environment helper may own a
separate emulator child process and adapt its native protocol. There is no second framework
socket/lease API between those logical components.
Multiple players in one game share one environment/barrier. Independent games use independent
sessions. Linked emulators require a composite backend with link-appropriate timing.
## 2. Ownership and authority
| Owner | State and responsibility |
| --- | --- |
| Application | Composition, persistent personas/brain lineage, lifecycle policies, supported interventions, application schema/history |
| Session | Clock/epoch, port assignments, admission, task ledger, executor state, barriers and coherent recovery |
| Agent | Private membrane, RNG, rates, learning, sensory encoding, decoder and tick remainder |
| Environment | World, actual controller application, backend parser, native media and state capabilities |
| Flybus | Opaque endpoint/topic routing, delivery/call correlation, bounded queues, artifact-owner graph and GC |
| Storage client | Durable event/checkpoint writes and replay APIs; owns artifact handles during writes |
| Presentation | Application UI, display focus, clocks/buffers, media processing, audio/stream output and narrative cues |
Immutable graph data can be shared; neural mutable state cannot. Do not concurrently dispatch
the existing single-job WorkerPool through cloned handles from different brains.
Applications use declared session capabilities, not arbitrary emulator writes. Bus registration
and method privileges preserve a single controller authority for a session. Browser/audience
clients do not gain controller access by knowing a service name. Existing public rules remain.
## 3. Domain independence
The kernel knows no task or bus. The environment knows no neuron populations. A task interprets
game state and requests outcomes/recovery; its executor translates a selected decision using
read-only current game/progress context. The coordinator orders and applies these results.
The bus handles no such semantics. Presentation combines framework observations with an
application-owned schema and can change without changing simulation behavior.
Persistent AssetRefs identify installed release content. Transient Flybus ArtifactRefs identify
live bytes with ownership. Domain epoch/step identity and bus route/store incarnation are
different: the first protects simulation order, the second protects delivery/resource validity.
Never substitute game-frame number, persona identity or array position for either.
## 4. Initial scope and compatibility
First build a generic bus example (RPC + pub/sub + artifact retained beyond message lifetime),
then a synthetic two-agent session using it. One local machine, Unix sockets/in-memory parity,
immutable file-backed artifacts, fixed cadence, 1-ms LIF, direct control and exact-checkpoint
synthetic backend are sufficient. Dolphin, MaleCNS and richer effects are later integrations.
Deferred: cross-machine artifact access, durable broker queues, wildcard/queue-group routing,
dynamic native plugins, hot-join, speculative netplay rollback and pooled GPU buffers.
Keep legacy-gameboy-v1 distinct from lockstep-v1. Preserve TypeScript as oracle, existing default
versions, historical arithmetic/fingerprints and FLYSIM01 reader. New identities include sensor,
readout/executor/task/scheduler semantics. New public feed v2 is an application/presentation
gateway contract built on the same internal bus; it does not replace the bus or expose it raw.
**Amendment, 2026-09-23 (operator decision of 2026-09-23).** "Keep legacy-gameboy-v1 distinct
from lockstep-v1" is superseded for scheduling. The operator decided on a **full port** of the
live fly onto the session framework: the legacy composition runs under `lockstep-v1` with one
agent, one port and one world, declared as [legacy-gameboy-v1](legacy-gameboy-v1.md). What the
sentence protected is kept, and now written down rather than implied: the legacy frame order is
this framework's transaction order (legacy-gameboy-v1 section 4); the historical fingerprint,
the default version strings and the compatibility string are unchanged and embedded, not
recomputed; the legacy clock is proven identical to the rational one (section 3); `FLYSIM01`
stays the format of record and its reader untouched until RETIRE-01; and the legacy restore and
rollback semantics are declared (`legacy-transient-reset`, `legacy-ratchet-rollback-v1`) rather
than approximated. The new identity covers what the old string never did: readout context,
decision, decoder configuration and macro channels, executor, slots and restore semantics.
## 5. Reuse criterion
Adding a third environment/application requires a backend, task/profile, composition and
application presentation. It must not require game-specific edits to the coordinator, router,
artifact manager, kernel, generic stores or transport. Schematized task extensions are valid;
an unchecked data blob or universal tournament schema is not a substitute for interfaces.

View file

@ -1,516 +0,0 @@
# Flybus v1 conformance report: the `flybus` crate against `bus-v1`
Status: **audit, 2026-09-22**. Subject: `services/flysim/crates/flybus`, the replayed
implementation of [Flybus v1](bus-v1.md) (draft 1, 2026-09-18), with no consumers yet. Scalar
encodings come from [ipc-v1](ipc-v1.md) sections 1 to 3. Acceptance lists come from the
[implementation guide](implementation.md) slices BUS-01, BUS-02 and BUS-03.
Every normative sentence of bus-v1 sections 2 to 11 gets a row. Four statuses:
| Status | Meaning |
| --- | --- |
| conforms | The implementation does what the sentence requires, and a test proves it. |
| deviates-allowed | It differs, and a quoted sentence of the draft permits the difference. |
| deviates-must-fix | It differs and the draft requires otherwise. |
| not-implemented | Not built yet; the row names who owns it. |
Counts over 195 rows: **conforms 178, deviates-allowed 9, deviates-must-fix 1 (fixed),
not-implemented 7**. The audit found the one deviates-must-fix — connection teardown could be
starved for the length of a whole frame by the writer it was waiting for — and it is fixed on
this branch, so the row for it now reads conforms and records the fix (section 9, "cannot be
starved"). Two contradictions inside the draft are recorded at the end and left alone.
Test names below are the functions in `services/flysim/crates/flybus/tests`, 239 of them in
this branch (`cargo test -p flybus`), plus the ignored measurement. Everything marked
"(both)" is generated twice by the `both_transports!` macro, once over the in-memory transport
and once over a Unix socket, so `tests/rpc.rs::request_reply_roundtrip` means
`in_memory::request_reply_roundtrip` and `unix_socket::request_reply_roundtrip`.
## 2. Client API
| Requirement | Status | Code | Test |
| --- | --- | --- | --- |
| One client/connection serves RPC, pub/sub and artifacts | conforms | `client/mod.rs::Client` | `tests/integration.rs::session_over_one_router` (both) |
| The illustrative surface (`connect`, `register`, `call`, `subscribe`, `publish`, `artifacts().allocate`, `seal`, `message.artifact`) | deviates-allowed: "Illustrative Rust surface (not yet implemented)"; `connect` takes the transport, `call` takes no `budget`, `next()` yields `Option` | `client/mod.rs`, `client/handles.rs` | `tests/rpc.rs`, `tests/pubsub.rs`, `tests/artifacts.rs` |
| The artifact store is a storage backend of the bus, not another messaging service | conforms | `store.rs`, reached only through `Artifacts`/`Artifact` | `tests/artifacts.rs::allocate_write_seal_read` (both) |
| Bulk data does not pass through router socket payloads; no separate data-transfer API | conforms: attachments carry `ArtifactRef` only; envelopes are capped at 64 KiB | `wire.rs::ArtifactRef`, `wire.rs::MAX_ENVELOPE_BYTES` | `tests/wire.rs::envelope_size_limits` (both), `tests/perf.rs` |
| `Artifact` is a read-only, cloneable handle | conforms | `client/handles.rs::Artifact` (no write API, `#[derive(Clone)]`) | `tests/conformance_artifacts.rs::extracted_artifact_outlives_the_message_it_came_from` (both) |
| `ArtifactWriter` is unique, not cloneable; sealing consumes its writable lifetime | conforms | `client/handles.rs::ArtifactWriter::seal_with_digest(mut self)` | `tests/artifacts.rs::seal_is_immune_to_live_writable_handles` (both) |
| Mapped slices cannot outlive their handle | conforms by construction: there is no mapping API; `ArtifactFile` owns its `Artifact` | `client/handles.rs::ArtifactFile` | `tests/bus_acceptance.rs::disconnect_releases_logical_ownership_without_mutating_open_bytes` (both) |
| Rust RAII automates releases | conforms | `client/handles.rs::OwnerGuard::drop` | `tests/artifacts.rs::fan_out_shares_one_object_and_the_last_consumer_collects` (both) |
| Other language bindings provide equivalent explicit close/context-manager behaviour | not-implemented (Rust only; section 1 says a binding "may" exist). Owner: a future binding | — | — |
| Garbage collection means reclaiming an unowned artifact, not inspecting game state | conforms | `router/state.rs::drop_roots` | `tests/conformance_artifacts.rs::collection_waits_for_every_retained_owner` (both) |
## 3. Addressing and identities
| Requirement | Status | Code | Test |
| --- | --- | --- | --- |
| Every identity of the table exists: `routerId`, `clientId`/`clientIncarnation`, `connectionId`, `service`/`serviceIncarnation`, `callId`, `topic`/`topicIncarnation`/`topicSequence`, `deliveryId`, `artifactId`/`generation`, `ownerId` | conforms | `router/mod.rs::fresh_tag`, `router/state.rs` (`serial_id` for `conn`/`svc`/`top`/`sub`/`dlv`/`own`/`a`) | `tests/conformance_wire.rs::hello_reports_the_contract_digest_and_valid_limits` (both), `tests/rpc.rs::registration_is_exclusive_and_pinned` (both) |
| A fresh `routerId`/`storeId` per incarnation; old handles fail after restart | conforms | `router/mod.rs::Router::new` | `tests/artifacts.rs::router_restart_invalidates_old_handles` (both) |
| Reconnect creates a new `clientIncarnation`; v1 does not resume a connection's queues or delivery owners | conforms | `router/state.rs::hello` refuses a reused incarnation; `disconnect` releases everything | `tests/sol_review_races.rs::caller_disconnect_cleanup_works_before_and_after_consumption` |
| Identifiers are bounded ASCII; `Id` and `U64` match ipc-v1 | conforms: `^[a-z0-9][a-z0-9._-]{0,63}$`, `"0"\|[1-9][0-9]*` | `wire.rs::is_id`, `wire.rs::parse_u64` | `wire.rs::tests::scalars`, `tests/conformance_wire.rs::malformed_envelope_scalars_are_rejected` (both) |
| Service/topic names are 1..192 of `[a-z0-9._-]` with no empty dot-separated segment | conforms | `wire.rs::is_name` | `wire.rs::tests::scalars` |
| Exact names only; wildcard routing and queue groups deferred | conforms: routing is `HashMap` lookup by exact name. `Pattern::Prefix` is a launcher grant, never a route | `router/state.rs::services`/`topics`, `policy.rs::Pattern` | `tests/pubsub.rs::subscription_and_topic_validation` (both), `tests/rpc.rs::authority_is_enforced` (both) |
| One live registration owns a service name; duplicate registration fails; no implicit round-robin or replacement | conforms (`CONFLICT`) | `router/state.rs::op_register` | `tests/rpc.rs::registration_is_exclusive_and_pinned` (both), `tests/conformance_routing.rs::duplicate_registration_by_owner_itself_is_rejected` (both) |
| Registration returns the incarnation; callers pin it; a change fails with `TARGET_CHANGED` | conforms | `router/state.rs::op_call` | `tests/rpc.rs::registration_is_exclusive_and_pinned` (both), `tests/bus_acceptance.rs::no_automatic_retry_or_failover_onto_a_replacement_registration` (both) |
| An unpinned call reaches whoever holds the name now | conforms | `router/state.rs::op_call` (`expectedIncarnation: null`) | `tests/conformance_routing.rs::unpinned_call_after_incarnation_replacement_reaches_the_new_holder` (both) |
| `Worker.Hello` stays a domain RPC, distinct from transport negotiation | conforms by absence: `bus.hello` carries no role, capability or session field | `router/state.rs::hello` | `tests/wire.rs::hello_negotiation` (both) |
| Service/topic access is configured per participant by the launcher; naming a target is not authority | conforms | `policy.rs::{Policy, Grants}`, checked in every `op_*` | `tests/rpc.rs::authority_is_enforced` (both), `tests/pubsub.rs::subscription_and_topic_validation` (both) |
| Presentation subscribes without gaining authority to invoke Advance | conforms: `subscribe` and `call` are separate grants | `policy.rs::Grants` | `tests/rpc.rs::authority_is_enforced` (both) |
| One live connection per client id, and a client id's last incarnation may not be reused | deviates-allowed (narrowing): section 3 makes `callId` "unique ... for this client incarnation" and section 6 keeps serials "per connected client", which two live connections for one id would make ambiguous. Cost: one small record per client id ever seen | `router/state.rs::{ClientRecord, hello}` | `tests/sol_review_races.rs::pending_connections_are_bounded_and_hello_expires` |
## 4. Wire envelope and framing
| Requirement | Status | Code | Test |
| --- | --- | --- | --- |
| Framing is `u32` little-endian JSON length, then UTF-8 JSON | conforms | `wire.rs::{read_frame, write_frame}` | `tests/conformance_wire.rs::length_prefix_is_little_endian` (both) |
| Envelope fields exactly `protocol`/`major`/`minor`/`id`/`replyTo`/`kind`/`op`/`body`/`attachments` | conforms | `wire.rs::Envelope::{decode, to_value}` | `tests/conformance_wire.rs::unknown_top_level_field_closes_the_connection` (both) |
| `ArtifactRef` fields `storeId`/`artifactId`/`generation`/`byteLength`/`contentType`/`digest` | conforms (`generation`/`byteLength` as U64 strings) | `wire.rs::ArtifactRef` | `tests/conformance_artifacts.rs::stale_store_incarnation_and_generation_are_rejected` (both) |
| `Attachment` is `{name, ref, ownerId}` | conforms | `wire.rs::Attachment` | `tests/conformance_artifacts.rs::allocate_write_seal_open_roundtrip_and_mismatches` (both) |
| Maximum total JSON envelope 65,536 bytes | conforms | `wire.rs::MAX_ENVELOPE_BYTES`, checked on decode and encode | `tests/conformance_wire.rs::frame_at_the_size_ceiling_is_accepted_one_byte_over_is_not` (both) |
| A delivery the router would build over the limit is refused at admission | conforms, and required by "Message length includes this wrapper" (section 5): admission sizes the delivery with the longest ids | `router/state.rs::frame_len` in `op_call`/`op_reply`/`op_publish` | `tests/sol_review_races.rs::unsent_oversized_call_rolls_back_its_local_slot`, `tests/wire.rs::envelope_size_limits` (both) |
| Up to 32 attachments, unique names; `contentType` nonempty ASCII <=127 | conforms | `wire.rs::{MAX_ATTACHMENTS, is_content_type}`, `Envelope::decode` | `tests/conformance_wire.rs::malformed_envelope_scalars_are_rejected` (both), `tests/artifacts.rs::quotas_are_enforced` (both) |
| No pixel/base64/checkpoint bytes in JSON; artifact sizes are independent of envelope size | conforms: bulk bytes only reach the store; `byteLength` is a string in the reference | `store.rs`, `wire.rs::ArtifactRef` | `tests/perf.rs` (1.2 MB frames, envelopes under 1 KiB) |
| Domain schemas enumerate every referenced artifact; generated bindings enforce it | not-implemented for domain schemas (the bus enforces its own attachment list). Owner: CONTRACT-01 / `fly-session-types` | `router/state.rs::check_attachments` enforces the bus half | `tests/conformance_artifacts.rs::forward_requires_the_source_owner_still_live` (both) |
| The router validates attachment declarations/ownership, not domain payload contents | conforms | `router/state.rs::{check_owned, check_attachments}`; `payload`/`outcome` stay opaque | `tests/conformance_artifacts.rs::owner_ids_are_scoped_to_their_connection` (both) |
| Commands have unique monotonically issued `msg-<U64>` ids per connection | conforms (strictly increasing) | `router/state.rs::handle` | `tests/conformance_wire.rs::{non_canonical_command_ids_are_rejected, non_increasing_command_ids_are_rejected}` (both) |
| Replies correlate with `replyTo`; notices and deliveries carry router-generated ids | conforms | `router/state.rs::{reply, outbound}` | `tests/conformance_wire.rs::non_null_reply_to_on_a_command_is_rejected` (both) |
| The router supplies authenticated sender/target metadata; senders cannot forge it in `body` | conforms on launcher-bound transports; the sender's `body` never supplies identity | `router/state.rs::{identity, Call::request_body}` | `tests/rpc.rs::raw_call_ids_and_forged_replies` (both), `tests/sol_review_races.rs::responder_survives_cancel_then_request_drop` |
| Identity is bound out of band before Hello; a mismatching Hello is refused before registration | conforms | `router/mod.rs::{serve_as, listen_unix_as}`, `state.rs::hello` | `tests/wire.rs::hello_refusals` (both) |
| Open/unbound transports are self-asserted, not authentication | deviates-allowed (explicit narrowing): section 1's "one trusted local deployment" and section 4's "The launcher provides expected client/registration privileges". `Policy::open()` is documented as test/trusted-only | `policy.rs::permits_unbound_transport`, `router/state.rs::add_conn` | `tests/sol_review_races.rs::pending_connections_are_bounded_and_hello_expires` |
| Reject duplicate JSON keys, invalid UTF-8, NaN/Infinity, unknown envelope fields, zero/oversize frames, invalid ranges | conforms | `wire.rs::{parse_json_strict, StrictVisitor, Fields::finish}`, `read_frame` | `tests/conformance_wire.rs::{duplicate_json_keys_are_rejected_at_every_depth, invalid_utf8_is_rejected, zero_length_frame_closes_the_connection, oversize_length_prefix_is_rejected_before_reading_body}` (both) |
| Read length before allocating | conforms | `wire.rs::read_frame` checks the prefix before `vec![0u8; len]` | `tests/conformance_wire.rs::oversize_length_prefix_is_rejected_before_reading_body` (both) |
| Handle partial reads/writes; serialise one writer per connection | conforms | `wire.rs::read_frame`, `router/mod.rs::{write_selected, write_loop}` (one writer task) | `tests/conformance_wire.rs::{a_frame_written_one_byte_at_a_time_still_decodes, a_reply_read_one_byte_at_a_time_still_decodes, truncated_frame_disconnects_cleanly}` (both) |
| No ancillary-FD tricks in the first file-backed implementation | conforms | `transport.rs` carries bytes only | — |
| A future memory backend keeps the same client/ownership API | not-implemented (future). Owner: a later storage backend | — | — |
| `bus.hello` body `{clientId, clientIncarnation, supportedMajors}`, no attachments; reply `{routerId, connectionId, selectedMajor, selectedMinor, contractDigest, limits}` | conforms | `router/state.rs::hello`, `limits.rs::Limits::to_json` | `tests/wire.rs::hello_negotiation` (both), `tests/conformance_wire.rs::{hello_refuses_attachments, hello_reports_the_contract_digest_and_valid_limits}` (both) |
| Refuse incompatible majors or identity mismatch before registration | conforms (`VERSION_MISMATCH`, `NOT_AUTHORIZED`) | `router/state.rs::hello` | `tests/conformance_wire.rs::hello_refuses_an_unsupported_major` (both), `tests/wire.rs::hello_refusals` (both) |
| Schema changes change `contractDigest` | conforms: the digest is the SHA-256 of `wire::CONTRACT`, which lists every operation, delivery and notice; the client refuses a router whose digest differs | `wire.rs::{CONTRACT, contract_digest}`, `client/mod.rs::connect` | `tests/conformance_wire.rs::memory_and_unix_negotiate_the_identical_contract` |
| Bodies reject unknown fields and `minor` must be 0 after hello | deviates-allowed (stricter than "unknown envelope fields", forbidden nowhere; section 4's envelope fixes `minor: 0`) | `wire.rs::Fields::finish`, `router/state.rs::handle` | `tests/wire.rs::body_errors_keep_the_connection` (both), `tests/sol_review_races.rs::client_rejects_unknown_reply_fields_and_invalid_direction_rules` |
| The connection reader dispatches replies and requests without blocking on user handlers | conforms: the reader hands `Request`/`Message` to unbounded per-handle channels and returns | `client/reactor.rs::{read_loop, on_delivery}` | `tests/pubsub.rs::saturated_subscriber_does_not_block_control` (both) |
| Artifact I/O and hashing run outside the routing critical section; no routing lock across slow I/O | conforms: `Outcome::Allocate`/`Seal` leave the lock, run on the blocking pool and re-enter; unlinks happen after the guard drops | `router/mod.rs::{read_loop, Inner::with_state}`, `store.rs::seal` | `tests/artifacts.rs::quotas_are_enforced` (both), `tests/perf.rs` (seal p50 1.3 ms, publish admission p50 0.4 ms) |
## 5. Operation registry
| Requirement | Status | Code | Test |
| --- | --- | --- | --- |
| Replies are `{ok:true, value}` or `{ok:false, error:{code, message, dispatch}}` | conforms | `router/state.rs::reply_body`, `client/reactor.rs::parse_reply` | `tests/wire.rs::body_errors_keep_the_connection` (both) |
| All 19 commands of the table exist with the listed bodies and reply values | conforms | `router/state.rs::handle` dispatch table; `wire.rs::CONTRACT` | `tests/conformance_wire.rs` + `tests/{rpc,pubsub,artifacts}.rs` (both) |
| `rpc.responder.release {callId, requestDeliveryId} -> {released}` is added to the registry | deviates-allowed: section 4's "Changes to these draft schemas change contractDigest" (this is a draft), and it is what keeps section 6's "bounded call correlation metadata" bounded when a handler keeps a responder after dropping the request. Recorded as an amendment in bus-v1 section 12 | `router/state.rs::op_responder_release`, `client/handles.rs::ReplyGuard` | `tests/sol_rereview_regressions.rs::{dropping_last_attached_responder_settles_the_call, dropping_last_responder_clone_settles_the_call}` |
| Release batches carry 1..64 ids | conforms | `wire.rs::MAX_BATCH`, `Fields::array` | `tests/artifacts.rs::release_ids_are_watermarked` (both) |
| No attachments on management commands except `rpc.call`, `rpc.reply`, `publish` | conforms | `router/state.rs::handle` | `tests/wire.rs::body_errors_keep_the_connection` (both) |
| `accepted`/`routed`/`removed`/`declared`/`cleared`/`deleted`/`replayLatest` are booleans; `subscribers`/`replaced`/`released` are U64 counts | conforms | `router/state.rs` (`.into()` for bools, `to_string()` for counts) | `tests/pubsub.rs::bounded_fifo_and_atomic_backpressure` (both), `client/reactor.rs::validate_reply_value` |
| Released counts count newly released roots, so an idempotent repeat may report zero | conforms | `router/state.rs::{op_consumed, op_release}` | `tests/artifacts.rs::release_ids_are_watermarked` (both) |
| Queue/credit requests are integers 1..65535 and cannot exceed configured limits | conforms | `wire.rs::MAX_CREDIT`, `op_register`/`op_subscribe` quota checks | `tests/pubsub.rs::subscription_and_topic_validation` (both), `tests/rpc.rs::service_queue_backpressure` (both) |
| Method strings are 1..128 printable ASCII | conforms | `wire.rs::is_method` | `wire.rs::tests::scalars` |
| The call target is a service name; `expectedIncarnation` is the registration id | conforms | `router/state.rs::op_call` (`f.name`, `f.nullable_id`) | `tests/rpc.rs::registration_is_exclusive_and_pinned` (both) |
| Location grants are `{storeId, relativePath}` resolved under the configured root; absolute paths, parent traversal and symlink escapes are rejected | conforms | `store.rs::resolve`, opened `O_NOFOLLOW` | `store.rs::tests::resolve_refuses_escapes`, `tests/conformance_artifacts.rs::issued_locations_are_relative_and_contained` (both) |
| Locations are SDK-private and do not appear in an application's `ArtifactRef`; runtime paths are not committed into schemas | conforms: `Location` only ever appears in `artifact.allocate`/`artifact.open` replies | `wire.rs::Location`, `client/handles.rs::Artifact::open` | `tests/conformance_artifacts.rs::issued_locations_are_relative_and_contained` (both) |
| Deliveries carry exactly the listed bodies (`rpc.request`, `rpc.result`, `topic.message`) | conforms | `router/state.rs::{Call::request_body, Call::result_body, TopicMsg::body}` | `tests/conformance_wire.rs` + `client/reactor.rs::on_delivery` strict parse |
| `caller`/`responder` include clientId and clientIncarnation | conforms | `wire.rs::Identity` | `tests/rpc.rs::request_reply_roundtrip` (both) |
| Delivery attachment ownerIds are replaced by the recipient's deliveryId; source tokens are never delegated | conforms, and the client refuses a delivery whose attachment owner is not its delivery | `router/state.rs::attachments_with_owner`, `client/reactor.rs::attachments` | `tests/conformance_artifacts.rs::owner_ids_are_scoped_to_their_connection` (both) |
| `topicSequence` and counters are U64 strings; the router assigns the ids; the SDK exposes typed payloads plus handles | conforms | `router/state.rs::TopicMsg::body`, `client/handles.rs::Message` | `tests/pubsub.rs::retained_replay_clear_delete_and_incarnations` (both) |
| Required bounded notices: route removal, subscription closure, call failure | conforms | `router/state.rs::{remove_service, shutdown, op_responder_release}` | `tests/pubsub.rs::router_shutdown_closes_subscriptions_with_notices` (both), `tests/rpc.rs::service_disconnect_fails_calls` (both) |
| Who gets those notices, and when | deviates-allowed: the draft names the notices but not their audience. `route.removed` goes to callers with open calls on the removed registration, `subscription.closed` only at router shutdown (nothing else ends a subscription without the client's own act), and `connection.closing` is an added notice before every router-initiated close | `router/state.rs::{remove_service, shutdown, violation}` | `tests/wire.rs::malformed_frames_close_the_connection` (both) |
| If notice capacity is exhausted, close the connection rather than lose control-plane correctness | conforms | `router/state.rs::push_control` | `tests/wire.rs::control_lane_exhaustion_closes` (both) |
## 6. RPC behaviour
| Requirement | Status | Code | Test |
| --- | --- | --- | --- |
| `call-<U64>` with increasing serials per connected client; reused or retired ids are rejected, never executed again | conforms: a syntactically valid id advances the watermark even when admission is refused | `router/state.rs::op_call` (`call_watermark`) | `tests/sol_review_races.rs::rejected_call_id_still_advances_monotonic_watermark`, `tests/rpc.rs::raw_call_ids_and_forged_replies` (both) |
| Reconnecting creates a new incarnation rather than reviving old calls | conforms | `router/state.rs::{hello, disconnect}` | `tests/conformance_routing.rs::unpinned_call_after_incarnation_replacement_reaches_the_new_holder` (both), `tests/sol_review_races.rs::caller_disconnect_cleanup_works_before_and_after_consumption` for the old calls half (its synchronisation was fixed on 2026-09-22; see "A flaky test and what it was measuring") |
| An RPC targets one registered service, not a broadcast subject | conforms | `router/state.rs::op_call` | `tests/rpc.rs::request_reply_roundtrip` (both) |
| First-dispatch FIFO per caller and service; responses may complete out of order and correlate by callId | conforms | `router/state.rs::{Svc::queue, dispatch_rpc}` | `tests/rpc.rs::fifo_dispatch_and_out_of_order_completion` (both), `tests/conformance_routing.rs::out_of_order_replies_correlate_across_concurrent_callers` (both) |
| A service dispatcher can answer status concurrently with a long mutation | conforms | `router/state.rs::dispatch_rpc` (in-flight credits, not one-at-a-time) | `tests/bus_acceptance.rs::a_status_rpc_responds_while_another_handler_is_delayed` (both) |
| The router implements no frame barriers or numerical ordering | conforms by absence | `router/state.rs` (no domain fields) | `tests/integration.rs::session_over_one_router` (both) |
| Admission validates route, pinned incarnation, size, quotas and every source artifact owner, and establishes request-delivery roots atomically before accepting | conforms: every check precedes the first mutation, then roots, queue entry and reply | `router/state.rs::op_call` | `tests/conformance_artifacts.rs::rejected_call_creates_no_roots` (both), `tests/artifacts.rs::failed_admission_is_atomic` (both) |
| Rejection establishes no delivery and drops provisional roots | conforms | `router/state.rs::op_call` (validate-then-mutate) | `tests/conformance_artifacts.rs::rejected_call_creates_no_roots` (both) |
| An accepted call is not proof that its handler ran | conforms: `accepted` is admission only; the terminal outcome arrives as `rpc.result` or `call.failed` | `client/mod.rs::call`, `client/handles.rs::PendingCall` | `tests/rpc.rs::service_disconnect_fails_calls` (both) |
| Mark dispatched before any request bytes can reach the target; later transport loss is an unknown outcome | conforms: `Phase::Dispatched` and the delivery id are set when the frame is selected, before a byte is written | `router/state.rs::{next_frame, dispatch_rpc}` | `tests/sol_rereview_regressions.rs::shutdown_cancels_partial_rpc_request_before_reclaiming_attachment` |
| The service replies with its own owned handles; the router establishes caller-result ownership before accepting the reply | conforms | `router/state.rs::op_reply` (`check_attachments`, then `add_roots`, then the phase change) | `tests/rpc.rs::endpoint_cache_replays_artifact_results` (both) |
| Only bounded call correlation metadata is kept until the result is consumed or the caller detaches; not an indefinite result cache | conforms: the record dies with consumption, detachment, disconnect or the last responder release; reply capabilities share the per-client owner bound | `router/state.rs::{remove_call, dispatch_rpc, op_responder_release}` | `tests/sol_review_races.rs::{cancel_after_request_consumption_retires_correlation, dropping_service_with_buffered_requests_retires_every_call}` |
| A second `rpc.reply` for the same call is rejected, not routed twice | conforms (`CALL_GONE`) | `router/state.rs::op_reply` | `tests/rpc.rs::replies_are_single_and_independent_of_the_request_guard` (both) |
| Responding does not release the request's delivery guard | conforms | `client/handles.rs::{Request, Responder}` (separate guards) | `tests/rpc.rs::replies_are_single_and_independent_of_the_request_guard` (both) |
| No automatic retry or failover; never route a retry automatically to a restarted worker | conforms | `router/state.rs::{remove_service, disconnect}` fail calls instead of re-queueing | `tests/bus_acceptance.rs::no_automatic_retry_or_failover_onto_a_replacement_registration` (both) |
| A deadline belongs to the calling client; on timeout it may cancel | conforms: no budget on the wire; `result()` is cancel-safe under `tokio::time::timeout` | `client/handles.rs::PendingCall::{result, cancel}` | `tests/rpc.rs::cancellation_states` (both) |
| Domain retries use a fresh callId with the same domain requestId/body, pinned to the same incarnation; endpoint dedup supplies safe replay; the router does not infer it from method names | conforms | `client/mod.rs::call`; the router carries `payload` opaquely | `tests/bus_acceptance.rs::a_retransmission_repeats_the_domain_request_under_a_fresh_call_id` (both), `tests/rpc.rs::endpoint_cache_replays_artifact_results` (both) |
| Queued cancellation releases its queued artifact roots and returns `cancelled-before-dispatch` | conforms | `router/state.rs::op_cancel` -> `remove_call` -> `drop_roots` | `tests/conformance_routing.rs::cancel_before_dispatch_releases_queued_artifact_roots` (both) |
| After dispatch, return `execution-unknown` and keep the recipient's delivery alive until consumed or disconnected | conforms | `router/state.rs::op_cancel` (`detached = true`, delivery untouched) | `tests/rpc.rs::cancellation_states` (both), `tests/conformance_routing.rs::caller_disconnect_detaches_dispatched_call_but_service_keeps_serving` (both) |
| A later reply to a detached call returns `routed:false` with no caller-result roots; the service still owns any retained result | conforms | `router/state.rs::op_reply` (`call.detached` branch, before `add_roots`) | `tests/conformance_routing.rs::cancel_after_dispatch_then_late_reply_with_artifact_is_not_routed` (both) |
| A terminal result already admitted makes cancellation report `completed`; the client drains and consumes it | conforms | `router/state.rs::op_cancel` (`Phase::Replied`), `client/reactor.rs::on_delivery` consumes an abandoned result | `tests/rpc.rs::{cancellation_states, dropped_call_is_cancelled_and_late_result_consumed}` (both) |
| A retired or unknown correlation reports `call-gone`; those four strings are the complete enum | conforms | `router/state.rs::op_cancel`, `client/handles.rs::CancelState` | `tests/rpc.rs::cancellation_states` (both), `client/reactor.rs::validate_reply_value` |
| No cancel state authorises re-execution; cancelling a future does not abandon incoming delivery ownership | conforms | `client/handles.rs::CallGuard::drop` (best-effort cancel, result still consumed) | `tests/rpc.rs::dropped_call_is_cancelled_and_late_result_consumed` (both), `tests/sol_review_races.rs::reply_racing_cancel_has_only_the_two_contract_outcomes` |
| Endpoint replay caches Artifact handles plus payload, not bare references, and owns holds until eviction | conforms (SDK support; the discipline is the endpoint's) | `client/handles.rs::Artifact::retain`, `ArtifactWriter::seal` returns a hold | `tests/rpc.rs::endpoint_cache_replays_artifact_results` (both), `tests/bus_acceptance.rs::a_lost_result_leaks_no_roots_and_the_endpoint_cache_still_replays` (both) |
| Re-delivery gets new delivery ids pointing to the same immutable bytes | conforms | `router/state.rs::dispatch_rpc` (fresh `dlv-<n>` per delivery) | `tests/rpc.rs::endpoint_cache_replays_artifact_results` (both) |
| An expired domain cache returns `RESULT_EXPIRED` | not-implemented: a domain code, not a transport code. Owner: `fly-session-rpc` (ipc-v1) | — | — |
## 7. Pub/sub semantics
| Requirement | Status | Code | Test |
| --- | --- | --- | --- |
| Topic declaration teaches the router nothing about meaning; no hardcoded frame/brain topics | conforms | `router/state.rs::{Topic, op_declare}` | `tests/pubsub.rs::retained_replay_clear_delete_and_incarnations` (both) |
| `latest`: one queued value, replacing only an undelivered one; replacement releases that entry's roots; delivered or in-use messages are never reclaimed early; maxQueued is exactly 1 | conforms | `router/state.rs::{op_publish (latest branch), op_subscribe}` | `tests/pubsub.rs::latest_coalesces_only_undelivered_values` (both), `tests/conformance_artifacts.rs::latest_mode_holds_at_most_two_roots_delivered_plus_queued` (both) |
| `bounded`: FIFO, no coalescing or silent loss; when capacity is unavailable, reject with `BACKPRESSURE` before admitting any delivery | conforms | `router/state.rs::op_publish` (pre-checks every subscriber) | `tests/pubsub.rs::bounded_fifo_and_atomic_backpressure` (both), `tests/conformance_routing.rs::bounded_overflow_rolls_back_all_artifact_roots` (both) |
| maxInFlight credits return only on `delivery.consumed`, not on socket write completion | conforms | `router/state.rs::release_owner` (credit returned when the owner is released) | `tests/pubsub.rs::credits_return_only_on_consume` (both), `tests/conformance_routing.rs::bounded_credit_waits_for_every_extracted_artifact` (both) |
| A latest subscriber with all credits in use still has one replaceable queued value | conforms | `router/state.rs::{Sub::queue, dispatch_topic}` (queue and credits are separate) | `tests/bus_acceptance.rs::both_transports_produce_equivalent_behaviour_traces` (events 21 and 24: `publish replaced=1`, then `latest seq=3 replaced=1`), `tests/integration.rs::session_over_one_router` (both: a renderer held for the whole run keeps one delivery in flight and one replaceable value, and receives snapshots 1 and 20 of 20) |
| Atomic subscriber/retention snapshot at admission; validate and reserve every queue entry and owner budget before accepting | conforms: one mutex, validate-then-mutate | `router/state.rs::op_publish` | `tests/artifacts.rs::failed_admission_is_atomic` (both) |
| A bounded overflow rejects the whole publish: no partial fan-out, no retained-latest update | conforms | `router/state.rs::op_publish` | `tests/conformance_routing.rs::bounded_overflow_rolls_back_all_artifact_roots` (both) |
| On acceptance, one `topicSequence` and roots for every delivery and the optional retained value | conforms; a refused publication spends no sequence number | `router/state.rs::op_publish` (`t.sequence += 1` after the checks) | `tests/pubsub.rs::bounded_fifo_and_atomic_backpressure` (both) |
| Different topics have no total ordering; multiple publishers follow router acceptance order | conforms: per-topic sequence only | `router/state.rs::Topic::sequence` | `tests/pubsub.rs::retained_replay_clear_delete_and_incarnations` (both) |
| The publication reply counts accepted subscriptions and replaced queue entries, not consumers that processed data | conforms | `router/state.rs::op_publish` reply | `tests/pubsub.rs::latest_coalesces_only_undelivered_values` (both) |
| `replaced` on a delivery reports how many undelivered messages were coalesced since that subscription's preceding delivery | conforms | `router/state.rs::{Sub::replaced, dispatch_topic}` (taken at dispatch) | `tests/pubsub.rs::latest_coalesces_only_undelivered_values` (both), `tests/integration.rs::session_over_one_router` (both: the 18 replacements the publisher was told about at admission are the same 18 the renderer is told about on delivery, and are exactly the snapshots it did not receive) |
| Optional `retained:latest` holds one last message and its artifacts independent of subscribers | conforms | `router/state.rs::op_publish` (retain branch) | `tests/conformance_artifacts.rs::retained_topic_value_holds_a_root_independent_of_subscribers` (both) |
| `replayLatest` enqueues the retained value before subsequent accepted publications; bounded preserves the order, latest may coalesce it | conforms | `router/state.rs::op_subscribe` (replay is enqueued under the subscribe lock) | `tests/conformance_routing.rs::latest_replay_is_ordered_ahead_of_a_racing_publish` (both: one racing publication to a bounded and a latest subscription at once; bounded must deliver replay then publication, and the latest branch is chosen by that publication's own `replaced` count, never by which side of the race the dispatcher won), `tests/pubsub.rs::retained_replay_clear_delete_and_incarnations` (both) |
| Replay uses the original topicSequence, a fresh deliveryId and explicit roots | conforms | `router/state.rs::op_subscribe` (`add_roots`, the same `Arc<TopicMsg>`) | `tests/pubsub.rs::retained_replay_clear_delete_and_incarnations` (both) |
| Without retention, a zero-subscriber publication retains no ownership after admission | conforms | `router/state.rs::op_publish` | `tests/pubsub.rs::zero_subscriber_publish_retains_nothing` (both) |
| Clearing a topic releases only its retained root, not active consumers | conforms | `router/state.rs::op_clear` | `tests/conformance_routing.rs::cleared_topic_gives_no_replay_until_a_fresh_publish` (both) |
| Topic count and retained bytes are capped | conforms; `max_retained_bytes` is added to the draft's table because this sentence requires it | `limits.rs::{max_topics, max_retained_bytes}`, `router/state.rs::{op_declare, op_publish}` | `tests/pubsub.rs::topic_and_retention_quotas` (both) |
| No durable replay, automatic redelivery or exactly-once claim | conforms by absence | `router/state.rs` (queues are in memory and die with the connection) | `tests/artifacts.rs::router_restart_invalidates_old_handles` (both) |
| Deleting or redeclaring a topic creates a fresh topicIncarnation; a reset sequence cannot be read as continuation | conforms | `router/state.rs::{op_delete, op_declare}` | `tests/pubsub.rs::retained_replay_clear_delete_and_incarnations` (both) |
| Old subscription deliveries keep their original incarnation and ownership until consumed | conforms | `router/state.rs::drop_subscription` (matches on the incarnation), delivery bodies carry it | `tests/pubsub.rs::unsubscribe_discards_queue_but_not_deliveries` (both) |
| `topic.delete` only with no subscribers | conforms (`CONFLICT`) | `router/state.rs::op_delete` | `tests/pubsub.rs::subscription_and_topic_validation` (both) |
| Bus admission, message consumption and durable storage acknowledgment are three different events | conforms: `publish` returns admission counts, `delivery.consumed` is separate, and there is no storage ack in the bus | `router/state.rs::{op_publish, op_consumed}` | `tests/pubsub.rs::credits_return_only_on_consume` (both) |
| A topic must be declared before publish or subscribe | deviates-allowed: the draft is silent on undeclared topics, while "Topic count and retained bytes are capped" and `topic.declare`'s "conflicting settings fail" both imply a registry a publication cannot create by accident. `NO_TOPIC` names the refusal (amendment, section 12); `topic.delete` of an unknown topic still answers `deleted:false` | `router/state.rs::{op_publish, op_subscribe, op_clear}` | `tests/pubsub.rs::subscription_and_topic_validation` (both) |
## 8. Artifact lifecycle and garbage collection
### 8.1 Immutable object lifecycle
| Requirement | Status | Code | Test |
| --- | --- | --- | --- |
| `ALLOCATED/WRITING -> SEALED -> owned -> COLLECTED`, and an abandoned or disconnected writer goes straight to COLLECTED | conforms | `router/state.rs::{ArtState, abandon_writer, finish_seal}` | `tests/artifacts.rs::{writer_drop_releases_staging, disconnect_releases_all_but_retained}` (both), `tests/conformance_artifacts.rs::disconnect_abandons_an_unsealed_writer` (both) |
| `ArtifactRef` is an identity, not an address or authority; opening needs a current root on that connection | conforms | `router/state.rs::{check_owned, op_open}` | `tests/conformance_artifacts.rs::owner_ids_are_scoped_to_their_connection` (both) |
| storeId is the store incarnation; old handles fail after restart | conforms (`ARTIFACT_GONE`) | `router/state.rs::check_owned` | `tests/artifacts.rs::router_restart_invalidates_old_handles` (both) |
| No artifact id or inode reuse; `generation` is 1 | conforms | `wire.rs::GENERATION`, `router/state.rs::next_artifact` (monotonic) | `tests/conformance_artifacts.rs::stale_store_incarnation_and_generation_are_rejected` (both) |
| Content hashes optional for live frames, mandatory where a domain contract says so | conforms: both paths exist and the router verifies what it is given | `client/handles.rs::ArtifactWriter::seal_with_digest`, `store.rs::copy_exact` | `tests/artifacts.rs::seal_checks_length_and_digest` (both) |
| The first backend is runtime-configured local files, optionally on tmpfs | conforms | `store.rs::Store::create` under `RouterConfig::store_root` | `store.rs::tests::orphans_are_removed_and_live_stores_kept` |
| The producer writes staging storage outside the message stream | conforms | `store.rs::create_staging`, `client/mod.rs::Artifacts::allocate` | `tests/artifacts.rs::allocate_write_seal_read` (both) |
| Seal closes writable handles in the SDK, checks length and digest, then finishes an immutable store-owned object before acknowledging | conforms: a writable descriptor kept after sealing reaches only the unlinked staging inode | `client/handles.rs::seal_with_digest` (drops the file first), `store.rs::seal` (fresh 0444 inode) | `tests/artifacts.rs::seal_is_immune_to_live_writable_handles` (both), `tests/conformance_artifacts.rs::seal_is_immutable_despite_a_stale_writable_handle` (both) |
| A copy into a fresh sealed inode is allowed; account for both allocations during sealing | conforms | `router/state.rs::op_seal` (`store_bytes += len` for the copy, released in `finish_seal`) | `tests/artifacts.rs::quotas_are_enforced` (both) |
| No per-frame fsync for transient media | conforms by absence | `store.rs` | `tests/perf.rs` (seal p50 1.3 ms for 1.2 MB) |
| Consumers resolve a readLocation through `artifact.open` and read it read-only | conforms | `router/state.rs::op_open`, `store.rs::open_read` | `tests/conformance_artifacts.rs::allocate_write_seal_open_roundtrip_and_mismatches` (both) |
| Locations are private grants, not placed in application bodies or public feeds | conforms | `router/state.rs::op_open` reply only | `tests/conformance_artifacts.rs::issued_locations_are_relative_and_contained` (both) |
| All filesystem access stays behind the client Artifact API; no second bulk-transfer server | conforms in the API. The store is only as private as the OS user, which the crate's Limitations section states | `client/handles.rs`, `store.rs` | `store.rs::tests::resolve_refuses_escapes` |
### 8.2 What owns an artifact
| Requirement | Status | Code | Test |
| --- | --- | --- | --- |
| Roots: producer hold/active writer, accepted queued delivery, in-flight delivery, retained latest, explicit hold | conforms, all five | `router/state.rs::{Owner, add_roots, drop_roots}` | `tests/conformance_artifacts.rs::{queued_deliveries_hold_roots_before_dispatch, collection_waits_for_every_retained_owner, retained_topic_value_holds_a_root_independent_of_subscribers}` (both) |
| Seal transfers the unique writer into a hold | conforms; the seal reply reuses the writer's own ownerId to express exactly that transfer | `router/state.rs::finish_seal` | `tests/artifacts.rs::allocate_write_seal_read` (both) |
| Admission creates destination roots before the sender may relinquish source roots | conforms: the SDK holds every source `OwnerGuard` until the router has answered | `client/reactor.rs::OutCommand::keep`, `router/state.rs::{op_call, op_reply, op_publish}` | `tests/conformance_artifacts.rs::forward_requires_the_source_owner_still_live` (both) |
| A timeout must not drop a source guard while an unsent operation might still be admitted; the client keeps the guard until the transport outcome is known | conforms: an unsent command's guards travel with it and are released only when it fails or is answered | `client/reactor.rs::{next_outgoing, fail_all}` | `tests/sol_review_races.rs::unsent_oversized_call_rolls_back_its_local_slot`, `tests/artifacts.rs::abandoned_futures_do_not_leak_owners` (both) |
| Every envelope lists its complete artifact set; duplicates in one delivery count once | conforms | `router/state.rs::{check_attachments, dedup}` | `tests/conformance_artifacts.rs::allocate_write_seal_open_roundtrip_and_mismatches` (both) |
| A retained topic and several consumers can reference the same bytes; the router updates metadata only and never copies bytes for fan-out | conforms | `router/state.rs::op_publish` (`Arc<TopicMsg>` plus root counts) | `tests/artifacts.rs::fan_out_shares_one_object_and_the_last_consumer_collects` (both), `tests/perf.rs` (store peak 3.7 MB for three consumers) |
### 8.3 Consumed means no remaining use
| Requirement | Status | Code | Test |
| --- | --- | --- | --- |
| The incoming message owns a shared DeliveryGuard; extracting an Artifact clones it; dropping the message alone does not consume the delivery | conforms | `client/handles.rs::{OwnerGuard, Message::artifact}` | `tests/artifacts.rs::extracted_artifacts_outlive_their_message` (both), `tests/conformance_artifacts.rs::extracted_artifact_outlives_the_message_it_came_from` (both) |
| Local handle clones need no bus round trip; dropping the last guard queues `delivery.consumed` on a bounded control lane | conforms | `client/handles.rs::OwnerGuard::drop`, `client/reactor.rs::push_control` | `tests/pubsub.rs::credits_return_only_on_consume` (both) |
| Ownership is at delivery granularity; independent retention needs `artifact.retain` before the guard is dropped | conforms | `client/handles.rs::Artifact::retain` | `tests/conformance_artifacts.rs::explicit_retain_outlives_the_original_hold` (both) |
| A domain acknowledgment implicitly drops nothing | conforms by absence: only a guard drop or an explicit release ends ownership | `client/handles.rs::OwnerGuard` | `tests/rpc.rs::endpoint_cache_replays_artifact_results` (both) |
| An allocation or seal grant that arrives after its caller abandoned the future is still processed and released; no owner the application never saw is leaked | conforms: the reactor builds the handle, so an undelivered reply drops it | `client/reactor.rs::{complete, Hook::Owner}` | `tests/artifacts.rs::abandoned_futures_do_not_leak_owners` (both) |
| An in-progress seal has a bounded I/O hold; on producer disconnect it cleans up and never publishes an ownerless object | conforms | `router/state.rs::finish_seal` (`owner_live` check), `router/mod.rs::read_loop` seal task | `tests/conformance_artifacts.rs::disconnect_abandons_an_unsealed_writer` (both) |
| Dropping a response future is not consumption: the client owns queued results until surfaced, discarded or disconnected | conforms | `client/reactor.rs::{CallSlot, on_delivery}` | `tests/rpc.rs::dropped_call_is_cancelled_and_late_result_consumed` (both) |
| Receivers await asynchronous CPU/GPU use before releasing the guard | conforms as far as the API can enforce: the guard lives as long as any `Artifact`/`ArtifactFile` clone | `client/handles.rs::{Artifact, ArtifactFile}` | `tests/conformance_routing.rs::bounded_credit_waits_for_every_extracted_artifact` (both) |
| A pointer from a mapping cannot outlive its Artifact; FFI wrappers enforce it | not-implemented: no mapping and no FFI surface exists. Owner: a future mmap or binding | — | — |
| Release commands are batched, idempotent and scoped to the owning connection | conforms | `router/state.rs::{op_release, op_consumed}`, `client/reactor.rs::take_batch` | `tests/artifacts.rs::owners_are_scoped_to_their_connection` (both) |
| Delivery and hold ids use monotonic per-connection serials with separate watermarks; a retired id is a no-op, a never-issued one an error; no tombstone per frame | conforms | `router/state.rs::{Conn::delivery_issued, Conn::hold_issued, op_consumed, op_release}` | `tests/artifacts.rs::release_ids_are_watermarked` (both) |
| Control-lane exhaustion closes the connection instead of losing releases | conforms on both sides | `router/state.rs::push_control`, `client/reactor.rs::push_control` | `tests/wire.rs::control_lane_exhaustion_closes` (both) |
### 8.4 Crash, disconnect and safe physical reclamation
| Requirement | Status | Code | Test |
| --- | --- | --- | --- |
| On disconnect: unregister services and subscriptions, cancel queued deliveries, release that connection's writers, holds and delivery roots | conforms | `router/state.rs::disconnect` | `tests/artifacts.rs::disconnect_releases_all_but_retained` (both), `tests/conformance_artifacts.rs::{disconnect_releases_an_explicit_hold, disconnect_releases_queued_and_dispatched_deliveries}` (both) |
| Retained topic roots stay router-owned | conforms | `router/state.rs::disconnect` (topics untouched) | `tests/conformance_routing.rs::subscriber_disconnect_releases_queued_and_delivered_artifacts_but_not_retention` (both) |
| Late replies and releases cannot attach to a new connection or service incarnation | conforms: owners are per connection and calls remember their service incarnation | `router/state.rs::{check_owned, op_reply, remove_service}` | `tests/artifacts.rs::owners_are_scoped_to_their_connection` (both), `tests/rpc.rs::raw_call_ids_and_forged_replies` (both) |
| Teardown reclaims a connection's roots without racing the frame it is writing: either a complete frame precedes the reclamation or a partial one is cut and nothing more is appended | conforms, **fixed on this branch** (see the section 9 row on starvation). Teardown now marks the stream closing once, before waiting for the poll already in progress, so only that one poll can still write and every later one is refused | `router/state.rs::WriteGate`, `router/mod.rs::write_selected`, `router/state.rs::close_conn` | `tests/sol_rereview_regressions.rs::{teardown_waits_for_an_active_transport_poll_before_reclaiming, shutdown_does_not_deliver_a_frame_after_releasing_its_owner, protocol_close_cancels_partial_topic_frame_before_reclaiming_attachment, shutdown_cancels_partial_rpc_request_before_reclaiming_attachment, shutdown_cancels_partial_rpc_result_before_reclaiming_attachment}` |
| GC removes the registry entry and unlinks the sealed object after its final root is gone | conforms; the unlink happens after the routing lock is released | `router/state.rs::drop_roots`, `router/mod.rs::Inner::with_state` | `tests/artifacts.rs::fan_out_shares_one_object_and_the_last_consumer_collects` (both) |
| Existing mappings stay valid until the OS closes them; never overwrite the inode or reuse its bytes | conforms: sealed files are 0444, written once and only unlinked | `store.rs::{seal, remove}` | `tests/bus_acceptance.rs::disconnect_releases_logical_ownership_without_mutating_open_bytes` (both) |
| Logical reclamation is not proof of physical release; measurements include OS mappings and client memory | conforms: `RouterStats` is documented as logical, and the measurement reports process RSS | `router/state.rs::RouterStats`, `tests/perf.rs` | `tests/perf.rs` (RSS 11 to 16 MB) |
| No TTL may reclaim a live owned artifact | conforms by absence: nothing in the router expires an owned root | `router/state.rs` | `tests/conformance_artifacts.rs::collection_waits_for_every_retained_owner` (both) |
| Limits may disconnect a consumer but cannot overwrite memory under a renderer | conforms: exhaustion closes the connection, which releases roots; bytes are never rewritten | `router/state.rs::push_control`, `store.rs` | `tests/wire.rs::control_lane_exhaustion_closes` (both) |
| Future pooled shared memory must prove equivalent lifetime/generation safety | not-implemented (deferred by the draft). Owner: a later storage backend | — | — |
| Router restart makes a new routerId/storeId, loses routes, queues and retention, and invalidates old handles | conforms | `router/mod.rs::Router::new`, `store.rs::Store::create` | `tests/artifacts.rs::router_restart_invalidates_old_handles` (both) |
| Orphan files from a stopped router are cleaned without being treated as durable checkpoints | conforms, at the next router start on the same root (a `flock`-free marked directory) | `store.rs::clean_orphans` | `store.rs::tests::orphans_are_removed_and_live_stores_kept` |
| Live sessions fail their epoch and use coherent recovery | not-implemented: a session responsibility. Owner: STATE-01 / step-v1 | — | — |
## 9. Bounds, scheduling and failure reporting
| Requirement | Status | Code | Test |
| --- | --- | --- | --- |
| Every default of the table, exactly: clients/services/topics 64/256/512; subscriptions 128 per client and 1024 total; control envelope 64 KiB; active calls 64; service queued/in-flight 16/16; latest 1/2; bounded 64/16; active owners 256; store 512 MiB and 128 MiB per object; per-client queued envelope bytes 1 MiB; reserved lane 128 frames and 1 MiB | conforms | `limits.rs::Limits::default`, `wire.rs::MAX_ENVELOPE_BYTES` | `tests/conformance_wire.rs::hello_reports_the_contract_digest_and_valid_limits` (both), `tests/pubsub.rs::topic_and_retention_quotas` (both) |
| Limits are configured explicitly and a configuration the router could not honour is refused | conforms | `limits.rs::Limits::validate`, `router/mod.rs::Router::new` | `tests/rpc.rs::active_call_limit` (both), `tests/artifacts.rs::quotas_are_enforced` (both) |
| The per-client queued envelope byte budget counts `bounded` subscriptions only, and latest slots are bounded at subscriptions x 64 KiB instead | conforms to the amended table. The draft's single row was the audit's contradiction 1: this section also forbids a latest spectator from being the reason a publication is refused, so its slot cannot sit in a budget whose overflow rejects one. The coordinator amended the row and gave latest slots their own (bus-v1 section 12, 2026-09-22) | `limits.rs::max_queued_bytes_per_client`, `router/state.rs::op_publish` | `tests/bus_acceptance.rs::a_latest_subscriber_never_refuses_a_publication` (both), `tests/sol_review_races.rs::{retained_replay_obeys_bounded_queue_byte_quota, latest_replay_remains_bounded_outside_the_bounded_byte_pool}` |
| Reserve an owner allowance for lifecycle and results separately from ordinary telemetry | conforms | `limits.rs::reserved_owners_per_client`, `router/state.rs::{ordinary_budget_left, dispatch_rpc}` | `tests/conformance_artifacts.rs::artifact_bounds_and_owner_budget_are_enforced` (both) |
| Memory quotas account for staging, seal copies, queued deliveries and caches | conforms | `router/state.rs::{op_allocate, op_seal, Conn::queued_bytes}` | `tests/artifacts.rs::quotas_are_enforced` (both) |
| Ownership metadata is bounded even when many roots share one artifact | conforms: a root is a counter, and owners are bounded per client | `router/state.rs::{Art::roots, ordinary_budget_left}` | `tests/conformance_artifacts.rs::artifact_bounds_and_owner_budget_are_enforced` (both) |
| Disk-full, allocation failure or hash mismatch returns a typed artifact error and cleans provisional storage and roots | conforms (`QUOTA_EXCEEDED`, `STORE_FAILURE`, `ARTIFACT_MISMATCH`) | `router/state.rs::{op_allocate, finish_allocate, op_seal, finish_seal}`, `store.rs::seal` | `tests/artifacts.rs::seal_checks_length_and_digest` (both), `tests/conformance_artifacts.rs::allocate_write_seal_open_roundtrip_and_mismatches` (both) |
| The router fairly services clients | deviates-allowed: fairness is Tokio's scheduling plus a yield every 32 commands from one connection, and round robin between a connection's services and subscriptions. The draft sets no fairness metric; the crate's Limitations section calls this modest | `router/mod.rs::read_loop`, `router/state.rs::{dispatch_rpc, dispatch_topic}` | `tests/pubsub.rs::saturated_subscriber_does_not_block_control` (both) |
| Replies, release, cancellation and route-health control cannot be starved by telemetry | conforms: control first, then RPC, then topic data, with topic data given a turn after 16 higher-priority frames | `router/state.rs::{next_frame, TOPIC_STARVATION_LIMIT}` | `tests/sol_rereview_regressions.rs::{fair_topic_insertion_preserves_router_envelope_order, sdk_accepts_fair_topic_insertion_through_saturated_control_backlog}`, `tests/pubsub.rs::saturated_subscriber_does_not_block_control` (both) |
| ... and neither can connection teardown be starved by the frame it is waiting for | **was deviates-must-fix, fixed on this branch**. The write gate was a plain mutex held across each synchronous transport poll, so a writer sending a frame one byte per poll re-acquired it hundreds of times while teardown waited for it, and could finish a whole delivery before teardown got in: `teardown_waits_for_an_active_transport_poll_before_reclaiming` failed 6 runs out of 6 in release and about 1 in 5 in debug. The gate now separates "teardown has begun" (a flag set once, without waiting) from "a poll is in progress" (a condvar teardown waits on), so teardown's window is one poll instead of a whole frame, and no byte can follow it. No public signature changed | `router/state.rs::WriteGate`, `router/mod.rs::write_selected` | `tests/sol_rereview_regressions.rs::teardown_waits_for_an_active_transport_poll_before_reclaiming` (rewritten: a 50 KB delivery the resumed writer cannot finish, and a channel instead of a sleep) |
| Preserve FIFO for calls to a target despite lane scheduling | conforms: the service queue is FIFO and lane choice never reorders it | `router/state.rs::dispatch_rpc` | `tests/rpc.rs::fifo_dispatch_and_out_of_order_completion` (both) |
| Classification is an explicit generic envelope operation or policy, not a topic-name heuristic | conforms by construction: `next_frame` chooses a lane from the queue an item sits in (control, then RPC, then topic), and neither it nor `pop_control`/`dispatch_rpc`/`dispatch_topic` reads a service or topic name. A name reaches the scheduler only as opaque bytes inside an already-classified frame | `router/state.rs::{next_frame, pop_control, dispatch_rpc, dispatch_topic}` | `tests/bus_acceptance.rs::a_topic_named_like_a_notice_is_still_classified_as_topic_data` (both), and `tests/sol_rereview_regressions.rs::fair_topic_insertion_preserves_router_envelope_order` for the lane order itself |
| No indefinite wait inside the router on subscriber readiness or artifact I/O | conforms: a slow reader stalls only its own writer task; I/O leaves the lock | `router/mod.rs::{write_loop, read_loop}` | `tests/pubsub.rs::saturated_subscriber_does_not_block_control` (both) |
| Admission is bounded; rejected callers choose their own policy | conforms | `router/state.rs::{op_call, op_publish}` | `tests/rpc.rs::service_queue_backpressure` (both) |
| The thirteen transport error codes exist with those names | conforms | `error.rs::ErrorCode` | `tests/wire.rs::body_errors_keep_the_connection` (both) |
| Three more codes: `CONFLICT`, `NO_TOPIC`, `ARTIFACT_MISMATCH` | deviates-allowed: "Transport errors **include** ..." is not an exhaustive list, and each names a refusal the draft requires but leaves unnamed. Recorded as an amendment in bus-v1 section 12 | `error.rs::ErrorCode` | `tests/pubsub.rs::subscription_and_topic_validation` (both), `tests/artifacts.rs::seal_checks_length_and_digest` (both) |
| Before admission report `not-dispatched`; once dispatch might have occurred report `dispatched` or `unknown` conservatively | conforms | `error.rs::BusError::new` (not-dispatched by default), `router/state.rs` dispatched notices, `client/reactor.rs::fail_all` (unknown) | `tests/rpc.rs::{cancellation_states, service_disconnect_fails_calls}` (both), `tests/sol_review_races.rs::writer_failure_terminates_reader_and_pending_work` |
| Bounded subscriptions can reject a publication; latest spectators cannot hold a session transaction indefinitely | conforms: a latest subscriber never causes `BACKPRESSURE` | `router/state.rs::op_publish` (the latest branch skips every capacity check) | `tests/bus_acceptance.rs::a_latest_subscriber_never_refuses_a_publication` (both: 100 publications of 60 KB into one unconsumed slot, six times the bounded pool, none refused, 98 coalesced), with `tests/pubsub.rs::{bounded_fifo_and_atomic_backpressure, saturated_subscriber_does_not_block_control}` (both) for the bounded half, and `tests/integration.rs::session_over_one_router` (both: 20 snapshot publications accepted by both subscriptions while the presentation consumer reads nothing) |
| Sustained pinned-artifact quota exhaustion is surfaced as pressure, not solved by freeing live data | conforms: `QUOTA_EXCEEDED`, never eviction | `router/state.rs::{op_allocate, op_seal}` | `tests/artifacts.rs::quotas_are_enforced` (both) |
| Session and application policies choose disconnect, pause or fail; the router does not know which | conforms by absence | `router/state.rs` | `tests/pubsub.rs::saturated_subscriber_does_not_block_control` (both) |
## 10. Native-frame bandwidth check
| Requirement | Status | Code | Test |
| --- | --- | --- | --- |
| 640x480 RGBA is 1,228,800 bytes; 73.728 MB/s at 60 fps is the planning dimension | conforms as measured, not as a claim | `tests/perf.rs::{W, H, HZ}` | `tests/perf.rs` (120 frames in 2.00 s, 0 late) |
| Two agent workers plus one presentation consumer read the same immutable frame object | conforms | `router/state.rs::op_publish` (roots, not copies) | `tests/perf.rs` (three consumers, store peak 3.7 MB = three 1.2 MB objects in flight, not nine) |
| Bus messages contain only references; no 3x byte fan-out through the router | conforms | `wire.rs::Attachment` | `tests/artifacts.rs::fan_out_shares_one_object_and_the_last_consumer_collects` (both), `tests/perf.rs` |
| Readers still incur memory traffic; renderer readback and seal copying remain real costs | conforms, and both are now measured separately | `tests/perf.rs` (`producer copy into staging`, `seal`, `consumer readback`) | `tests/perf.rs` |
| No claim of zero-copy capture or measured host capacity | conforms: the measurement section below says so in those words | `tests/perf.rs` header | — |
| Nothing game-specific, no rendering, publishing or sampling logic in Flybus | conforms: no crate in the workspace depends on flybus yet, and the crate names no game, brain or stream concept | `crates/flybus/**` | `tests/integration.rs::session_over_one_router` (both; the domain lives in the test) |
## 11. Acceptance tests and implementation sequence
| bus-v1 item | Status | Test |
| --- | --- | --- |
| 1. Wire/router: schema, framing, Hello, exclusive routes, pinned incarnations, request/reply, disconnect, bounds; in-memory passes the same tests as Unix sockets | conforms | `tests/conformance_wire.rs` (45), `tests/wire.rs` (12), `tests/rpc.rs` (26), all `both_transports!`; `tests/bus_acceptance.rs::both_transports_produce_equivalent_behaviour_traces` |
| 2. Pub/sub: exact topics, FIFO/bounded rejection, latest coalescing, retained replay and clear, atomic fan-out, fair control/reply delivery under a saturated subscriber | conforms | `tests/pubsub.rs` (20), `tests/conformance_routing.rs` (24) |
| 3. Artifacts: allocate/seal/read; publication before seal fails; fan-out owns one object; the last consumer releases; a retained extracted frame survives a message drop | conforms | `tests/artifacts.rs` (28), `tests/conformance_artifacts.rs` (36) |
| 4. Faults: sender drops after admission, consumer dies mid-read, reply lost, queued frame replaced, subscription closes with in-use deliveries, router restarts, old release arrives; no double-free, use-after-reuse, unbounded tombstones or hidden replay | conforms | `tests/conformance_routing.rs::{caller_disconnect_detaches_dispatched_call_but_service_keeps_serving, subscriber_disconnect_releases_queued_and_delivered_artifacts_but_not_retention}`, `tests/artifacts.rs::{release_ids_are_watermarked, router_restart_invalidates_old_handles}`, `tests/bus_acceptance.rs::{a_lost_result_leaks_no_roots_and_the_endpoint_cache_still_replays, disconnect_releases_logical_ownership_without_mutating_open_bytes}`, `tests/sol_rereview_regressions.rs` (11) |
| 5. RPC cache: an endpoint retains an artifact-bearing result, the original caller consumes it, a domain retry still returns valid bytes, eviction drops the last hold | conforms | `tests/rpc.rs::endpoint_cache_replays_artifact_results` (both), `tests/bus_acceptance.rs::a_retransmission_repeats_the_domain_request_under_a_fresh_call_id` (both) |
| 6. Integration: two parallel fake agents, a complete-batch environment RPC, committed snapshot publication and a deliberately slow presentation consumer on one router | conforms; the consumer is slow by construction, held until the publisher's completion is observed, so its coalescing is forced rather than raced for | `tests/integration.rs::session_over_one_router` (both) |
| 7. Performance: 640x480x60 with three consumers, one delayed; p50/p95/p99 RPC latency, router CPU, copy and readback cost separately, RSS, store live and peak bytes, outstanding roots, collection lag, queue lengths, for one, two and four agents | conforms | `tests/perf.rs::frames_at_60hz_with_three_consumers` (`--ignored`); numbers below |
| The first executable example: a counter RPC, a pub/sub observer and a frame artifact held past message consumption, in one small Rust program, no game or browser | conforms | `examples/demo.rs` (`cargo run -p flybus --example demo`), asserted by `tests/example_demo.rs::the_example_shows_a_counter_rpc_an_observer_and_a_held_frame` |
## The implementation guide's acceptance bullets
Every bullet of BUS-01, BUS-02 and BUS-03, with the named test that proves it. A bullet a
pre-existing suite already covered is cited here rather than duplicated; the rest are the
tests in `tests/bus_acceptance.rs`, named after their bullet.
### BUS-01 — router and RPC, in-memory and Unix socket parity
| Bullet | Test |
| --- | --- |
| Partial frames and writes | `tests/conformance_wire.rs::{a_frame_written_one_byte_at_a_time_still_decodes, a_reply_read_one_byte_at_a_time_still_decodes, truncated_frame_disconnects_cleanly, length_prefix_is_little_endian}` (both), `tests/sol_rereview_regressions.rs::{shutdown_cancels_partial_rpc_request_before_reclaiming_attachment, protocol_close_cancels_partial_topic_frame_before_reclaiming_attachment}` |
| Disconnect after request | `tests/rpc.rs::service_disconnect_fails_calls` (both), `tests/conformance_routing.rs::caller_disconnect_detaches_dispatched_call_but_service_keeps_serving` (both), `tests/sol_review_races.rs::caller_disconnect_cleanup_works_before_and_after_consumption` |
| Lost result | **new** `tests/bus_acceptance.rs::a_lost_result_leaks_no_roots_and_the_endpoint_cache_still_replays` (both) |
| Retransmission fixtures | **new** `tests/bus_acceptance.rs::a_retransmission_repeats_the_domain_request_under_a_fresh_call_id` (both), with `tests/rpc.rs::endpoint_cache_replays_artifact_results` (both) |
| No automatic retry | **new** `tests/bus_acceptance.rs::no_automatic_retry_or_failover_onto_a_replacement_registration` (both) |
| Cancel after dispatch reports execution-unknown | `tests/rpc.rs::cancellation_states` (both), `tests/sol_review_races.rs::reply_racing_cancel_has_only_the_two_contract_outcomes` |
| Incarnation replacement is visible | `tests/rpc.rs::registration_is_exclusive_and_pinned` (both), `tests/conformance_routing.rs::{duplicate_registration_by_owner_itself_is_rejected, unpinned_call_after_incarnation_replacement_reaches_the_new_holder}` (both) |
| Out-of-order replies | `tests/rpc.rs::fifo_dispatch_and_out_of_order_completion` (both), `tests/conformance_routing.rs::out_of_order_replies_correlate_across_concurrent_callers` (both) |
| Status RPC responds while another handler is delayed | **new** `tests/bus_acceptance.rs::a_status_rpc_responds_while_another_handler_is_delayed` (both) |
| Saturation is bounded | `tests/rpc.rs::{service_queue_backpressure, active_call_limit}` (both), `tests/wire.rs::control_lane_exhaustion_closes` (both), `tests/sol_review_races.rs::pending_connections_are_bounded_and_hello_expires` |
| Both transports produce equivalent behaviour traces | **new** `tests/bus_acceptance.rs::both_transports_produce_equivalent_behaviour_traces`, over the trace recorder in `tests/common/mod.rs::Trace` |
The recorder keeps behaviour and refuses operational identity: `Trace::record` panics on any
router-issued id, so a trace holds methods, payload fields, counts, sequences, credits, cancel
states and error codes only. The two scenarios (an RPC one and a pub/sub-plus-artifact one)
produce 29 events, identical over both transports; `FLYBUS_TRACE=1` prints them.
### BUS-02 — pub/sub, retention and backpressure
| Bullet | Test |
| --- | --- |
| Overflow rejects a bounded publication before partial fan-out | `tests/pubsub.rs::bounded_fifo_and_atomic_backpressure` (both), `tests/conformance_routing.rs::bounded_overflow_rolls_back_all_artifact_roots` (both) |
| Latest replaces only queued messages | `tests/pubsub.rs::latest_coalesces_only_undelivered_values` (both), `tests/conformance_artifacts.rs::latest_mode_holds_at_most_two_roots_delivered_plus_queued` (both) |
| Delivery consumption returns credits | `tests/pubsub.rs::credits_return_only_on_consume` (both), `tests/conformance_routing.rs::bounded_credit_waits_for_every_extracted_artifact` (both) |
| Unsubscribe preserves already-delivered ownership | `tests/pubsub.rs::unsubscribe_discards_queue_but_not_deliveries` (both), `tests/conformance_routing.rs::unsubscribe_cannot_reach_another_connections_subscription_id` (both) |
| Retained replay is ordered | `tests/pubsub.rs::retained_replay_clear_delete_and_incarnations` (both), `tests/conformance_routing.rs::{latest_replay_is_ordered_ahead_of_a_racing_publish, cleared_topic_gives_no_replay_until_a_fresh_publish}` (both), `tests/sol_review_races.rs::{retained_replay_obeys_bounded_queue_byte_quota, latest_replay_remains_bounded_outside_the_bounded_byte_pool}` |
| Stalled observers cannot starve RPC replies | `tests/pubsub.rs::saturated_subscriber_does_not_block_control` (both), `tests/sol_rereview_regressions.rs::{fair_topic_insertion_preserves_router_envelope_order, sdk_accepts_fair_topic_insertion_through_saturated_control_backlog}` |
All six were already covered, so BUS-02 added no test of its own. The equivalence trace above
carries a pub/sub and artifact scenario, so BUS-02's behaviour is in the transport comparison
too, and `a_latest_subscriber_never_refuses_a_publication` (both) proves the rule that sits
behind "latest replaces only queued messages": the replacement never turns into a refusal.
### BUS-03 — artifact-backed messages and automatic lifetimes
| Bullet | Test |
| --- | --- |
| Last owner collects | `tests/artifacts.rs::fan_out_shares_one_object_and_the_last_consumer_collects` (both), `tests/conformance_artifacts.rs::collection_waits_for_every_retained_owner` (both) |
| Extracted handle survives message drop | `tests/artifacts.rs::extracted_artifacts_outlive_their_message` (both), `tests/conformance_artifacts.rs::{extracted_artifact_outlives_the_message_it_came_from, explicit_retain_outlives_the_original_hold}` (both) |
| Forward before release is safe | `tests/conformance_artifacts.rs::forward_requires_the_source_owner_still_live` (both), `tests/sol_review_races.rs::unsent_oversized_call_rolls_back_its_local_slot`, `tests/integration.rs::session_over_one_router` (both; the coordinator forwards a frame to two agents) |
| Lost replies and cache replay remain valid | **new** `tests/bus_acceptance.rs::a_lost_result_leaks_no_roots_and_the_endpoint_cache_still_replays` (both), with `tests/rpc.rs::endpoint_cache_replays_artifact_results` (both) |
| Disconnect releases logical ownership without mutating still-mapped bytes | **new** `tests/bus_acceptance.rs::disconnect_releases_logical_ownership_without_mutating_open_bytes` (both), with `tests/artifacts.rs::{disconnect_releases_all_but_retained, seal_is_immune_to_live_writable_handles}` (both) |
| Retained latest and queue replacement release the correct roots | `tests/conformance_artifacts.rs::{latest_mode_holds_at_most_two_roots_delivered_plus_queued, retained_topic_value_holds_a_root_independent_of_subscribers, queued_deliveries_hold_roots_before_dispatch}` (both), `tests/conformance_routing.rs::subscriber_disconnect_releases_queued_and_delivered_artifacts_but_not_retention` (both) |
| Measure 640x480 RGBA x 60 with three readers: one stored image, no raw pixels in router messages, bounded CPU/RSS/owners/queues, reader and copy costs recorded | `tests/perf.rs::frames_at_60hz_with_three_consumers`; see the measurement below |
## The crate README's differences from the draft
Each of the ten differences the crate lists, kept with the sentence that allows it or fixed.
| README difference | Verdict |
| --- | --- |
| 1. Extra error codes `CONFLICT`, `NO_TOPIC`, `ARTIFACT_MISMATCH` | Kept. Allowed by section 9: "Transport errors **include** `INVALID_ENVELOPE`, ..." — an inclusive list. Each names a refusal the draft requires without naming its code, so all three are now in the bus-v1 amendment (section 12) |
| 2. Topics must be declared; `topic.clear` of an unknown topic is `NO_TOPIC` while `topic.delete` answers `deleted:false` | Kept. The draft is silent; section 7's "Topic count and retained bytes are capped" and `topic.declare`'s "conflicting settings fail" both presuppose a registry. See the section 7 row |
| 3. When notices are sent | Kept. Section 5 requires the three notices but says nothing about audience or timing: "Required bounded notices are route removal, subscription closure and call failure" |
| 4. Byte budgets: `max_queued_bytes_per_client` counts bounded subscriptions only; `max_retained_bytes` added | Kept, and no longer a difference: the section 9 table now names the bounded pool and bounds latest slots separately (amendment, contradiction 1), and section 7's "Topic count and retained bytes are capped" requires the retained cap |
| 5. Admission sizes a delivery with the router-added ids at their longest, so an inbound envelope near 65,536 bytes can be refused although it fits | Kept, and required: section 5's "Message length includes this wrapper" plus section 4's fixed maximum mean the delivery the router would build must also fit. ipc-v1 section 2's "must not silently truncate a payload" forbids the alternative |
| 6. One live connection per client id; a client id's last incarnation may not be reused; only `*_as` endpoints authenticate the Hello id | Kept. See the section 3 and 4 rows: section 3's per-incarnation callId uniqueness and section 4's launcher-provided privileges |
| 7. The seal reply's ownerId is the writer's own id, now a hold | Kept. Section 8.2: "seal transfers its unique writer" — one owner token, transferred |
| 8. No `budget` argument on calls, and no router executable | Kept. Section 1 calls the executable "optional"; section 6 puts the deadline in the calling client, and the section 2 sketch no longer shows a budget either (amendment, contradiction 2) |
| 9. Wire strictness: unknown fields in management bodies refused, `minor` must be 0 after hello | Kept. Stricter than section 4's "unknown envelope fields", forbidden nowhere, and section 4's envelope literally fixes `minor: 0` |
| 10. `rpc.responder.release` and its terminal `call.failed` | Kept. Section 4 allows draft schema changes ("Changes to these draft schemas change contractDigest"), and it is how section 6's "bounded call correlation metadata" stays bounded when a handler outlives its request delivery. Added to the bus-v1 amendment (section 12) |
## Measured on the dev VM
Two complete release runs of `cargo test --release -p flybus --test perf -- --ignored
--nocapture` on the development VM (4 CPUs), 640x480 RGBA at 60 Hz for 2 s per schedule, three
latest-mode consumers with the third delayed 40 ms per frame, and one, two and four agent
services called every frame. The router runs on its own two-thread Tokio runtime whose threads
carry a distinct name, so its CPU (routing plus the seal copies on its blocking pool) is
measured apart from the clients' four threads in the same process. Each cell is the range over
the two runs.
| Metric | 1 agent | 2 agents | 4 agents |
| --- | --- | --- | --- |
| Frames produced (late) | 120 (0) | 120 (0) | 120 (0 and 3) |
| RPC round trip ms p50/p95/p99 | 0.51-0.60 / 0.84-1.47 / 1.12-2.05 | 0.76-0.78 / 1.34-2.21 / 2.14-6.18 | 1.04-1.08 / 1.67-5.01 / 2.06-10.02 |
| allocate (quota + staging file) ms | 0.96-1.01 / 1.13-1.28 / 1.34-1.49 | 0.98-1.02 / 1.22-3.50 / 1.82-5.61 | 0.80-0.94 / 1.15-5.32 / 1.31-6.60 |
| producer copy into staging ms | 0.37-0.38 / 0.45-0.49 / 0.67-0.75 | 0.37 / 0.45 / 0.51-0.55 | 0.36-0.39 / 0.41-0.49 / 0.48-0.77 |
| seal, router copy to a sealed inode, ms | 1.31-1.35 / 1.65-1.73 / 1.89-1.90 | 1.27-1.42 / 1.69-4.97 / 1.87-7.41 | 1.14-1.30 / 1.64-5.33 / 1.72-7.00 |
| publish admission ms | 0.41-0.42 / 0.61-0.85 / 0.68-1.11 | 0.41-0.43 / 0.83-1.07 / 1.17-3.10 | 0.42-0.49 / 0.73-2.75 / 1.52-4.54 |
| consumer readback of 1.2 MB ms | 0.65-0.75 / 1.19-1.53 / 1.45-1.92 | 0.76-0.91 / 1.45-2.20 / 1.76-6.02 | 0.89-0.99 / 1.67-4.85 / 2.04-6.38 |
| Router CPU (cores, of 2 threads) | 0.175-0.185 | 0.200 | 0.175-0.215 |
| Whole process CPU (cores, of 6 threads) | 0.325-0.335 | 0.410-0.425 | 0.370-0.460 |
| VmRSS / VmHWM | 11.2-13.6 / 13.6-14.4 MB | 11.8-14.1 / 15.0 MB | 15.7-15.8 / 16.4-16.6 MB |
| Store bytes peak / live after drain | 3.7 MB / 0 | 3.7 MB / 0 | 3.7 MB / 0 |
| Outstanding roots peak / live | 5-6 / 0 | 6 / 0 | 5-6 / 0 |
| Queue length peak / live | 1 / 0 | 1 / 0 | 1 / 0 |
| Collection lag after the last frame | 63.5-74.7 ms | 73.6-83.0 ms | 44.1-68.2 ms |
| Frames seen by the three consumers (coalesced) | 120, 120, 49 (0, 0, 70-71) | 120, 120, 49 (0, 0, 71) | 120, 120, 47-49 (0, 0, 70-71) |
What the numbers do and do not say:
- **These are not capacity claims.** Two runs of a synthetic two-second schedule, in one
process, on one shared virtual machine with fewer CPUs than the process has threads, over
temporary local storage, with no capture device, encoder or renderer in the path. They are a
floor on cost, not a ceiling on throughput, and nothing here licenses a sizing decision.
- The second run's tails are three to five times the first run's (RPC p99 10.02 ms against
2.06 ms at four agents, three late frames against none) because other work shared the VM's
four CPUs during it. The p50s barely moved. That spread is the honest width of a measurement
on a shared host, not a property of the bus.
- No byte fan-out: three consumers of a 1.2 MB frame kept the store's peak at 3.7 MB, which is
one sealed object plus the staging and sealing copies of the next frame, not one object per
consumer. Outstanding roots peaked at six.
- Copy costs are real and dominate routing: the producer's copy into staging (p50 0.4 ms), the
router's seal copy into a fresh inode (p50 1.1 to 1.4 ms) and a consumer's readback (p50 0.7
to 1.0 ms) each cost as much as or more than publish admission (p50 0.4 ms).
- The delayed consumer coalesced 70 or 71 of 120 frames and never caused a rejected
publication, which is section 9's rule about latest spectators in one number.
- Every schedule drained completely: live store bytes, roots and queue lengths are zero
afterwards, 44 to 83 ms behind the last frame.
- Router CPU grows with agent count much more slowly than the whole process (0.18 to 0.22
cores against 0.33 to 0.46), because the clients own the copies. A thread that exits between
two samples takes its CPU with it, so the router figure is a floor.
## A flaky test and what it was measuring, 2026-09-22 (MEDIA-01)
`tests/sol_review_races.rs::caller_disconnect_cleanup_works_before_and_after_consumption`
failed intermittently on `main` after the bus slice merged. Reproduced here at
**37 failures in 240 runs** (four parallel loops of 60, debug, on the loaded dev VM), always
on the same line and always the same way: `responder.reply(...)` returned `routed:true` where
the test asserted `false`.
The mechanism is a synchronisation gap in the test, not a routing defect.
`router/state.rs::op_reply` returns `routed:false` only when `call.detached` is set, and for a
disconnected caller that flag is set by `router/state.rs::disconnect`, which the router runs
when **its** connection task reads EOF. `Client::close` documents what it waits for — "flushes
queued releases, closes the connection and waits until the reader has stopped ... the router
releases what they owned" — which is the client side only. So after `close()` returns, the
router may not have torn the caller's connection down yet, and a reply that reaches it first is
routed to a connection that is already closing. Nothing escapes: `disconnect` then releases
that connection's roots along with the queued result, which is why the test's own later
`settle` calls always passed. Only the `routed` flag, read one step too early, was wrong.
The fix is in the test: it now waits for the teardown it is talking about
(`e.settle("caller-a disconnected", |s| s.connections == 1)`) before asserting the
reply-to-a-detached-call sentence of section 6. That is the same bounded
poll-until-the-router-settles the rest of the file already uses for router-side consequences;
no sleep, no timing constant, and the assertion now has the precondition its contract sentence
names. **360 runs after the fix, 0 failures** (240 debug, 120 release).
Two other intermittent failures were seen in the same sweep. They belonged to the bus slice
rather than to this one and were fixed there, in the same way and for the same reason:
- `tests/example_demo.rs::the_example_shows_a_counter_rpc_an_observer_and_a_held_frame`,
2 failures in 40 standalone runs plus 1 in 12 full-suite runs. It printed
"while the frame is held: 1 artifact(s), 2 root(s)" instead of 1 root: the producer's hold
release is queued on the control lane and had not been applied when the example read the
counts. `examples/demo.rs` now waits for that release before reading the counts, the same
bounded poll it already used for collection eight lines below, so the line the guide quotes
is an observation rather than a race. The printed output is unchanged.
- `tests/integration.rs::session_over_one_router`, 1 failure in 12 full-suite runs and 0 in 40
standalone runs, at the assertion that the deliberately slow consumer skipped snapshots.
Under load it kept up, so the assertion was a timing claim about the machine: section 7
permits a latest subscriber to miss values, it does not oblige it to. The renderer is now
held until the publisher's twentieth receipt has returned -- the publisher's completion
observed, not timed -- so the coalescing is forced by construction, and the test asserts the
guarantees that do hold: each delivery carries the frame of the snapshot it announces,
deliveries arrive in publication order, the last value received is the latest published, the
renderer receives snapshots 1 and 20 of 20, both subscriptions accept all twenty publications
while the spectator reads nothing, and the eighteen replacements reported to the publisher at
admission are the same eighteen reported to the renderer on delivery and are exactly the
snapshots it did not receive. 16 failures in 40 runs beside four busy loops before, 0 in 40
after; the whole crate went from 10 failed runs in 20 to 0, and the workspace suite from 2
in 5 to 0.
## Contradictions
Two, both inside bus-v1, both minor, neither resolved by changing code. Both were referred to
the coordinator rather than guessed at, and both now carry a dated amendment in bus-v1
section 12; the rows above cite the amended wording. The original reading is kept here because
it is the reason for the amendment.
1. **The per-client queued envelope byte budget versus the latest-mode guarantee.** Section 9's
table has "Per-client ordinary queued envelope bytes | 1 MiB". Section 7 requires that a
latest subscriber always has one replaceable queued value, and section 9 itself says "latest
spectator subscriptions cannot hold a required session transaction indefinitely" — that is, a
latest subscriber must never be the reason a publication is rejected. A byte budget that
covered latest slots and rejected on overflow would violate the second statement; a budget
that excludes them is not the sentence in the table. The crate excludes them, which keeps the
normative sentence and loosens the table row: the worst case becomes subscriptions x 64 KiB
(8 MiB at the default 128 subscriptions per client) instead of 1 MiB. **Resolved in the
spec, not the code** (2026-09-22): the table row now names the bounded pool and latest slots
have their own row, so the implementation conforms as written. No behaviour changed, and
`a_latest_subscriber_never_refuses_a_publication` now proves the guarantee directly.
2. **A call `budget` versus a client-owned deadline.** Section 2's illustrative surface passes a
`budget` into `bus.call(...)`, while section 6 states "A deadline belongs to the calling
client" and gives the router no timeout behaviour, and section 5's `rpc.call` body has no
budget field. The crate follows sections 5 and 6 and has no budget argument, which is safe
because section 2 is labelled illustrative. **Resolved in the spec, not the code**
(2026-09-22): the sketch drops `budget` and shows the deadline at the caller, so the
illustrative surface and the wire contract now agree.
Ambiguities resolved without treating them as contradictions, for the record:
- Section 5's "Reply only by the registered recipient" is read as "by the connection the request
was delivered to", so that section 6's "dispatched calls can still be answered" after
`service.unregister` remains possible. The crate correlates replies by request delivery id on
that connection, not by the live registration.
- Section 9's "Active calls per client 64" is read as calls the caller still awaits: a call
detached by a post-dispatch cancel frees its caller slot while the router keeps the bounded
correlation record until the service consumes or answers it.
## Not implemented, and who owns it
- A standalone router executable (section 1 calls it optional): embed `Router`.
- Bindings in other languages (section 1: a binding "may" exist), and the mapping/FFI pointer
lifetime rules that section 8.3 writes for them.
- A memory storage backend (section 4) and pooled shared memory with generation reuse
(section 8.4), both deferred by the draft.
- Domain-schema enforcement that every referenced artifact is listed in attachments (section 4),
and the domain `RESULT_EXPIRED` outcome (section 6): CONTRACT-01 and `fly-session-rpc`.
- Epoch failure and coherent recovery after a router restart (section 8.4): the session
contract, STATE-01.
- Any consumer at all: no other crate depends on flybus yet, so the feed and control surfaces of
[feed-protocol](../../feed-protocol.md) and [control-api](../../control-api.md) are untouched
and the crate's "Wiring still pending" list still stands.

View file

@ -1,525 +0,0 @@
# Flybus v1: a small Rust RPC and pub/sub bus
Status: **draft 1**, 2026-09-18. Selected architecture for the new framework, not implemented
runtime code. This document supersedes the earlier proposal for direct worker sockets plus
a separate application bus and coordinator-owned buffer release protocol.
**One library, one router, one wire protocol. Small messages carry metadata and artifact
handles; large immutable bytes live in a managed local store. Ownership follows deliveries
and explicit retention.** The router moves messages and tracks generic resource ownership.
Applications own orchestration, presentation, effects and streaming. Sessions own simulation
ordering. The bus knows nothing about brains, game frames, matches or Twitch.
## 1. Scope and implementation shape
Start with a Rust/Tokio library, an embeddable router and an optional small router executable.
The same client API supports an in-memory test transport and Unix-domain stream sockets.
All participants use router semantics even when colocated; there is no separate fast-path
RPC protocol to maintain. Python helpers may implement a thin binding to the same schema;
browser integration belongs to the application's presentation gateway.
```text
Application / supervisor ─┐
Session coordinator ──────┤ managed artifact store
Agent workers ────────────┼── Flybus router ── immutable bytes
Environment backend ──────┤ handles in messages
Presentation / recording ─┤
Audience adapter ─────────┘
```
V1 supports one trusted local deployment with a shared local store. It does not require NATS,
a service mesh, durable broker queues, cross-machine artifact transfer, load-balanced stateful
workers, global transactions, dynamic plugins or a video codec. A private backend adapter may
still speak an emulator's native pipes/protocol internally; that is not a second framework bus.
Suggested crate: `flybus`, with `wire`, `router`, `client`, `artifact`, and `transport` modules.
Split crates only when useful. Keep session/game types out of this library. Use Tokio, serde
and a checked framing layer; persistent histories/checkpoints stay in application/session storage.
## 2. Client API
Illustrative Rust surface (not yet implemented):
```rust
let bus = Client::connect(config).await?;
let service = bus.register("agent.fly-a", service_config).await?;
let reply = timeout(deadline, bus.call(target, "Agent.Prepare", payload, attachments)).await?;
let subscription = bus.subscribe("session.demo.snapshots", subscription_config).await?;
bus.publish("session.demo.snapshots", payload, attachments).await?;
let mut writer = bus.artifacts().allocate(size, content_type).await?;
writer.write_all(&pixels)?;
let frame = writer.seal().await?; // consumes writer; immutable Artifact handle
bus.publish("world.demo.frame", metadata, [("frame", frame.clone())]).await?;
let message = subscription.next().await?;
let image = message.artifact("frame")?;
drop(message); // image still owns the delivery guard
render(image).await?; // last handle drop releases ownership
```
RPC, pub/sub and artifacts all use this client/connection. The artifact store is a data
structure/storage backend of the bus, not another messaging service. Bulk data does not
pass through the router's socket payloads or require a separate application data-transfer API.
`Artifact` is a read-only, cloneable handle. `ArtifactWriter` is unique and not cloneable;
sealing consumes its writable lifetime. Mapped slices cannot outlive their handle. Rust RAII
automates releases; other language bindings provide equivalent explicit close/context-manager
behavior. Garbage collection means reclaiming an unowned artifact, not inspecting game state.
## 3. Addressing and identities
| Type | Meaning |
| --- | --- |
| `routerId` | Fresh router/store incarnation; changes after restart |
| `clientId`, `clientIncarnation` | Configured participant identity and SDK client lifetime; reconnect creates a new incarnation |
| `connectionId` | Fresh connection; v1 does not resume its queues or delivery owners |
| `service`, `serviceIncarnation` | Named endpoint and opaque router-issued registration identity |
| `callId` | Unique RPC correlation ID for this client incarnation; not a domain operation ID |
| `topic`, `topicIncarnation`, `topicSequence` | Exact topic, declaration lifetime and acceptance order within it |
| `deliveryId` | One recipient's message delivery and artifact ownership root |
| `artifactId`, `generation` | Immutable byte object identity within a store incarnation |
| `ownerId` | A connection-owned delivery or explicit artifact hold |
Identifiers are bounded ASCII strings; scalar Id and U64 encodings match the common types
in [session RPC contracts](ipc-v1.md). Service/topic names use 1..192 characters from
`[a-z0-9._-]`, with no empty dot-separated segment. Exact names only in v1; wildcard routing
and queue groups are deferred. The router treats names as opaque addresses.
One live registration owns a service name. Duplicate registration fails; there is no implicit
round-robin balancing or replacement of a stateful agent. Registration returns the incarnation.
Callers pin it after discovery. If it changes, calls fail with `TARGET_CHANGED` rather than
silently reaching another brain/environment. `Worker.Hello` remains a domain capability RPC,
distinct from transport connection negotiation.
Service/topic access is configured per participant by the launcher/application. Presentation
subscribes to observations; it does not receive authority to invoke environment Advance.
Audience effects go through application/session admission. Naming a target is not authority
to control it. Public feed/control interfaces remain separate compatibility boundaries.
## 4. Wire envelope and framing
One connection handles both directions and every operation:
```text
u32 little-endian JSON byte length | UTF-8 JSON object
```
```ts
interface BusEnvelope {
protocol: "flybus"; major: 1; minor: 0;
id: Id; replyTo: Id | null;
kind: "command" | "reply" | "delivery" | "notice";
op: string;
body: object;
attachments: Attachment[];
}
interface ArtifactRef {
storeId: Id; artifactId: Id; generation: U64;
byteLength: U64; contentType: string;
digest: Digest | null;
}
interface Attachment {
name: Id; ref: ArtifactRef; ownerId: Id;
}
```
Maximum total JSON envelope is **65,536 bytes**. Up to 32 attachments, with unique names;
contentType is a nonempty ASCII string <=127 bytes. No pixel/base64/checkpoint bytes in JSON.
Artifact sizes are independent of envelope size. Domain schemas referencing artifacts MUST
enumerate every referenced artifact in attachments; generated client bindings enforce this.
The router validates attachment declarations/ownership, not the contents of domain payloads.
Commands have unique monotonically issued `id` serials per connection (canonical `msg-<U64>`).
Replies correlate with `replyTo`; routing notices/deliveries have router-generated IDs.
The router supplies authenticated sender/target metadata on deliveries; senders cannot forge
it by putting another participant's name in body. Domain `requestId` and callId remain distinct.
Reject duplicate JSON keys, invalid UTF-8, NaN/Infinity, unknown envelope fields, zero/oversize
frames and invalid ranges. Read length before allocating. Handle partial reads/writes and
serialize one writer per connection. There are no ancillary-FD tricks in the first file-backed
implementation. A future memory backend must retain the same client/ownership API.
### Connection negotiation
First command `bus.hello` has body `{clientId, clientIncarnation, supportedMajors}` and no
attachments. Its reply reports `{routerId, connectionId, selectedMajor, selectedMinor,
contractDigest, limits}`. The launcher provides expected client/registration privileges and
local endpoint/store configuration. Refuse incompatible majors or identity mismatch before
registration. Changes to these draft schemas change contractDigest; incompatible released
schemas require a major version bump.
The connection reader must dispatch incoming replies and requests without blocking on user
handlers. Blocking neural computation runs on dedicated workers; artifact I/O/hashing runs
outside the router's routing critical section. No mutable routing-state lock across slow I/O.
Example RPC command (the counter service is a generic test, not a built-in router feature):
```json
{
"protocol": "flybus", "major": 1, "minor": 0,
"id": "msg-7", "replyTo": null, "kind": "command", "op": "rpc.call",
"body": {
"callId": "call-2", "target": "example.counter", "expectedIncarnation": "service-a",
"method": "Counter.Increment", "payload": { "amount": 1 }
},
"attachments": []
}
```
An image-bearing publish has the same envelope shape. Its payload contains dimensions/time
and an application-defined attachment binding; its attachment entry contains ArtifactRef and
ownerId. byteLength may be `"1228800"`, while the entire message remains a small JSON object.
## 5. Operation registry
All operations use the envelope above. Replies are `{ok:true, value:object}` or
`{ok:false, error:{code, message, dispatch}}`, where dispatch is `not-dispatched`, `dispatched`
or `unknown`. Message length includes this wrapper. Transport error != domain error.
| Command | Body / reply value | Semantics |
| --- | --- | --- |
| `service.register` | `{name, maxQueued, maxInFlight}` → `{serviceIncarnation}` | Exclusive endpoint, bounded capacities |
| `service.unregister` | `{name, serviceIncarnation}` → `{removed}` | Stop new calls; queued calls fail; dispatched results follow §6 |
| `rpc.call` | `{callId, target, expectedIncarnation:null|Id, method, payload}` → `{accepted, serviceIncarnation}` | Admission acknowledgment only, eventual rpc.result follows |
| `rpc.reply` | `{callId, requestDeliveryId, outcome}` → `{routed}` | Reply only by the registered recipient; outcome is domain success/error |
| `rpc.cancel` | `{callId}` → `{state}` | Best effort; only canceled-before-dispatch establishes no invocation |
| `topic.declare` | `{name, retained:"none"|"latest"}` → `{declared, topicIncarnation}` | Compatible redeclaration allowed; conflicting settings fail |
| `topic.clear` | `{name}` → `{cleared}` | Release retained topic root; does not invalidate deliveries |
| `topic.delete` | `{name}` → `{deleted}` | Only when no subscribers; releases retained state |
| `subscribe` | `{topic, mode:"latest"|"bounded", maxQueued, maxInFlight, replayLatest}` → `{subscriptionId, topicIncarnation}` | Exact topic; no durable history |
| `unsubscribe` | `{subscriptionId}` → `{removed}` | Discard queued messages; already delivered handles remain valid |
| `publish` | `{topic, payload}` → `{topicSequence, subscribers, replaced}` | Atomic publication admission/fan-out, not consumption |
| `delivery.consumed` | `{deliveryIds:Id[]}` → `{released}` | Idempotent; processing and all local artifact uses have finished |
| `artifact.allocate` | `{byteLength, contentType}` → `{artifactId, generation, ownerId, writeLocation}` | Reserve quota and private writable staging storage |
| `artifact.seal` | `{artifactId, generation, ownerId, digest:null|Digest}` → `{ref, ownerId}` | Finish immutable publication; writeLocation no longer valid |
| `artifact.open` | `{ref, ownerId}` → `{readLocation}` | Resolve an owned sealed object for read-only mapping, never return its bytes |
| `artifact.retain` | `{ref, ownerId}` → `{ownerId}` | Create independent explicit hold while source ownership still exists |
| `artifact.release` | `{ownerIds:Id[]}` → `{released}` | Drop explicit holds/staging writers; not another client's owners |
Names/arrays/ranges follow §3/4/9. Release batches contain 1..64 IDs. No attachments are
allowed on management commands except rpc.call/rpc.reply/publish. `outcome` is a bounded
domain object; an incoming RPC result has its own attachments and delivery ownership.
`accepted`, `routed`, `removed`, `declared`, `cleared`, `deleted`, `replayLatest` are booleans;
`subscribers`, `replaced`, `released` are U64 counts. Released counts count newly released
roots, so an idempotent repeat may report zero. Queue/credit requests are integers 1..65535
and cannot exceed configured limits. Method strings are 1..128 printable ASCII characters.
call target is a service name; expectedIncarnation is its registration ID, not worker process ID.
Location grants are `{storeId, relativePath}` resolved by the client beneath the configured
local store root; absolute paths, parent traversal and symlink escapes are rejected. They
are SDK-private and do not appear in the application's ArtifactRef. Runtime paths are not
committed into application schemas or source configuration.
Deliveries:
- `rpc.request`: `{deliveryId, callId, caller, target, serviceIncarnation, method, payload}`.
- `rpc.result`: `{deliveryId, callId, responder, serviceIncarnation, outcome}`.
- `topic.message`: `{deliveryId, subscriptionId, topic, topicIncarnation, topicSequence, replaced, payload}`.
`caller`/`responder` include clientId and clientIncarnation. Delivery attachment ownerIds are
replaced by the recipient's deliveryId; source owner tokens are never delegated verbatim.
`topicSequence` and counters are U64 strings. The router assigns these IDs; the SDK exposes
typed payloads plus Artifact handles. Required bounded notices are route removal, subscription
closure and call failure. If even notice capacity is exhausted, close the connection instead
of silently losing control-plane correctness; disconnect is itself a typed client failure.
## 6. RPC behavior
callId uses `call-<U64>` with increasing serials per connected client. Keep an admission
watermark plus active-call entries; reused/retired call IDs are rejected, never executed again.
Reconnecting creates a new clientIncarnation/connection rather than reviving its old calls.
An RPC targets one registered service, not a broadcast subject. Preserve first-dispatch FIFO
per caller/service; responses may complete out of order and correlate by callId. Service
dispatchers can answer status concurrently with a long mutation, subject to their domain
state rules. The router does not implement frame barriers or numerical ordering.
Admission validates route, pinned incarnation, size, quotas and every source artifact owner.
It establishes request-delivery roots atomically before accepting. Rejection establishes no
delivery and drops any provisional roots. An accepted call is not proof that its handler ran.
Before any request bytes can reach the target, mark it dispatched; subsequent transport loss
is conservatively an unknown execution outcome.
The service publishes a reply with its own owned artifact handles. The router establishes
caller-result ownership before accepting that reply. It keeps only bounded call correlation
metadata until result delivery is consumed or the caller detaches/disconnects. It is not an
indefinite RPC result cache. A second rpc.reply for the same call is rejected, not routed twice.
Responding does not release the request's delivery guard; the handler does so when finished.
**No automatic retry or failover.** A deadline belongs to the calling client. On timeout the
client may send rpc.cancel, but cancellation after dispatch cannot undo work. Domain retries
use a fresh bus callId containing the **same domain requestId/body**, pinned to the same
service incarnation. Endpoint-level deduplication supplies safe replay; the router does not
infer it from method names. Never route a retry automatically to a restarted worker.
Queued cancellation releases its queued artifact roots and returns `cancelled-before-dispatch`.
After dispatch return `execution-unknown`; keep the recipient's delivery alive until consumed
or disconnected. A later reply to a detached call returns `routed:false`, with no caller-result
roots. The service still owns any retained result; domain state may have changed.
If a terminal result is already admitted, cancellation reports `completed`; the client drains
and consumes any result it no longer exposes to its caller. A retired/unknown correlation
reports `call-gone`. These four strings are the complete rpc.cancel state enum. None authorizes
re-execution, and cancelling a future does not abandon incoming delivery ownership.
To replay an artifact-bearing response safely, endpoint code caches **Artifact handles plus
payload**, not bare references. That cache owns explicit holds or delivery guards until domain
acknowledgment/eviction. Re-delivery gets new delivery IDs pointing to the same immutable bytes.
Once the domain cache expires it returns RESULT_EXPIRED; it cannot regenerate the operation
merely because the transport correlation entry was removed.
## 7. Pub/sub semantics
Topic declaration does not teach the router what a topic means. An application can publish
session observations, presentation cues, dataset jobs or unrelated typed events through the
same API. There are no hardcoded frame/brain topics inside the router.
- `latest`: one queued value per subscription, replacing only an **undelivered** value.
Replacing it releases that queue entry's artifact roots. Already delivered/in-use messages
are never reclaimed early. maxQueued is exactly 1 in this mode.
- `bounded`: FIFO queue, no coalescing or silent loss. When required queue/owner capacity
is unavailable, reject the publication with BACKPRESSURE before admitting any deliveries.
- maxInFlight credits are returned only by delivery.consumed, not socket write completion.
A latest subscriber with all credits in use still has one replaceable queued value.
Take an atomic subscriber/retention snapshot at admission. Validate and reserve all required
queue entries and artifact-owner budgets before accepting. For a bounded subscriber overflow,
reject the **whole publish**; no partial fan-out or retained-latest update. On acceptance,
assign one topicSequence and create roots for every delivery and optional retained value.
Different topics have no total ordering. Multiple publishers on one topic follow router
acceptance order, which is not automatically a deterministic application event order.
Publication reply counts accepted subscriptions/replaced queue entries, not consumers that
processed data. `replaced` on a delivery reports how many undelivered messages were coalesced
since that subscription's preceding delivery. Sequence gaps can also arise from joining late;
they are not evidence of a simulation step being skipped.
Optional `retained:latest` holds one last message and its artifacts independent of subscribers.
New subscriptions with replayLatest enqueue it before subsequent accepted publications;
bounded mode preserves that order, while latest mode may coalesce it before delivery under
the ordinary latest rule. Replay uses the original topicSequence, a fresh deliveryId and
explicit roots. Without retention,
zero-subscriber publication retains no artifact ownership after admission. Clearing a topic
releases only its retained root, not active consumers. Topic count and retained bytes are capped.
There is no durable replay, automatic redelivery, or exactly-once processing claim in v1.
Deleting/redeclaring a topic creates a fresh topicIncarnation; a reset sequence cannot be
mistaken for continuation of the deleted topic. Old subscription deliveries retain their
original incarnation and ownership until consumed.
If an application requires history, its recorder persists events and exposes a normal RPC
for recovery/query. Bus admission, message consumption and durable storage acknowledgment
are three different events. The bus must not conflate them.
## 8. Artifact lifecycle and garbage collection
### 8.1 Immutable object lifecycle
```text
ALLOCATED / WRITING → SEALED → referenced by owners → last owner drops → COLLECTED
└─ writer abandoned/disconnected ──────────────────────→ COLLECTED
```
`ArtifactRef` is an identity, not an address, filename or authority to read. Opening it requires
a current ownership root belonging to that connection. storeId is the router/store incarnation;
old handles fail after restart. V1 does not reuse an artifact ID/inode; generation is 1 and
remains in the contract for future pool implementations. Content hashes are optional for live
frames and mandatory for checkpoint/durable-content handoff, as specified by domain contracts.
The first storage backend uses runtime-configured local files, optionally on tmpfs. Producer
writes staging storage outside the message stream. Seal closes writable mappings/handles in
the SDK, checks length and any requested digest, then finishes an immutable store-owned
object before acknowledging. A correctness-first implementation may copy into a fresh sealed
inode; account for both allocations during sealing. No per-frame fsync for transient media.
Consumers resolve a store-issued readLocation through artifact.open and map/read it read-only.
Locations are private grants; they are not placed in application bodies or public browser feeds.
All filesystem access stays behind the client Artifact API. Do not inline binary data or create
a second bulk-transfer server just because it is stored outside the socket.
### 8.2 What owns an artifact?
The store tracks an ownership graph, not only a naive refcount incremented by every packet:
| Root | Lifetime |
| --- | --- |
| Producer explicit hold / active writer | Until last local handle releases, seal transfers its unique writer, or connection is lost |
| Accepted queued delivery | Until replaced/cancelled or transferred into recipient delivery ownership |
| In-flight delivery | Until message processing AND all extracted artifact uses finish |
| Retained latest topic | Until replaced, cleared, deleted, or router stops |
| Explicit retained hold | Until release; used for caches, rendering, checkpoint writes and forwarding |
Admission creates destination roots before the sender may relinquish source roots. A forward
or reply uses a live handle/owner; the client holds it until admission succeeds or definitively
fails. A timeout must not drop a source guard while an unsent operation might still be admitted.
The client cancels/discards the unsent frame or retains the guard until the transport outcome
is known; connection teardown ends that ambiguity for the old connection.
Every envelope must list its complete artifact set. Duplicate references in one delivery are
counted once. A retained topic and several consumers can reference the same physical bytes.
The router performs metadata updates only; it does not copy image bytes for fan-out.
### 8.3 Consumed means no remaining use
The incoming message owns a shared **DeliveryGuard**. Extracting an Artifact clones the guard;
dropping the message alone does not consume the delivery while a renderer/encoder still uses
its artifact. Local handle clones do not each require a bus round trip. Dropping the last
guard queues delivery.consumed through a bounded control lane.
V1 deliberately owns at delivery granularity: keeping one artifact from a message may keep
the other attachments alive too. For independent long-lived retention, artifact.retain creates
a specific explicit hold before the original guard is dropped. Domain RPC caches must use
that hold when they outlive message processing/credits. An acknowledgment of domain success
does not implicitly drop either the incoming guard or cached outgoing holds.
If an allocation/seal grant arrives after its caller abandoned the future, the SDK reactor
still processes and releases that grant. It must not leak an owner the application never saw.
An in-progress seal/copy has a bounded internal I/O hold; on producer disconnect it either
finishes cleanup or aborts safely, never publishes an ownerless object into a new connection.
Async task cancellation/drop of a response future is not necessarily consumption: the client
must own queued results until surfaced, explicitly discarded, or disconnected. Receivers must
await completion of asynchronous CPU/GPU use before releasing its guard. A pointer extracted
from a mapping cannot outlive its Artifact; FFI wrappers must enforce this lifetime explicitly.
Release commands are batched, idempotent and scoped to the connection that owns the IDs.
Delivery/hold IDs use monotonic per-connection serials. Keep issued watermarks plus active
owner maps; releasing an already retired ID is a no-op, a never-issued/future ID is an error.
This avoids a tombstone per frame forever. Control-lane exhaustion closes the connection
instead of silently losing releases and leaking an unbounded ownership graph.
### 8.4 Crash, disconnect and safe physical reclamation
On disconnect, unregister services/subscriptions; cancel queued deliveries and release that
connection's active writers/explicit/delivery roots. Retained topic roots remain router-owned.
Late replies and releases cannot attach to a new connection or service incarnation.
GC removes the registry entry and unlinks/closes the sealed object after its final root is
gone. Existing immutable file mappings may remain valid until the OS closes the last mapping;
do not overwrite their inode or reuse their bytes. Logical reclamation is not proof that a
disconnected process released its physical pages. The supervisor handles stuck processes;
resource measurements include OS mappings and client memory, not just registry totals.
No TTL may reclaim a live owned artifact. Limits may disconnect a consumer, triggering the
explicit cleanup above, but cannot overwrite memory under a renderer. Future pooled shared
memory must prove equivalent lifetime/generation safety before replacing immutable files.
Router restart creates a new routerId/storeId, loses routes/queues/retention and invalidates
all old handles. Live sessions fail their current epoch and use coherent recovery. Persistent
artifacts come from the application's durable store and are re-imported as new bus objects.
Orphan files from a stopped router are cleaned without treating them as durable checkpoints.
## 9. Bounds, scheduling and failure reporting
Configure limits explicitly; these defaults are a prototype starting point, not capacity data:
| Resource | Default |
| --- | ---: |
| Connected clients / services / topics | 64 / 256 / 512 |
| Subscriptions per client / total | 128 / 1024 |
| Control envelope | 64 KiB |
| Active calls per client | 64 |
| Service queued / in-flight calls | 16 / 16; worker dispatcher further limits mutations |
| Latest subscription queued / in-flight deliveries | 1 / 2 |
| Bounded subscription queued / in-flight deliveries | 64 / 16 |
| Active owners per client | 256 |
| Total artifact storage / per object | 512 MiB / 128 MiB |
| Per-client ordinary bounded queued envelope bytes | 1 MiB |
| Latest subscription slots | subscriptions × 64 KiB |
| Reserved management/reply lane | 128 frames and 1 MiB per client |
Reserve an owner allowance for lifecycle/results separately from ordinary telemetry; memory
quotas account for staging/seal copies, queued deliveries and caches. Ownership metadata is
bounded even if many roots share one artifact. Disk-full, allocation failure or hash mismatch
returns a typed artifact error and cleans provisional storage/roots.
The router fairly services clients. Replies, release, cancellation and route-health control
cannot be starved by telemetry. Preserve FIFO for calls to a target despite lane scheduling;
classification is an explicit generic envelope operation/policy, not a topic-name heuristic.
No indefinite wait inside the router on subscriber readiness or artifact I/O. Admission is
bounded; rejected callers choose their own retry/fail/pause policy.
Transport errors include `INVALID_ENVELOPE`, `VERSION_MISMATCH`, `NOT_AUTHORIZED`,
`NO_SERVICE`, `TARGET_CHANGED`, `BACKPRESSURE`, `CALL_GONE`, `ARTIFACT_UNSEALED`,
`ARTIFACT_GONE`, `OWNER_INVALID`, `QUOTA_EXCEEDED`, `STORE_FAILURE`, `ROUTER_LOST`, and the
three of section 12.
Before admission use dispatch:not-dispatched. Once dispatch might have occurred, report
unknown/dispatched conservatively; a caller-side timeout must not imply no mutation.
Bounded event subscriptions can reject publication; latest spectator subscriptions cannot
hold a required session transaction indefinitely. Per-client credit/owner budgets and
supervision enforce that distinction. Sustained pinned-artifact quota exhaustion is surfaced
as resource pressure, not solved by freeing live data. Session/app policies choose whether to
disconnect an observer, pause, or fail; the router does not know which outcome is appropriate.
## 10. Native-frame bandwidth check
At 640×480 RGBA, a frame is 1,228,800 bytes. At 60 fps, production is **73.728 MB/s**
(decimal); at 30 fps, 36.864 MB/s. These are planning dimensions; a backend advertises its
actual output. Two agent workers plus one presentation consumer can read the same immutable
frame object. Bus messages contain only references; there is no 3× byte fan-out through the
router. Readers still incur memory traffic/page faults, and renderer readback/seal copying
remain real costs. This is not a claim of zero-copy GPU capture or measured host performance.
The environment emits native game frames/audio. The application/presentation layer owns
resizing, overlays, compositing, browser delivery, codec choice and stream output. Do not put
1080p rendering, Twitch publishing or game-specific sampling logic in Flybus. A presentation
pipeline may itself exchange large artifacts through this same bus if useful.
## 11. Acceptance tests and implementation sequence
1. **Wire/router:** schema/framing, Hello, exclusive routes, pinned incarnations, request/reply,
disconnect and bounds. In-memory transport must pass the same tests as Unix sockets.
2. **Pub/sub:** exact topics, FIFO/bounded rejection, latest coalescing, retained replay/clear,
atomic fan-out and fair control/reply delivery under a saturated subscriber.
3. **Artifacts:** allocate/seal/read; publication before seal fails; fan-out owns one physical
object; last consumer releases; retaining an extracted frame after message drop works.
4. **Faults:** sender drops after admission; consumer dies mid-read; reply is lost; queued frame
is replaced; subscription closes with in-use deliveries; router restarts; old release arrives.
No double-free, use-after-reuse, unbounded tombstones or hidden operation replay.
5. **RPC cache:** endpoint retains an artifact-bearing result, original caller consumes it,
and a domain retry still returns valid bytes. Eviction drops the last cache hold correctly.
6. **Integration:** two parallel fake agents, complete-batch environment RPC, committed snapshot
publication and a deliberately slow presentation consumer over the same router.
7. **Performance:** 640×480×60 artifact production with three consumers, one delayed; measure
p50/p95/p99 RPC latency, router CPU, copy/readback cost separately, RSS, store live/peak bytes,
outstanding roots, collection lag and queue lengths. Compare one/two/four agent schedules.
The first executable example should show a counter RPC, a pub/sub observer, and a frame
artifact held past message consumption in one small Rust program. No game or browser required.
Distributed simulation ordering remains the [session contract's](step-v1.md) responsibility.
## 12. Amendments
Draft 1 stands as written above. Each amendment below names something the draft requires but
left unnamed, and is dated. The implementation and the sentence-by-sentence audit behind these
entries are in the [conformance report](bus-conformance.md).
**2026-09-22, from the flybus conformance audit.** Three error codes, because section 9's list
is inclusive and these three refusals had no name:
| Code | Reason |
| --- | --- |
| `CONFLICT` | Section 3's duplicate registration, section 5's conflicting topic redeclaration and section 5's `topic.delete` with subscribers are refusals of a live claim, not a missing route, a quota or a bad envelope. |
| `NO_TOPIC` | Publishing to or subscribing to a name nobody declared is a missing topic, and `NO_SERVICE` names the service case only. |
| `ARTIFACT_MISMATCH` | Section 9's "hash mismatch returns a typed artifact error", plus a sealed length that disagrees with the allocation and a reference that disagrees with the artifact it names; `STORE_FAILURE` would blame the store for the caller's claim. |
**2026-09-22, same audit.** One added operation, because section 6 requires bounded call
correlation and gives no way to end it when a handler keeps reply authority after releasing the
request delivery:
| Command | Body / reply value | Semantics |
| --- | --- | --- |
| `rpc.responder.release` | `{callId, requestDeliveryId}` -> `{released}` | The recipient gives up reply authority for a dispatched call. The final release for an attached call retires the correlation and emits `call.failed` with dispatch `dispatched`: `CALL_GONE` while the route is live, `NO_SERVICE` after route loss. Request consumption (section 8.3) stays independent of it. |
Both amendments change `contractDigest`, which section 4 already provides for.
**2026-09-22, coordinator decision on the audit's contradiction 1.** Section 9's table row
"Per-client ordinary queued envelope bytes | 1 MiB" now reads "Per-client ordinary **bounded**
queued envelope bytes", and latest slots get their own row, "subscriptions × 64 KiB", because
section 7's unconditional one-slot guarantee and the structural 1/2 cap outweigh one imprecise
table row: a budget whose overflow rejects a publication cannot contain a subscription that
this same section forbids to reject one.
**2026-09-22, coordinator decision on the audit's contradiction 2.** Section 2's sketch no
longer passes a `budget` into `bus.call` and shows the deadline at the caller instead, because
section 5's wire contract for `rpc.call` has no budget field and section 2 is self-labelled
illustrative.

View file

@ -1,216 +0,0 @@
# Checkpoint envelope v1: `FLYSESS1`
Status: **draft 1**, 2026-09-22. Specified by CONTRACT-01 of the
[implementation guide](implementation.md); required by
[session artifacts, native media and recovery](state-media-v1.md) section 4, which says to
"use a new envelope version; specify exact byte layout before production files". Reference
implementations of the layout: `services/flysim/crates/fly-session-types/src/checkpoint.rs`
and `packages/session-types/src/checkpoint.ts`; fixture:
`.../fly-session-types/fixtures/checkpoint-envelope.json`.
This is the byte layout and the durable commit sequence. The store itself, generations,
rotation, the writer thread and the capture RPC flow are the STATE-01 slice.
## 1. Why a new format
The historical envelope (`FLYSIM01`, `crates/flybrain-core/src/envelope.rs`) is a magic, a
`u32` manifest length, a JSON manifest, `u32`-prefixed chunks in manifest order and a CRC32
footer, with chunk names restricted to ASCII letters so the TypeScript reader can never name a
prototype key. It stays exactly as it is, and its reader stays separately readable: nothing in
this document changes a byte of it, and a `FLYSIM01` file is refused by a `FLYSESS1` reader at
the magic.
A coherent all-participant session checkpoint needs what that format does not have:
- payload names that are `Id`s (`agent-fly-a`, `executor-fly-a`), so the letters-only
constraint is widened **deliberately, in a new version**, rather than quietly;
- a per-payload content digest, because state-media-v1 section 1 makes digests mandatory on
checkpoint payloads and a group install must be able to fail one participant's bytes;
- a payload table with explicit offsets and lengths, so a reader can map one participant's
payload without walking every preceding chunk;
- SHA-256 over the whole prefix instead of CRC32, matching the `Digest` type these contracts
already use everywhere else.
## 2. Byte layout
All integers are unsigned little-endian. All digests are raw 32-byte SHA-256 (the manifest
records the same digests as lowercase hex `Digest` strings).
### Header, 32 bytes
| Offset | Size | Field |
| ---: | ---: | --- |
| 0 | 8 | Magic, ASCII `FLYSESS1` |
| 8 | 4 | `envelopeVersion`, `1` |
| 12 | 4 | `headerBytes`, `32` |
| 16 | 4 | `manifestBytes` |
| 20 | 4 | `payloadCount`, at most 64 |
| 24 | 4 | `tableOffset` |
| 28 | 4 | Reserved, must be zero |
### Manifest
`manifestBytes` bytes of canonical JSON (RFC 8785) at offset 32, no trailing newline. It is
canonical so the envelope's own digest is stable under reserialization, and a reader rejects a
manifest that is not already canonical rather than silently accepting a second spelling.
### Payload table
At `tableOffset`, which is `32 + manifestBytes` rounded up to a multiple of 8.
`payloadCount` entries of 112 bytes each, in write order:
| Offset in entry | Size | Field |
| ---: | ---: | --- |
| 0 | 64 | Name: an `Id` in ASCII, NUL-padded, no bytes after the terminator |
| 64 | 8 | `offset` |
| 72 | 8 | `byteLength` |
| 80 | 32 | SHA-256 of exactly `byteLength` bytes at `offset` |
### Payloads
Each payload starts at its declared offset. The first starts at the end of the table rounded
up to a multiple of 8; each subsequent one starts at the previous payload's end rounded up the
same way. Padding bytes are zero. Offsets are ascending and non-overlapping, which a reader
checks rather than assumes.
### Footer, 48 bytes
| Offset from end | Size | Field |
| ---: | ---: | --- |
| 48 | 8 | `fileBytes`, the total length including the footer |
| 40 | 32 | SHA-256 of every byte before the footer |
| 8 | 8 | Magic, ASCII `FLYSESSF` |
A truncated file therefore fails at the footer magic or the recorded length, not at an
arbitrary payload.
## 3. Manifest fields
State-media-v1 section 4 lists what the manifest records. The names below are the JSON field
names; a manifest missing any of them is not a complete checkpoint.
| Field | Contents |
| --- | --- |
| `envelopeVersion` | `1` |
| `checkpointId` | `Id`, the identity every participant's capture shares |
| `sourceScope` | `Scope`: session, epoch and the committed step |
| `episodeId` | `Id` |
| `worldTime` | `RationalNs`, the environment's logical time at that boundary |
| `schedulerId` | The coordinator's scheduler identity, `lockstep-v1` in v1 |
| `compositionDigest` | Coordinator scheduler and configuration identity |
| `portMap` | The exact port-to-agent map, `[{portId, agentId}]` |
| `compatibility` | Backend, content, patch, controller, parser and state-format identities |
| `agents` | Per agent: profile, dataset, **index** and model identities, resolved seed, tick count, remainder and the payload name holding its state |
| `coordinator` | Task ledger, prior world inspection, per-agent executor state, admission state and event watermarks, each as a payload name or an inline value |
| `helperState` | External-helper state required for exact resume, as payload names |
| `payloads` | `[{name, byteLength, digest}]`, mirroring the payload table |
**Amendment, 2026-09-22 (PUBLISH-01).** The `agents` row gains `indexDigest`, the index the
agent attested to at `Agent.Initialize`, and it joins that agent's compatibility identity.
Without it a replacement fly that built another graph -- the same dataset, the same neuron
count, another index -- passed the group check and was then published under its predecessor's
`indexDigest`, which is a graph identity crossing a recovery and exactly what section 5's rules
exist to prevent. It is recorded from the worker's attestation rather than recomputed from the
dataset, because the point is that the two can disagree. `envelopeVersion` stays `1`, which the
required-manifest-field rule below allows only while no production `FLYSESS1` file exists; once
one does, adding a required manifest field must bump it.
**Amendment, 2026-09-22 (STATE-01).** The table above names a holder for every payload except
the environment's own, although section 6's fixture has one (`world`) and a group install has
to map it by name like any other participant's. The manifest therefore also records:
| Field | Contents |
| --- | --- |
| `environment` | `{workerId, payload}`: which worker the world belonged to and the payload name holding its state |
The reference implementations' required-field set was also missing `helperState`, which this
section has listed from the start. Both are now in `REQUIRED_MANIFEST_FIELDS` in Rust and in
TypeScript, and the fixture was regenerated by the existing example. The schema set is
untouched, so `contractDigest` is unchanged.
`coordinator.eventWatermarks` is `{lastSourceStep, issued}`. The fixture illustrated
`{lastEventId, lastOrdinal}`, and it is the illustration that changed: an event id is derived
from the epoch, so a watermark spelled as one cannot be compared across the restore that
gives the session a new epoch, while a source step and an issued count can.
**A required-manifest-field change is compatibility-relevant and `contractDigest` does not
cover it.** The digest is taken over the schema set, and this manifest is not in it, so
`envelopeVersion` is the only thing that can carry such a change. It stays `1` here only
because no production `FLYSESS1` file exists yet: once one does, adding or removing a required
manifest field **must** bump `envelopeVersion`, because a reader of the older version would
otherwise accept a file it cannot completely read, or refuse one it could.
`payloads` is redundant with the table on purpose: the table is what a reader needs to map
bytes, and the manifest is what a store lists, compares and reports without opening the
payload area. A reader checks that the two agree.
What the manifest must **not** contain (state-media-v1 section 4): a transient bus `storeId`,
artifact ID, owner token, mapping or pointer. Payload bytes and durable content identity are
the only things that survive; on restore the durable store imports fresh bus artifacts, and
`sourceScope` is provenance, not a claim on the current router.
## 4. What a reader enforces
In this order, so a corrupt file fails on its own terms rather than on a derived value:
1. Length at least header plus footer; magic; version; `headerBytes`; reserved word zero.
2. Footer magic, `fileBytes` equal to the actual length, and the prefix digest.
3. Manifest inside the payload area, valid strict JSON (duplicate keys, invalid UTF-8 and
non-finite numbers refused) and already canonical.
4. `tableOffset` exactly at the laid-out position; the table inside the payload area.
5. Per entry: an `Id` name with no bytes after its terminator, names unique, the declared
offset exactly at the aligned end of the previous payload, the payload inside the payload
area, and its digest matching its bytes.
6. No padding between the last payload and the footer.
7. The required manifest field set, `envelopeVersion` of 1, and a `payloads` list that matches
the table name for name, length for length and digest for digest.
Failing any of these is a corrupt or foreign file. The group install rule of state-media-v1
section 5 then applies: corrupt any participant and installation fails as a group.
## 5. Durable commit
State-media-v1 section 6, in the order the writer performs it:
1. Write the envelope to a temporary generation file in the store directory.
2. `fsync` the file.
3. `rename` it to its final generation name.
4. `fsync` the store directory.
5. Write the store manifest to its own temporary file, `fsync`, `rename`, `fsync` the
directory.
**The store manifest rename is the durable commit point.** Before it, the generation file is
an unreferenced temporary that is never a restore candidate. After it, and only after it, the
writer reports a saved acknowledgment and moves the high-water mark.
Consequences the writer must respect rather than reinterpret:
- Bus publications for `captured`, `queued`, `committed`, `failed` and `superseded` are
distinct events; only durable completion produces the saved acknowledgment.
- A lost save reply never advances durable metadata: the coordinator resolves the same
operation or fails the epoch, and an unreferenced generation stays unreferenced.
- A failed write releases its owned ephemeral captures under the configured retry policy and
reports the failure. It never reports false durability.
- The writer owns the bus artifact handles until the bytes are committed or the job fails, and
drops them afterwards; durable files are outside the bus's ephemeral collection.
- No per-payload `fsync` inside one envelope: the single file `fsync` in step 2 covers it.
## 6. Fixture
`fixtures/checkpoint-envelope.json` holds one complete envelope: the manifest, five payloads
(one agent, one executor, the task ledger, the prior inspection and a world payload), the
envelope's base64 bytes, its exact layout (header size, manifest offset and length, table
offset, every entry's offset, length and digest, footer offset, total length) and six
corruptions a reader must refuse, each naming the byte to flip.
The two implementations are held to it from both directions: each parses the fixture and
checks every recorded offset, and the TypeScript side re-encodes the same manifest and
payloads and requires the bytes to be identical to the fixture. A layout change that only one
language makes therefore fails on the next test run.
## 7. Out of scope
Generations, rotation, hot versus durable copies, the capture queue and its bounds, the
`State.Capture` / `State.StageRestore` / `State.ActivateRestore` flow, compatibility
comparison rules and group fencing. Those are STATE-01, over this layout. `FLYSIM01` and the
legacy composition keep their own format and their own reader, unchanged.

View file

@ -1,311 +0,0 @@
# Future-agent implementation guide
Status: **draft 2**, paired with the [contract index](README.md). This is the execution guide
for the new process architecture; it refines the broader
[MaleCNS/modular backlog](../malecns-modular-implementation.md), not a request to implement
every future feature in one branch.
## 1. Start here
Before coding:
1. Read repository instructions, the architecture tour, public feed/control contracts, and all
documents in this directory. Reconcile any newer main/feature-branch changes with this baseline.
2. Record what the next slice will change, its compatibility surface and expected tests.
3. Use a dedicated branch/worktree. Follow the repository's coordinator/build/review roles.
4. Preserve the TypeScript oracle, default numerical version strings and legacy deployment.
5. Resolve contract contradictions before implementing; do not fill gaps with an implicit
asynchronous best-effort policy or a public controller API.
The immediate target is **one small Rust Flybus example with RPC, pub/sub and a frame artifact
held beyond the message object's lifetime**. Then build the synthetic two-agent session over
that same router using in-memory and Unix-socket transports. No separate worker transport,
NATS service, coordinator-owned lease manager or raw-frame socket channel is to be implemented.
## 2. Proposed implementation map
Begin under the existing Rust workspace; extract physical package locations separately.
The following are proposed names, not files that exist today:
```text
services/flysim/crates/
fly-session-types/ scopes, contracts, schema validation, canonical digests
flybus/ generic router/client/wire/artifact store; embedded or standalone
fly-session-rpc/ domain schemas/deduplication over flybus; NOT another transport
fly-session/ phase machine, coordinator, admission, task/executor traits
fly-session-worker/ dispatch shell, status/shutdown, agent/environment adapters
fly-session-store/ participant captures, manifest commit, group recovery
services/flysim/crates/flysim/
legacy/ compatibility composition (extract without changing behavior)
composition/ new config/registries and worker launching
packages/feed/ existing public v1, later public v2 schemas/fixtures
packages/brain/ reference model/readout and new contract-relevant golden generators
```
Do not create empty crates to satisfy this tree. In the first slice, types/transport/coordinator
may be modules in one small crate; split when dependencies and consumers justify it. Keep
Melee parser/codecs and Game Boy FFI out of the session-types crate. Worker executables can
be subcommands of one binary initially; process boundaries do not require separate repos.
## 3. Ordered slices
### CONTRACT-01 — Executable schemas and trace format
**Inputs:** [bus spec](bus-v1.md), session RPC, step, worker, state/media and publishing documents.
**Implement:** Flybus wire schema separately from session domain schemas; common scalar types,
closed enums, method payload validation and schemas;
canonical digests; fixture loaders in Rust/TypeScript. Specify seed derivation and exact
checkpoint envelope bytes before their respective real-agent/store slices. Create the trace
format from step-v1 section 8, distinguishing behavior fields from operational IDs/time.
**Acceptance:**
- JSON round trips across both languages; U64/float boundaries reject correctly.
- Duplicate keys, invalid UTF-8, envelopes over 64 KiB and unknown required fields fail.
- Distinguish bus callId, domain requestId, artifact identity and delivery/hold owner tokens.
- Fixtures include rational zero/reduced form, overflow, duplicate ports and analog limits.
- Contract digest is generated from a documented canonical schema set, not source formatting.
**Stop:** do not wire a real worker until payload ambiguities and retry identity rules agree.
### BUS-01 — Router and RPC, in-memory and Unix socket parity
**Depends on:** CONTRACT-01.
**Implement:** one flybus crate with bounded framing, bus.hello, exclusive service registration,
incarnation-pinned RPC/reply/cancel, typed route/admission errors and independent read/write
dispatch. Begin with artifact-free calls; do not claim full bus conformance until BUS-03.
**Acceptance:**
- Partial frames/writes, disconnect after request, lost result and retransmission fixtures.
- No automatic retry/failover; timeout/cancel-after-dispatch reports uncertain execution.
- Service incarnation replacement is visible. Request/reply correlation survives out-of-order
replies; status RPC can respond while another handler is delayed. Saturation is bounded.
- Both transports produce equivalent behavior traces for the same scenario.
### BUS-02 — Pub/sub, retention and backpressure
**Depends on:** BUS-01.
**Implement:** exact topics, subscribe/unsubscribe, bounded FIFO and latest policies, optional
retained latest/clear, per-recipient delivery IDs/consumption credits and fair control lanes.
**Acceptance:** overflow rejects a bounded publication before partial fan-out; latest replaces
only queued messages; delivery consumption returns credits; unsubscribe preserves already-
delivered ownership; retained replay is ordered; stalled observers cannot starve RPC replies.
Durable event history is a storage client, not a second broker built into Flybus.
### BUS-03 — Artifact-backed messages and automatic lifetimes
**Depends on:** BUS-02.
**Implement:** immutable file-backed store, allocate/seal/open, bus attachment validation,
producer/queue/delivery/retention owners, RAII DeliveryGuard and explicit cache holds. Root
creation/admission is atomic. Start with ordinary local files/tmpfs; no pooled slot reuse yet.
**Acceptance:** last owner collects; extracted handle survives message drop; forward-before-
release is safe; lost replies/cache replay remain valid; disconnect releases logical ownership
without mutating still-mapped bytes; retained latest and queue replacement release correct roots.
Measure 640×480 RGBA×60 with three readers: one stored image, no raw pixels in router messages,
bounded CPU/RSS/owners/queues. Record reader/copy costs rather than claiming zero-copy capture.
### SESSION-01 — Synthetic sequential transaction
**Depends on:** BUS-03.
**Implement:** small fake agent workers, one counter/arena environment, identity executors
and a deterministic task. Follow Prepare→Advance→Evaluate→Commit exactly. Use explicit seeds
and rational clock accumulation; the fake model must expose a mutation counter for tests.
Implement domain request deduplication/result caches over bus calls; retaining result artifacts
is an endpoint responsibility. Domain Acknowledge differs from bus delivery.consumed.
**Acceptance:**
- One world advance per complete batch; every agent Prepared before Advance.
- Task evaluates once; every agent commits before next Prepare or committed publication.
- A synthetic 60-Hz/1-ms profile produces 16,17,17 ticks and remainder zero after three steps.
- Pause mid-step completes the step and pauses at its committed boundary.
- Bootstrap/warm-up cannot advance the environment or produce gameplay rewards.
- Domain retries use a new callId with the original requestId; they never repeat ticks/reward.
### SESSION-02 — Parallel processes and fault behavior
**Depends on:** SESSION-01.
**Implement:** one agent process per fly and one environment process under the coordinator;
compare with in-process and dedicated-thread variants. Enforce total thread budgets and
configured agent/port identities. Failure stops the epoch rather than neutralizing a player.
**Acceptance:** sequential, reversed order and parallel completion produce equivalent traces;
delayed one-agent result holds the world; worker/helper death has a bounded diagnosed outcome;
an uncertain Advance never creates a second batch; partial Commit never permits next-step play.
### MEDIA-01 — Native observation schemas and presentation handoff
**Depends on:** SESSION-02.
**Implement:** view/sample descriptors and producing-step validation on top of bus ArtifactRef,
not a second buffer system. Environment outputs native media; sensor transforms remain agent
profiles, while presentation owns viewer resizing/composition/audio/streaming.
**Acceptance:** bad strides/lengths/producer times fail; shared image reaches both agents through
owned attachments; spectators use latest subscriptions and cannot corrupt sensory state;
delayed rendering retains its handle. Distinguish AssetRef from transient ArtifactRef.
### AGENT-01 — Existing neural core worker
**Depends on:** SESSION-02, MEDIA-01 and the profile/identity foundation in the broader backlog.
**2026-09-22:** blocked. The profile/identity foundation is FOUNDATION-02
(`feat/brain-profile-contract`) in the [MaleCNS backlog](../malecns-modular-implementation.md),
which has not been built. Not started.
**2026-09-23:** unblocked. The operator split FOUNDATION-02 (decision of 2026-09-23): its legacy
half, PROF-02a, is the [legacy Game Boy composition](legacy-gameboy-v1.md) contract with
machine-readable profile, readout-context and decision schemas in `fly-session-types` and
`@flybrain/session-types`; the MaleCNS half (PROF-02b) is later and does not gate this slice.
AGENT-01 builds the legacy profile `gameboy-legacy-fafb-v783-v1` first. It must: report
`brainTicks` equal to the legacy `network.ms` (legacy-gameboy-v1 section 3); consume
`gameboy-readout-context-v1` and return `gameboy-channels-v1`; keep the held channel, blocked
window and last location as private readout state; report `stimulusRemainingMs`; answer
`Agent.Rollback` under capability `legacy-ratchet-rollback-v1`; and choose the mapping from the
legacy rate-role names (`command_0`, `macro_*`, which are not `Id`s) to `AgentGraph.rateRoles`,
which that contract leaves open.
**Implement:** adapter over existing LIF, plasticity, retina and fixed readout primitives;
reference-first composition/goldens; independently seeded agent state and shared immutable data.
Avoid using the old whole-frame `tick` wrapper if it changes the specified phase ordering.
**Acceptance:** per-agent state agrees with the reference across Prepare/Commit, stimulation,
zero/nonzero reward, warm-up and pauses. Two agents cannot share gains/RNG/holds; swapping
dispatch order and varying worker count preserves results. Keep 64-role limits explicit.
### ENV-01 — Game Boy compatibility environment
**Depends on:** AGENT-01 and environment/task extraction in the broader backlog.
**2026-09-22:** blocked. AGENT-01 is blocked, and environment/task extraction is
RUNTIME-01 (`refactor/environment-task-boundary`) in the same backlog, which has not been
built. Not started.
**2026-09-23:** unblocked. RUNTIME-01's contract is the RT-01a amendments of 2026-09-23 to
[workers-v1](workers-v1.md) (sections 1, 3, 4, 5 and the new section 7),
[step-v1](step-v1.md) (sections 2, 3, 5 and 6) and [state-media-v1](state-media-v1.md)
(sections 2, 3, 4, 5 and 7), with the Game Boy specifics in
[legacy-gameboy-v1](legacy-gameboy-v1.md), all implementing the operator's decisions of
2026-09-23. ENV-01 and AGENT-01 may proceed in parallel against the schemas and fixtures; ENV-01
needs FND-01's trace harness for its "legacy fixtures unchanged" acceptance. ENV-01 must also:
add the one read-only bulk memory read to the shim and prove it mutates nothing; publish the
memory image per boundary; declare the one-frame setup scaffold; convert audio to f32 and leave
the DC blocker to the edge; implement `gameboy-slots-v1`; and make the coordinator refuse
`episodeRequest.kind = "rollback"` in any composition that declares no rollback policy (the
synthetic coordinator today pauses on every episode request, which is safe but not the rule).
**Amendment, 2026-09-23 (operator decision of 2026-09-23).** The "Implement" paragraph below said
to keep `legacy-gameboy-v1` separately routed. The operator decided on a full port instead: the
legacy composition runs on `lockstep-v1` as declared in legacy-gameboy-v1, and "exact old
ordering/hash semantics" is kept by construction and by test rather than by a separate route --
the frame order maps one to one (section 4), the legacy clock equals the rational one
(section 3), and the fingerprint and compatibility string are embedded unchanged (sections 2
and 12). The acceptance criteria below stand.
**Implement:** binjgb environment, task-local memory inspector and identity/existing action
adapter. Keep `legacy-gameboy-v1` separately routed with exact old ordering/hash semantics.
**Acceptance:** legacy fixtures/goldens/restore outcomes unchanged; the new composition has
its own identity and public adapter selection. No console-specific state enters generic
session types. ROM-backed checks are optional explicit jobs, not required downloads.
### STATE-01 — Coherent all-participant checkpoint/recovery
**Depends on:** SESSION-02, MEDIA-01; validate with fake agents first, then AGENT-01/ENV-01.
**Implement:** exact new envelope schema, compatibility manifest, Capture/StageRestore/
ActivateRestore, bounded writer, durable commit acknowledgments and fresh-epoch fencing.
Keep old `FLYSIM01` reader separate. Payloads and coordinator state must refer to one boundary.
**Acceptance:** uninterrupted versus resumed synthetic/real-agent traces match after accounting
for new epoch metadata; corrupt any participant and installation fails as a group; lost save
reply doesn't advance durable metadata; failure during activation cannot resume half a world.
Checkpoint queue stress remains bounded; verify old media/parser data cannot cross recovery.
### PUBLISH-01 — Committed snapshots and observer isolation
**Depends on:** SESSION-02, MEDIA-01; public v2 contract work is a separate prerequisite to
publishing a supported multi-agent browser feed.
**Implement:** internal [publication boundary](publishing-v1.md) over the SAME bus, latest-value
observations, application-owned state/cues, bounded events, descriptor repair/query and a fake
multi-agent consumer. Presentation gateway owns browser delivery; no generic show/tournament
service or codec is added to the router. Then implement
the approved public v2 wire schemas/fixtures and stage adapters together.
**Acceptance:** browser disconnect/backpressure never advances/stalls the world; future agent
state is not mixed with old media; descriptor/index mismatch is visible; committed actions
are labeled as the transition that just ended. PNG/browser review gates apply to actual UI.
### DOLPHIN-01 — Substitute the backend, not the coordinator
**Depends on:** measured MELEE-01/02 spikes from the [Melee audit](../melee-framework-audit.md),
SESSION-02, MEDIA-01 and declared recovery support.
**Implement:** bus-connected helper over pinned Dolphin/libmelee or the chosen narrow hook,
complete port batch adapter, frame-identified sensory output and task-local Melee inspection.
Keep actual rendering/input/save semantics behind the same Environment API.
**Acceptance:** all generic backend conformance tests plus delayed-port/flush ordering,
game-frame reset, parser recovery, pixel latency and neutral/release tests. If exact snapshot
is unsupported, advertise episode-restart and use only the matching prototype policy.
## 4. Failure injection checklist
Tests must deliberately inject these cases; success-path demos are insufficient:
| Injection | Required invariant |
| --- | --- |
| Duplicate Prepare after lost reply | No extra ticks, RNG draws, stimulation or decode |
| Same batch with altered controls | Conflict, never a second world mutation |
| Lost Advance result after world step | Resolve same operation or fail epoch |
| One Commit fails after another succeeds | No next world step; coherent recovery only |
| Old worker replies after restore | Stale epoch/incarnation rejected |
| Message drops while extracted image is rendering | DeliveryGuard keeps bytes alive through last use |
| First agent releases a shared image early | Artifact remains until every other owner finishes |
| Cached RPC artifact is consumed by its first caller | Domain cache still owns it for retry |
| Latest queued frame is replaced | Only that queue root drops; in-use images remain valid |
| Router restarts during a world advance | Old handles/routes invalid; epoch fails and restores coherently |
| Viewer holds output indefinitely | Only spectator data is dropped/disconnected |
| Backend waits for input while capture is requested | No deadlock; capture only at valid quiescent boundary |
| Capture writer stalls | Finite queue/memory, honest durable status |
| StageRestore validates three participants, fourth fails | Nothing is resumed |
| ActivateRestore fails halfway | Group remains fenced, no new gameplay |
| Old epoch audio arrives after reset | Discontinuity handling; no stale playback as current |
## 5. Verification and handoff
Before each implementation merge, run the repository's required `npm test`,
`npm run typecheck`, `cargo test --workspace` from the Rust workspace, and
`infra/tests/lint.sh`. Run affected browser/PNG gates for presentation changes. Keep the
existing committed FAFB real-data goldens mandatory; larger new datasets and game-backed
jobs report explicit optional skips.
Performance checks report total physical-core allocation, one/two/four agents, within-agent
worker count, router/RPC latency, artifact production/read/copy time, critical-path percentiles,
memory peaks, owner/GC statistics and
bounded queue behavior. No host capacity claim follows from a local synthetic timing test.
Deployment-host work is separately authorized/claimed/serialized under repository rules.
Every completed slice leaves:
1. The implemented contract/schema revision and compatibility decisions.
2. A minimal runnable synthetic example and exact test commands/results.
3. Behavior traces demonstrating its acceptance criteria.
4. Known unsupported capabilities and remaining measured questions.
5. Updated planning status, with no claims that a stub provides real emulator semantics.
Do not start by moving every directory, adding a service mesh, or rewriting the model. The
first useful deliverable is the small artifact-backed bus example, followed by the synthetic
distributed step transaction using it. No one-off communication stack per component.

View file

@ -1,216 +0,0 @@
# Session RPC contracts over Flybus
Status: **draft 2**, 2026-09-18. The filename is retained for existing links. This document
now defines **domain contracts carried by [Flybus v1](bus-v1.md)**. It no longer defines a
separate socket protocol, direct worker connections, or coordinator-owned buffer service.
The [architecture index](README.md) states scope and precedence. Public feed/control v1 stay
unchanged; this is the new internal session path.
## 1. One transport, domain-specific meaning
Every session/worker RPC is a Flybus call to a named, incarnation-pinned service. Pub/sub,
application supervision and artifact bookkeeping use the same bus. The router moves messages;
the receiver validates its method payload and the [session step machine](step-v1.md).
The bus owns framing, connection identity, route registration, bounded delivery and artifact
ownership. This document owns Scope, model-related scalar types, worker capability negotiation,
domain operation deduplication and errors. Domain request identity is independent of the bus
callId: a safe retry has a new transport callId but the original domain requestId/body.
## 2. Common domain types
```ts
type Id = string; // ^[a-z0-9][a-z0-9._-]{0,63}$
type U64 = string; // "0" or [1-9][0-9]*; <= 18446744073709551615
type Digest = string; // 64 lowercase hexadecimal digits (SHA-256)
interface Scope { sessionId: Id; epoch: Id; step: U64 }
interface RationalNs { numerator: U64; denominator: U64 }
interface SchemaRef { id: Id; version: number; digest: Digest }
interface TypedValue { schema: SchemaRef; value: object }
interface SessionRpcRequest { requestId: Id; scope: Scope | null; params: object }
```
All fields are required unless marked `?`. Schema version is integer 1..65535. Fractions
are reduced, denominators positive, durations positive; zero is encoded 0/1. Arithmetic is
checked. JSON numbers representing rates/rewards/controls are finite. Counters/clocks use
decimal strings. Task/profile schemas bound collections and numeric ranges before mutation.
First session composition limit: 4 agents, 4 ports and 64 rate roles per agent; these are
session/model limits, not limits on the number of application personas or generic bus clients.
Each TypedValue has a canonical JSON size limit of **32 KiB**, while the complete envelope
must still fit Flybus's 64-KiB maximum. Large typed state goes in a listed Artifact attachment
under an explicit schema, not an oversized inline object. Changing the old draft's 1-MiB
worker envelope to Flybus must not silently truncate a payload.
## 3. Request/reply mapping
Illustrative client call:
```text
bus.call(
target = {service: "agent.fly-a", expectedIncarnation: pinnedRegistration},
method = "Agent.Prepare",
payload = {requestId: "req-41", scope: {sessionId, epoch, step: "41"}, params},
attachments = ownedArtifactHandles
)
```
Flybus's eventual rpc.result `outcome` is one of:
```ts
interface SessionRpcSuccess {
type: "result"; requestId: Id; workerId: Id; incarnationId: Id;
scope: Scope | null; result: object;
}
interface SessionRpcFailure {
type: "error"; requestId: Id; workerId: Id; incarnationId: Id;
scope: Scope | null;
error: { code: ErrorCode; message: string; mutation: "none" | "applied" | "unknown" };
}
```
Replies echo the original scope. The receiver identity and bus service incarnation must
match the negotiated worker. Bus route/admission failure is not a SessionRpcFailure produced
by the handler. A bus admission acknowledgment is not an Agent.Prepare/Environment.Advance
completion. Only a matching terminal domain reply resolves a simulation phase.
ArtifactRefs inside request/result payloads must be declared in bus attachments and backed by
live owned handles. Domain canonical-body digests include the references but exclude changing
bus callIds, deliveryIds and owner tokens. A cached result owns Artifact handles independently
of the first delivery; it is not a JSON object holding unowned pointers.
## 4. Worker negotiation and status
After bus connection/registration, call Worker.Hello (`scope:null`):
```ts
interface HelloParams {
sessionId: Id; expectedWorkerId: Id;
role: "agent" | "environment" | "coordinator";
supportedMajors: number[];
}
interface HelloResult {
selectedMajor: 1; selectedMinor: 0;
workerId: Id; incarnationId: Id; role: "agent" | "environment" | "coordinator";
buildDigest: Digest; contractDigest: Digest;
capabilities: Id[];
limits: { maxAgents: number; maxPorts: number; workerThreads: number };
}
```
`limits.workerThreads` is the thread allocation the worker's launcher started it within; it
is an integer >=1 and its rule belongs to [worker interfaces](workers-v1.md) section 2, whose
2026-09-22 amendment added it.
The bus supplies caller identity; do not accept a forged caller in params. Bind a worker's
session authority to the expected coordinator identity/incarnation during negotiation and
initialization. Wrong worker/role, no common major or missing required capability refuses
the composition. Required capabilities are agent-step-v1 and world-step-v1 for their roles;
checkpoint-v1 and pixel-observation-v1 are conditional. Artifact transport capability is
negotiated once by Flybus, not as another worker memory API.
Worker.Status has params `{}` and the caller's last known scope (null before initialization):
```ts
interface StatusResult {
state: "uninitialized" | "ready" | "preparing" | "prepared" | "advancing"
| "committing" | "capturing" | "staged-restore" | "restoring"
| "failed" | "stopping";
currentScope: Scope | null;
activeRequestId: Id | null; lastCompletedRequestId: Id | null;
lastBatchId: Id | null; progressCounter: U64;
}
```
ProgressCounter advances on computational/phase progress, not on status queries. Dispatch
Status through the same service without waiting for a long numerical operation. One mutation
executes at a time; at most one may be pending, and normal stepping pipelines neither. The
router's larger RPC capacity is not permission to overlap worker mutations. Never hold a
simulation lock while waiting for network I/O, artifact resolution or release bookkeeping.
## 5. Domain idempotency and retention
`requestId` is `req-` plus a canonical U64 serial, increasing for newly issued operations
per caller/worker pair. Retries reuse it unchanged even though bus callId changes. Flybus
preserves first-dispatch order per caller/service; worker handlers maintain that request
admission order while allowing read-only status alongside compute.
The operation key for step mutations is `(sessionId, epoch, step, method, workerId)`.
There is at most one Prepare, Commit or Advance for that key.
- Same key/request/body returns its cached reply, with fresh bus delivery ownership over
retained artifacts. It never repeats ticks, stimulation, controller execution or reward.
- Changed ID/body for an existing key is CONFLICT. Canonical comparison uses RFC 8785 over
method, scope and validated params. A rejected duplicate does not undo the earlier result.
- Check retained request identity before phase checks or artifact dereferencing. A duplicate
may arrive after the original input delivery was consumed; it needs only the cached result.
- Keep current/immediately previous step result records. Eviction never enables reexecution:
highest-issued request serial and step watermarks reject expired retries/old steps.
- Original expired serial → RESULT_EXPIRED; fresh serial naming an old step → STALE_STEP.
An exact duplicate arriving while execution is active receives the terminal domain error
IN_PROGRESS for that **bus call**. The original bus call still completes normally. Retry or
query Status later; no second mutation is started. This avoids multiple terminal replies to
one bus call and does not mistake IN_PROGRESS for the original operation's failure.
Lifecycle/capture replies are retained until Worker.Acknowledge:
`params:{requestIds:Id[]}` (1..16), result `{acknowledged:Id[]}`. It drops domain cache handles,
not another consumer's bus delivery. Already released/unknown IDs are ignored. Serial
watermarks reject reuse after acknowledgment without an unbounded tombstone list.
**Amendment, 2026-09-22 (CONTRACT-01):** those ids are domain request ids in the `req-<U64>`
serial form, not arbitrary `Id`s. The serial watermark rule in the sentence above cannot reject
reuse after acknowledgment unless the acknowledged id carries its serial, so a bus callId or a
bare `Id` is refused there.
Bound unacknowledged lifecycle replies at 16, then BUSY before application. Status and
Acknowledge use a cache of their last 16 replies; current/previous step records have their
separate finite retention. Caches containing big artifacts consume bus owner/byte budgets;
configure capture limits consistently. Never evict a promised replay artifact but keep a
successful pointer-only reply. An intentionally expired result returns RESULT_EXPIRED.
## 6. Timeout and failure handling
Timeouts are measured on the caller's monotonic clock. Prototype defaults: probe after two
seconds without reply, fail after ten seconds without progress; long boot/capture have separate
budgets. These are failure-detection values, not a gameplay latency goal.
After an uncertain call:
1. Stop further world-step dispatch.
2. If the same bus/service/worker incarnation still exists, query Status or issue a fresh
bus call with the original domain requestId/body and retained input attachments.
3. Resolve only a matching terminal result. Never repeat an Advance with a new domain ID.
4. If routes/ownership were lost, incarnation changed or retained result expired, fail the
epoch and restore/reset the group.
Endpoint crash or bus restart is not covered by in-memory deduplication. Router/store restart
invalidates all transient artifacts and routes. Worker disconnect also invalidates its bus
owners and registration; v1 does not silently reattach that worker to an active epoch.
Recover coherently even if an OS process survived with some numerical state in memory.
## 7. Domain errors
| Code | Meaning |
| --- | --- |
| INVALID_ARGUMENT | Invalid schema/range, before mutation |
| UNSUPPORTED | Missing method/capability |
| IDENTITY_MISMATCH | Wrong session/profile/port/build/asset identity |
| STALE_EPOCH / STALE_STEP / FUTURE_STEP | Timeline/order mismatch |
| INVALID_PHASE | Wrong worker phase |
| CONFLICT | Existing logical operation with changed ID/body |
| IN_PROGRESS | Original operation still executing; duplicate bus call did not start work |
| BUSY | Domain capacity unavailable before admission |
| BUFFER_INVALID | Missing/unowned/mismatched artifact or invalid media shape |
| RESULT_EXPIRED | Safe replay is no longer available; never recompute to replace it |
| INCOMPATIBLE_STATE | Restore validation failed before activation |
| BACKEND_FAILURE / INTERNAL | Runtime fault, with explicit mutation certainty |
Messages are <=512 code points and exclude raw game memory/credentials. Errors after partial
mutation use unknown unless completion is established. No error authorizes skipping a fly,
pressing fallback controls, or continuing a partially committed match.
Worker.Shutdown, params `{reason:Id}`, returns `{stopping:true}` if responsive and terminates
the worker after replying. It does not imply saved state. Only configured supervisors may
invoke it; workers have no authority to shut down the coordinator. Shutdown/release notifications
travel on the same bus; there is no reverse lease socket or Buffer.Release/Reclaim RPC.

View file

@ -1,437 +0,0 @@
# Legacy Game Boy composition v1
Status: **contract**, 2026-09-23. It covers PROF-02a, the legacy half of the FOUNDATION-02 split,
and the Game Boy parts of RT-01a. The generic parts of RT-01a are dated amendments to
[worker interfaces](workers-v1.md), [step protocol](step-v1.md) and
[session media/state](state-media-v1.md), and they point back here. This document changes no
runtime behaviour: `flysim` is unchanged and so is its compatibility string, 648 bytes, sha256
`4929f3409b591ae21cf4a6d53e8e758b975c70f424eabb0b37db75c658b9ebd9`.
## 1. The decision and what it replaces
On 2026-09-23 the operator decided that the live fly gets a **full port** onto the session
framework. It is not kept outside the framework as a separately routed legacy loop. The
decisions this document implements are:
| Subject | Decision |
| --- | --- |
| FOUNDATION-02 | Split. The legacy profile ships now; MaleCNS bundles ship later (02b, section 17) |
| Profile | `gameboy-legacy-fafb-v783-v1`. It embeds today's schema-1 fingerprint, and the kernel `lif-1ms-f64-v2` and plasticity `fly-kc-mbon-rstdp-v2` are unchanged |
| Readout context | Location is allowed and declared: `gameboy-readout-context-v1 {boot, bound[], location\|null}` |
| Decision | `gameboy-channels-v1`: the eight buttons plus the macro group's winner |
| Decoder identity | The decoder and macro-channel configuration go in the composition digest, not the legacy compatibility string |
| Environment boundary | Macros run in the coordinator's ActionExecutor. It reads a 64 KiB memory image each boundary, carried as an artifact in `inspection`, plus the ROM as an AssetRef |
| Emulator shim | One read-only bulk memory read is added. The joypad stays the only write |
| Task and executor | One object, declared as the extension `executor: pokered-macros-v1` |
| Audio | The environment converts u8 samples to f32, and the edge applies the DC blocker |
| Initialize | `Environment.Initialize` runs one frame with no button pressed |
| Rollback | `legacy-ratchet-rollback-v1` plus the environment extension `gameboy-slots-v1` |
| Restore | `restore: legacy-transient-reset`, which clears the ledgers, the location and the held channel |
| Sugar | Admission reads `reward_remaining` from the last commit's telemetry. A lag of one commit is accepted |
| Checkpoint | FLYSIM01 stays the format of record until RETIRE-01 |
Several earlier statements said the legacy composition stays outside `lockstep-v1`:
[README](README.md) section 4, [implementation guide](implementation.md) ENV-01,
[state-media-v1](state-media-v1.md) section 7 and the
[modular-session analysis](../malecns-modular-sessions.md) section 5.2. Each now carries a
dated amendment that cites this decision. None was silently rewritten. The reason for the change is in
section 4: the legacy frame order already *is* the lockstep order, with one agent, one port
and one world. Moving it onto the framework therefore keeps every ordering fact those
statements protected.
## 2. The profile `gameboy-legacy-fafb-v783-v1`
Exactly one document exists. Every field of it is fixed, so both the document and its digest are constants of
this contract. Any other value is another profile and needs another id. The document is canonical
JSON (RFC 8785), its `AssetRef` is `{id: "gameboy-legacy-fafb-v783-v1", format:
"fly-profile-v1", digest, byteLength}`, and the digest and length are taken over the canonical
bytes. The current values are in `fixtures/gameboy-legacy.json`: digest `41e5d1ac…c60878`, length 1137.
| Field | Value | Why it is fixed |
| --- | --- | --- |
| `profileId` | `gameboy-legacy-fafb-v783-v1` | |
| `datasetId`, `fingerprintSchema` | `fafb-v783`, `1` | The schema-1 fingerprint is the seven SHA-256 digests joined with `:` |
| `datasetFingerprint` | Today's value, byte for byte the compatibility string's segment 2 | This is the "embeds today's schema-1 fingerprint" of the decision. `flysim`'s `legacy_profile_identity` test recomputes it from `data/fafb-v783` |
| `kernelVersion`, `plasticityVersion` | `lif-1ms-f64-v2`, `fly-kc-mbon-rstdp-v2` | Pinned defaults (CLAUDE.md). The same test compares them with the built network |
| `tickDuration` | `1000000/1` ns | One model tick |
| `warmupMs` | `2500` | Fresh-start warm-up with learning disabled, `DEFAULT_WARMUP_MS`. A service configured with another warm-up (`loop.warmup_ms`, `FLYSIM_LOOP_WARMUP_MS`) is **another profile** and needs its own id; the legacy composition refuses to start this profile with any other value. It matters only on a fresh start, because a restore never warms up, but it is identity all the same |
| `view` | `lcd`, 160 x 144 | The retina's native frame |
| `supportedStimuli` | `["reward-pulse"]` | Sugar and task reward events both drive `stimulate(durationMs)` |
| `readoutContextSchema`, `decisionSchema` | The registered references of sections 5 and 6 | |
| `legacyExceptions` | `["macro-roles-outside-fingerprint"]` | The `macro_*` roles are merged after the fingerprint is taken ([modular analysis](../malecns-modular-sessions.md) 2.2). This profile declares the gap instead of repairing it |
The profile does not name the decoder timings or the macro channels. Those belong to the
composition (section 12), because the legacy compatibility string never covered them and the
decision puts them in the composition digest.
## 3. Clock
`stepDuration` is one Game Boy frame: 70224 cycles of a 4194304 Hz clock, which is
**`8572265625/512` ns** exactly. The legacy loop accumulates the `f64` constant
`1000 / (4194304 / 70224)`. That constant is exactly `548625/32768` ms, because it is dyadic and
the division rounds to the true value. Every remainder of the loop's `remainder += ms_per_frame`
is therefore a multiple of 2^-15 ms below 32, which is exact in `f64`. As a result, the legacy accumulator and the rational
accumulator of [step-v1](step-v1.md) section 5 produce **identical** tick counts and remainders.
Both languages assert this over the first 100,000 (Rust) and 20,000 (TypeScript) frames, and
the fixture records the first twelve frames: 16, 17, 17, 16, and so on. No separate legacy
arithmetic is needed, and step-v1's rule that the clock must not accumulate rounded time holds unchanged. A
FLYSIM01 remainder (`f64` ms) converts exactly to a `RationalNs`.
The task's clock is the agent's brain time. `PreparedDecision.brainTicks` times 1 ms is the
legacy `network.ms`, and it counts warm-up. AGENT-01 must make `brainTicks` equal to that value,
because the ratchet windows and the reward adapter's timing read it.
## 4. Placement in `lockstep-v1`
The legacy `Sim::step_frame` order maps onto the transaction phases one to one:
| Legacy step | Lockstep phase |
| --- | --- |
| Drain commands (sugar) | Admission cut at `Ready(k)`. The sugar enters `Prepare.preStepStimulations` (section 15) |
| `network.step(ticks)` | Phase A: `Agent.Prepare` advances the ticks |
| `decode_bound(rates, ms, boot, blocked, bound)` | Phase A: readout with the context of section 5. The blocked rule stays inside the agent |
| `MacroLayer::decide` | Phase B: the `pokered-macros-v1` executor reads O[k]'s memory image (section 10) |
| `set_buttons`, `run_frame` | Phase B: one `Environment.Advance` with the complete joypad batch |
| Framebuffer, `take_audio_u8` | The environment returns O[k+1]: view, audio chunk, memory image |
| `adapter.sample` | Phase C: the task, which is the same object, evaluates old/new inspection once |
| `stimulate` per event, `reinforce(sum)` | Phase D: `Agent.Commit` installs the input, then the stimulations in event order, then one reinforcement |
| `MacroLayer::observe`, `location()` | Phase C: this produces the next context's `bound` and `location` |
| Ratchet observe, capture, recover | Phase C decides. `Environment.SaveSlot` and the rollback run at `Ready(k+1)` (section 11) |
| Milestone archive | A durable save at `Ready(k+1)`, exported as FLYSIM01, **after** that boundary's slot save (section 16) |
**Amended 2026-09-23, review round 1.** One order differs from the legacy loop and is declared.
Legacy `track_rank` archives the milestone *before* the ratchet captures, in the same frame, so a
legacy archive holds the pre-capture ratchet (`best` = the previous rung) with the previous
snapshot. Here the ratchet ledger commits `best = r` in Phase C, and the slot is only filled by
`Environment.SaveSlot` at `Ready(k+1)`. A capture ordered before that save would pair `best = r`
with the previous slot's contents -- or an empty slot on the first climb -- on every rank climb.
Section 16 therefore orders the save first, and a ported archive holds the **post-capture**
ratchet and slot. Both are internally consistent; they are not the same bytes.
The input installed at Commit and ticked at the next Prepare is the frame the legacy loop
hands `set_visual_frame` before it samples rewards. Rewards are sampled from the frame just
produced. This is the ordering that [modular analysis](../malecns-modular-sessions.md) 2.1 says must not
move, and it does not. FND-01's trace harness is where this mapping is proved against the running
loop.
## 5. Readout context `gameboy-readout-context-v1`
```ts
interface GameboyReadoutContext {
boot: boolean; // the adapter's boot gate after the last transition
bound: ChannelName[]; // the executor's bound macro channels, composition order; [] in raw mode
location: { area: number; x: number; y: number } | null; // the adapter's location, or no information
}
```
`ChannelName` is `^[a-z][a-z0-9_]{0,63}$`. Decoder channel and rate-role names carry `_`, so
they are not `Id`s. The context is what the task hands the decoder with each Prepare (the
`initialDecisionContext`, then every `nextDecisionContext`). `bound` is an ordered subset of the
composition's `macroChannels`.
**Location is allowed and declared.** It is task inspection data, and it reaches exactly one
place: the readout's blocked-direction window ([readout](../../readout.md), "Blocked-direction
cooldown"), which restarts when the location changes. It never reaches the network, it
changes no score and it is not neural input. Declaring it here satisfies
[workers-v1](workers-v1.md) section 4: the context is typed, bounded, versioned and
allowlisted by the profile. The blocked direction itself is **not** in the context. The agent
computes it from its own held channel, its own clock, `blockedMs` and the location history.
The held channel, the start of the blocked window and the last location are private readout
state of the agent.
## 6. Decision `gameboy-channels-v1`
```ts
interface GameboyChannelsDecision {
buttons: { id: "up"|"down"|"left"|"right"|"a"|"b"|"start"|"select"; down: boolean }[]; // all eight, this order
macro: ChannelName | null; // the macro-group channel active in this decode
}
```
The `buttons` array is the decoder's active set packed in `GAMEBOY_BUTTON_BITS` order, so bit *i* is
`buttons[i]`. `macro` is the macro group's channel in the active set, which is always one of the
context's `bound` channels. Together they are everything `MacroLayer::decide` reads: the raw
mask and the active macro. No port assignment or inspection field is in the decision.
## 7. Controller
One port, `ControllerSchema {schema: gameboy-joypad-v1, buttons: [up, down, left, right, a, b,
start, select], axes: []}`. The executor's `ControllerIntent` is the joypad mask, and it
becomes the port's `PortControl`. It is the only input the environment applies to a running game.
## 8. Inspection `gameboy-memory-inspection-v1`
```ts
interface GameboyMemoryInspection {
memory: ArtifactRef; // 65,536 bytes: $0000..=$FFFF as the CPU sees it at this boundary
romDigest: Digest; // == EnvironmentDescriptor.contentDigest == the executor's rom AssetRef digest
}
```
- **The image.** Byte *i* is what `fly_gb_read_mem(i)` returns at this boundary, which is
what every task and executor read in the legacy loop sees through the per-frame read cache.
It is a listed bus attachment, content type `application/octet-stream`. Its digest is optional,
because it is a transient live artifact ([state-media-v1](state-media-v1.md) section 1). At
59.73 frames per second it is about 3.9 MB/s.
- **The shim.** The emulator shim gains one function that fills a 65,536-byte buffer from
`emulator_read_mem` in address order. It is read-only. The implementing slice proves this by
exporting the emulator state before and after the call, comparing the bytes, and comparing
the buffer with 65,536 single reads. Nothing else in the shim changes. `fly_gb_set_buttons` stays
the only write, and there is no memory-write path. `read_uncached` is a probe tool and is
not available to a task or executor.
- **The ROM.** Macros read ROM banks (`MemoryReader::read_rom(bank, address)`) that the image
does not map. The executor gets the cartridge as a persistent `AssetRef` in the composition
(section 12), and it is refused unless its digest is the environment's `contentDigest`. The
image never carries ROM banks beyond the ones the CPU has mapped.
- **Retention.** The coordinator keeps O[k]'s image until transition k→k+1 has been evaluated.
The executor reads it in Phase B, and the task reads old and new images in Phase C.
## 9. Environment
- **Initialize.** The backend configuration declares a setup scaffold of **one frame with no
button down**, as the legacy fresh start runs. O[0] follows it, with `engineFrame` `"1"` and
`worldTime` `0/1`. That frame's audio is not published: O[0] carries no chunk
([state-media-v1](state-media-v1.md) 2), and the audio origin is the first sample of
transition 0→1.
- **Advance.** Apply the mask, run one frame, and return O[k+1]. The view is `lcd` 160 x 144 `rgba8`
(row stride 640) with `observationDelaySteps` 0. `engineFrame` is the legacy frame counter as a
decimal string.
- **Audio.** The environment converts binjgb's unsigned 8-bit interleaved stereo to f32 with
binjgb's host rule, `sample / 255`. The result is unipolar in [0, 1] with silence at 0.0. The
stream is `f32le-interleaved`, 2 channels, at the configured rate (48,000 by default). The
environment does not filter. The **edge** applies the DC blocker (pole 0.995, per channel)
before presentation. It is presentation state: it is reset only when the edge restarts, it
is never in a checkpoint, and it never reaches an agent.
- **Descriptor.** `stepDuration` is `8572265625/512`, `inspectionSchema` is section 8, `recovery` is
`exact-checkpoint`, and `determinism` is `fixed-build`.
- **Slots, `gameboy-slots-v1`.** This is an environment capability for `Environment.SaveSlot` and
`Environment.RestoreSlot` ([workers-v1](workers-v1.md) section 7). A slot holds the emulator's
exported state and the framebuffer that was on screen, as the ratchet's `Snapshot` does. Slot
ids are declared by the composition (the legacy composition declares one, `best`). A save
replaces the slot. A restore imports the state, releases the buttons and returns the
archived framebuffer as a fresh view artifact, with a memory image read after the import.
It runs no frame. Every slot is part of the environment's `State.Capture` payload, as
FLYSIM01's `ratchet_game` and `ratchet_frame` are today. A slot save due at a boundary
completes before any `State.Capture` or FLYSIM01 export at that boundary (section 16).
## 10. Executor `pokered-macros-v1`
The Pokémon Red task (reward adapter, ladder, ratchet ledger) and its action executor (the
macro layer) are **one object**. It implements both [workers-v1](workers-v1.md) section 4
interfaces and is declared as the extension `executor: pokered-macros-v1`. They cannot be
split without an undeclared channel between them, for three reasons. The executor's scene
observation produces the `bound` set the task hands the decoder. The macros read the adapter's
exploration and boundary ledgers. The macro layer's "nearer the objective" is a ratchet progress
signal. The object serves exactly one agent on one port.
- **Phase B** (`ActionExecutor.apply`): inputs are the decision (section 6), O[k]'s memory
image, the ROM and the brain clock. The output is a joypad mask plus the macro start and finish
events. In raw mode it passes the decision's mask through. While a macro runs, the macro owns the pad.
- **Phase C** (`Task.evaluate_transition`): the adapter samples O[k+1]. Each reward event
becomes one `Reward` (its value) and one `Stimulus` of kind `reward-pulse` (its
`stimulation_ms`), in event order. The macro layer observes O[k+1]. The location is read. The
ratchet observes, and may request `SaveSlot` at `Ready(k+1)` or a rollback (section 11). Then
the next contexts `{boot, bound, location}` are produced.
- **Capture.** The task ledger is the adapter state (FLYSIM01's `reward` chunk) and the
ratchet state. The executor's ledgers (blocked, reached, talked, errand) and a running macro
are session state. They are **not** captured (section 14).
- **Cancel.** `cancel(ms)` abandons a running macro. It is followed by `observe` on the world
the fly now stands in.
## 11. Episode policy `legacy-ratchet-rollback-v1`
The ratchet's game-only rollback is a declared episode policy. When the ratchet fires during
transition k→k+1, the task returns `episodeRequest {kind: "rollback", reason, outcome}`. The
outcome is `legacy-ratchet-rollback-v1 {slotId, trigger: "stall" | "game-over"}`. The
transition's rewards commit first. The coordinator then applies the policy **at `Ready(k+1)`,
before the next Prepare, without pausing**:
1. If this boundary also has a slot save due, `Environment.SaveSlot` runs first.
2. It picks a new epoch e'. `Environment.RestoreSlot(scope e',k+1; slotId, priorEpoch e)` restores
the slot and returns O'[k+1]. The boundary number stays the same. `worldTime` continues,
`engineFrame` continues, the view is the slot's archived frame, and there is no audio chunk.
3. Coordinator-local: the task clears the adapter's transient reward observations. The
executor runs `cancel`, then `observe(O'[k+1])`, which gives the next context.
4. `Agent.Rollback(scope e',k+1; priorEpoch e, input O'[k+1], context)` runs on every agent.
It clears the decoder holds (`clearHolds(now)`: holds, winners, fatigue, lockout) and the
plastic eligibility. It installs the slot frame as the next input, drops the held channel,
restarts the blocked window at now, and takes the location from the context. It runs **no
tick**, no reinforcement, no stimulation and no calibration.
5. With every reply in hand, the session is `Ready(e', k+1)`. Any failure fails the epoch and the
group restores from the last durable checkpoint. No participant resets alone.
6. A durable save follows, as the legacy loop checkpoints after a recovery. Any capture at this
boundary -- before or after the rollback -- is taken after step 1's slot save.
**Worker status and lost replies (amended 2026-09-23, review round 1).** While it executes
`Environment.SaveSlot` a worker's `Worker.Status` reports `capturing`; while it executes
`Environment.RestoreSlot` or `Agent.Rollback` it reports `restoring`, with `currentScope` still
the prior `(e, k+1)`. After the reply it reports `ready` at `(e', k+1)`. These are mutations
under the [session RPC](ipc-v1.md) section 5 operation key `(session, e', k+1, method, worker)`
(`SaveSlot`: `(session, e, k+1, …)`), so a lost reply goes through ipc-v1 section 6 first: stop
dispatch, query `Worker.Status` or retransmit the same request id and body to the same
incarnation, and resolve only the matching cached result. Only an unresolvable outcome -- a
changed incarnation, lost routes or ownership, `RESULT_EXPIRED` -- fails the epoch, and then the
group restores from the last durable checkpoint. No participant is ever left in `e'` while
another continues in `e`: the coordinator issues no Prepare until every rollback reply is
resolved.
The following continue through the rollback: brain clock, membrane, RNG, learned gains, rates and
reward history, the ratchet ledger (its attempt and lifetime budgets were spent in Phase C), and
the adapter's persistent state. The first audio chunk after the rollback marks a
discontinuity. This is the legacy `recover_game` sequence with the neural half moved into the agent.
Each half touches only its own state, so the order between the halves does not matter.
## 12. Composition declaration and digest
```ts
interface LegacyGameboyComposition {
compositionId: Id;
scheduler: "lockstep-v1";
profile: AssetRef; // the section 2 document
executor: { id: "pokered-macros-v1"; rom: AssetRef; adapter: Id; symbolProvenance: string;
mode: "raw" | "macros"; macroChannels: ChannelName[] }; // [] iff raw
decoderConfigDigest: Digest; // SHA-256 of the gameboy-decoder-config-v1 form
environment: { extensions: ["gameboy-slots-v1"]; slots: Id[]; stepDuration: RationalNs;
inspectionSchema: SchemaRef; controllerSchema: SchemaRef; setupFrames: 1;
audio: { sampleRate: number; channels: 2 } };
episodePolicy: "legacy-ratchet-rollback-v1";
restore: "legacy-transient-reset";
checkpointFormatOfRecord: "FLYSIM01";
flysimCompatibility: string; // the FLYSIM01 string, recorded, not reinterpreted
}
```
- `decoderConfigDigest` is the SHA-256 of the canonical JSON of the form
`gameboy-decoder-config-v1` of the effective decoder configuration (amended 2026-09-23,
review round 1): `{form, exclusive, macros, pulses, clearLockoutMs}`, each group
`{channels: [{channel, role}], decisionMs, holdMs, hysteresis, fatigueGain, fatigueDecay,
blockedFatigue, blockedMs}` or `null`, each pulse `{channel, role, holdMs, cooldownMs,
threshold, boot: {cooldownMs, threshold} | null, throttleGroup: string | null}`. Channels are
an array because their order breaks argmax ties and canonical JSON sorts object keys. The
shared vectors are `fixtures/gameboy-decoder-config.json` (raw mode and the 31-channel
Pokémon Red group): `flysim`'s `legacy_profile_identity` test computes them from
`gameboy_decoder_config_with_macros` (and rewrites them under `FLY_UPDATE_FIXTURES=1`), and
`@flybrain/session-types` reproduces them from the oracle's `gameboyDecoderConfig`. The
example composition carries the real macros-mode digest. A change to a decoder
timing, a threshold or the macro channel set therefore changes the composition digest.
None of those changes touches the legacy compatibility string, which never covered them.
- The declaration's digest is the SHA-256 of its canonical JSON. The coordinator's
`compositionDigest` recipe (`fly-session/composition-v1`: session, epoch, contract, one line
per agent) gains one final line, `declaration=<digest>`, for a composition that has a
declaration. The synthetic composition has none and gains no line.
- `flysimCompatibility` must agree with the declaration in its kernel, adapter, fingerprint,
plasticity and `pokered:` segments, so the two cannot describe two different flies. Until RETIRE-01
the **restore gate** is still that string and the legacy decision rules
(`flybrain_gb::compatibility::decide`, `FLY_ACCEPT_ADAPTERS`). The composition digest is the identity
for publication, traces and descriptor revisions, not a restore gate. This matches today's
behaviour, where a decoder change does not refuse a checkpoint.
## 13. Machine-readable parts
| Where | What |
| --- | --- |
| `fly-session-types/src/gameboy.rs`, `packages/session-types/src/gameboy.ts` | The five registered payload schemas, the profile, the composition declaration and their readers and cross-checks |
| `fly-session-types/src/extensions.rs`, `packages/session-types/src/extensions.ts` | `SaveSlot*`, `RestoreSlot*` and `AgentRollback*` payloads, which are in the session schema set |
| `fixtures/gameboy-legacy.json` (derived) | The extension set and its digest, every `SchemaRef`, the profile document with its canonical bytes and `AssetRef`, the frame clock, and an example composition with its digest and the digest recipe |
| `fixtures/gameboy-decoder-config.json` | The `decoderConfigDigest` vectors (raw and Pokémon Red macros), written and checked by `flysim`'s `legacy_profile_identity` test, reproduced by the TypeScript oracle |
| `TraceBehaviour.boundaryActions`, `TraceOperational.captures` | The section 16 order rule, refused by `TransitionTrace` validation in both languages |
| `fixtures/valid.json`, `invalid.json` | Accepted and refused cases for every new type, held to both languages |
| `flysim/tests/legacy_profile_identity.rs` | Recomputes the fingerprint, versions, frame size, warm-up, clock and button order from the committed dataset and the service defaults |
A payload schema's `SchemaRef.digest` is the SHA-256 of its canonical declaration
`{registry, id, version, source, fields}`. The legacy schemas are digested by their own
extension set, not by `contractDigest`: the session contract stays free of console state.
The generic changes of RT-01a (`stimulusRemainingMs`, `EpisodeRequestKind.rollback`, the six
extension payloads, `maxSlots`) *are* in the session schema set, and they moved
`contractDigest` to the value in `fixtures/contract-digest.json`. Regenerate with
`cargo run -p fly-session-types --example update_fixtures`. `tests/schema_set.rs` refuses
stale files.
**Open for AGENT-01:** the legacy rate roles (`command_0`, `macro_go_item`, and so on) are not
valid `Id`s, while `AgentGraph.rateRoles` and `AgentTelemetry.rates[].roleId` are `Id`s. The
mapping belongs to the agent adapter. This contract does not choose it.
## 14. Restore `legacy-transient-reset`
The legacy composition declares that a restore (a FLYSIM01 load today, and any group restore
under this composition) is **not** an exact replay. The restored parts are those FLYSIM01
carries: agent state, emulator, framebuffer, adapter state, ratchet state and slots, frame
counter, remainder, buttons and event watermark. The rest starts cleared, as it does in a fresh
process:
- the executor's ledgers (blocked, reached, talked, errand) are empty, with no macro running;
- the agent's readout transient starts exactly as a fresh legacy process has it (amended
2026-09-23, review round 1): no held channel, no last location, and the blocked window
starting at brain time **0 ms**, not at the restored clock. The decoder state itself
(`DecoderState`: holds, winners, fatigue) *is* restored. The consequence AGENT-01 must
reproduce: on the first decode after a restore, `now - 0 >= blockedMs`, so the restored
direction winner, if any, is passed as `blocked` and its fatigue is raised to
`blockedFatigue`. Only after that decode does the window restart, because the held channel
changed from none to the winner, and again when the first location is observed. A rollback
(section 11) differs: it clears the holds and winners and restarts the window at the
current brain time;
- the adapter's transient observations are cleared, as they are on a rollback.
Resume tests compare against the legacy restore outcome, not against an uninterrupted trace
([state-media-v1](state-media-v1.md) section 4 amendment). This is the property the operator's
unstick procedure depends on: restarting the service clears the ledger-shaped traps and keeps
the rung.
## 15. Sugar admission
The coordinator owns admission, with the legacy rules: the per-minute limiter, and "no overlap
with an active pulse". The pulse is read from `AgentTelemetry.stimulusRemainingMs` of the **last
completed commit** (an `Agent.Rollback` reply counts as one). The value can therefore be one
commit old, and the operator accepted this lag. The consequences, each bounded to one frame:
- a pulse that ended inside the in-flight transition still reads as running, so the request is
refused and retried;
- a reward pulse added by the in-flight transition is not yet visible, so a sugar can be
admitted over it where the legacy loop would have refused;
- after a restore, and until the first commit of the new epoch, the pulse is unknown and every
request is refused with a retry, where the legacy loop admits against the restored pulse at
once (amended 2026-09-23, review round 1).
The duration is clamped to `[1, sugar_max_ms]`. An admitted sugar is a `reward-pulse`
`Stimulus` in the next Prepare's `preStepStimulations`, which is the position of the legacy
drain at the top of a frame. Legacy admission *is* application. Here, the epoch can fail
between the two, so each admission record carries its interaction id. If the Prepare that
applies it never commits, the admission is reported aborted and the edge refunds it. The
bridge's fulfil path and refund path both get tests in the slice that wires them
([workers-v1](workers-v1.md) section 5 amendment).
## 16. Checkpoint format of record
**Order at a boundary (amended 2026-09-23, review round 1).** A slot save due at `Ready(k)`
completes -- its `Environment.SaveSlot` reply in hand -- before any `State.Capture` or FLYSIM01
export at `Ready(k)`, whether that export is periodic, a milestone archive or the post-rollback
save. So a checkpoint whose ratchet ledger names `best = r` always carries the slot saved for
rung `r`. This is a **declared difference** from the legacy loop, which archives a milestone
before capturing the ratchet snapshot. A legacy milestone archive holds `best = r-1` and the
rung `r-1` snapshot; a ported one holds `best = r` and the rung `r` snapshot. In practice the
two converge: after `fly-reset-to-milestone` onto a legacy archive, the ratchet captures rung
`r` again on the first safe frame (the rung is above the recorded best) and resets the attempt
counter. The difference is only where the rung `r` slot sits -- on the exact frame of the climb
(ported) or on the first safe frame after the reset (legacy) -- and it is visible only if the
fly stalls or hits game over before any safe frame, when legacy falls back to rung `r-1`'s save
and the ported loop to rung `r`'s (amended 2026-09-23, review round 2). The operator's confirmation of this difference is requested with the
CUT-01 shadow run. The rule is machine-checked in the step trace: `TraceBehaviour.boundaryActions`
records the saves and the rollback in order, `TraceOperational.captures` records each capture
with the number of boundary actions before it, and a `TransitionTrace` in which a capture
precedes a slot save is refused ([step-v1](step-v1.md) section 8 amendment; fixtures in
`valid.json` and `invalid.json`).
FLYSIM01 stays the format of record until RETIRE-01. Every durable save of this composition
(periodic, milestone archive, after a rollback) **exports a FLYSIM01 envelope** that the
current `flysim` reads, under the unchanged compatibility string. The deploy gate
(`--print-compatibility`) and `fly-reset-to-milestone` keep working on those files. A FLYSESS1
checkpoint may be written beside it, but it is not what a restore selects until RETIRE-01
says so.
## 17. PROF-02b: MaleCNS bundles (later)
Dataset manifests, original-ID mapping, anatomical roles, sensory and readout bindings, strict
graph validation for new bundles, and the composite behaviour identity of FOUNDATION-02 are
**not** in this document. They ship with PROF-02b, before DATA-01, as their own contract.
Nothing here constrains them, except that a new profile never reuses this profile's id or its
legacy exception.

View file

@ -1,208 +0,0 @@
# Application, presentation and audience contracts
Status: **draft 2**, 2026-09-18. All internal communication uses [Flybus](bus-v1.md). This
document defines ownership and logical data contracts, not the public feed v2 byte format.
Existing public v1 contracts remain unchanged for the legacy application.
## 1. Applications orchestrate; presentation owns the show
Fly Plays Pokémon is an application assembled from simulation and presentation components.
Its supervisory code and interface share an application-owned, versioned state/event schema.
A Melee competition or ecosystem can choose another schema. Director, tournament, bracket,
cast of 32 personas and story segments are examples, not mandatory framework services/types.
The framework supplies sessions, agents, backend/task interfaces, capability descriptions,
native observations and generic UI/client primitives. The application chooses lifecycle,
profiles, game-aware macros/recovery, identities/history, interventions and narrative behavior.
Frame-by-frame scheduling remains inside the session; the application need not RPC each tick.
Presentation owns selection/layout, resizing, overlays, compositing, audio mixing, encoding,
browser-facing delivery, recording choices and stream output. Native 480p game data is a
perfectly valid framework output. Neither the router nor generic session assumes Twitch,
1920×1080, particular colors, specific React components or an automatic tournament dashboard.
## 2. One bus, complementary data sources
```text
Session ── committed observations/events + artifact handles ──┐
├─ Flybus ── presentation application
Application ── own state/events/presentation cues ────────────┘
Presentation gateway ── application-defined browser delivery ── frontend
```
Example addresses (chosen by composition, not recognized by router code):
| Address | Pattern / purpose |
| --- | --- |
| `session.demo` | RPC: domain lifecycle/status/capabilities; exact methods require session API schemas |
| `session.demo.descriptor` | Pub/sub, retained latest: framework descriptor |
| `session.demo.snapshots` | Pub/sub, latest: committed simulation values and native media refs |
| `session.demo.events` | Pub/sub, bounded: scoped domain events; not a durable log |
| `app.pokemon.state` | Pub/sub, retained latest: application-specific state |
| `app.pokemon.cues` | Pub/sub: application narrative/presentation events under declared delivery policy |
| `app.pokemon` | RPC: application queries/admission, e.g. restore UI state or request a supported effect |
**Amendment, 2026-09-22 (PUBLISH-01).** The repair path above needs exact methods, and
"exact methods require session API schemas" left the row unbuildable. The session registers
one **read-only** service, `session.<id>.query`, with exactly two methods, both ordinary
[session RPCs](ipc-v1.md) answering from what the session already published:
`Session.GetDescriptor` takes an optional `{revision: U64}` and returns that `SessionDescriptor`
or, with no revision, the newest; `Session.GetSnapshot` takes no parameters and returns the
latest `CommittedSnapshot`. A revision the session never published is `IDENTITY_MISMATCH`, not
an empty answer. Nothing on this service mutates, selects a participant or reaches a worker, so
it is not the controller API section 7 rules out; adding a third method that did would be.
These two names are **internal and provisional**: they are what the internal boundary needs in
order to be buildable now, and the later public v2 step is free to rename them, supersede them
or expose a different repair surface entirely. Nothing about them is browser-facing, and the
public step does not inherit them by default merely because they landed first.
Descriptor revisions and scope link observations to schemas. Cross-topic ordering is not
guaranteed; a subscriber receiving an unknown descriptor revision must fetch it through the
application/session query contract or buffer a bounded number of snapshots, not infer shape.
Latest retained descriptors accelerate startup; RPC querying remains the repair path.
## 3. Common simulation descriptors and committed values
Types use [session RPC](ipc-v1.md), [workers](workers-v1.md), [state/media](state-media-v1.md):
```ts
interface SessionDescriptor {
sessionId: Id; revision: U64; compositionDigest: Digest;
schedulerId: "lockstep-v1";
environment: EnvironmentDescriptor; taskSchema: SchemaRef;
agents: {
agentId: Id; portId: Id; profileDigest: Digest;
datasetDigest: Digest; indexDigest: Digest; neuronCount: U64;
rateRoles: Id[]; supportedStimuli: Id[];
}[];
assets: AssetRef[];
}
interface CommittedSnapshot {
descriptorRevision: U64; publisherIncarnation: Id;
scope: Scope; episodeId: Id; sequence: U64; worldTime: RationalNs;
agents: {
agentId: Id; telemetry: AgentTelemetry;
selectedDecision: TypedValue | null;
appliedControls: PortControl | null;
}[];
progress: TypedValue;
media: { views: ViewRef[]; audio: AudioRef[] };
eventIds: Id[];
}
```
**Amendment, 2026-09-22 (PUBLISH-01).** "Null at initial boundary 0" is the rule for a
boundary this epoch *produced*. A group restore ([state/media](state-media-v1.md) section 5)
re-establishes a committed boundary `k > 0` that this epoch did not run a transition into, and
the abandoned epoch's decisions are not this session's to republish under a new epoch. So the
rule is: `selectedDecision` and `appliedControls` are null at boundary 0 and at a boundary
*installed* by a restore, present otherwise, and always **together** and for **every agent or
none**. A snapshot where one fly carries an action and another does not would be two different
boundaries in one value, and is refused. Without this, the section 6 requirement to publish the
recovery could not be met at all: the restored boundary's snapshot would be unrepresentable.
Publish only after all agent commits establish Ready(k). Decisions/controls describe the
transition ending at that boundary, null at initial boundary 0. Health updates are separate
and never claim an uncommitted future boundary. Every transient media reference is a declared
bus attachment held through publication admission. Ordinary snapshot publication is latest/
bounded and never waits for a spectator to consume it.
Publication sequence is monotonic within publisherIncarnation. Epoch determines simulation
timeline; router topicSequence only determines bus acceptance order. Never equate these.
Geometry/spike mapping requires indexDigest, not merely the same number of neurons. Persistent
AssetRefs survive release packaging; ephemeral ArtifactRefs never become permanent asset URLs.
## 4. Flexible data, authored UI
Common UI primitives should understand media, agents, controllers, typed measurements,
progressions, collections and events. Descriptors change infrequently; values change frequently.
Application-specific structures remain namespaced, schema-validated extensions, such as
`pokemon.progress.v1` or `melee.match.v1`. They are not mandatory fields on every snapshot.
Proposed measurement vocabulary to formalize with public v2 schemas:
```text
Definition: id, owner, label, kind, unit, optional range, schema revision
Sample: id, producing scope/time, validity, value
Validity: measured | unknown | unsupported | stale
```
Kinds include number/counter, gauge, duration, state, progression and collection with typed
item schemas. A measured zero is distinct from unknown. Stale values retain original timestamps.
Units/ranges are metadata, not pixel sizes. Unknown optional extensions may be omitted or shown
generically; unsupported required schemas are visible errors. No remote executable UI payloads.
Application state carries whatever the experience needs: progress history, featured fly,
competition records, season state or sponsor effects. It is developed with its presentation,
not forced into a framework-wide “show state”/tournament schema. An application can reuse
generic components and add its own panels without changing worker/transport contracts.
## 5. Artifact consumption and browser boundary
The native presentation client is a regular bus subscriber. Its renderer may hold an extracted
Artifact after dropping the message; the SDK delays consumption until actual use finishes.
Latest coalescing only drops queued values. A stalled consumer is constrained by finite credits,
owners and store budgets; it cannot make the router overwrite an in-use image.
The browser does not receive private owner tokens or local storage paths. A presentation
gateway resolves/copies/encodes artifacts into its chosen browser transport and then drops
its bus handles. That is an application-edge adapter, not a second framework communications
stack. Compositor/encoder/recorder processes inside the application can exchange their own
artifacts through the same bus when useful.
Dense spike publication is optional and identifies agent, index digest and covered ticks.
The runtime need not publish every neuron every millisecond. Required sensory data and
optional spectator data have distinct budgets; UI focus never changes an agent's input,
controller assignment or an already resolved stimulation/effect target.
## 6. Events, persistence and recovery visibility
Events identify session/epoch, source boundary, episode, optional agent, kind and typed payload.
Task events are emitted after committed transitions; capture/admission events describe their
actual phase. Bus publish acceptance and delivery consumption are not durable acknowledgments.
When durability is required, call a configured event-store client/service (over the same bus)
and await its append/commit acknowledgment under the session's configured policy. Pub/sub
remains useful for live observers; reconnecting clients query durable history through ordinary
RPCs. The initial conformance policy pauses at the next safe boundary if durable event
admission/commit fails, retaining only a bounded pending batch. No hidden durable broker queue.
After rollback publish old/new epochs, checkpoint identity and abandoned step ranges.
Application history can mark outcomes aborted/superseded; it does not erase records merely
because emulator time moved backward. Media reports the corresponding discontinuity.
## 7. Supervisory and audience effects
Application supervision uses bus RPCs for configured lifecycle/intervention capabilities
and pub/sub for application state/cues. The Twitch adapter can be a constrained bus client
of the application's admission service; viewers/browser clients never obtain worker control.
Legacy HTTP bridge behavior remains until deliberately migrated.
Effects are declared by the task/backend/profile: valid targets, parameter schema, timing,
duration/stacking, implementation capability and outcome events. Examples include neural
stimulation or future game items/modifiers, where verified implementations exist. Generic
game boons and a public v2 admission schema are follow-on work; initially new-session audience
effects remain disabled. No arbitrary controller/game-memory write endpoint is introduced.
```text
requested → rejected
→ accepted(target, epoch, earliestStep) → scheduled → applied → expired
└─ failed / cancelled
applied → rolled-back
```
The application persists interaction identity/target and defines retry, redemption, refund
and recovery policy. A gift is an intervention, not automatically an earned neural reward.
Presentation cues may be immediate; simulation effects apply at declared boundaries. The
supervisor does not bypass the complete-batch step barrier. Chat text remains presentation
data; template-only replies/quiet mode and existing no-public-button rules continue.
## 8. Presentation acceptance criteria
- Per-agent/session stores and rate/afterglow state, not one mutable global fly.
- Framework plus application schema streams, with descriptor repair/reconnect behavior.
- Native view dimensions and aspect; application-controlled output resolution and composition.
- Explicit audio ownership, timestamped overlays and bounded queues/discontinuities.
- Last-use artifact release, cached/replay-safe references and slow-observer isolation.
- Game-specific labels/views without Pokémon fields in generic runtime/router schemas.
- Actual UI changes reviewed as PNGs with browser/legibility gates. This contract is not screen approval.

View file

@ -1,101 +0,0 @@
# Seed derivation v1
Status: **draft 1**, 2026-09-22. Specified by CONTRACT-01 of the
[implementation guide](implementation.md), required by
[worker interfaces](workers-v1.md) section 2 before the real-agent slice. Reference
implementations: `services/flysim/crates/fly-session-types/src/seed.rs` and
`packages/session-types/src/seed.ts`; test vectors:
`services/flysim/crates/fly-session-types/fixtures/seed-vectors.json`.
## 1. What this is for
`Agent.Initialize` takes `seed`, a signed 32-bit integer, matching the current RNG input.
Workers-v1 section 2 requires that the coordinator derive independent per-agent seeds from
**its recorded master seed and stable agent IDs** under a versioned algorithm, and that the
algorithm be specified and tested before the real agent slice. This is that algorithm.
It is a reproducibility rule, not a secret: a run manifest records the master seed in the
clear, and anyone with the manifest can recompute every agent's seed. It is not a key
derivation function and must not be used as one.
`seed-derivation-v1` is part of composition identity. Changing any byte of it requires a new
identifier (`seed-derivation-v2`), because two runs that agree on every other identity but
disagree here are not the same experiment.
## 2. Inputs
| Input | Type | Source |
| --- | --- | --- |
| `masterSeed` | `U64` decimal string | Recorded once per run by the application/supervisor |
| `agentId` | `Id` | The configured agent identity, stable across restarts and epochs |
Both are the ipc-v1 section 2 scalars. An `agentId` that is not an `Id` is an error, not
something to normalize. The master seed is the whole 64-bit range: a 32-bit master seed would
be no wider than the seed it derives.
## 3. Derivation
```text
material = "flybrain/seed-derivation-v1" LF masterSeed LF agentId LF
digest = SHA-256(material)
lanes = digest read as eight big-endian uint32 values, in order
seed = the first nonzero lane, reinterpreted as a two's-complement int32
```
`LF` is one `0x0a` byte. `masterSeed` is its canonical decimal form: `"0"`, or no leading
zero. The prefix is a domain separator, so a digest from this algorithm can never collide with
one taken over some other pair of strings.
Zero lanes are skipped because the pinned kernel's RNG is an xorshift generator, whose state
must not be zero: a derivation that could hand out `0` would silently produce a stalled
generator. If every one of the eight lanes were zero, the material is rehashed with a counter
suffix (`material || "1" LF`, then `"2" LF`, then `"3" LF`) and the search repeats; no input
has ever needed it, and four rounds exhausted is an error rather than a fallback seed.
The seed is the *negative* number when the lane's high bit is set. That is deliberate: the
existing RNG input is a signed 32-bit integer, and half the range is negative.
## 4. Properties
- **Deterministic.** The seed is a function of the two recorded inputs and nothing else: not
of wall time, agent order, port assignment, worker process or thread count.
- **Independent per agent.** Distinct agent IDs give unrelated seeds; there is no arithmetic
relationship between `fly-a` and `fly-b` for a caller to exploit or accidentally rely on.
- **Stable across recovery.** Restore, episode reset and a new epoch do not re-derive a
different seed for the same agent ID under the same master seed. The seed is persisted as run
configuration and state, and the capture compatibility digest covers the resolved seed
(workers-v1 section 2), so a checkpoint cannot be installed into a differently seeded
instance.
- **Equal IDs give equal seeds.** That is the only way to get identical seeds, and workers-v1
allows identical seeds only when an experiment declares them. A composition therefore
refuses a repeated agent ID rather than quietly sharing a seed between two agents.
Non-properties, stated so nobody assumes them: this is not uniform over the int32 range beyond
what SHA-256 gives, it is not a stream (one seed per agent per run, not per step), and it says
nothing about how a model consumes its seed.
## 5. Test vectors
`fixtures/seed-vectors.json` carries the full table: five master seeds (`0`, `1`, `42`, `2^63`
and the `U64` maximum) across four agent IDs, each with the exact material string, its SHA-256
and the derived seed, plus one four-agent composition and the inputs that must be refused.
Both implementations reproduce every row, and each records the material as well as the seed so
a third implementation can find where it diverges.
The first two rows:
| masterSeed | agentId | material | seed |
| --- | --- | --- | ---: |
| `0` | `fly-a` | `flybrain/seed-derivation-v1\n0\nfly-a\n` | 1828176714 |
| `0` | `fly-b` | `flybrain/seed-derivation-v1\n0\nfly-b\n` | 1218785088 |
Refused: an agent ID that is not an `Id` (uppercase, empty, over 64 characters), a master seed
that is not a canonical `U64`, and a composition with a repeated agent ID.
## 6. Out of scope
Choosing the master seed, recording it in the run manifest, and the hand-selected explicit
seeds that workers-v1 allows for the first synthetic composition. This document defines only
the derivation. A profile that needs several independent streams inside one agent derives them
from the agent's own seed under its own documented rule; that is a profile concern, not a
session one.

View file

@ -1,314 +0,0 @@
# Session artifacts, native media and recovery
Status: **draft 2**, 2026-09-18. [Flybus](bus-v1.md) owns generic artifact storage, delivery
ownership, retention and garbage collection. This document specifies the **domain meaning**
of those artifacts: native observations, clock association and coherent session checkpoints.
Read [session RPC](ipc-v1.md), [step ordering](step-v1.md) and [worker interfaces](workers-v1.md).
## 1. Use the bus ArtifactRef
Large payload fields use `ArtifactRef` from bus-v1, and every referenced artifact is listed
in the surrounding bus attachments. There is no separate BinaryRef, buffer-region registry,
coordinator lease endpoint, or Buffer.Release/Reclaim protocol. Profile/dataset release assets
use `AssetRef` (a persistent content identity); transient bus ArtifactRefs are not those assets.
The environment publishes one immutable native image. The coordinator may forward the same
owned handle to multiple agent Commit calls and publish it for presentation. Flybus creates
destination ownership before releasing the source. It does not send another copy of the
pixel bytes per recipient through its sockets.
An agent consumes/encodes pixels during Initialize/Commit and drops its handle when no longer
used. A renderer may keep its extracted handle after dropping the message; the DeliveryGuard
keeps the artifact alive until rendering has finished. A domain cached RPC result keeps its
own handles so replay remains valid after the original recipient consumes its delivery.
Content digests are optional on transient live frames, mandatory on checkpoint payloads and
persistent asset import. Ownership/index/byte-shape validation is always required. A digest
does not replace epoch or observation-time identity.
## 2. Native observation types
```ts
interface ViewDescriptor {
viewId: Id;
width: number; height: number;
format: "rgba8"; rowStride: number;
pixelAspect: { numerator: number; denominator: number };
observationDelaySteps: number;
}
interface ViewRef { viewId: Id; producedStep: U64; pixels: ArtifactRef }
interface AudioDescriptor {
streamId: Id; sampleRate: number; channels: number;
format: "f32le-interleaved";
}
interface AudioRef {
streamId: Id; firstSample: U64; sampleFrames: number;
samples: ArtifactRef; discontinuity: boolean;
}
```
Epoch is inherited from the domain observation; the bus treats it as opaque payload. View
dimensions are integers 1..4096, rowStride exactly 4×width, no padded rows in v1. Pixel aspect
numerator/denominator are positive integers <=65535; observationDelaySteps is integer 0..8.
Pixels are top-left RGBA8 and artifact length equals rowStride×height. Other formats require
a media-schema change, not special-case code inside the router.
Required sensory views have producedStep equal to
`max(0, observation.boundary - observationDelaySteps)`. Bootstrap may repeat O[0] until the
declared pipeline delay fills. Beyond that, missing/extra-delay sensory input is a step
failure, not an arbitrary latest frame. Observer publication may omit/coalesce frames while
preserving each artifact's actual producing boundary.
Audio sampleRate is integer 8000..192000, channels 1..8, sampleFrames 0..192000 per chunk.
Samples are finite f32; artifact length is sampleFrames×channels×4. firstSample identifies
the sample position relative to the episode's configured audio origin, with intended PTS
firstSample/sampleRate. Crash restore preserves sample position under a new epoch; first
chunk marks discontinuity. Within an epoch, chunks cannot overlap or go backwards.
**Amendment, 2026-09-22 (MEDIA-01).** Two readings of the paragraphs above, made explicit
because they are now enforced:
- The bootstrap window is exactly the boundaries where `max(0, boundary - observationDelaySteps)`
is zero, that is `boundary <= observationDelaySteps`. Inside it the repeated `O[0]` is the
**same artifact**, not a fresh render of the same scene; outside it the producing boundary
advances one per step, and a frame from any other boundary -- older or newer -- is a step
failure. A producer therefore keeps a queue of `observationDelaySteps + 1` frames and nothing
more, so there is no older frame available to substitute.
- Within an epoch, `discontinuity` marks a range the stream actually skipped. The first chunk
after a restore marks it, and a later chunk may mark it when it starts past where the previous
chunk ended; a chunk that continues the previous one exactly is continuous by construction and
its flag is refused. Without that reading the restore rule is advisory, because a stream could
set the flag on every chunk and satisfy it by accident. The requirement is one-directional: a
fresh epoch's first chunk **may** mark a discontinuity, because section 6's recovery
establishes a fresh timeline and publishes one.
The environment provides **native game output**. Sensor transformations belong to the agent
profile. Resizing for viewers, overlays, composition, audio mixing/resampling, encoding,
browser delivery and streaming belong to the application/presentation layer. No bus or
generic session configuration assumes a 1080p show or Twitch output.
**Amendment, 2026-09-23 (RT-01a; operator decision of 2026-09-23).** For the Game Boy
environment of the legacy composition ([legacy-gameboy-v1](legacy-gameboy-v1.md) section 9):
the environment converts binjgb's unsigned 8-bit interleaved stereo to the declared
`f32le-interleaved` with binjgb's host rule `sample / 255` (unipolar, silence at 0.0), and does
not filter. The DC blocker the running service applies before publishing (pole 0.995, per
channel) moves to the **edge**, as presentation: its state is the edge's, never checkpointed,
reset only when the edge restarts, and never seen by an agent. The observations a slot restore
returns carry no audio chunk, like the ones `ActivateRestore` returns (section 5 amendment of
2026-09-22), and the next chunk marks the discontinuity.
640×480 RGBA at 60 fps produces 73.728 MB/s of raw image data. Artifact fan-out references
one stored object; reads and any staging/seal copy still consume memory bandwidth. This is
reasonable to measure before introducing codecs or pooled GPU buffers. Native dimensions
come from the backend, not a hardcoded GameCube or broadcast resolution.
## 3. Domain retention and backpressure
Use the same bus call/publish API for observations and artifacts. Bus ownership tracks bytes;
the session decides which observations are required and when they have been used.
| Use | Rule |
| --- | --- |
| Required agent input | Retain through encoding/Commit; no coalescing or overwrite |
| Step-result replay | Retain in the endpoint's current/previous-step cache until domain eviction |
| Spectator snapshot | Latest subscription, finite in-flight credits; release after actual use |
| Long rendering/storage job | Explicit artifact hold with a finite byte/count budget |
| Hot checkpoint | Coalesce only queued replaceable captures, releasing their holds |
| Durable checkpoint | Acknowledge after durable commit; reject/defer before capture when saturated |
Initial session defaults: two outstanding coherent captures and at most the bus-configured
latest/in-flight frame credits per observer. Presentation audio can target 250 ms and cap at
one second, but that is a presentation policy, not a bus or brain-clock requirement.
Budget cached step observations, active agent deliveries, retained latest and spectator holds
together. The producer dropping its handle does not free cached/queued/in-use objects.
A slow spectator exhausts its own credits; new latest messages replace its queued value.
If it violates configured resource policy, disconnect/restart that observer instead of freeing
live data or silently skipping simulation input. Global store exhaustion is an explicit fault
or pause condition; the router cannot guess that a particular live object is disposable.
**Amendment, 2026-09-23 (RT-01a).** An artifact-backed inspection -- the legacy composition's
64-KiB memory image per boundary -- is required coordinator input, not spectator data: the
coordinator retains `O[k]`'s image through the executor's use in the next transition's Phase B
and the task's old/new evaluation in its Phase C, and drops it after. It is never coalesced and
never published to observers by the session; about 3.9 MB/s at the Game Boy's cadence, budgeted
with the cached step observations above.
**Amendment, 2026-09-22 (PUBLISH-01).** "Disconnect/restart that observer" names an action
no participant can take under [Flybus v1](bus-v1.md). Section 5 there makes publish admission
all or nothing -- "for a bounded subscriber overflow, reject the **whole** publish; no partial
fan-out or retained-latest update" -- and the router exposes no per-subscriber eviction, so a
session meeting a full bounded queue cannot drop that one subscriber and deliver to the rest.
The realisable reading, which the session now implements, is three-part: observation topics
are published `latest`, and a latest subscriber can never refuse a publication (it loses its
own queued value and is told how many by `replaced`); a bounded subscriber's refusal, which
`bus-v1` section 6 explicitly permits, is a named and counted publication outcome that takes
no world step, stalls nothing and fences no epoch, and the exact value stays recoverable
through the [publishing-v1](publishing-v1.md) section 2 query path; and disconnecting the
offender is an operator action against the topic the ledger names, not something the session
performs. A per-subscriber drop would need a router operation Flybus v1 does not have, and
inventing one here would be a transport change written into the wrong document.
No coordinator tracks per-reader socket acknowledgments or calls a producer's reclaim method.
The SDK and bus perform that bookkeeping. File-backed immutable mappings are safe after
unlink; physical pages disappear when all OS mappings close. Pooled reuse is deferred until
it provides equivalent safety. Bare handles in history are not persistent saved bytes.
## 4. Checkpoint identity and content
Exact-checkpoint sessions capture at Ready(k) or Paused(k), after all Agent.Commit replies,
with no Advance in flight. Block next Prepare until all participants supply immutable captures.
The manifest records:
- Envelope version, checkpoint ID and source session/epoch/step/episode/world time.
- Coordinator scheduler/configuration identity and exact port-to-agent map.
- Backend/content/patch/controller/parser/state-format compatibility.
- Per-agent profile/dataset/model identities, seed, tick count/remainder and payload digests.
- Task ledger, prior world inspection, per-agent executor state, next sensory/decision state
or reproducible reconstruction inputs, admission state and event watermarks.
- Payload names, lengths and hashes, including external-helper state required for exact resume.
**Amendment, 2026-09-23 (RT-01a; operator decision of 2026-09-23).** A composition may declare
**restore semantics** other than exact. The legacy composition declares
`restore: legacy-transient-reset` ([legacy-gameboy-v1](legacy-gameboy-v1.md) section 14): its
executor's ledgers and running macro, the agent's readout transient (held channel, blocked
window, last location) and the task's transient observations are **not** captured and start
cleared on every restore, exactly as the running service does on a restart. The
[modular analysis](../malecns-modular-sessions.md) section 5.4 already asked for this to be
labelled "legacy continuation semantics, not exact session replay"; the declaration is that
label, in the composition digest. The consequence is explicit: for this composition the
uninterrupted-versus-resumed trace equality of STATE-01 does not apply; its resume tests
compare against the legacy restore outcome. The operator's unstick procedure depends on the
reset. An environment's slots (`gameboy-slots-v1`) **are** world state and are captured.
Use a new envelope version; specify exact byte layout before production files. The historical
letter-only chunk-name constraint is not silently widened, and FLYSIM01 remains separately
readable. Persist payload **bytes and durable content identity**, not transient bus storeId,
artifact IDs, ownership tokens, mappings or pointers.
A checkpoint writer owns the bus Artifact handles until bytes are committed or the job fails.
It then drops them; durable files are outside Flybus's ephemeral GC. On restore, the durable
store imports fresh immutable bus artifacts. sourceScope is provenance, while the new handles
belong to the current router/store. Broker retention is never a substitute for a checkpoint.
## 5. State RPCs over the bus
Common to workers advertising checkpoint-v1:
```ts
interface CaptureParams { checkpointId: Id }
interface CaptureResult {
checkpointId: Id; boundary: U64;
compatibilityDigest: Digest;
payload: ArtifactRef; // listed attachment; digest required
}
interface StageRestoreParams {
checkpointId: Id; sourceScope: Scope;
compatibilityDigest: Digest;
payload: ArtifactRef; // newly imported owned attachment
}
interface StageRestoreResult { checkpointId: Id; restoreToken: Id }
interface ActivateRestoreParams { restoreToken: Id }
interface ActivateRestoreResult {
committedStep: U64; checkpointId: Id;
observation: WorldObservation | null; // environment required, agent null
}
```
State.Capture uses the committed scope. It completes after immutable capture exists, not when
a backend save was requested. The cached reply retains its artifact until Worker.Acknowledge.
The coordinator/writer obtains its own live ownership before acknowledging that cache.
State.StageRestore uses a proposed **new epoch** at source boundary k and is allowed only
on an uninitialized replacement or a quiescent worker. Launcher configuration supplies the
expected profile/backend identities; no implicit warm-up/reinitialization changes the saved
brain. It validates into replacement state, without exposing mutations to the live session.
After every participant and coordinator state validates, State.ActivateRestore installs
each staged token under that new scope without advancing a tick. Tokens are bound to scope/
payload/checkpoint and can activate only once; duplicate domain requests replay the cached
reply, while a fresh request trying to reuse an activated token is a conflict.
The environment returns the coherent restored observation with fresh artifact references and
restored time. It cannot advance gameplay to manufacture it. Capture/reconstruction therefore
covers render/inspection state and any pending sensor pipeline. Agent state agrees with it;
do not replay reward or recalibrate merely to fill missing cached data.
**Amendment, 2026-09-22 (STATE-01).** Three readings of this section, made explicit because
they are now enforced:
- `compatibilityDigest` on `CaptureResult` and `StageRestoreParams` is the **participant's**
capture compatibility digest of [worker interfaces](workers-v1.md) section 2 -- profile,
resolved seed, numerical model version and effective instance configuration for an agent;
backend, content, patch, controller and parser identity for an environment. It is not the
manifest's `compatibility` block of section 4, which is the composition's and which the
coordinator compares before anything is asked to stage. Both exist because they answer
different questions, and a restore that passed the second could still be handing an agent
another agent's brain.
- The observation `ActivateRestore` returns ran no transition, so it carries **no audio
chunk**, and one in it is refused. Section 2's chunk is the audio of an interval and this
observation covers none; MEDIA-01 implemented that rule as "boundary 0 carries no chunk",
which is true of the only such observation that slice could produce and false of this one.
The rule is about provenance, not about the boundary number.
- A participant that staged into a group install the coordinator then abandoned must be
**replaced** before another restore, exactly as one that activated must. It is holding a
validated replacement state that nothing installed, and [session RPC](ipc-v1.md) section 6
already refuses to silently reattach such a participant to an active epoch. Without this the
group's second attempt meets its own leftovers and calls them a conflict.
If emulator validation requires mutation, stage a stopped replacement emulator. If that cannot
provide externally atomic resume, advertise episode-restart, not exact-checkpoint. After all
activation acknowledgments, install the coordinator's staged task/executor/admission state
and establish Paused(new epoch,k). Failure during activation never permits half a group to run.
**Amendment, 2026-09-23 (operator decision of 2026-09-23).** `FLYSIM01` remains the **format of
record** for the legacy composition until RETIRE-01: every durable save exports a `FLYSIM01`
envelope the current service reads under its unchanged compatibility string, and restore
selects from those files through the legacy compatibility decision. A `FLYSESS1` checkpoint
may be written beside it; it is not a restore candidate for this composition until RETIRE-01
says so ([legacy-gameboy-v1](legacy-gameboy-v1.md) section 16).
## 6. Durable commit, router failure and recovery
Write payload/envelope temporary generation, fsync, rename, fsync directory, then atomically
write/fsync/rename the store manifest and fsync its directory. **Manifest commit is the durable
commit point.** Unreferenced temporary generations are not automatic restore candidates.
Publish distinct captured/queued/committed/failed/superseded events over the same bus. Only
durable completion produces a saved acknowledgment/high-water mark. Failed writes release
owned ephemeral captures according to retry policy, without reporting false durability.
After participant/coordinator/router failure:
1. Stop steps, abandon the epoch and fence old participants/routes.
2. Connect to a live router and select a complete compatible durable checkpoint.
3. Import its payloads as new artifacts; stage/activate every participant and coordinator.
4. Verify identity/boundary, flush old media/parser queues and publish recovery/discontinuity.
5. Establish Paused(k), then resume only after the group invariant holds.
A router restart loses ephemeral topics, queues, roots and correlations. Continuing with
old handles is invalid even if some mapped bytes survived. Reconnect is not transparent
mid-step recovery. The durable log records old/new epochs and abandoned step ranges; rollback
can lose post-checkpoint work. Durable input replay requires a separate application/session
journal policy, not an exactly-once claim about Flybus.
## 7. Episode reset
Reset differs from crash restore. The application selects a policy; the coordinator records
the old episode's result/abort and creates a new epoch/episode at step zero. World initial
state and retained/fresh brain components are explicit. Gain retention, eligibility/hold
clearing, calibration and first sensory input are part of the policy, tested independently.
Legacy Pokémon ratchet behavior remains in the legacy composition. Shared competitive worlds
never restore one player's environment independently of the other players.
**Amendment, 2026-09-23 (RT-01a; operator decision of 2026-09-23).** The sentence above kept the
ratchet outside the framework. The operator decided on a full port instead, so the ratchet's
game-only rollback is now a declared episode policy of the legacy composition,
`legacy-ratchet-rollback-v1`, with the environment capability `gameboy-slots-v1`
([workers-v1](workers-v1.md) section 7, [step-v1](step-v1.md) section 6 amendment,
[legacy-gameboy-v1](legacy-gameboy-v1.md) section 11). It is a rollback, not a reset: a new
epoch at the same boundary, the episode and the brain continuing, the environment restoring a
slot, agents clearing holds and eligibility and installing the slot frame without a tick, the
executor cancelling then observing. What the original sentence protected still holds: the
policy is single-agent and a shared competitive world must not declare it.

View file

@ -1,325 +0,0 @@
# Lockstep step protocol v1
Status: **draft 2**. This is the authoritative new-session ordering contract. All method
arrows below are RPCs through the same [Flybus router](bus-v1.md); the router itself never
implements the barrier. Read [architecture](README.md) and [session RPC](ipc-v1.md) first. Method payloads are in
[worker interfaces](workers-v1.md).
**Amendment, 2026-09-23 (operator decision of 2026-09-23).** The live Game Boy fly is ported
onto this protocol as the legacy composition of [legacy-gameboy-v1](legacy-gameboy-v1.md),
scheduled by `lockstep-v1` with one agent, one port and one world; it is no longer a separate
ordering. Its frame order is this document's transaction order (legacy-gameboy-v1 section 4
maps it step by step), so the amendments below add capabilities to the protocol and change
none of its ordering rules.
## 1. Committed boundary
At `Ready(epoch, k)`:
- Environment state is at boundary `k`, with no outstanding action batch.
- Every agent has consumed the outcome of transition `k-1 → k`, including its new encoded
sensory input and rewards, and is ready to compute the decision for transition `k → k+1`.
- Task ledger, action-executor state, event identity and agent tick remainders agree with `k`.
- The current sensory observation may have a declared fixed render delay; its producing
boundary is explicit. “Ready” does not imply latest wall-clock screenshot.
- No normal step operation from an older boundary may mutate the session.
Only a committed boundary is eligible for a coherent checkpoint or normal pause. Boot/reset
establishes the same invariant with no preceding reward. The public snapshot represents this
boundary, not an in-progress combination of some new agent states and an old world.
## 2. State machine
```text
Starting → Ready(k) → Preparing(k) → Applying(k) → Observing(k+1)
↑ │
└──────────── Ready(k+1) ← Committing(k) ┘
Ready(k) → Paused(k) → Ready(k)
Ready(k) / Paused(k) → Capturing(k) → same boundary
pause requested mid-step → Committing(k) → Ready(k+1) → Paused(k+1)
any unresolved partial failure → Failed → Restoring(new epoch) → Paused(k)
terminal episode → Paused(k) → Resetting(new epoch) → Ready(0)
```
`Committing(k)` refers to completing transition `k → k+1`. Requests throughout that
transition carry `scope.step=k`; result fields identify `nextStep=k+1` where applicable.
Do not send Agent.Commit with step `k+1` merely because the observation is newer.
**Amendment, 2026-09-22.** The mid-step pause line above adds no new edge: a pause requested
during a transition is served by the ordinary `Committing(k) → Ready(k+1)` edge followed by
`Ready(k+1) → Paused(k+1)`. It is written into the machine because section 6 requires the
transition to finish first, so the only boundary such a pause can land on is the one the
transition just committed.
**Amendment, 2026-09-23 (RT-01a; operator decision of 2026-09-23).** One edge is added, for a
composition that declares a rollback policy:
```text
Ready(e, k) → RollingBack(e', k) → Ready(e', k)
```
It is taken only when the transition that reached `k` returned `episodeRequest.kind =
"rollback"`, after that transition fully committed and before the next Prepare (section 6
amendment). The boundary number does not change; the epoch does. A pause requested during it
lands on `Ready(e', k)`; any failure inside it is `Failed → Restoring(new epoch)`. Capture is
not allowed in `RollingBack`.
## 3. Transaction sequence
### Phase A: prepare all agents concurrently
At Ready(k), freeze the task's per-agent decision contexts and the coordinator's admitted
pre-step stimulation list. Inputs accepted after this cut wait for the next boundary.
Send `Agent.Prepare(scope=k)` to every active agent. Each worker:
1. Verifies its committed boundary/profile/context and applies admitted pre-step stimulation
in deterministic command sequence order. Chat text is never included.
2. Advances the numerical model for the environment interval, using the input encoded at
the preceding Commit (or initialization).
3. Reads rates and performs the fixed readout with the declared decision context.
4. Stores and returns `PreparedDecision`; it then enters Prepared(k) and waits for Commit.
This operation **mutates** the brain, RNG, clock and decoder. “Prepare” does not mean a
database transaction that can be rolled back cheaply. If another agent fails, do not ask a
prepared agent to prepare again or advance to the next step. Resolve/recover the whole session.
All agents see the same environment interval and the same world boundary, with only their
permitted view/context differences. Their completion order never affects port/action order.
### Phase B: build and apply one complete batch
After every PreparedDecision arrives:
1. Validate agent IDs, intent schemas and profile identities.
2. Run each task-local action executor once, in sorted agent-ID order, against coherent current
game state, task progress/objectives and clock from this boundary.
Direct-control profiles use an identity executor. Macro profiles are explicit extensions.
*Amendment, 2026-09-23 (RT-01a):* the legacy composition's extension is
`pokered-macros-v1`. "Coherent current game state" is the boundary's 64-KiB memory image
from `O[k].inspection` plus the ROM `AssetRef` -- never a live emulator read -- and "clock"
is the agent's brain time after its Prepare ([legacy-gameboy-v1](legacy-gameboy-v1.md)
sections 8 and 10).
3. Assemble all configured port controls in descriptor port order; reject duplicates/missing
ports. Uncontrolled ports are configured neutral before the epoch, not supplied ad hoc.
4. Send exactly one `Environment.Advance(scope=k, batchId, controls)`.
The environment applies all controls at its agreed boundary, advances exactly one interval,
and returns StepResult for `k+1`. It MUST NOT advance another interval while waiting for
the next request. Transport/control scaffolding may have a measured fixed latency; it must
be declared in its descriptor and conformance tests.
### Phase C: observe and evaluate the task
The coordinator receives the environment result and verifies batch identity, boundary,
cadence, inspection schema and required sensory views. A missing spectator frame is tolerable;
a missing required sensory input is not silently replaced.
Call the task's `evaluate_transition` once with old/new inspection observations and applied
controls. It returns scoped rewards/stimulation, next decision contexts, progress/events and
an optional episode request. Commit its ledger update in memory and retain the result for
this transition. No task output directly writes controllers or neural state.
**Amendment, 2026-09-23 (RT-01a).** The task may also ask for two things that happen at the
boundary this transition reaches, after Phase D, never inside it: a slot save
(`Environment.SaveSlot`, composition capability `gameboy-slots-v1`) and a rollback
(`episodeRequest.kind = "rollback"`). Both are recorded with the transition's result and
applied by the coordinator in the order *save, then rollback* (section 6 amendment). A slot
save due at a boundary completes before any `State.Capture` or FLYSIM01 export at that boundary
(amended 2026-09-23, review round 1; [legacy-gameboy-v1](legacy-gameboy-v1.md) section 16). The
retained old inspection is what makes "evaluate once against old/new inspection" possible when
the inspection is artifact-backed: the coordinator keeps `O[k]`'s image until this evaluation
finishes.
### Phase D: commit all agent outcomes concurrently
Send `Agent.Commit(scope=k)` with that agent's next sensory observation and routed outcomes.
Each agent, in this order:
1. Encodes/installs the next sensory input for the following Prepare.
2. Applies task-derived stimulation in returned event order.
3. Sums that agent's reward values in returned event order and calls reinforcement once at
its current brain time when learning is enabled. A zero sum still follows the profile's
specified legacy-equivalent reinforce behavior; do not optimize it away without evidence.
4. Retains the next decision context/digest and acknowledges committed boundary `k+1`.
There are no additional neural ticks in Commit. Sugar accepted for a future Prepare is not
silently merged with task reward modulation. The default synthetic learning mechanism remains
separate from the neural stimulation path.
Once **all** commits succeed, the coordinator advances its committed boundary to `k+1`,
finalizes the observation snapshot and scoped events, drops no-longer-needed artifact handles and
allows the next Prepare. If one Commit fails after others succeeded, the epoch is failed;
there is no partial-match continuation.
These phases establish logical coordination, not a distributed durable two-phase commit.
Crash recovery returns to the last complete checkpoint, not necessarily the last displayed step.
## 4. Sequence example
```text
Coordinator Agent A Agent B Environment
| Prepare(k) ------>| | |
| Prepare(k) ----------------------->| |
|<-- Prepared(A) ---| | |
|<-- Prepared(B) --------------------| |
| [executor + complete port batch] |
| Advance(k, batch-x) --------------------------------->|
|<-------------------- StepResult(k+1, batch-x) ----------|
| [task evaluation; route each reward once] |
| Commit(k, O[k+1], R_A) ->| | |
| Commit(k, O[k+1], R_B) ------------>| |
|<-- Committed(k+1) -----| | |
|<-- Committed(k+1) -----------------| |
| [Ready(k+1); publish; next boundary] |
```
The shared camera is one immutable artifact forwarded through bus-owned deliveries; both
workers can encode it without two renders or routing two full images through sockets.
Domain RPC replay caches retain handles, so consumption by one client cannot invalidate a
promised replay. Publication uses bus pub/sub and never waits for spectator consumption.
The coordinator retains each domain request's input handles until its terminal outcome is
resolved, beyond the shorter bus-admission lifetime, so safe domain retries still have valid
attachments. If ownership is lost, fail/recover instead of sending bare expired references.
The coordinator cannot publish a committed state as soon as the faster agent answers.
## 5. Time and pacing
The environment descriptor supplies a fixed reduced `stepDuration: RationalNs`. Each agent
profile supplies `tickDuration: RationalNs`. The existing LIF adapter uses exactly one ms.
For each Prepare:
```text
accumulator += environment step duration
ticks = floor(accumulator / model tick duration)
accumulator -= ticks * model tick duration
```
Use checked rational/integer arithmetic; remainder is always >=0 and < one model tick.
Do not accumulate rounded microseconds or nanoseconds for a fractional frame period.
Persist remainder, executed tick count and warm-up offset. Language implementations must
agree on remainder fixtures. Conversion to the legacy model's f64 millisecond clock must
preserve its representable integral ticks; refuse a run exceeding the supported exact range.
Example: a synthetic 60-Hz environment with a 1-ms model tick produces 16,17,17 ticks
over three steps, totaling 50. A real backend's measured/declared emulated cadence may
differ; never substitute this example's duration for Game Boy or Dolphin clocks.
**Amendment, 2026-09-23 (PROF-02a).** The Game Boy's declared cadence is one frame of 70224
cycles at 4194304 Hz, `stepDuration = 8572265625/512` ns. The legacy service accumulates the
`f64` constant `1000 / (4194304 / 70224)` ms, which is exactly `548625/32768` ms, and every
remainder it produces is a multiple of 2^-15 ms below 32 -- exact in `f64`. The legacy
"floating remainder arithmetic" and this section's rational accumulator therefore give identical
ticks and remainders for every frame; both implementations assert it and
`fixtures/gameboy-legacy.json` records the first twelve frames (16, 17, 17, 16, ...). No legacy
exception to this section is needed ([legacy-gameboy-v1](legacy-gameboy-v1.md) section 3).
Wall time is only for pacing, health and presentation. The coordinator schedules absolute
deadlines after committed boundaries; when behind, it omits sleep and reports lag. It does
not skip world steps, drop neural ticks, or let one agent advance more slowly than another.
Only one pacing authority is active. Backend throttling and coordinator pacing must be
configured/tested so they do not unintentionally double-throttle the session.
First-version profiles have a fixed cadence within an epoch. Supporting variable-duration
world advances requires a new capability and tests before enabling it.
## 6. Initialization, pause and episodes
Initialize the environment first while stopped, obtaining observation O[0]. Bootstrap the
task and initial decision contexts, then initialize agent workers with their permitted inputs.
Agent warm-up has learning disabled; calibration occurs on settled rates; no warm-up actions
advance the environment. All required acknowledgments establish Ready(0).
A normal pause request arriving mid-step means “finish this transition, then pause.” It does
not truncate neural computation or capture half an action batch. If completing the transition
is impossible, use failure/recovery, not an apparently successful Pause acknowledgment.
Paused workers retain state and answer Status; world controls do not advance the world.
Task terminal events are evaluated and their final rewards committed once. Before another
gameplay transition, enter Paused and apply the declared episode policy. Reset uses a new
epoch/episode and step 0. A profile may retain learned gains/brain state, but must identify
exactly what is retained, cleared, warmed or recalibrated. No worker independently resets.
Changing port assignment, agent membership, model/profile, cadence or task schema requires
a new composition/epoch. Hot-join and hot-swap during an active match are not v1 capabilities.
**Amendment, 2026-09-23 (RT-01a; operator decision of 2026-09-23).** A declared rollback policy
is an episode policy that does **not** pass through Paused and does **not** start a new episode.
For `legacy-ratchet-rollback-v1` ([legacy-gameboy-v1](legacy-gameboy-v1.md) section 11), once
every Commit of the transition that reached `k` has succeeded:
1. If the same transition asked for a slot save, `Environment.SaveSlot(scope e,k)` first.
2. Choose a new epoch `e'`. `Environment.RestoreSlot(scope e',k; priorEpoch e)` returns the
restored `O'[k]`: same boundary, `worldTime` and `engineFrame` continue, no audio chunk.
3. Coordinator-local: the task clears its transient observations; the executor cancels any
running action and observes `O'[k]`, which yields the next decision contexts.
4. `Agent.Rollback(scope e',k; priorEpoch e)` on every agent concurrently: holds and
eligibility cleared, `O'[k]`'s view installed, no tick.
5. With every reply in hand: `Ready(e', k)`, then the usual durable save. Every capture at
this boundary, before or after the rollback, follows step 1.
A worker reports `capturing` during `SaveSlot` and `restoring` during `RestoreSlot` or
`Agent.Rollback`. A lost reply is resolved by [session RPC](ipc-v1.md) section 6 against the
same operation key before anything fails the epoch (legacy-gameboy-v1 section 11).
Exactly what is retained, cleared and installed is named by the policy, as this section already
requires; nothing is reset by a worker on its own initiative, and a failure at any step fails
the epoch and restores the group coherently. "Reset uses a new epoch/episode and step 0"
remains the rule for terminal episodes; a rollback keeps the episode and the step numbering
because the brain, the audience's history and the frame counter all continue across it, as they
do in the running service. The policy is single-agent: a shared competitive world must not
declare it, because it would rewind one world under every player on one player's stall, which
[state-media-v1](state-media-v1.md) section 7 forbids.
**Amendment, 2026-09-23 (RT-01a).** Sugar admission in the legacy composition reads the pulse
from the last completed commit's telemetry, so an admission decided while a transition is in
flight is at most one commit stale; the admitted stimulus still enters only at the next
admission cut of section 3, Phase A ([workers-v1](workers-v1.md) section 5 amendment).
## 7. Failure rules
| Failure point | Required response |
| --- | --- |
| Before any Prepare admitted | Reject request/config or remain Ready; nothing advanced |
| Some agents Prepared | Stop dispatch; resolve matching requests or fail epoch/group restore |
| Advance acknowledgment lost | Query/retransmit same request to same incarnation; never new batch |
| World advanced, sensory data unavailable | Fail transition; do not reward/continue using guessed input |
| Task interpretation fails | Fail epoch; prepared brains/world already changed |
| Some Commit replies missing | Resolve exact requests; no next world step until all committed |
| Worker incarnation changes | All live participants belong to an invalid epoch; restore/reset together |
| Publisher/browser disconnected | Simulation continues; bound/drop spectator work |
| Durable storage fails | Report actual failure; apply configured pause/continue-with-stale-checkpoint policy |
Retries return original results; they never recompute a decision with updated rates or new
world data. A coordinator restart has no authority to assume any remote participant's phase;
recover from a coherent checkpoint into a new epoch or start an explicitly new episode.
## 8. Required trace assertions
The synthetic integration test must record, for every transition:
- Scope, Prepare request IDs, agent/profile IDs, tick counts/remainders and decision digests.
- Complete batch ID/control digest and acknowledged world boundary.
- Observation producing boundaries and task event/outcome IDs in order.
- All Commit acknowledgments and published committed boundary.
**Amendment, 2026-09-23 (RT-01a, review round 1).** For a composition with boundary actions the
record also carries, for the boundary the transition reached:
- in behaviour, `boundaryActions`: every `Environment.SaveSlot` (slot id and saved state
digest) and a rollback (slot id), in the order applied -- saves first, each slot once, at most
one rollback, last. FND-01's harness compares it like any other behaviour field;
- in operational metadata, `captures`: every checkpoint capture or FLYSIM01 export at that
boundary, in the order taken, each with the number of boundary actions already applied.
Captures are operational because their schedule is wall-clock policy.
A trace whose capture precedes one of the boundary's slot saves, or counts more actions than
were applied, is refused. The synthetic composition records both lists empty.
Evaluate agents sequentially, concurrently, and in reversed dispatch/completion order. All
committed state/action/reward results must match, excluding wall time, request IDs and other
explicitly operational metadata. Delayed/lost/duplicate messages must not add a neural tick,
world step, reward update or controller flush corresponding to another logical step.

View file

@ -1,517 +0,0 @@
# Worker and task interfaces v1
Status: **draft 2**. Uses [Flybus](bus-v1.md) for every call/publication and the domain
types/outcomes in [session RPC](ipc-v1.md), ordering in
[step protocol](step-v1.md), and binary/state types in [state and media](state-media-v1.md).
All method results below are the `result` object inside the domain result carried by a bus
rpc.result. Large inputs/outputs use owned bus attachments, never another worker data channel.
## Method registry
| Method | Caller → receiver | State/owner |
| --- | --- | --- |
| `Worker.Hello` | Authorized caller → named worker service | Domain identity/capabilities after bus negotiation |
| `Worker.Status` | Authorized caller → named worker service | Read-only; responsive during compute |
| `Worker.Acknowledge` | Coordinator → worker | Bounded lifecycle-result retention |
| `Worker.Shutdown` | Coordinator → worker | Terminal lifecycle request |
| `Agent.Initialize` | Coordinator → agent | Uninitialized → Ready(0) |
| `Agent.Prepare` | Coordinator → agent | Ready(k) → Prepared(k) |
| `Agent.Commit` | Coordinator → agent | Prepared(k) → Ready(k+1) |
| `Environment.Initialize` | Coordinator → environment | Uninitialized → boundary 0 |
| `Environment.Advance` | Coordinator → environment | Boundary k → boundary k+1 |
| `State.Capture` | Coordinator → agent/environment | Immutable snapshot of committed boundary |
| `State.StageRestore` | Coordinator → agent/environment | Validate replacement state under new epoch |
| `State.ActivateRestore` | Coordinator → agent/environment | Install staged state; remain quiescent |
| `Environment.SaveSlot` | Coordinator → environment | Capability `gameboy-slots-v1`; record a slot at the committed boundary (section 7) |
| `Environment.RestoreSlot` | Coordinator → environment | Capability `gameboy-slots-v1`; boundary k under a new epoch (section 7) |
| `Agent.Rollback` | Coordinator → agent | Capability `legacy-ratchet-rollback-v1`; Ready(e,k) → Ready(e',k), no tick (section 7) |
State methods have payloads in [state and media](state-media-v1.md). Artifact lifetime and
message consumption are bus operations managed by the SDK, not Worker/Coordinator methods.
Worker.Acknowledge releases a domain result cache, distinct from consuming a bus delivery.
Task/executor interfaces below are local library methods, not extra communication protocols.
## 1. Shared data model
```ts
interface AssetRef {
id: Id; digest: Digest; byteLength: U64; format: Id;
}
interface SensoryInput {
boundary: U64; // environment boundary being observed
views: ViewRef[]; // only the views this agent is allowed to consume
structured: TypedValue | null;
}
interface Stimulus {
id: Id; kindId: Id; durationMs: number;
}
interface Reward {
eventId: Id; ruleId: Id; value: number;
}
interface AgentTelemetry {
brainTicks: U64;
populationRateHz: number;
rates: { roleId: Id; hz: number }[];
learning: { enabled: boolean; updates: U64; changed: U64; signal: number };
}
// Amendment 2026-09-23: AgentTelemetry also carries
// stimulusRemainingMs: number | null; // pulse still running after the operation; null = reports none
```
`AssetRef` names persistent content in a preprovisioned local registry; it is not an arbitrary path or
URL for a worker to fetch. A profile artifact contains all effective numerical, sensory,
readout, learning and schema identities. Dataset artifacts must already be installed and
verified. No implicit runtime network download or latest-version selection is allowed.
Transient `ArtifactRef` is instead defined by Flybus and resolves only through owned handles.
Views and structured input are separate capabilities. A pixel-only profile rejects non-null
structured input. Max views per sensory input: 8; any individual TypedValue is at most 32 KiB
of canonical JSON, and the complete bus envelope must fit 64 KiB. Larger typed state uses
an explicit artifact-backed schema. Frame bytes never go into JSON.
`Stimulus.kindId` resolves through a profile-declared capability to an anatomical binding
and fixed drive, including supported duration bounds. A caller cannot specify arbitrary
neuron indices or change drive values. `durationMs` must be finite and >0. Arrays of stimuli
or rewards are bounded to 64 per operation and retain their supplied order.
Rates must be finite/nonnegative, unique by role ID and in profile-defined order, at most
64 entries. Learning values must be finite; integer counters fit U64. Reward values are
finite; shipped positive-only task profiles reject negatives. Empty rewards do not imply a
different numerical rule. `id`/`eventId` is unique within its outcome or command namespace;
the coordinator assigns stable IDs before sending a mutating request.
**Amendment, 2026-09-23 (RT-01a; operator decision of 2026-09-23).** `AgentTelemetry` gains
`stimulusRemainingMs: number | null`: the milliseconds of stimulation pulse still running when
the operation that reports the telemetry completed, or `null` for an agent that has no pulse to
report. The operator decided that sugar admission reads the legacy `reward_remaining` from the
last commit's telemetry (section 5 amendment), and no field carried it. It is finite and
nonnegative; it is a report, never an input. The field is required-and-nullable like every
optional field in these contracts, so every existing producer now writes `null`. It changes
`contractDigest`, which [session RPC](ipc-v1.md) section 4 already provides for.
## 2. Agent methods
### Agent.Initialize
Allowed only on an uninitialized agent with negotiated `agent-step-v1`. Scope is the new
epoch at step `0`; restore uses the state interface instead of Initialize.
```ts
interface AgentInitializeParams {
agentId: Id;
profile: AssetRef;
seed: number; // signed 32-bit integer, matching current RNG input
initialInput: SensoryInput;
initialDecisionContext: TypedValue;
workerThreads: number; // integer >=1; within launcher allocation
}
interface AgentInitializeResult {
agentId: Id; profileDigest: Digest; tickDuration: RationalNs;
warmupTicks: U64; committedStep: U64; // committedStep == "0"
decisionContextDigest: Digest;
telemetry: AgentTelemetry;
graph: AgentGraph;
}
interface AgentGraph {
datasetDigest: Digest; indexDigest: Digest; neuronCount: U64;
rateRoles: Id[]; // <=64, unique; AgentTelemetry.rates is in this order
supportedStimuli: Id[]; // <=64, unique; an undeclared kind is UNSUPPORTED
}
```
**Amendment, 2026-09-22 (PUBLISH-01).** `AgentInitializeResult` gains `graph`, because
[publishing-v1](publishing-v1.md) section 3 requires `datasetDigest`, `indexDigest`,
`neuronCount`, `rateRoles` and `supportedStimuli` in every published `AgentDescriptor` and no
worker method carried any of them. Without this the only available source is the composition
that asked for the agent, so a descriptor could only ever agree with itself and the section 3
rule that "geometry/spike mapping requires indexDigest, not merely the same number of neurons"
would have nothing to compare. Initialize is where the agent has just loaded its dataset and
built its index, so the attestation belongs there. `rateRoles` is the "profile-defined order"
section 1 already requires `AgentTelemetry.rates` to be in, and the result is refused when the
two disagree; `supportedStimuli` is the profile capability section 1 already requires a
stimulus kind to resolve through, and a kind outside it is refused with `UNSUPPORTED` before
the model is touched. It changes `contractDigest`, which [session RPC](ipc-v1.md) section 4
already provides for.
**Amendment, 2026-09-22 (SESSION-02).** `HelloResult.limits` gains `workerThreads`, an
integer >=1 reporting the allocation the launcher started that worker within, because
"within launcher allocation" above had no wire-level proof: the launcher passes the number to
the worker out of band, and a coordinator that is not also its own launcher had no contract
path to it. Hello is where a worker already proves its identity and reports its limits, so the
allocation belongs there. A caller asking for more than the worker reports is refused with
`BUSY` before the model is constructed, which this section already required; the amendment
only makes the number visible to whoever must respect it. It changes `contractDigest`, which
[session RPC](ipc-v1.md) section 4 already provides for.
The profile fixes warm-up/calibration behavior and supported schema versions. Validate inputs
and required roles before model construction. Install the initial sensory input, warm the
brain with learning disabled, calibrate the fixed readout and establish Ready(0). Do not
generate gameplay rewards or controls that advance the world during warm-up.
Seed is persisted as run configuration and state; the coordinator derives independent seeds
from its recorded master seed and stable agent IDs under a versioned derivation algorithm.
That algorithm is part of composition identity and MUST be specified/tested before the real
agent slice; hand-selected explicit seeds are supported for the first synthetic composition.
Identical explicit seeds are allowed only when the experiment intentionally declares them.
The profile artifact digest identifies the profile definition; the capture compatibility
digest additionally covers the resolved seed, numerical model version and effective instance
configuration. Never assume identical profile digests make differently initialized state
interchangeable without matching that instance configuration.
### Agent.Prepare
Allowed from Ready(k), or as an exact domain retry under the session RPC deduplication rules.
```ts
interface PrepareParams {
agentId: Id; profileDigest: Digest;
interval: RationalNs;
decisionContextDigest: Digest;
preStepStimulations: Stimulus[];
}
interface PreparedDecision {
agentId: Id;
ticksAdvanced: U64; brainTicks: U64; remainder: RationalNs;
decision: TypedValue;
}
```
Verify the cached context digest from Initialize/last Commit, expected agent/profile and
interval. Apply pre-step stimuli, advance ticks and decode as specified by the step protocol.
The returned decision schema is the profile's registered intent schema. It may describe a
controller state or selected semantic action, but cannot assign a port or include hidden
task-inspection fields. Context may mask declared available actions; it cannot change the
readout weights, invent a default winner or inject arbitrary neural observations.
Successful response leaves the worker at Prepared(k). A duplicate returns the same decision,
ticks and remainder. It MUST NOT resample randomness, repeat stimulation or calibrate again.
### Agent.Commit
Allowed only at Prepared(k), matching the current transition and exact Prepare request.
```ts
interface CommitParams {
agentId: Id;
preparedRequestId: Id;
nextInput: SensoryInput; // boundary == k+1
nextDecisionContext: TypedValue;
rewards: Reward[];
taskStimulations: Stimulus[];
}
interface AgentCommitResult {
agentId: Id; committedStep: U64; // k+1
decisionContextDigest: Digest;
telemetry: AgentTelemetry;
}
```
Validate the complete request and required owned artifacts before applying it. Follow exact input→
stimulation→reinforcement ordering in the step protocol. A missing required view is an error,
not zero input. An outcome requesting unsupported learning/stimulation is an error, not a
silent no-op. Disabled learning is a declared profile/run state, not “unsupported.”
No tick is executed in Commit. Cache its response before accepting the following Prepare.
The next context is retained for that Prepare; its canonical digest is returned and checked.
After encoding/copying and all asynchronous use finish, the worker drops its input Artifact
handles. The SDK consumes the delivery when the last associated guard disappears. A stored
input pointer must retain its handle. Cached replies retain their own artifact ownership.
### Agent interface implementation boundary
An agent worker bundles numerical model, sensor encoder and readout. It need not copy the
legacy `NeuralAgent::tick` call order: the existing service already orchestrates substeps.
Use a small adapter over the reference primitives and preserve the new specified ordering.
New numerical semantics require reference-first implementation and new model identities;
this protocol is not permission to change the pinned default kernel.
## 3. Environment methods
### Controller and descriptor types
```ts
interface ControllerSchema {
schema: SchemaRef;
buttons: Id[]; // <=32, unique, fixed order
axes: { id: Id; range: "bipolar" | "unit"; neutral: number }[]; // <=16
}
interface PortControl {
portId: Id;
buttons: { id: Id; down: boolean }[];
axes: { id: Id; value: number }[];
}
interface EnvironmentDescriptor {
backendDigest: Digest; contentDigest: Digest; configurationDigest: Digest;
stepDuration: RationalNs;
ports: { portId: Id; controls: ControllerSchema }[];
inspectionSchema: SchemaRef;
views: ViewDescriptor[];
audio: AudioDescriptor[];
recovery: "exact-checkpoint" | "episode-restart";
determinism: "fixed-build" | "unverified";
}
```
Every active port control must include every declared button and axis in descriptor order.
All IDs must match exactly; no duplicates, extra controls or omissions. Bipolar axes are
finite [-1,1]; unit axes [0,1]. Neutral lies in range. Do not silently clamp an out-of-range
caller value. Hardware-specific quantization/dead zones are backend configuration, applied
exactly once and tested against observed controls.
`fixed-build` asserts tested repeatability under the pinned configuration, not universal
bit-exact behavior across CPU architectures, GPU drivers or emulator versions. Those limits
must be in the backend implementation guide and run manifest. `unverified` cannot satisfy
an exact-replay production composition without an explicit scope change.
### Environment.Initialize
```ts
interface EnvironmentInitializeParams {
backendConfig: AssetRef;
taskConfig: AssetRef;
episodeId: Id;
portBindings: { portId: Id; agentId: Id }[];
}
interface EnvironmentInitializeResult {
descriptor: EnvironmentDescriptor;
observation: WorldObservation; // boundary 0, worldTime zero
}
interface WorldObservation {
boundary: U64;
worldTime: RationalNs; // logical time since episode start; preserved on crash restore
engineFrame: string | null; // backend-defined signed counter; <=64 characters
sensoryViews: ViewRef[];
inspection: TypedValue;
broadcastViews: ViewRef[];
audio: AudioRef[];
}
```
Scope is new epoch step 0. Backend/task config artifacts identify exact game content,
patches, initial-state/setup policy, graphics/timing/parser and controller conversion.
No unresolved “latest” settings. Environment setup may be application-specific, but it is
declared lifecycle scaffold, not actions attributed to a fly. The environment is stopped
when it returns O[0] and cannot free-run during brain initialization.
The environment only needs backend-relevant portions of task setup, not reward rules or
neural policies. `taskConfig` resolves a declared setup configuration; the complete task
implementation and ledger stay in the coordinator.
**Amendment, 2026-09-23 (RT-01a; operator decision of 2026-09-23).** Three readings for the
Game Boy environment of the legacy composition ([legacy-gameboy-v1](legacy-gameboy-v1.md)
sections 8 and 9), stated here because they are about this method's shape:
- *Setup scaffold.* The backend configuration may declare setup frames the environment runs
with every control neutral before it returns O[0]. The legacy composition declares exactly
**one frame with no button down**, which is what its fresh start does; O[0] then has
`engineFrame` `"1"`, `worldTime` `0/1` and no audio chunk. These frames are declared
scaffold, attributed to no fly, and never a transition.
- *Inspection may be artifact-backed.* `inspection` is a `TypedValue` under the descriptor's
schema; the legacy schema `gameboy-memory-inspection-v1` carries a 64-KiB memory image as an
`ArtifactRef` (listed in the bus attachments) plus the ROM's digest. This is the "explicit
artifact-backed schema" section 1 requires for typed state over 32 KiB, and it is the one
bulk transfer per boundary the section 4 rule against per-byte remote reads asks for. The
environment's only write to a running game remains the controller batch.
- *Audio.* The environment publishes native samples in the declared f32 format; converting a
backend's integer samples to f32 is the environment's job (binjgb: `sample / 255`), and any
filtering for listening -- the legacy DC blocker -- is presentation, applied by the edge.
### Environment.Advance
```ts
interface AdvanceParams {
batchId: Id;
controls: PortControl[];
}
interface StepResult {
batchId: Id;
appliedFromStep: U64; nextStep: U64;
appliedControlsDigest: Digest;
observation: WorldObservation;
}
```
Validate scope k, complete controls and identities before releasing a backend input barrier.
Batch IDs are unique within an epoch; reusing one for a different request/step is a conflict.
Apply the complete batch to its interval and advance one framework step. Record batch ID,
result and next boundary before acknowledging. StepResult's control digest is over validated
canonical requested controls; backend quantization does not silently change that definition.
Observed game-pad values, if provided, are separately schema-labeled inspection data.
The environment returns exactly boundary k+1, worldTime advanced by its stepDuration, and
the required sensory views or a typed failure. An adapter with measured input latency must
describe it in its versioned backend config and prove its frame mapping; it cannot claim an
unmeasured same-frame response. An emulator may execute internal cycles/polls, but cannot
hide multiple framework steps behind one result.
### Environment pause behavior
At a committed world boundary the backend already awaits the next Advance; normal session
pause does not need a separate per-frame RPC. When an emulator requires an explicit hardware/
CPU pause to hold that invariant, the adapter owns it and must prove it. Status/Shutdown
remain responsive. A render/audio worker may drain already-produced data while stopped,
but no new gameplay state may advance.
## 4. Coordinator-local task and executor interfaces
These are library interfaces in v1, not additional bus services. Equivalent typed interfaces
may be implemented in Rust; names below specify semantics rather than compilable code.
```text
Task.bootstrap(initialInspection, bindings)
→ perAgentDecisionContexts, progress, initialEvents
Task.evaluate_transition(scope, oldInspection, newInspection, appliedControls)
→ perAgentOutcomes, perAgentNextDecisionContexts, progress, events, episodeRequest
ActionExecutor.apply(scope, agentDecision, currentGameState, progressView, clock)
→ ControllerIntent, executionEvents
Task.capture / validate_restore / install_restore
ActionExecutor.capture / validate_restore / install_restore
```
Task owns a checkpointable ledger. The coordinator calls each transition evaluation exactly
once after its acknowledged world step and retains the output until all agent commits finish.
If task mutation is followed by failure, restore the group; never reevaluate against a later
observation. Task-produced outcomes are keyed by configured agent ID; unknown/missing agents
are errors. Every agent receives explicit outcome arrays, including empty ones.
`ControllerIntent` contains buttons/axes conforming to the assigned port's controller schema,
but not a port assignment. The coordinator supplies the port. Per-agent executor state is
private; a running macro may emit controls according to its declared policy, but only after
neural selection. The first implementation supports the stateless identity executor only.
**Amendment, 2026-09-23 (RT-01a; operator decision of 2026-09-23).** The legacy composition
declares the extension **`executor: pokered-macros-v1`**: its task and its action executor are
**one object** implementing both interfaces above, serving one agent on one port
([legacy-gameboy-v1](legacy-gameboy-v1.md) section 10). They share state that neither could
reach through a declared channel if split: the executor's scene observation is the `bound` set
the task hands the decoder, the macros read the task's exploration ledgers, and the macro layer's
progress signal feeds the ratchet. The executor reads the boundary's memory image and the ROM
`AssetRef`, never the emulator. "Stateless identity executor only" remains true of the
synthetic composition; a stateful executor is permitted exactly where a composition declares
one by name, and its state is captured or declared transient by that composition's restore
semantics (state-media-v1 section 4 amendment).
The executor's currentGameState is a coherent read-only inspector view at this boundary;
progressView supplies task history/objectives. It updates its selected action every step
(movement, path replanning, interaction, completion), not merely replaying a blind button
sequence. These game-aware inputs stay in the task/executor layer. For an external backend,
they arrive as typed observation/artifact data over the same bus, not per-byte remote reads.
Task contexts sent to the neural readout are typed, bounded, versioned and allowlisted by its profile.
They may express boot state or available actions. They are distinct from neural sensory
input and broadcast telemetry. A profile using game-state features as neural input must
explicitly declare structured sensing; the task cannot smuggle it into an opaque context.
Task events carry `{id, kindId, sourceStep, agentId:null|Id, payload:TypedValue}`. Event order
is task-defined and deterministic; `sourceStep` is the newly reached boundary k+1 for a
transition event (0 for bootstrap events). Event IDs are derived deterministically from
epoch, source step, task/rule and event ordinal, encoded as an Id. Rewards and stimulation
referencing these events must
have a configured recipient. Broadcast text is generated by task/presentation templates,
not arbitrary raw inspector memory or incoming chat.
`episodeRequest` is either null or `{kind:"terminal", reason:Id, outcome:TypedValue}`.
It requests a coordinator-owned policy transition after final reward commit; it cannot reset
the environment directly. Generic progress is a TypedValue, not mandatory Pokémon ladder data.
**Amendment, 2026-09-23 (RT-01a; operator decision of 2026-09-23).** `episodeRequest.kind` is
`"terminal"` or **`"rollback"`**. A rollback request asks for the composition's declared
rollback policy; its `outcome` is that policy's registered schema. The only policy defined is
`legacy-ratchet-rollback-v1` (outcome `{slotId, trigger}`), applied at the boundary the
transition just committed without a pause ([step-v1](step-v1.md) section 6 amendment). A
composition that declares no rollback policy treats the request as a task failure. It still
"cannot reset the environment directly": the coordinator applies the policy through the
section 7 methods.
## 5. Admission and audience boundary
The first synthetic implementation has no audience input. Later integration maps permitted
public requests into a coordinator admission record containing stable interaction ID, agent
ID, accepted epoch/earliest step and profile-supported stimulus. Only the coordinator can
place that stimulus in Prepare. Viewer names/chat never enter the neural worker contract.
Rate limits and target resolution occur before the step's command cut. A later-selected
UI focus or new match cannot retarget an already accepted interaction. Bus/domain RPC deduplication
does not itself define payment/redemption semantics across epoch recovery; a future public
v2 contract must specify accepted/applied/rolled-back/aborted states and reconciliation before
paid interactions are enabled. Do not inherit a claim of durable exactly-once stimulation
from these in-memory worker request caches.
**Amendment, 2026-09-23 (RT-01a; operator decision of 2026-09-23).** Sugar in the legacy
composition is admitted by the coordinator with the legacy rules -- the rate limiter and "no
overlap with an active pulse" -- reading the pulse from `stimulusRemainingMs` of the **last
completed commit**. The operator accepted that this value can be one commit old; both
consequences are bounded to one frame and are listed in
[legacy-gameboy-v1](legacy-gameboy-v1.md) section 15. Admitted sugar is a profile-supported
`reward-pulse` stimulus in the next Prepare's `preStepStimulations`. Because admission and
application are now separate, each admission record carries its interaction id and ends
`applied` or, if the epoch fails before that Prepare commits, `aborted`; the edge fulfils the
first and refunds the second, and the slice wiring the bridge tests both. This is the
accepted/applied/aborted distinction the paragraph above asks for, for this one interaction
kind; paid interactions in general still need the public v2 contract.
## 6. Health, shutdown and extensions
All workers implement the common Hello/Status/Shutdown/Acknowledge methods. Capture/restore
methods are in the state contract and mandatory only when exact-checkpoint capability is
advertised. Unsupported methods return `UNSUPPORTED`, mutation none.
New task-specific fields belong in registered TypedValue schemas. New worker capabilities,
variable-duration stepping, subscriptions or additional sensor modalities require a contract
change and shared fixtures. An unconstrained plugin dictionary is not a substitute for that.
## 7. Extension methods (amendment, 2026-09-23)
**Amendment, 2026-09-23 (RT-01a; operator decision of 2026-09-23).** The operator decided on a
full port of the live fly, whose ratchet rolls the *game* back to a saved state while the brain
continues. Neither half of that is expressible with sections 2 and 3: there is no method that
saves or restores world state outside a coherent group checkpoint, and none that installs a new
input in an agent without a transition. These three methods are that, generically shaped, each
behind a capability a worker advertises in `Worker.Hello`; a worker without it answers
`UNSUPPORTED`, mutation none. Payloads are in `fly-session-types` (`extensions`) and
`@flybrain/session-types`, in the session schema set.
```ts
// Environment.SaveSlot -- capability gameboy-slots-v1. Scope: the committed boundary (e, k).
interface SaveSlotParams { slotId: Id } // a slot the composition declares
interface SaveSlotResult { slotId: Id; boundary: U64; // == scope.step
stateDigest: Digest; byteLength: U64 }
// Environment.RestoreSlot -- capability gameboy-slots-v1. Scope: (e', k), a NEW epoch.
interface RestoreSlotParams { slotId: Id; priorEpoch: Id; // the environment is Ready(e, k)
policy: "legacy-ratchet-rollback-v1" }
interface RestoreSlotResult { slotId: Id; committedStep: U64; // == k: no transition ran
observation: WorldObservation } // boundary k, no audio chunk
// Agent.Rollback -- capability legacy-ratchet-rollback-v1. Scope: (e', k), the same new epoch.
interface AgentRollbackParams { agentId: Id; priorEpoch: Id; // the agent is Ready(e, k)
policy: "legacy-ratchet-rollback-v1";
input: SensoryInput; // boundary k: the restored world
decisionContext: TypedValue } // for the next Prepare
interface AgentRollbackResult { agentId: Id; committedStep: U64; // == k: no tick ran
decisionContextDigest: Digest; telemetry: AgentTelemetry }
```
- Both environment methods run only at a committed boundary with no Advance outstanding.
`SaveSlot` replaces the slot's contents with the world state and the frame on screen at that
boundary; slots are environment state and belong to its `State.Capture` payload. It is one
operation per boundary under the section-5 operation key of [session RPC](ipc-v1.md).
- `RestoreSlot` and `Agent.Rollback` move a participant from `(priorEpoch, k)` to
`(scope.epoch, k)`; `priorEpoch` must differ from the scoped epoch, and after the move every
request scoped to the prior epoch is `STALE_EPOCH`. The restored observation keeps the
boundary number, `worldTime` and `engineFrame`, and returns fresh artifacts; it carries no
audio chunk, and the next chunk marks a discontinuity (state-media-v1 section 2).
- `Agent.Rollback` applies the policy's agent half and nothing else: for
`legacy-ratchet-rollback-v1`, clear decoder holds and plastic eligibility, install `input`
without a tick, reset the readout's transient (held channel, blocked window, location from
the context), keep the brain clock, membrane, RNG, rates and gains. No reward, stimulation or
calibration. It is the only way to install an input outside a Commit.
- A failure of any of these methods mid-policy fails the epoch; recovery is the coherent group
restore of [state-media-v1](state-media-v1.md) section 6. There is no partial rollback.
- `maxSlots` is 4 (a crate-chosen bound, published in the schema set).
The sequence that uses them is [step-v1](step-v1.md) section 6's amendment and
[legacy-gameboy-v1](legacy-gameboy-v1.md) section 11.

View file

@ -177,10 +177,6 @@ achievements.
| Wild win | +0.1, +0.05, +0.0333 | at most three observed wild KOs per `(map,species,level)` | | Wild win | +0.1, +0.05, +0.0333 | at most three observed wild KOs per `(map,species,level)` |
| Badge | +3 | each newly set badge bit | | Badge | +3 | each newly set badge bit |
That is the prototype's catalog as it shipped, kept here as history. The live one is
`docs/rewards-learning.md`: `pokered-unique8-v7` adds `boundary` (v5), `catch` (v6), and `talk` and
`item` with no `boundary` payout indoors (v7, the operator's decision of 2026-09-23).
Every value is positive; there were no loss or blackout penalties. Values in a frame summed into Every value is positive; there were no loss or blackout penalties. Values in a frame summed into
`R`, then `m = tanh(R)`. PAM stimulation ran 80 to 400 ms depending on reward kind, with `R`, then `m = tanh(R)`. PAM stimulation ran 80 to 400 ms depending on reward kind, with
overlapping pulses taking their maximum. overlapping pulses taking their maximum.

View file

@ -19,16 +19,6 @@ the project."
on the encoder output and `/status`, and records the release in `docs/stream-mvp-plan.md`. on the encoder output and `/status`, and records the release in `docs/stream-mvp-plan.md`.
No approval round trip with the operator; he sees the record. No approval round trip with the operator; he sees the record.
## Unstick while a fix cooks (the operator, 2026-09-23)
"lets unstick the fly so stream stays interesting. lets make that a rule. while a fix cooks,
revert." When the watcher confirms a trap and its review agent is dispatched, the coordinator
restarts flysim only (claimed in the host log): a restore starts with empty macro ledgers and
keeps the rung. A recurrence gets another restart; only a fly still trapped about ten brain
minutes after a restart is rolled back with `fly-reset-to-milestone`. This is an action outside
the fly: it presses no button and changes no ROM, readout, catalog or adapter, and it never
replaces the fix, which still ships through the loop above.
## The ethos check (every one must hold, or the fix is not shipped) ## The ethos check (every one must hold, or the fix is not shipped)
- The ROM is never modified. Game memory is read, never written. The only write into the - The ROM is never modified. Game memory is read, never written. The only write into the

View file

@ -99,17 +99,6 @@ argmax of the same normalized scores over the same rates. It is the same kind of
the retina already sees, arriving through a much narrower channel, and it is disclosed on the the retina already sees, arriving through a much narrower channel, and it is disclosed on the
honesty panel with the rest of the readout. honesty panel with the rest of the readout.
**Where the location comes from on the session framework (2026-09-23).** When the live fly runs
on the session framework (the operator's port decision of 2026-09-23), the sim loop that owned
the position is split: the task reads the location, the agent owns the decoder. The location
then reaches the decoder as a **declared** field of its decision context,
`gameboy-readout-context-v1 {boot, bound, location}`
([legacy Game Boy composition](design/session-framework/legacy-gameboy-v1.md) section 5). The rule is
unchanged: the location only restarts the blocked window, `null` is still no information, the
held channel and the window stay the readout's own state, and the blocked channel is still
computed here, never handed in. The profile allowlists the field, and the task cannot put
anything else in it.
## Macro group (2026-09-16) ## Macro group (2026-09-16)
`DecoderConfig.macros` is a second `ExclusiveGroup` with the same fields and the same decision rules `DecoderConfig.macros` is a second `ExclusiveGroup` with the same fields and the same decision rules

View file

@ -1,6 +1,6 @@
# Rewards and learning # Rewards and learning
The live reward catalog of the Pokémon Red adapter, `pokered-unique8-v7`. The code of record is The live reward catalog of the Pokémon Red adapter, `pokered-unique8-v5`. The code of record is
`services/flysim/crates/flybrain-gb/src/pokemon_red/` (`catalog.rs` holds the values, `mod.rs` the `services/flysim/crates/flybrain-gb/src/pokemon_red/` (`catalog.rs` holds the values, `mod.rs` the
gates and the rules); this page says what each rule pays for and why it is allowed to. The gates and the rules); this page says what each rule pays for and why it is allowed to. The
prototype's own `docs/rewards-learning.md` in `fly-plays-pokemon` is where the first seven rules prototype's own `docs/rewards-learning.md` in `fly-plays-pokemon` is where the first seven rules
@ -23,10 +23,7 @@ change what the fly can do.
| `trainer` | `trainer` | +0.5 | 200 ms | Each named `EVENT_BEAT_*` flag once, except the flags classified as story milestones | | `trainer` | `trainer` | +0.5 | 200 ms | Each named `EVENT_BEAT_*` flag once, except the flags classified as story milestones |
| `battle` | `wildwin` | +0.1, +0.05, +0.0333 | 100 ms | At most three observed wild KOs per `(map, species, level)` | | `battle` | `wildwin` | +0.1, +0.05, +0.0333 | 100 ms | At most three observed wild KOs per `(map, species, level)` |
| `badge` | `badge` | +3 | 400 ms | Each newly set badge bit | | `badge` | `badge` | +3 | 400 ms | Each newly set badge bit |
| `boundary` | `explore` | +0.05, +0.10 | 100 ms | First tile adjacent to one of the map's exits, and the exit tile itself; once per `(map, exit)` for the lifetime of the ledger. **Nothing on an indoor map** (since v7): the exit is still recorded, and pays 0 | | `boundary` | `explore` | +0.05, +0.10 | 100 ms | First tile adjacent to one of the map's exits, and the exit tile itself; once per `(map, exit)` for the lifetime of the ledger |
| `catch` | `wildwin` | +0.30, +0.10 | 150 ms | A wild Pokémon kept by a ball: +0.30 for a species this run had never owned, +0.10 for a repeat; at most three payouts per species for the lifetime of the ledger |
| `talk` | `explore` | +0.10 | 100 ms | A conversation the fly opened with a person or a sign **indoors**, paid when its box closes; once per `(map, sprite slot or sign text id)` for the lifetime of the ledger |
| `item` | `explore` | +0.15 | 120 ms | An item ball or a hidden item picked up, on any map; once per item for the lifetime of the ledger |
Every value is positive: there are no loss or blackout penalties, and `catalog::rule("blackout")` Every value is positive: there are no loss or blackout penalties, and `catalog::rule("blackout")`
is `None` by test. The values in one frame sum into `R`, and the network reinforces once with is `None` by test. The values in one frame sum into `R`, and the network reinforces once with
@ -36,147 +33,6 @@ The feed-kind column is `RewardKind::from_adapter` in `services/flysim/crates/fl
`docs/feed-protocol.md` publishes seven counters, and an adapter kind that has no counter of its `docs/feed-protocol.md` publishes seven counters, and an adapter kind that has no counter of its
own shares the nearest one. It still reaches the page as an event with its own label. own shares the nearest one. It still reaches the page as an event with its own label.
Two consequences of that sharing are worth stating rather than discovering. `catch` publishes on
`wildwin` because a catch is a wild battle the fly won by keeping the Pokémon, and *not* on
`pokedex` because the `species` rule already pays for the Pokédex bit the same catch sets --
counting it twice would be the dishonest option. And the stage's ticker copy is keyed on the feed
kind, not on the catalog kind (`apps/stage/src/games/pokemon-red.ts`), so the row for a catch
currently reads "wild win". The event's own label, `CAUGHT #<species>`, is what reaches the event
log, `/status` and the checkpoint. Changing the ticker copy means opening the feed's closed kind
set, which this rule deliberately did not do.
`talk` and `item` publish on `explore`, for the same reason `boundary` does: each is the fly
finding what is in a place -- new ground, a door, a person or sign it opened, an item it picked up
-- at the same quiet scale (0.05 to 0.15). Not `area`, which counts maps and is a notable row; not
`story`, which is the plot; not `wildwin`, which is a battle. No feed kind was added, so
`docs/feed-protocol.md` and the stage's switch statements did not move. The Pokémon Red ticker's
`explore` row still says "new place" ("3 new places" collapsed) for all four: "new find" was
proposed and the operator kept "new place" (2026-09-23), so a conversation or an item reads as a
new place on screen. The event labels -- `TALKED TO #<slot> IN AREA <map>`, `READ SIGN #<id> IN
AREA <map>`, `FOUND ITEM #<item>`, `FOUND A HIDDEN ITEM` -- reach the event log, `/status` and the
checkpoint. Both also reset the stage's stall meter, which counts `explore`: engaging with a
building is progress in the sense the operator asked for.
## Catch rewards
The operator's decision of 2026-09-22: the fly is paid for *keeping* a wild Pokémon, not only for
knocking one out. The rule is one kind with two payouts, the way `boundary` is.
**How a catch is read.** From `wCapturedMonSpecies` (`$d11c`), whose comment in `ram/wram.asm` at
the pinned commit is "0 if no mon was captured". `ItemUseBall` zeroes it before every throw
(`.canUseBall`) and writes `wEnemyMonSpecies` into it only on the branch that keeps the Pokémon;
`UseBagItem`'s `.returnAfterCapturingMon` zeroes it again and sets `wBattleResult` to 2 on the way
out of the battle. `wBattleResult` is 2 on exactly two paths in the whole game -- that one, and a
link battle whose opponent ran -- so requiring both the species and the result means a byte read
out of a half-initialised battle cannot pay. The adapter records the species during the battle and
pays on the way out, where the wild-KO payout already lives.
Not from `wPartyCount`. A catch with a full party raises `wBoxCount` instead, and `wPartyCount`
also rises for a gift, a trade and a Pokémon taken out of the PC, so it would need a second rule
to mean anything. The cartridge's own flag needs none.
**What counts as a new species.** The `species` payout inside the same battle. Nothing but a catch
can set a `wPokedexOwned` bit during a wild battle, so a `species` payout between the battle
starting and the ball keeping the Pokémon *is* that Pokémon being new to the run. It is read this
way rather than off `wCapturedMonSpecies` because that byte is the cartridge's **internal** species
index while the owned bitset is by **Pokédex number**, and nothing in WRAM converts between the two
(`docs/design/macros-wram.md` section 2, "species numbering"). A battle restored from a checkpoint
written before this rule existed carries no "species payouts when it started", which reads as
"cannot tell" and pays the repeat amount: the conservative half, and at most 0.20 once.
**The budget.** Three payouts per species for the lifetime of the ledger, the same cap and the
same reason as the wild-KO rule's three: a species the fly can find over and over is a farm, and
three is enough for the behaviour to be learned. A rollback blocks every species already paid,
exactly as it blocks every wild-KO key already paid, so the same catch cannot be replayed for
reward. A Safari Zone or old-man battle pays nothing, because the whole sample is dropped a step
earlier with a visible mode; a trainer battle pays nothing, because balls cannot be thrown in one.
**The scale.** 0.30 on its own is below a new Pokédex entry (0.50), below a story flag (1.0) and
well below a badge (3.0). A catch of a new species pays 0.80 across two kinds, which sits between
a story flag and a badge -- deliberately, because it is the one event that is both a discovery and
a thing the fly had to do on purpose.
## Engagement rewards
The operator's decision of 2026-09-23, recorded with the port decisions: reward the fly for
engaging *inside* buildings and stop paying it for leaving them. It was chosen over a pad rule and
over weighting the choice, and it is a catalog change -- an operator decision, like the catch
reward -- not a loop-review fix (`docs/loop-review.md`). It answers a shape the loop reviews kept
finding in Pewter: `GO OBJECTIVE` into a building and `GO OUT` straight back, paid for the door on
the way out and for nothing inside.
**Indoors** is two of the cartridge's own tables, and nothing hand-classified
(`pokemon_red/engage.rs`, `indoor`). `CheckIfInOutsideMap` (`home/overworld.asm`) is the game's
outdoor test -- tileset `OVERWORLD` or `PLATEAU` -- and `WarpFound2` labels its other branch
`.indoorMaps`; on its own that would call Viridian Forest and every cave indoors, and their exits
are how the fly gets anywhere. `BikeRidingTilesets` (`data/tilesets/bike_riding_tilesets.asm`) is
the list of places the bicycle may be ridden -- `OVERWORLD`, `FOREST`, `UNDERGROUND`, `SHIP_PORT`,
`CAVERN` -- and the bike is the one thing the cartridge refuses inside a building by rule. A map is
indoors when its `wCurMapTileset` is in neither: every house, mart, Pokémon Center, gym, gate, lab
and museum, the S.S. Anne, Silph Co., the Pokémon Tower, the Mansion, the Rocket Hideout and the
Indigo Plateau's rooms. Not the forest, a cave, the Underground Path or Vermilion's dock.
**`talk`, +0.10.** Paid on the sample the text box closes, for a conversation that
1. *the fly opened*: on the last sample before the font bit (`wFontLoaded` bit 0) rose, the fly had
the joypad -- no `wJoyIgnore`, no simulated input, no scripted movement -- was standing still
(`wWalkCounter` zero, the only state the overworld reads A in) and stood where it stands now. A
script's text usually opens with the joypad taken; one opened by a map script the frame after a
step ends can still look opened by the fly, and is then held to rule 2 (below);
2. *is with the thing in front of it*: `DisplayTextID` copies its argument into `wSpriteIndex` --
a sprite slot up to `wNumSprites`, or a text id -- and the sprite must stand on the tile the
player faces (or one further, across a counter, on a tileset that has counter tiles, which is
`IsSpriteOrSignInFrontOfPlayer`'s own long reach), or the text id must be the sign's on that
tile. The byte arrives about **twenty frames after** the font bit (measured on the cartridge:
`DisplayTextIDInit` loads the font's tiles first) and until then still names the previous
text's subject, so it is read once it has changed or 45 samples have passed, and only while the
bottom dialogue box is drawn -- the start menu draws its own box elsewhere. An item ball is a
sprite but not a person, and pays `item`;
3. *opened indoors*, on the map the box opened on;
4. *finished*: the box closed on the same map. A conversation that ends in a warp, a rollback or a
restore pays nothing.
The ledger is the adapter's lifetime `seen` set, keyed `talk:<map>:sprite:<slot>` or
`talk:<map>:sign:<text id>` -- the same "map and object index" the macros' `talked` ledger uses,
but **not** that ledger: the macros' ledger is session state and is thrown away on a restore; this
one is checkpointed and survives a rollback, so talking to the same person again, after a restore
or not, pays nothing. The trainer the fly speaks to before a battle is a person and pays once; the
nurse, a clerk and a sign each pay once per map.
**`item`, +0.15.** Read from the cartridge's own "this one has been taken" bits, on any map.
An item ball is one of the map's toggleable sprites (`wToggleableObjectList`, sprite slot and
global index) whose `wMapSpriteExtraData` is `(item id, 0)` -- the shape `LoadMapHeader` writes for
an `ITEM` `object_event` and for nothing else (a trainer's is `(class, number)` with numbers from
1, a person's two zeroes); `PickUpItem` sets its global bit in `wToggleableObjectFlags` through
`HideObject`, and only after `GiveItem` succeeded, so a full bag pays nothing. A hidden item is a
bit of `wObtainedHiddenItemsFlags`, set by `FoundHiddenItemText` after `GiveItem` and by nothing
else; hidden *coins* have their own bitset and are not items. Either pays when its bit rises
between two playable samples, keyed `item:<global index>` or `hidden:<index>`, once for the life
of the ledger. A gift item from a script (the Old Amber, a TM from a person) is not an item ball:
the conversation pays `talk`, and the item nothing.
**The seed.** The first playable sample that finds the key `items:seeded` absent -- a fresh
adapter, or a `v6` ledger restored under `v7` -- writes a key for every bit the game already shows
as taken and pays for none of them, so a rollback to a slot from before a `v6`-era pickup cannot
pay for taking it again. The two item balls a script *reveals* are left out of the seed, because
their bits are set from a new game until Giovanni's defeat clears them: the Rocket Hideout's Silph
Scope and Lift Key (`$87`, `$88`), the only `ITEM` entries `data/maps/toggleable_objects.asm` starts
`OFF`.
**`boundary` indoors.** Every exit on an indoor map is still written to the ledger, so
`exit_visited` answers exactly what it did and the macros see no change, but nothing is paid. A
town's doors, a route's edges, the forest's gates and a cave's ladders pay as before. One
consequence, measured on the cartridge (`tests/rom_engage.rs`): for the thirty-odd frames of
`PlayMapChangeSound` the cartridge has written the destination into `wCurMap` while the tileset and
the warp table are still the map being left, so the exit the fly is standing on is classified by
the map it belongs to. Walking into a building through a town door still pays that door's on-exit
half, 0.10, once, keyed under the building's id as it always was; walking out pays nothing.
**The scale.** A building's worth of engagement -- a few people, a sign, perhaps a ball -- is
0.3 to 0.6: more than the 0.15 its door paid for being left, less than a new Pokédex entry per
person, far below a badge. Everything is once per thing for the lifetime of the ledger, so no
building can be farmed.
## Gates ## Gates
Semantic rewards are enabled for exactly one cartridge, the SHA-256 in `SUPPORTED_ROM`. Any other Semantic rewards are enabled for exactly one cartridge, the SHA-256 in `SUPPORTED_ROM`. Any other
@ -205,10 +61,6 @@ therefore replays none of it.
## Boundary rewards ## Boundary rewards
Since `pokered-unique8-v7` everything below holds **outdoors** -- towns, routes, the forest,
caves -- and on an indoor map the same keys are written and nothing is paid ("Engagement rewards"
above has the definition of indoors and the one transition frame worth knowing about).
`docs/design/room-escape.md` section 2. The rule pays 0.05 the first time the fly stands on a tile `docs/design/room-escape.md` section 2. The rule pays 0.05 the first time the fly stands on a tile
orthogonally adjacent to one of the current map's exits, and 0.10 the first time it stands on the orthogonally adjacent to one of the current map's exits, and 0.10 the first time it stands on the
exit tile itself. Both are keyed into the adapter's lifetime `seen` ledger as exit tile itself. Both are keyed into the adapter's lifetime `seen` ledger as
@ -276,32 +128,7 @@ body picks the macro; the descending neurons press the buttons.**
## Honesty ## Honesty
The catalog now includes conversations and items (v7). Paying for a conversation is the closest The catalog now includes exits. That is worth saying plainly on the honesty panel, because paying
the catalog has come to paying for a *button*: A is what opens one. It is still a reward, not a
press. Nothing in the adapter presses A, chooses when, or tells the fly who is there; the payout is
read out of WRAM after a conversation the fly's own buttons -- or the macro the mushroom body chose
-- opened and finished, and it is once per person or sign for the life of the run, so the thing
that is learned is "the people in a building are worth a visit", not "press A". It is also why the
rule demands evidence that the fly opened the box. That evidence is not proof: a map script runs
one frame after a step ends and may open text while the fly is still, controllable and on the same
tile. Such text pays only if it names the person or sign the fly is facing, once per key; in the
early game none can (checked: the museum ticket man, the Route 22 and Route 5 guards, Viridian
Mart, Oak's Lab), and a few late ones can once each (the Fighting Dojo master, the Elite Four
after their battles). Review of 2026-09-23.
The catalog also includes catches. The honesty panel's copy is not data-driven from the catalog --
`apps/stage/src/lib/schedule.ts`'s rotating card is four written lines and lists no kinds -- so
there was nothing to regenerate and the copy is unchanged. The sentences below are where the
argument lives.
Paying for a catch does not move the fly: the ball is thrown by a macro the mushroom body chose
among the ones the battle scene put on the pad, and the payout is read out of WRAM after the
frame. What it does do is make one of the palette's existing macros worth choosing, which is the
same kind of pressure every other rule applies. The cap is what keeps it from becoming a farm: a
run that finds one patch of grass and throws balls at the same species all night earns 0.50 from
it and then nothing.
The catalog also includes exits. That is worth saying plainly on the honesty panel, because paying
for a door is closer to telling the fly where to go than paying for a badge is: for a door is closer to telling the fly where to go than paying for a badge is:
- **still no button path.** Nothing in the adapter chooses or biases a button. The reward is read - **still no button path.** Nothing in the adapter chooses or biases a button. The reward is read

View file

@ -607,274 +607,3 @@ fixes (report in the coordinator session's scratchpad; 188 items), the floated e
list. The loop watcher schedule is stopped; the in-box watchdog check 10 keeps reporting. list. The loop watcher schedule is stopped; the in-box watchdog check 10 keeps reporting.
Next release order: merge shops (after its stall fix), tag, deploy; merge the ratchet fix; Next release order: merge shops (after its stall fix), tag, deploy; merge the ratchet fix;
then purge; then the stale-doc pass. then purge; then the stale-doc pass.
- 2026-09-22 02:22 UTC (v0.4.1): first release cut from the public repository (fresh history at
v0.4.0); the DESCRIBE card carries the repository URL. Deployed to the release container with a
flysim and flystage restart, checkpoint carried over at rank 9, lag 0, encoder output clean.
- 2026-09-22 (v0.4.2, loop review, auto): rung 9 for 69 h. The between-turns battle row dealt
BACK with no list open (175 of 959 starts, instant, net nothing) and THROW BALL threw at species
the party already held. Fixed: BACK only where a list is open; THROW BALL skips held species.
Trap hunt: tiles 216 -> 286, listless BACK 175 -> 0, dialog frames 23,548 -> 0. Ethos check
held. Row 41 named: a nurse box answered YES 474 times on one tile.
- 2026-09-22 (for v0.4.3): map-aware walks merged (section 15): the whole loaded map is decoded
into a walkability grid from the map and tileset data (read-only, ROM bank reads over the
cartridge image), A* plans over the map, the frontier is the nearest unstood tile anywhere,
the window reader is the fallback. Verified against real presses on two maps. Trap hunt: 286 ->
489 tiles, timeouts 6 -> 1; flagged windows rose 61 -> 70, all battle windows (the battle pad
review in flight).
- 2026-09-22 (v0.4.3, loop review, auto): the watchdog flagged NEXT/BACK repeating 373 times in
ten brain minutes after v0.4.2. NEXT on the main battle menu confirmed FIGHT and opened the move
list, whose BACK closed it: a pair that undoes itself. NEXT is now off every pad with an
input-accepting cursor, MOVE 1 is the main menu backstop, the bag is the fly's turn. ROM test
fails on v0.4.2 and passes here. Trap hunt: 1 -> 260 tiles, windows under four tiles 73 -> 2.
Ships with map-aware walks. Ethos check held.
- 2026-09-22 (v0.4.3, loop review, auto): the watchdog flagged NEXT/BACK repeating 373 times in
ten brain minutes after v0.4.2. NEXT on the main battle menu confirmed FIGHT and opened the move
list, whose BACK closed it: a pair that undoes itself. NEXT is now off every pad with an
input-accepting cursor, MOVE 1 is the main menu backstop, the bag is the fly's turn. ROM test
fails on v0.4.2 and passes here. Trap hunt: 1 -> 260 tiles, windows under four tiles 73 -> 2.
Ships with map-aware walks. Ethos check held.
- 2026-09-22 (v0.4.4, loop review, auto): rung 10 reached 06:49 UTC. In the Pewter museum's upper
floor every list emptied (no geography row, exhibits reached, staircase blocked-windowed), leaving
MENU alone; the menu scene's BACK undid it. MENU is off every pad; a stranded room offers its
way out regardless of ledger windows. Also found: the battle menu is two columns, so ITEM opened
the party list and SWITCH the bag since v0.4.0; fixed, THROW BALL now 15/0 in the forest run.
ROM test fails on v0.4.3 and passes here. Ethos check held. Row 41 (a nurse box answered YES
1,278 times) is next.
- 2026-09-22 (v0.4.5, loop review, auto): row 41. In the Pewter center the nurse's conversation is a
ring of 46 A presses with one YES/NO choice; the dialog pad dealt NEXT and YES unconditionally
(one press, two names) and TALK was bound over the counter but recorded one tile ahead, so the
nurse never entered the talked ledger: YES x2,142. Fixed: a readable prompt deals its answers with
NEXT off it, only the answer that changes something is bound, TALK is off at a rested nurse, the
nurse is talked after a heal or a decline, a prompt that reopens unchanged is excluded. ROM test:
leaves the center on frame 326. Hunt: 1 -> 437 tiles, YES 1,424 -> 4. Ethos check held.
- 2026-09-22 (session framework, wave 1): BUS slice merged. The flybus crate is audited section
by section against bus-v1 (195 rows: 178 conform, 9 allowed deviations each quoting the
sentence that permits it, 7 not implemented and owned), the teardown-versus-in-flight-poll
race is fixed (the write gate now separates "closing" from "a poll is in progress", so
teardown waits one poll instead of a frame), the BUS-01 to BUS-03 acceptance bullets are
named tests over both transports with a 29-event trace equivalence, and the guide's first
deliverable exists: one example with a counter RPC, a latest observer and a frame artifact
held past its message. Measured on the dev VM, not capacity claims: 640x480 RGBA at 60 Hz to
three consumers, one delayed, RPC p50 0.5-1.1 ms, seal p50 1.1-1.4 ms, router 0.18-0.22 cores,
RSS 11-17 MB. Two spec contradictions were resolved in bus-v1 rather than in the code (the
per-client byte budget now names bounded queues only, with latest slots capped separately;
the illustrative client sketch drops its budget argument for a caller-side deadline).
- 2026-09-22 (session framework, wave 1 complete): the CONTRACT and SESSION slices merged. There
is now one executable contract for the new architecture: a types crate and a matching
TypeScript package that read the same fixtures, RFC 8785 canonical JSON, a contract digest
generated from the schema set rather than from source formatting, the four identities typed so
they cannot be mistaken for one another, and two new specifications with test vectors for seed
derivation and the checkpoint envelope. On top of it a synthetic lockstep session runs over the
bus: fake agents, a counter world, identity executors and a deterministic task, stepping
prepare, advance, evaluate, commit in that order, with the rational clock proving 16, 17 and 17
ticks and a zero remainder, request deduplication that refuses a changed body and replays a
cached one, and the guide's failure injections as tests that compare an injected run against a
clean one on behaviour, mutation count and world counter. Reviews caught two defects worth
naming: a merge that stopped short of the TypeScript half, whose absence silently took its own
test gate with it, and an unchecked addition that would have wrapped in release. Both fixed
before merge.
- 2026-09-22 (v0.4.6, loop review, auto): two rollbacks on rung 10. The "BACK in a text box" was
a scripted overworld frame with no box (the cartridge holding the joypad) dealt NEXT/BACK; the
museum had no map-graph row so no hop reached the gym; a frontier behind the admission desk
never retired; the ratchet counted covered ground as no progress. Fixed: a scripted frame with
nothing drawn deals nothing; the museum rows are on the graph; an unreachable frontier is a
per-map mark; nearer the objective in map hops counts as progress. ROM test: museum to the gym
interior on 15 macros, TALK on the pad at the leader. The trap hunt did NOT improve on the
reviewer's criterion (tiles 193 -> 175, windows 59 -> 69) because the after arm reaches new
ground with a new trap (row 54: GO FRONTIER, GO HEAL, GO ROUTE cycling, GO HEAL x204 at net 0);
Fable shipped it anyway: the hunt criterion compares within ground both arms reach, and a trap on
newly opened ground is a new row, not a regression. Row 54 review started at once.
## 2026-09-22 - session framework wave 2
Per-fly processes and native observations landed on top of the wave-1 contract and
lockstep session.
- The session runs in three execution modes that share one coordinator, one worker and
one router: in process, one thread per participant, and one process per fly plus one
for the environment over Unix sockets. The worker is a subcommand of the existing
binary, not a new crate. A launcher owns the thread budget, proves each participant's
configured identity and allocation on the wire before the coordinator pins anything,
polls health on its own clock, and reaps its children.
- A caller deadline that expires no longer fails the epoch on its own. It runs the
contract's resolution procedure against the same request and the same incarnation, so
a merely slow participant completes its step, and the epoch fails only on a definite
refusal, a lost incarnation or an exhausted budget. Which of the two bounds ended a
resolution is recorded and named in the failure rather than inferred.
- Failures name the participant, and a failed session fences its epoch: the boundary
stops, handles are dropped, and no further transition or publication is possible.
Worker death, helper death, a router restart mid-advance and stale replies after a
restart all have bounded, diagnosed outcomes, each proved in every mode.
- The environment now emits native output: one shared RGBA frame per boundary reaching
both flies through owned attachments, and one audio chunk per transition on an exact
rational sample budget. Observation delay is a real queue, so nothing stale can be
substituted. Spectators watch a latest subscription with finite credits and cannot
perturb what the flies sense. Audio never enters sensory input.
- Every media rule is enforced rather than assumed: strides, dimensions, formats,
lengths, producing step, timeline continuity, and the distinction between a persistent
asset and a transient artifact. A missing or malformed frame or chunk fails its step.
- Three contract silences were closed by dated amendments rather than by convention: the
launcher's thread allocation is now on the wire in the worker's hello, the audio
discontinuity rule is one-directional, and a mid-step pause is defined.
Measured on the development box, not capacity claims: with two flies the critical path
per transition sat near 10 to 12 ms at the median across all three modes, so a process
boundary costs little at the median and shows in the tail. What the split costs is
memory, roughly 5.7 MiB per participant process, while the coordinator's own footprint
is flat and lowest once the workers leave it.
Two flaky bus tests predating this work assert timing rather than contract and are being
rewritten separately.
- 2026-09-22 (v0.4.7, loop review, auto): row 54 in Pewter and on Route 3. The player's coordinates
change at the END of a sixteen-frame step, so the whole-map grid refused every moving frame and
walks fell back to the window; a landing tile went unrecorded for fifteen frames and stayed a
frontier; an errand aimed at a door underfoot settled where it stood; the errand ledger was
session state and re-armed on restore. Fixed: the walk anchor is measured from the screen
neighbourhood, landing tiles retire, errands arrive inside facing the counter, the errand ledger
persists. Route 3's north edge is a survey item (table says west+east). From the Pewter checkpoint
the fly wins the Boulder Badge at 10.78 brain minutes. The hunt's flagged windows did not fall
(73/73) because 82% of the fixed run is battle time; Fable shipped it on the same judgement as
v0.4.6 and started row 50 (MOVE n blocked on an unresponsive move list). The on-screen chat ring
now survives a sim restart (sidecar in the hot dir, never in the checkpoint).
- 2026-09-22 (v0.5.0, the operator's decision): the fly is paid for keeping a wild Pokémon. New
catalog kind `catch` (0.30 for a species this run never caught, 0.10 for a repeat, three payouts
per species), read from the captured-species byte and the battle result together; the existing
species rule still pays on top. Adapter `pokered-unique8-v6`; the compatibility string differs
in the adapter segment only, and a deploy with `FLY_ACCEPT_ADAPTERS=pokered-unique8-v5` migrates
a v5 checkpoint instead of refusing it. `fly-reset-to-milestone <N>` restarts the run from a
ladder rung (archives both stores first). The live run restarts from rung 7 with this release, so
the ladder is climbed again with the catch reward and the row-54 walks in place.
- 2026-09-22 19:59 UTC (v0.5.0 deployed): rung 7 had no milestone archive (the ratchet passed it
inside one commit), so the run restarted from rung 8, VIRIDIAN CITY, with
`fly-reset-to-milestone 8`; the v5 checkpoint migrated to v6 as designed. The previous state is
archived beside the store.
## 2026-09-22 - session framework: what landed and what stopped
The session framework slices from docs/design/session-framework/implementation.md were built
in ordered waves, each on its own branch with an independent review before merge.
Landed on main: CONTRACT-01, BUS-01 through BUS-03, SESSION-01, SESSION-02, MEDIA-01,
STATE-01 and PUBLISH-01. Together they give the repo an executable session contract with a
TypeScript oracle, a conforming bus with a written conformance table, a lockstep session that
runs in process, on threads or as one process per fly, native frame and audio observations
with spectator isolation, a coherent all-participant checkpoint with group restore and a
liftable fence, and an internal publication boundary with committed snapshots over the same
bus. Every contract silence met on the way was closed by a dated amendment in the affected
document rather than by convention; none touched doctrine.
Also landed: the bus test suite asserts guarantees rather than the machine's timing, and a
coordinator defect found through one of those flakes is fixed, where a lifecycle
acknowledgement that legitimately releases nothing was treated as a fault.
Stopped: AGENT-01 and ENV-01 are blocked on FOUNDATION-02 and RUNTIME-01 from the MaleCNS
backlog, which do not exist yet. The profile contract and the environment boundary are
decisions for the operator, so the swarm stopped here. DOLPHIN-01 was never in scope.
Measured on the development box, not capacity claims: bus RPC near one millisecond at the
median; a two-fly transition near 10 to 12 ms at the median in every execution mode; about
5.7 MiB per participant process when split.
- 2026-09-22 (v0.5.1, loop review, auto): row 50. The cartridge never clears the move-list cursor
bytes after a turn, so every frame of a turn's text, animation and reply read as the fly's own
turn on an open list; the pad dealt MOVE 1-4 and BACK on all of them and the cursor step pressed
at a list nobody was reading. Surveyed by pressing: a press was honoured on 231 of 231 frames
where the menu box is drawn and on none where it is not. Fix: one gate on the drawn box in the
battle seam; a frame with no box is between turns, NEXT only. From the forest checkpoint MOVE n
blocked starts 838 -> 0, every battle entered is ended, worst battle 503 -> 283 macros; hunt
tiles up on both arms (296 -> 430 forest, 163 -> 184 Route 3), flagged windows again rise
because the fly is inside battles it is fighting (same judgement as v0.4.6). Next row: the
battle bag's list id outlives the bag the same way.
- 2026-09-22 (v0.5.2, loop review, auto): row 55, opened by the v0.4.7 errand change. A mart's
list id says the counter is open, not which screen is up, so the clerk's closing text box read
as an open buy list, and the buy list scrolls, so a stock position past the third row is never a
cursor index. BUY ANTIDOTE aimed at row 3 of a list reporting one row, blocked on frame 0 with
no button pressed, and was dealt again every hold. Fix: the shop screen is read from what the
game draws (a clerk's text box lists nothing, so a cursor step waits), and a purchase past the
three cursor rows is not on the pad. From the live checkpoint the fly leaves the mart in 0.19
brain minutes where it never left before; hunt tiles 1 -> 62, blocked starts 1,494 -> 82.
- 2026-09-23 (v0.5.3, loop review, auto): row 56, the Pewter Gym guide. His YES/NO box is drawn
at a different place from the one row 41 pinned, so the seam read no prompt on any of the
frames it was up, dealt NEXT beside YES and NO on a choice, and every NO un-armed the pending
TALK, so the guide never entered the talked ledger and his fifty-two-press ring ran for hours.
Fix: the two-option border is found around the cursor the game parks in it, read whole; which
kind a NO was is decided when the box closes. From the live checkpoint the fly leaves the gym
on frame 1,337 where it never left in twenty brain minutes; hunt tiles 83 -> 441, dialog frames
32,496 -> 2,849. Rung 11 is not reached on either hunt arm: GO OBJECTIVE is on the Pewter pad
and the fly picks the frontier instead, which is the fly's choice, not a trap. The watchdog's
distinct-macro rule cannot see a four-macro ring; the watcher's zero-progress check covers it.
- 2026-09-23 (v0.5.4, loop review, auto): row 57, Pewter City. The pad was GO ROUTE alone,
refused `no route` every 0.8 s for over two hours with no button pressed; the watchdog counted
starts only and saw one. Mechanism: the youngster's escort walls the tile each escorted walk
set out from, up to 26 tiles away, until the fly stands in a pocket no route leaves; every
refusal re-stamped the gym door's blocked window, so GO OBJECTIVE never came back. Fix: an
escort walls the tile where the script took over; a refusal that teaches the ledger nothing is
not dealt again from that tile for the window; a last resort that cannot reach the objective's
door takes a way out it can. ROM test: refusals in a row 746 -> 1, out of the pocket on frame
517 instead of 36,325; seeded hunt tiles 303 -> 439, flagged windows 70 -> 66 of 73 counting
refusals as decisions. Check 10 now counts outcomes (refused, blocked, done) and flags
`stalled` and `zero-progress`, still never acting. The GO OBJECTIVE / GO OUT ring at the gym
door is row 58. Ethos check held. While it cooked the operator's new unstick rule kept the
stream moving with flysim restarts (loop-review.md).
- 2026-09-23 (v0.5.5, loop review, auto): row 58, the Pewter Gym door. GO OBJECTIVE walked in,
GO OUT walked out, for hours, with no reward; restarts bought twenty to forty minutes each.
Mechanism: the objective's people were read from the sprites the cartridge draws, and from the
doormat Brock and the Jr. Trainer are off screen, so with the guide talked the rung had nobody
and the gym dealt its ways out. Three moments the game holds the joypad (a warp's first frames,
a trainer's challenge before the battle screen, a trainer walking up) wrote the fly's ledgers.
Fix: the objective reads off-screen people by the cartridge's own availability rule; facing any
of them is arrival; a warp's tear deals an empty pad (at most 90 frames); `controllable` reads
`wCurOpponent`; ledger writes wait for the joypad to come back and drop if a battle starts.
ROM test: base 40 arrivals, 38 straight back out; branch reaches Brock. Route survey from the
live checkpoint, 33 brain minutes: GO OUT 1,118 -> 0, rung 10 -> 11, BOULDER BADGE. The trap
hunt's stub readout never walks the ring on either arm (flagged 17 -> 23, all one-tile windows
in the gym's dialogs and battles; tiles 329 -> 437), so the proof is the ROM test and the
survey: the ethos check held in spirit, recorded as a deviation from the hunt's letter. Check
10 gains `unrewarded` (100+ decisions, no reward, no new ground, two probes), still never
acting. Also shipped: EDGE-01, the feed over flybus behind FLY_FEED_VIA, off (`direct`)
everywhere. Next: row 59, Route 3's neighbours and Mt. Moon's doors in the geography table.
- 2026-09-23 (the operator's reset): the live run restarted from milestone 1, the bedroom, with the
brain as it was then (65 brain seconds), to watch the macros in the early game. The v5 archive
migrated to v6. It reached rung 9, Viridian Forest, 31 minutes later.
- 2026-09-23 (v0.6.0, the operator's decision, plus loop review row 60): the fly is paid for
engaging inside buildings and not for leaving them. New catalog kinds `talk` (+0.10, the first
conversation the fly opens with each person or sign on an indoor map, once per key for the run)
and `item` (+0.15, each item ball or hidden item, once); `boundary` pays nothing for an indoor
exit. Indoor is the cartridge's own tables (not CheckIfInOutsideMap's outdoor tilesets and not a
bike tileset). Both publish on `explore`; the ticker still reads "new place" (the operator kept
the wording). Adapter `pokered-unique8-v7`; a deploy with FLY_ACCEPT_ADAPTERS=pokered-unique8-v6
migrates the run and seeds every item the game already shows taken without paying it. Row 60:
from the fresh run, TAIL WHIP (MOVE 2) was dealt beside TACKLE after the cartridge had begun
refusing it ("Nothing happened!": the stage at -6 or the stat at 1), and the brain's MOVE 2
preference drew battles out until Squirtle fainted. A move the cartridge would refuse -- read
from its move table and the target's stages, status, types, Mist and substitute -- is not dealt
beside one that works, as a move out of PP is not. ROM test: 2,657 of 2,657 own-turn frames dealt
the refused move before, 0 of 568 after; battles won 0 -> 3 of 12 -> 14. The real-brain hunt did
not finish at this load; recorded as a deviation. Ethos check held.
- 2026-09-23 (v0.6.1, loop review, auto): row 59, Mt. Moon. With the Boulder Badge the live fly
reached rung 12 and rang on Route 4 (GO ROUTE 233, GO OBJECTIVE 122, GO OUT 111 per ten minutes,
one new tile), and restarts did not hold: the map graph was wrong, not a ledger. The geography
table is now the cartridge's own headers and warps: Route 3 connects north to Route 4, Mt.
Moon's doors are on Route 4 (1F at (18,5), B1F's exit at (24,5)); Routes 14/15 and 24/25 were
in the wrong compass columns and Routes 22/23 were missing; a map can be several pieces (Route
4 two, Mt. Moon B1F four chambers, B2F three), the fly's piece read from the decoded grid and a
warp landing in the piece that holds its destination; sea and fence connections (Pallet/21,
Cinnabar/20, 20/19, 22/23) are no longer exits. A trainer's challenge leaves five frames of
plain overworld before the battle, and a push-back is now written only after thirty frames of
the fly's own. From the live Route 4 checkpoint: crossings of the west doors 56 -> 13, into Mt.
Moon at frame 278; from the badge checkpoint the fly reaches Mt. Moon and rung 12 where the base
rang at Pewter. The reviewer checked all 36 connection rows and every piece against the
disassembly. The trap hunt was not run at this load (recorded deviation). Also shipped: FND-01,
one LegacyFrame for the service and every harness (no live behaviour change), FLY_TRACE and the
sugar journal. Ethos check held.
- 2026-09-24 (v0.6.2, loop review, auto): row 61, the Viridian Forest corridor. In the fresh run the
fly rang between the forest, its south gate and Route 2 (GO OBJECTIVE / GO OUT / GO ROUTE) for
twenty minutes at rung 9. A Bug Catcher stands on the forest's only corridor north; when he saw
the fly, the frames of his "!" bubble and the five frames between his text and the battle read as
the fly's own overworld, and a held push-back walled (1,18) for good, so GO OBJECTIVE had no road
north. The macros now read the cartridge's BIT_TRAINER_BATTLE (wStatusFlags7 bit 3, set at the
"!", cleared after every battle, a lost one too) and treat those frames as the cartridge's: no
pad, no ground recorded, no push decided. Row 59's 30-frame debounce already closed the five-frame
gap; this is the cartridge-fact layer under it. ROM test: base walls (1,18) and stays at rung 9;
branch reaches Pewter on frame 23,755, and offers no button on any of the 67 challenge frames
(67 of 67 before). Survey seed 7: rung 9 -> BOULDER BADGE. The trap hunt's stub cannot see this
trap; the ROM test and survey are the proof (recorded deviation). Ethos check held.

95
infra/05-deploy.sh Normal file → Executable file
View file

@ -112,11 +112,6 @@ fi
require_pve_host require_pve_host
need pct need pct
# Who serves the feed (docs/design/flybus.md): refused here, before section 1 flips
# /opt/fly/current, rather than half way through the deploy or at flysim's boot.
FLY_FEED_VIA_EFFECTIVE="$(feed_via_normalize "${FLY_FEED_VIA:-}")" \
|| die "05-deploy: FLY_FEED_VIA must be 'direct' or 'bus', got '${FLY_FEED_VIA}'"
# CHROMIUM_PROFILE is validated here, not left to the launcher: a typo or a # CHROMIUM_PROFILE is validated here, not left to the launcher: a typo or a
# `vgl` on a container that never had VirtualGL installed would only show up as # `vgl` on a container that never had VirtualGL installed would only show up as
# flystage refusing to start, i.e. a black stream, minutes after the deploy # flystage refusing to start, i.e. a black stream, minutes after the deploy
@ -191,36 +186,6 @@ else
log "05-deploy: CPUSET unset — heavy in-container steps run unpinned (no partition configured)" log "05-deploy: CPUSET unset — heavy in-container steps run unpinned (no partition configured)"
fi fi
# Whether the only difference between two compatibility strings is the adapter
# segment, and FLY_ACCEPT_ADAPTERS names the adapter the live checkpoints carry.
#
# The bash half of flybrain_gb::compatibility::decide, which is what flysim
# itself applies at restore. Both have to agree: a gate that let a deploy
# through and a flysim that then refused every checkpoint would be the black
# stream this whole section exists to prevent. The string is
# {kernel}/{adapter}/{fingerprint}/{plasticity}/binjgb:{rev}/pokered:{commit}/statefmt:{id},
# so the adapter is segment 1 and nothing else may move.
adapter_migration_accepted() {
local live="$1" new="$2" accepted="$3"
local -a live_parts new_parts
IFS='/' read -r -a live_parts <<< "$live"
IFS='/' read -r -a new_parts <<< "$new"
[ "${#live_parts[@]}" -eq "${#new_parts[@]}" ] || return 1
local i differing=0 index=-1
for ((i = 0; i < ${#live_parts[@]}; i++)); do
if [ "${live_parts[$i]}" != "${new_parts[$i]}" ]; then
differing=$((differing + 1))
index=$i
fi
done
[ "$differing" -eq 1 ] && [ "$index" -eq 1 ] || return 1
local entry
for entry in ${accepted//,/ }; do
[ "$entry" = "${live_parts[1]}" ] && return 0
done
return 1
}
# cpu_pin CMD [ARGS...] — run CMD inside the container on the page cpus. # cpu_pin CMD [ARGS...] — run CMD inside the container on the page cpus.
# Falls through to a plain ct_exec when no partition is configured, so this is # Falls through to a plain ct_exec when no partition is configured, so this is
# a no-op on an unpartitioned container rather than a new failure mode (a # a no-op on an unpartitioned container rather than a new failure mode (a
@ -293,21 +258,9 @@ if [ -n "$RELEASE_TARBALL" ]; then
# BEFORE the symlink moves. Cost: one dataset load, a second or two. # BEFORE the symlink moves. Cost: one dataset load, a second or two.
# #
# FLY_RESET_STATE=1 is the deliberate override: it archives the durable # FLY_RESET_STATE=1 is the deliberate override: it archives the durable
# checkpoints (kept, never deleted) and clears the tmpfs hot ring — the hot # checkpoints (kept, never deleted) and clears the tmpfs hot ring, so the
# checkpoints and the on-screen chat ring's sidecar — so the new build warms # new build warms up fresh. Everything learned so far is thrown away, which
# up fresh. Everything learned so far is thrown away, which is why it is not # is why it is not the default.
# the default.
#
# FLY_ACCEPT_ADAPTERS is the *other* override, and the opposite one: it keeps
# the run. It names adapter version strings whose checkpoints the new build
# may migrate — e.g. FLY_ACCEPT_ADAPTERS=pokered-unique8-v6 for the deploy
# that adds the engagement rewards (v7; v5 -> v6 was the catch reward's).
# It only applies when the adapter segment is the
# ONLY difference between the two strings and the new build's adapter says it
# can read that one; a dataset, kernel, emulator or state-format change is
# still a refusal, because none of those has a migration. The same variable is
# written into /etc/fly/fly.env below, so flysim applies the same rule at
# restore that this gate applied at deploy.
# ----------------------------------------------------------------------- # -----------------------------------------------------------------------
state_dir="${FLY_STATE_DIR:-/srv/fly/state}" state_dir="${FLY_STATE_DIR:-/srv/fly/state}"
hot_dir="${FLY_STATE_HOT_DIR:-/run/fly/state}" hot_dir="${FLY_STATE_HOT_DIR:-/run/fly/state}"
@ -333,11 +286,6 @@ if [ -n "$RELEASE_TARBALL" ]; then
log "05-deploy: no decodable checkpoint in ${state_dir} — nothing to compare, continuing" log "05-deploy: no decodable checkpoint in ${state_dir} — nothing to compare, continuing"
elif [ "$new_compat" = "$live_compat" ]; then elif [ "$new_compat" = "$live_compat" ]; then
log "05-deploy: checkpoint compatibility matches the live state, the new build will restore it" log "05-deploy: checkpoint compatibility matches the live state, the new build will restore it"
elif [ -n "${FLY_ACCEPT_ADAPTERS:-}" ] \
&& adapter_migration_accepted "$live_compat" "$new_compat" "$FLY_ACCEPT_ADAPTERS"; then
log "05-deploy: FLY_ACCEPT_ADAPTERS=${FLY_ACCEPT_ADAPTERS} — the adapter version is the only difference, and it is named; the run is KEPT and migrated"
log "05-deploy: live: $live_compat"
log "05-deploy: new: $new_compat"
elif [ "${FLY_RESET_STATE:-0}" = 1 ]; then elif [ "${FLY_RESET_STATE:-0}" = 1 ]; then
archive="${state_dir}.$(date -u +%Y%m%d%H%M%S)" archive="${state_dir}.$(date -u +%Y%m%d%H%M%S)"
log "05-deploy: FLY_RESET_STATE=1 — compatibility CHANGED, archiving the durable state to ${archive} and clearing the hot ring" log "05-deploy: FLY_RESET_STATE=1 — compatibility CHANGED, archiving the durable state to ${archive} and clearing the hot ring"
@ -346,17 +294,13 @@ if [ -n "$RELEASE_TARBALL" ]; then
# state_dir is its own mountpoint, so the directory itself cannot be # state_dir is its own mountpoint, so the directory itself cannot be
# renamed; its contents move instead. # renamed; its contents move instead.
ct_exec "$CTID" -- sh -c "mkdir -p '$archive' && mv '${state_dir}'/*.checkpoint '${state_dir}/manifest.json' '$archive'/ 2>/dev/null; chown -R fly:fly '$archive'" ct_exec "$CTID" -- sh -c "mkdir -p '$archive' && mv '${state_dir}'/*.checkpoint '${state_dir}/manifest.json' '$archive'/ 2>/dev/null; chown -R fly:fly '$archive'"
ct_exec "$CTID" -- sh -c "rm -f '${hot_dir}'/*.checkpoint '${hot_dir}/manifest.json' '${hot_dir}/chat-ring.json' 2>/dev/null; true" ct_exec "$CTID" -- sh -c "rm -f '${hot_dir}'/*.checkpoint '${hot_dir}/manifest.json' 2>/dev/null; true"
else else
die "05-deploy: REFUSING to deploy release ${version}: its checkpoint compatibility string does not match the live state in ${state_dir}, so flysim would refuse every checkpoint there and then refuse to start at all — a black stream. die "05-deploy: REFUSING to deploy release ${version}: its checkpoint compatibility string does not match the live state in ${state_dir}, so flysim would refuse every checkpoint there and then refuse to start at all — a black stream.
live state: ${live_compat} live state: ${live_compat}
new build: ${new_compat} new build: ${new_compat}
The difference is usually an adapter/ladder or dataset version bump. Three ways forward: The difference is usually an adapter/ladder or dataset version bump. Two ways forward:
* deploy a build whose string matches (check out the commit the running release was built from), or * deploy a build whose string matches (check out the commit the running release was built from), or
* if the ADAPTER VERSION is the only segment that differs and the new build documents a
migration from the old one, re-run with FLY_ACCEPT_ADAPTERS set to the adapter id in the live
string (e.g. FLY_ACCEPT_ADAPTERS=pokered-unique8-v6). The run is kept; flysim applies the same
rule at restore. See docs/design/flysim.md, \"Restoring across an adapter version\", or
* accept losing everything the brain has learned and re-run with FLY_RESET_STATE=1, which * accept losing everything the brain has learned and re-run with FLY_RESET_STATE=1, which
archives ${state_dir}'s checkpoints to ${state_dir}.<timestamp> (kept, not deleted) and archives ${state_dir}'s checkpoints to ${state_dir}.<timestamp> (kept, not deleted) and
clears ${hot_dir} so the new build warms up fresh. clears ${hot_dir} so the new build warms up fresh.
@ -419,7 +363,6 @@ log "05-deploy: non-secret env files"
# never drift apart (see cpuset_partition's own header comment). They are # never drift apart (see cpuset_partition's own header comment). They are
# assigned in section 0b, which needs them earlier than this for the # assigned in section 0b, which needs them earlier than this for the
# deploy-time cpu pinning; nothing between here and there changes them. # deploy-time cpu pinning; nothing between here and there changes them.
tmp_fly_env="$(mktemp)" tmp_fly_env="$(mktemp)"
tmp_flypush_env="$(mktemp)" tmp_flypush_env="$(mktemp)"
trap 'rm -f "$tmp_fly_env" "$tmp_flypush_env"' EXIT trap 'rm -f "$tmp_fly_env" "$tmp_flypush_env"' EXIT
@ -506,16 +449,6 @@ trap 'rm -f "$tmp_fly_env" "$tmp_flypush_env"' EXIT
# "palette"/"plan" as "macros" with a warning, and refuses an unrecognised # "palette"/"plan" as "macros" with a warning, and refuses an unrecognised
# value outright. # value outright.
echo "FLY_MACRO_MODE=${FLY_MACRO_MODE:-raw}" echo "FLY_MACRO_MODE=${FLY_MACRO_MODE:-raw}"
# Who serves the feed WebSocket (docs/design/flybus.md, "Feed over the
# bus"). "direct" is the default and is flysim binding :7400 itself, as
# every release before this knob. "bus" makes flysim publish on its
# embedded feed bus and leave :7400 to flyedge.service, which this script
# never enables: see that unit's header for the switch. Written
# unconditionally, like FLY_MACRO_MODE, so one grep says which a box runs.
# Watchdog check 2 reads this line to know whose /metrics carries the
# feed counters (flysim's :9101, or flyedge's loopback :9102).
# Validated and lowercased above (feed_via_normalize).
echo "FLY_FEED_VIA=${FLY_FEED_VIA_EFFECTIVE}"
# How long a macro leaves a target alone after a walk to it aborted # How long a macro leaves a target alone after a walk to it aborted
# (macros.md section 12.1, the Viridian stall). Only written when it is set, # (macros.md section 12.1, the Viridian stall). Only written when it is set,
# because the default lives in the crate and a box that has not tuned it # because the default lives in the crate and a box that has not tuned it
@ -523,16 +456,6 @@ trap 'rm -f "$tmp_fly_env" "$tmp_flypush_env"' EXIT
if [[ -n "${FLY_MACRO_BLOCKED_MINUTES:-}" ]]; then if [[ -n "${FLY_MACRO_BLOCKED_MINUTES:-}" ]]; then
echo "FLY_MACRO_BLOCKED_MINUTES=${FLY_MACRO_BLOCKED_MINUTES}" echo "FLY_MACRO_BLOCKED_MINUTES=${FLY_MACRO_BLOCKED_MINUTES}"
fi fi
# Adapter versions whose checkpoints this build may migrate
# (flybrain_gb::compatibility, docs/design/flysim.md "Restoring across an
# adapter version"). Only written when it is set, because the safe state is
# absent: an empty or missing variable migrates nothing, which is what every
# deploy before 2026-09-22 did. It stays in fly.env for as long as the
# operator leaves it on the deploy command line, so removing the opt-in is
# one deploy without it.
if [[ -n "${FLY_ACCEPT_ADAPTERS:-}" ]]; then
echo "FLY_ACCEPT_ADAPTERS=${FLY_ACCEPT_ADAPTERS}"
fi
# flybridge (services/bridge/src/config.ts). Nothing wrote these before, so # flybridge (services/bridge/src/config.ts). Nothing wrote these before, so
# flybridge.service had no EnvironmentFile= at all and the service refused to # flybridge.service had no EnvironmentFile= at all and the service refused to
# start with "CHANNEL is required / BOT_USER is required / GAME_TITLE is # start with "CHANNEL is required / BOT_USER is required / GAME_TITLE is
@ -650,11 +573,9 @@ if [ -n "${CPUSET:-}" ]; then
"leaves cpuset.cpus.effective empty and the unit unstartable." "leaves cpuset.cpus.effective empty and the unit unstartable."
else else
read -r sim_cpus page_cpus encoder_cpus <<< "$(cpuset_partition "$CPUSET" "$RAYON_THREADS_EFFECTIVE" "$ENCODER_CORES_EFFECTIVE")" read -r sim_cpus page_cpus encoder_cpus <<< "$(cpuset_partition "$CPUSET" "$RAYON_THREADS_EFFECTIVE" "$ENCODER_CORES_EFFECTIVE")"
log "05-deploy: cpuset partition — flysim=$sim_cpus, xvfb/flystage/flystage-web/pulse/mediamtx/flyedge=$page_cpus, flycast=$encoder_cpus" log "05-deploy: cpuset partition — flysim=$sim_cpus, xvfb/flystage/flystage-web/pulse/mediamtx=$page_cpus, flycast=$encoder_cpus"
tmp_dropin="$(mktemp)" tmp_dropin="$(mktemp)"
# flyedge is off by default, but its drop-in is written with the rest so that the day for u in flysim xvfb flystage flystage-web flycast pulse mediamtx; do
# it is enabled it serves the page from the page's CPUs, never from flysim's.
for u in flysim xvfb flystage flystage-web flycast pulse mediamtx flyedge; do
case "$u" in case "$u" in
flysim) cpus="$sim_cpus" ;; flysim) cpus="$sim_cpus" ;;
flycast) cpus="$encoder_cpus" ;; flycast) cpus="$encoder_cpus" ;;
@ -685,7 +606,7 @@ fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
log "05-deploy: converging bin/ helpers to /opt/fly/bin" log "05-deploy: converging bin/ helpers to /opt/fly/bin"
ct_exec "$CTID" -- mkdir -p /opt/fly/bin ct_exec "$CTID" -- mkdir -p /opt/fly/bin
for name in fly-watchdog fly-recap fly-retention fly-reset-to-milestone flypush flystage-launch flycast-launch wait-for-x wait-for-stage wait-for-health; do for name in fly-watchdog fly-recap fly-retention flypush flystage-launch flycast-launch wait-for-x wait-for-stage wait-for-health; do
converge_file "$CTID" "$INFRA_DIR/bin/$name" "/opt/fly/bin/$name" 0755 root:root >/dev/null converge_file "$CTID" "$INFRA_DIR/bin/$name" "/opt/fly/bin/$name" 0755 root:root >/dev/null
done done

View file

@ -1,77 +0,0 @@
#!/usr/bin/env bash
# infra/bin/fly-reset-to-milestone — restart the run from an earlier ladder rung,
# instead of from scratch.
#
# The operator's decision of 2026-09-22: "restart the live run from an early
# checkpoint instead of from scratch". 05-deploy's FLY_RESET_STATE=1 cannot do
# that — it archives the durable state and the next start warms up a fresh fly,
# losing everything the brain has learned. This promotes one milestone archive
# (milestone-<N>.checkpoint, written at the first commit at a new best rank and
# never rotated away) to being what both stores restore.
#
# Usage: fly-reset-to-milestone <N>
# Run INSIDE the container, as root, with flysim STOPPED. It refuses
# otherwise, and it refuses a rung this run never reached.
#
# The whole sequence — stop, reset, deploy with the adapter opt-in, start,
# verify the rank — is in infra/docs/runbook.md, "Restart the run from a rung".
# Nothing here is destructive on its own: every file in both stores is copied to
# a dated directory next to the durable one before anything is rewritten.
set -euo pipefail
: "${FLY_STATE_DIR:=/srv/fly/state}"
: "${FLY_STATE_HOT_DIR:=/run/fly/state}"
: "${FLY_RELEASE_DIR:=/opt/fly/current}"
: "${FLY_SERVICE:=flysim.service}"
: "${FLY_USER:=fly}"
FLYSIM="${FLY_BIN:-${FLY_RELEASE_DIR}/flysim}"
log() { echo "fly-reset-to-milestone: $*" >&2; }
die() { log "$*"; exit 1; }
RANK="${1:-}"
if [ "$#" -ne 1 ] || ! [[ "$RANK" =~ ^[0-9]+$ ]]; then
die "usage: fly-reset-to-milestone <rung> (e.g. fly-reset-to-milestone 9)"
fi
# --- refusals ----------------------------------------------------------------
# A running flysim owns both stores: it commits a hot checkpoint every few
# seconds and a durable one every few minutes, so a reset underneath it would be
# overwritten within the minute and the tool would have lied.
if command -v systemctl >/dev/null 2>&1 && systemctl is-active --quiet "$FLY_SERVICE"; then
die "$FLY_SERVICE is running. Stop it first: systemctl stop $FLY_SERVICE"
fi
[ -x "$FLYSIM" ] || die "no flysim binary at $FLYSIM (set FLY_BIN to point at one)"
milestone="${FLY_STATE_DIR}/milestone-${RANK}.checkpoint"
# The binary refuses this too, and refuses before it copies anything; checking
# here as well is what makes the message name the rungs that do exist.
if [ ! -f "$milestone" ]; then
log "no milestone archive for rung ${RANK}: $milestone does not exist."
log "rungs this run reached:"
ls -1 "${FLY_STATE_DIR}"/milestone-*.checkpoint 2>/dev/null \
| sed 's|.*/milestone-||; s|\.checkpoint$||' | sort -n | tr '\n' ' ' >&2 || true
echo >&2
exit 1
fi
# --- the reset ---------------------------------------------------------------
log "resetting to rung ${RANK} (durable ${FLY_STATE_DIR}, hot ${FLY_STATE_HOT_DIR})"
FLY_STATE="$FLY_STATE_DIR" FLY_STATE_HOT="$FLY_STATE_HOT_DIR" \
"$FLYSIM" --reset-to-milestone "$RANK"
# flysim runs unprivileged; this tool runs as root, so everything it wrote and
# everything it archived has to go back to the service account.
if command -v chown >/dev/null 2>&1 && id "$FLY_USER" >/dev/null 2>&1; then
chown -R "${FLY_USER}:${FLY_USER}" "$FLY_STATE_DIR" "$FLY_STATE_HOT_DIR" 2>/dev/null || true
for dir in "${FLY_STATE_DIR}".reset-*; do
[ -d "$dir" ] && chown -R "${FLY_USER}:${FLY_USER}" "$dir"
done
fi
log "done. Next, per infra/docs/runbook.md:"
log " 1. deploy the build whose adapter wrote that checkpoint, or deploy the new"
log " one with FLY_ACCEPT_ADAPTERS set to the checkpoint's adapter id"
log " 2. systemctl start $FLY_SERVICE"
log " 3. curl -s localhost:7401/status | grep -o '\"rank\":[0-9]*'"

View file

@ -32,9 +32,6 @@ log_info() {
: "${FLY_CONTROL_URL:=http://127.0.0.1:7401}" : "${FLY_CONTROL_URL:=http://127.0.0.1:7401}"
: "${FLY_METRICS_URL:=http://127.0.0.1:9101}" : "${FLY_METRICS_URL:=http://127.0.0.1:9101}"
# flyedge's loopback /metrics (units/flyedge.service), read by check 2 when
# fly.env says FLY_FEED_VIA=bus.
: "${FLY_EDGE_METRICS_URL:=http://127.0.0.1:9102}"
: "${FLY_STATE_HOT:=/run/fly/state}" : "${FLY_STATE_HOT:=/run/fly/state}"
: "${FLY_MEDIA_DIR:=/srv/fly/media}" : "${FLY_MEDIA_DIR:=/srv/fly/media}"
: "${MEDIAMTX_API:=http://127.0.0.1:9997}" : "${MEDIAMTX_API:=http://127.0.0.1:9997}"
@ -70,8 +67,6 @@ log_info() {
: "${WD_LOOP_MIN_REPEATS:=20}" # ... repeating at least this many times : "${WD_LOOP_MIN_REPEATS:=20}" # ... repeating at least this many times
: "${WD_LOOP_DOMINANCE_PCT:=95}" # or one macro being this share of the window : "${WD_LOOP_DOMINANCE_PCT:=95}" # or one macro being this share of the window
: "${WD_LOOP_MIN_EVENTS:=20}" # floor under the dominance rule (see check 10) : "${WD_LOOP_MIN_EVENTS:=20}" # floor under the dominance rule (see check 10)
: "${WD_LOOP_STALL_PCT:=90}" # decisions that ended refused/blocked/timeout: this share is a stall (row 57)
: "${WD_LOOP_BUSY_MIN:=100}" # this many decisions with no reward and no new ground, two probes running, is busy going nowhere (row 58)
: "${WD_LOOP_REPORT:=${WD_RUN_DIR}/loop.json}" : "${WD_LOOP_REPORT:=${WD_RUN_DIR}/loop.json}"
mkdir -p "$WD_RUN_DIR" "$WD_STATE_DIR" mkdir -p "$WD_RUN_DIR" "$WD_STATE_DIR"
@ -192,7 +187,7 @@ write_textfile_metrics() {
# convention as fly_watchdog_encoder_degraded), while the flag itself # convention as fly_watchdog_encoder_degraded), while the flag itself
# starts at 0, because a 0/1 flag carrying -1 reads as a loop to every # starts at 0, because a 0/1 flag carrying -1 reads as a loop to every
# alert expression that would ever use it. # alert expression that would ever use it.
echo "# HELP fly_loop_suspected 1 when the last check-10 probe found a short macro cycle repeating, one macro dominating, or the decisions stalled (refused/blocked) or completing nothing, with no growth in the exploration count. Report only: the watchdog never restarts or presses anything for this." echo "# HELP fly_loop_suspected 1 when the last check-10 probe found a short macro cycle repeating with no growth in the exploration count. Report only: the watchdog never restarts or presses anything for this."
echo "# TYPE fly_loop_suspected gauge" echo "# TYPE fly_loop_suspected gauge"
echo "fly_loop_suspected $(cat "${WD_RUN_DIR}/loop.suspected" 2>/dev/null || echo 0)" echo "fly_loop_suspected $(cat "${WD_RUN_DIR}/loop.suspected" 2>/dev/null || echo 0)"
echo "# HELP fly_loop_period Length in macro labels of the shortest repeating block found at the end of the window (0 when nothing repeats, -1 before the first probe)." echo "# HELP fly_loop_period Length in macro labels of the shortest repeating block found at the end of the window (0 when nothing repeats, -1 before the first probe)."
@ -204,23 +199,6 @@ write_textfile_metrics() {
echo "# HELP fly_loop_distinct_macros Distinct macro names started in the window (-1 before the first probe)." echo "# HELP fly_loop_distinct_macros Distinct macro names started in the window (-1 before the first probe)."
echo "# TYPE fly_loop_distinct_macros gauge" echo "# TYPE fly_loop_distinct_macros gauge"
echo "fly_loop_distinct_macros $(cat "${WD_RUN_DIR}/loop.distinct" 2>/dev/null || echo -1)" echo "fly_loop_distinct_macros $(cat "${WD_RUN_DIR}/loop.distinct" 2>/dev/null || echo -1)"
# Row 57: outcomes, not only starts. A pad whose one button refuses
# every hold reads as one start and one name; these say what the
# decisions came to (-1 before the first probe).
echo "# HELP fly_loop_refused Macro presses refused (nothing pressed) in the last check-10 window (-1 before the first probe)."
echo "# TYPE fly_loop_refused gauge"
echo "fly_loop_refused $(cat "${WD_RUN_DIR}/loop.refused" 2>/dev/null || echo -1)"
echo "# HELP fly_loop_blocked Macros that ended blocked or timed out in the last check-10 window (-1 before the first probe)."
echo "# TYPE fly_loop_blocked gauge"
echo "fly_loop_blocked $(cat "${WD_RUN_DIR}/loop.blocked" 2>/dev/null || echo -1)"
echo "# HELP fly_loop_done Macros that ended done in the last check-10 window (-1 before the first probe)."
echo "# TYPE fly_loop_done gauge"
echo "fly_loop_done $(cat "${WD_RUN_DIR}/loop.done" 2>/dev/null || echo -1)"
# Row 58: an undo pair diluted by other macros completes everything and
# earns nothing; the reward events in the window are what say so.
echo "# HELP fly_loop_rewards Reward events in the last check-10 window (-1 before the first probe)."
echo "# TYPE fly_loop_rewards gauge"
echo "fly_loop_rewards $(cat "${WD_RUN_DIR}/loop.rewards" 2>/dev/null || echo -1)"
echo "# HELP fly_places_delta Growth in game.uniqueLocations (the exploration count) since the previous check-10 probe. -1 when there is no previous probe to compare against." echo "# HELP fly_places_delta Growth in game.uniqueLocations (the exploration count) since the previous check-10 probe. -1 when there is no previous probe to compare against."
echo "# TYPE fly_places_delta gauge" echo "# TYPE fly_places_delta gauge"
echo "fly_places_delta $(cat "${WD_RUN_DIR}/loop.places_delta" 2>/dev/null || echo -1)" echo "fly_places_delta $(cat "${WD_RUN_DIR}/loop.places_delta" 2>/dev/null || echo -1)"
@ -344,32 +322,10 @@ check_flysim() {
# read-only metrics listener (infra.md section 5; not superseded by the # read-only metrics listener (infra.md section 5; not superseded by the
# feed/control contracts). Flat frames counter across two passes, or zero # feed/control contracts). Flat frames counter across two passes, or zero
# clients, means the page is dead/frozen even though Chromium is alive. # clients, means the page is dead/frozen even though Chromium is alive.
#
# The two counters belong to whoever serves the feed: flysim itself, or with
# FLY_FEED_VIA=bus in fly.env, flyedge (docs/design/flybus.md, "Feed over the
# bus"), which exports them under the same names. FLY_FEED_METRICS_URL in the
# watchdog's own environment overrides both.
# ============================================================================ # ============================================================================
feed_metrics_url() {
if [ -n "${FLY_FEED_METRICS_URL:-}" ]; then
echo "$FLY_FEED_METRICS_URL"
return
fi
local via=""
if [ -f "$FLY_ENV_FILE" ]; then
# Lowercased: flysim reads the value case-insensitively, so `Bus` is bus mode.
via="$(awk -F= '/^FLY_FEED_VIA=/ { print $2; exit }' "$FLY_ENV_FILE" 2>/dev/null | tr -d ' \r"' | tr '[:upper:]' '[:lower:]' || true)"
fi
if [ "$via" = "bus" ]; then
echo "$FLY_EDGE_METRICS_URL"
else
echo "$FLY_METRICS_URL"
fi
}
check_flystage() { check_flystage() {
local metrics frames clients ok=1 local metrics frames clients ok=1
metrics="$(curl -fsS "$(feed_metrics_url)/metrics" 2>/dev/null || true)" metrics="$(curl -fsS "${FLY_METRICS_URL}/metrics" 2>/dev/null || true)"
if [ -z "$metrics" ]; then if [ -z "$metrics" ]; then
ok=0 ok=0
else else
@ -780,32 +736,10 @@ check_capture_freeze() {
# time, not wall time — a box running under 1.0x realtime would otherwise get # time, not wall time — a box running under 1.0x realtime would otherwise get
# a shorter window than the thresholds were measured over), the distinct macro # a shorter window than the thresholds were measured over), the distinct macro
# names in it, and the shortest block up to WD_LOOP_MAX_PERIOD that repeats at # names in it, and the shortest block up to WD_LOOP_MAX_PERIOD that repeats at
# the end of the sequence. The sequence is the fly's DECISIONS: a `start`, or a # the end of the sequence. Start events only: a start is one decision, and
# `refused` (a bound button that was pressed and did nothing, with no start # counting the `done` beside it would double every sequence and halve every
# beside it). Counting the `done` beside a start would double every sequence # period. They are the same names trap_hunt prints, so its numbers and these
# and halve every period. They are the same names trap_hunt prints, so its # are comparable.
# numbers and these are comparable.
#
# Row 57 (macros-traps.md): counting starts alone was blind to a pad whose
# one button refused. `GO ROUTE refused` ~740 times per 10 brain minutes for
# two hours read as 1 start, 1 distinct name, nothing flagged. So the window
# also counts every OUTCOME (done/blocked/timeout/refused), and two rules read
# them, both behind the same "no new ground" gate as the others:
# stalled — WD_LOOP_MIN_EVENTS+ decisions and WD_LOOP_STALL_PCT% of
# them ended refused, blocked or timed out;
# zero-progress — decisions in the window, not one `done` among them, on
# this probe AND the previous one (two probes, so a single
# unlucky window never flags).
#
# Row 58: `GO OBJECTIVE` into the Pewter Gym, `GO OUT` straight back out, for
# 25 minutes, with GO ITEM / GO FRONTIER / YES / NO mixed in — ten distinct
# names, every macro `done`, so neither the sequence rule (four names at
# most) nor zero-progress could fire, and the exploration count sat still.
# What the window did not have was a single reward event. So the stream also
# carries the `reward` events, and one more rule reads them, behind the same
# gate:
# unrewarded — WD_LOOP_BUSY_MIN+ decisions and no reward event in the
# window, on this probe AND the previous one.
# #
# The tile rule is what separates a loop from a legitimately repeating # The tile rule is what separates a loop from a legitimately repeating
# explorer (macros-traps.md: `GO FRONTIER` x19 over 93 tiles is a walk longer # explorer (macros-traps.md: `GO FRONTIER` x19 over 93 tiles is a walk longer
@ -857,45 +791,31 @@ loop_status_fields() {
loop_macro_stream() { loop_macro_stream() {
[ -f "$FLY_EVENT_LOG" ] || return 0 [ -f "$FLY_EVENT_LOG" ] || return 0
tail -n "$WD_LOOP_TAIL_LINES" "$FLY_EVENT_LOG" 2>/dev/null \ tail -n "$WD_LOOP_TAIL_LINES" "$FLY_EVENT_LOG" 2>/dev/null \
| jq -r 'fromjson? // empty | select(.kind == "macro" or .kind == "reward") | "\(.brainMs)\t\(.label)\t\(.kind)"' -R 2>/dev/null \ | jq -r 'fromjson? // empty | select(.kind == "macro") | "\(.brainMs)\t\(.label)"' -R 2>/dev/null \
|| true || true
} }
# loop_analyze — reads that stream on stdin and echoes one tab-separated line: # loop_analyze — reads that stream on stdin and echoes one tab-separated line:
# #
# total US distinct US topCount US period US repeats US windowFrom US # total US distinct US topCount US period US repeats US windowFrom US
# windowTo US topName US block US tail US decisions US refused US blocked US # windowTo US topName US block US tail
# timeout US done US rewards
# #
# `total` counts start events inside the window, `decisions` starts plus # `total` counts start events inside the window, `period`/`repeats` describe
# refusals (the sequence, `distinct` and `topCount` are over decisions), and
# the next four count each outcome in the window, and `rewards` the reward
# events in it (row 58). `period`/`repeats` describe
# the shortest repeating block at the END of the sequence (0/0 when nothing # the shortest repeating block at the END of the sequence (0/0 when nothing
# repeats), `block` is that block comma-joined, and `tail` is the last 12 # repeats), `block` is that block comma-joined, and `tail` is the last 12
# names for context. The window ends at the newest macro event's own brain # names for context. The window ends at the newest macro event's own brain
# clock, not at `now`: brain time is the only clock the event log carries. # clock, not at `now`: brain time is the only clock the event log carries.
loop_analyze() { loop_analyze() {
awk -F'\t' -v win="$WD_LOOP_WINDOW_MS" -v maxp="$WD_LOOP_MAX_PERIOD" ' awk -F'\t' -v win="$WD_LOOP_WINDOW_MS" -v maxp="$WD_LOOP_MAX_PERIOD" '
# Reward lines are counted and nothing else: the window still ends at the { ms[NR] = $1 + 0; lbl[NR] = $2; n = NR }
# newest macro event, as it always has.
$3 == "reward" { rms[++nr] = $1 + 0; next }
{ ms[++n] = $1 + 0; lbl[n] = $2 }
END { END {
if (n == 0) { printf "0\0370\0370\0370\0370\0370\0370\037\037\037\0370\0370\0370\0370\0370\0370\n"; exit } if (n == 0) { printf "0\0370\0370\0370\0370\0370\0370\037\037\037\n"; exit }
to = ms[n]; from = to - win to = ms[n]; from = to - win
k = 0; distinct = 0; topn = 0; top = "" k = 0; distinct = 0; topn = 0; top = ""
starts = 0; refused = 0; blocked = 0; timeout = 0; done = 0; rewards = 0
for (q = 1; q <= nr; q++) if (rms[q] >= from) rewards++
for (i = 1; i <= n; i++) { for (i = 1; i <= n; i++) {
if (ms[i] < from) continue if (ms[i] < from) continue
if (lbl[i] ~ / blocked$/) { blocked++; continue } if (lbl[i] !~ / start$/) continue
if (lbl[i] ~ / timeout$/) { timeout++; continue } name = lbl[i]; sub(/ start$/, "", name)
if (lbl[i] ~ / done$/) { done++; continue }
if (lbl[i] ~ / refused$/) refused++
else if (lbl[i] ~ / start$/) starts++
else continue
name = lbl[i]; sub(/ (start|refused)$/, "", name)
seq[++k] = name seq[++k] = name
if (!(name in cnt)) { cnt[name] = 0; distinct++ } if (!(name in cnt)) { cnt[name] = 0; distinct++ }
cnt[name]++ cnt[name]++
@ -919,9 +839,8 @@ loop_analyze() {
tailstr = "" tailstr = ""
start = k - 11; if (start < 1) start = 1 start = k - 11; if (start < 1) start = 1
for (j = start; j <= k; j++) tailstr = tailstr (tailstr == "" ? "" : ", ") seq[j] for (j = start; j <= k; j++) tailstr = tailstr (tailstr == "" ? "" : ", ") seq[j]
printf "%d\037%d\037%d\037%d\037%d\037%d\037%d\037%s\037%s\037%s\037%d\037%d\037%d\037%d\037%d\037%d\n", \ printf "%d\037%d\037%d\037%d\037%d\037%d\037%d\037%s\037%s\037%s\n", \
starts, distinct, topn, period, repeats, from, to, top, block, tailstr, \ k, distinct, topn, period, repeats, from, to, top, block, tailstr
k, refused, blocked, timeout, done, rewards
}' }'
} }
@ -979,21 +898,13 @@ check_loop() {
echo "$delta" > "${WD_RUN_DIR}/loop.places_delta" echo "$delta" > "${WD_RUN_DIR}/loop.places_delta"
local analysis total distinct topn period repeats win_from win_to top block tailstr local analysis total distinct topn period repeats win_from win_to top block tailstr
local decisions refused blocked timeouts completed rewards
analysis="$(loop_macro_stream | loop_analyze)" analysis="$(loop_macro_stream | loop_analyze)"
IFS=$'\037' read -r total distinct topn period repeats win_from win_to top block tailstr \ IFS=$'\037' read -r total distinct topn period repeats win_from win_to top block tailstr <<< "$analysis"
decisions refused blocked timeouts completed rewards <<< "$analysis"
total="${total:-0}"; distinct="${distinct:-0}"; topn="${topn:-0}" total="${total:-0}"; distinct="${distinct:-0}"; topn="${topn:-0}"
period="${period:-0}"; repeats="${repeats:-0}" period="${period:-0}"; repeats="${repeats:-0}"
decisions="${decisions:-0}"; refused="${refused:-0}"; blocked="${blocked:-0}"
timeouts="${timeouts:-0}"; completed="${completed:-0}"; rewards="${rewards:-0}"
echo "$distinct" > "${WD_RUN_DIR}/loop.distinct" echo "$distinct" > "${WD_RUN_DIR}/loop.distinct"
echo "$period" > "${WD_RUN_DIR}/loop.period" echo "$period" > "${WD_RUN_DIR}/loop.period"
echo "$repeats" > "${WD_RUN_DIR}/loop.repeats" echo "$repeats" > "${WD_RUN_DIR}/loop.repeats"
echo "$refused" > "${WD_RUN_DIR}/loop.refused"
echo "$(( blocked + timeouts ))" > "${WD_RUN_DIR}/loop.blocked"
echo "$completed" > "${WD_RUN_DIR}/loop.done"
echo "$rewards" > "${WD_RUN_DIR}/loop.rewards"
local prev_suspected=0 local prev_suspected=0
[ -f "${WD_RUN_DIR}/loop.suspected" ] && prev_suspected="$(cat "${WD_RUN_DIR}/loop.suspected" 2>/dev/null || echo 0)" [ -f "${WD_RUN_DIR}/loop.suspected" ] && prev_suspected="$(cat "${WD_RUN_DIR}/loop.suspected" 2>/dev/null || echo 0)"
@ -1006,57 +917,25 @@ check_loop() {
local grown=1 local grown=1
[ "$delta_known" -eq 1 ] && [ "$delta" -le 0 ] && grown=0 [ "$delta_known" -eq 1 ] && [ "$delta" -le 0 ] && grown=0
# Zero progress is judged over two probes: a window with decisions and no
# `done` among them is remembered, and only a second one in a row flags.
local idle_file="${WD_RUN_DIR}/loop.prev_idle" prev_idle=0 idle=0
[ -f "$idle_file" ] && prev_idle="$(cat "$idle_file" 2>/dev/null || echo 0)"
case "${prev_idle:-}" in ''|*[!0-9]*) prev_idle=0 ;; esac
[ "$decisions" -gt 0 ] && [ "$completed" -eq 0 ] && [ "$grown" -eq 0 ] && idle=1
echo "$idle" > "$idle_file"
# Busy going nowhere (row 58), judged over two probes like zero progress:
# many decisions, not one reward event, no new ground.
local busy_file="${WD_RUN_DIR}/loop.prev_unrewarded" prev_busy=0 busy=0
[ -f "$busy_file" ] && prev_busy="$(cat "$busy_file" 2>/dev/null || echo 0)"
case "${prev_busy:-}" in ''|*[!0-9]*) prev_busy=0 ;; esac
[ "$decisions" -ge "$WD_LOOP_BUSY_MIN" ] && [ "$rewards" -eq 0 ] && [ "$grown" -eq 0 ] && busy=1
echo "$busy" > "$busy_file"
local failed=$(( refused + blocked + timeouts ))
local suspected=0 reason="" local suspected=0 reason=""
if [ "$decisions" -gt 0 ] && [ "$grown" -eq 0 ]; then if [ "$total" -gt 0 ] && [ "$grown" -eq 0 ]; then
if [ "$decisions" -ge "$WD_LOOP_MIN_EVENTS" ] \ if [ "$distinct" -le "$WD_LOOP_MAX_DISTINCT" ] && [ "$repeats" -ge "$WD_LOOP_MIN_REPEATS" ]; then
&& [ $(( failed * 100 )) -ge $(( decisions * WD_LOOP_STALL_PCT )) ]; then
# Row 57: the fly keeps deciding and nothing it decides runs. A
# refused press is a decision with no start, which is why the
# rules below were blind to a pad of one button that refused.
suspected=1
reason="stalled"
elif [ "$idle" -eq 1 ] && [ "$prev_idle" -eq 1 ]; then
suspected=1
reason="zero-progress"
elif [ "$distinct" -le "$WD_LOOP_MAX_DISTINCT" ] && [ "$repeats" -ge "$WD_LOOP_MIN_REPEATS" ]; then
suspected=1 suspected=1
reason="sequence" reason="sequence"
elif [ "$decisions" -ge "$WD_LOOP_MIN_EVENTS" ] \ elif [ "$total" -ge "$WD_LOOP_MIN_EVENTS" ] \
&& [ $(( topn * 100 )) -ge $(( decisions * WD_LOOP_DOMINANCE_PCT )) ]; then && [ $(( topn * 100 )) -ge $(( total * WD_LOOP_DOMINANCE_PCT )) ]; then
# One macro and almost nothing else. The floor under it is not in # One macro and almost nothing else. The floor under it is not in
# the spec but is load-bearing: two starts in ten brain minutes are # the spec but is load-bearing: two starts in ten brain minutes are
# 100% of a window and mean the fly is barely deciding at all, # 100% of a window and mean the fly is barely deciding at all,
# which is silence (above), not a loop. # which is silence (above), not a loop.
suspected=1 suspected=1
reason="dominant" reason="dominant"
elif [ "$busy" -eq 1 ] && [ "$prev_busy" -eq 1 ]; then
# Row 58: an undo pair diluted by other macros. Every one of them
# completes, none of them earns anything, and the ground stays put.
suspected=1
reason="unrewarded"
fi fi
fi fi
echo "$suspected" > "${WD_RUN_DIR}/loop.suspected" echo "$suspected" > "${WD_RUN_DIR}/loop.suspected"
local dom_pct=0 local dom_pct=0
[ "$decisions" -gt 0 ] && dom_pct=$(( topn * 100 / decisions )) [ "$total" -gt 0 ] && dom_pct=$(( topn * 100 / total ))
# The sequence a reader wants: the repeating block when there is one, the # The sequence a reader wants: the repeating block when there is one, the
# single dominant name when the dominance rule is what fired. # single dominant name when the dominance rule is what fired.
local shown="$block" local shown="$block"
@ -1077,12 +956,6 @@ check_loop() {
--argjson repeats "$repeats" \ --argjson repeats "$repeats" \
--argjson distinct "$distinct" \ --argjson distinct "$distinct" \
--argjson total "$total" \ --argjson total "$total" \
--argjson decisions "$decisions" \
--argjson refused "$refused" \
--argjson blocked "$blocked" \
--argjson timeouts "$timeouts" \
--argjson completed "$completed" \
--argjson rewards "$rewards" \
--argjson windowFrom "${win_from:-0}" \ --argjson windowFrom "${win_from:-0}" \
--argjson windowTo "${win_to:-0}" \ --argjson windowTo "${win_to:-0}" \
--argjson windowMs "$WD_LOOP_WINDOW_MS" \ --argjson windowMs "$WD_LOOP_WINDOW_MS" \
@ -1109,9 +982,6 @@ check_loop() {
window: { window: {
brainMsFrom: $windowFrom, brainMsTo: $windowTo, brainMs: $windowMs, brainMsFrom: $windowFrom, brainMsTo: $windowTo, brainMs: $windowMs,
macroStarts: $total, macroStarts: $total,
decisions: $decisions,
outcomes: { done: $completed, blocked: $blocked, timeout: $timeouts, refused: $refused },
rewards: $rewards,
tail: (if $tail == "" then [] else ($tail | split(", ")) end) tail: (if $tail == "" then [] else ($tail | split(", ")) end)
}, },
dominant: { name: (if $dominant == "" then null else $dominant end), dominant: { name: (if $dominant == "" then null else $dominant end),
@ -1135,17 +1005,13 @@ check_loop() {
local mins=$(( WD_LOOP_WINDOW_MS / 60000 )) local mins=$(( WD_LOOP_WINDOW_MS / 60000 ))
if [ "$suspected" -eq 1 ]; then if [ "$suspected" -eq 1 ]; then
if [ "$reason" = "unrewarded" ]; then if [ "$reason" = "sequence" ]; then
log_err "loop suspected (unrewarded): ${decisions} decisions and no reward event in the last ${mins} brain minutes, two probes running — ${completed} done, top [${top}] x${topn}, ${distinct} distinct name(s), tail [${tailstr}], exploration count ${places:-?} unchanged (delta ${delta}), rung ${rank:-?} '${mlabel:-?}' for ${since:-?}s. NOT acting: nothing restarted, nothing pressed, the game untouched — macros that complete and earn nothing are an undo pair or a ring. Report: ${WD_LOOP_REPORT}; see infra/docs/runbook.md 'loop suspected'."
elif [ "$reason" = "stalled" ] || [ "$reason" = "zero-progress" ]; then
log_err "loop suspected (${reason}): [${shown}] — ${decisions} decisions in the last ${mins} brain minutes, ${total} started, ${completed} done, ${refused} refused, $(( blocked + timeouts )) blocked or timed out, exploration count ${places:-?} unchanged (delta ${delta}), rung ${rank:-?} '${mlabel:-?}' for ${since:-?}s. NOT acting: nothing restarted, nothing pressed, the game untouched — a pad whose buttons cannot run is a macro bug. Report: ${WD_LOOP_REPORT}; see infra/docs/runbook.md 'loop suspected'."
elif [ "$reason" = "sequence" ]; then
log_err "loop suspected: [${shown}] x${repeats} (period ${period}) in the last ${mins} brain minutes — ${total} macro starts, ${distinct} distinct name(s), exploration count ${places:-?} unchanged (delta ${delta}), rung ${rank:-?} '${mlabel:-?}' for ${since:-?}s. NOT acting: nothing restarted, nothing pressed, the game untouched — a loop is a macro target-choice bug and a bounce would only restore the same loop. Report: ${WD_LOOP_REPORT}; see infra/docs/runbook.md 'loop suspected'." log_err "loop suspected: [${shown}] x${repeats} (period ${period}) in the last ${mins} brain minutes — ${total} macro starts, ${distinct} distinct name(s), exploration count ${places:-?} unchanged (delta ${delta}), rung ${rank:-?} '${mlabel:-?}' for ${since:-?}s. NOT acting: nothing restarted, nothing pressed, the game untouched — a loop is a macro target-choice bug and a bounce would only restore the same loop. Report: ${WD_LOOP_REPORT}; see infra/docs/runbook.md 'loop suspected'."
else else
log_err "loop suspected: [${shown}] is ${dom_pct}% of ${total} macro starts in the last ${mins} brain minutes (${distinct} distinct name(s)), exploration count ${places:-?} unchanged (delta ${delta}), rung ${rank:-?} '${mlabel:-?}' for ${since:-?}s. NOT acting: nothing restarted, nothing pressed, the game untouched. Report: ${WD_LOOP_REPORT}; see infra/docs/runbook.md 'loop suspected'." log_err "loop suspected: [${shown}] is ${dom_pct}% of ${total} macro starts in the last ${mins} brain minutes (${distinct} distinct name(s)), exploration count ${places:-?} unchanged (delta ${delta}), rung ${rank:-?} '${mlabel:-?}' for ${since:-?}s. NOT acting: nothing restarted, nothing pressed, the game untouched. Report: ${WD_LOOP_REPORT}; see infra/docs/runbook.md 'loop suspected'."
fi fi
elif [ "$prev_suspected" -eq 1 ]; then elif [ "$prev_suspected" -eq 1 ]; then
log_info "loop cleared: ${total} macro starts (${decisions} decisions, ${completed} done, ${refused} refused) over ${distinct} distinct name(s) in the last ${mins} brain minutes, longest repeat x${repeats} (period ${period}), exploration count ${places:-?} delta ${delta}. Nothing was ever done about the loop; if a fix went in, this is it landing." log_info "loop cleared: ${total} macro starts over ${distinct} distinct name(s) in the last ${mins} brain minutes, longest repeat x${repeats} (period ${period}), exploration count ${places:-?} delta ${delta}. Nothing was ever done about the loop; if a fix went in, this is it landing."
fi fi
} }

View file

@ -79,12 +79,6 @@ log "building in $crate_dir for target-cpu=haswell (the host is E5-2660 v3, Hasw
( (
cd "$crate_dir" cd "$crate_dir"
RUSTFLAGS="-C target-cpu=haswell" cargo build --release --target "$CARGO_TARGET" --bin flysim "${features_args[@]}" RUSTFLAGS="-C target-cpu=haswell" cargo build --release --target "$CARGO_TARGET" --bin flysim "${features_args[@]}"
# fly-edge (FLY_FEED_VIA=bus, docs/design/flybus.md): the feed WebSocket
# served from flysim's feed bus. Small, and no cargo features of its own;
# built every time so a release can switch a container onto the bus
# without a rebuild. It lands next to OUT_PATH, where package-release.sh
# looks for it.
RUSTFLAGS="-C target-cpu=haswell" cargo build --release --target "$CARGO_TARGET" --bin fly-edge
) )
built="${crate_dir}/target/${CARGO_TARGET}/release/flysim" built="${crate_dir}/target/${CARGO_TARGET}/release/flysim"
@ -112,10 +106,4 @@ fi
cp "$built" "$OUT_PATH" cp "$built" "$OUT_PATH"
chmod 0755 "$OUT_PATH" chmod 0755 "$OUT_PATH"
log "built $OUT_PATH ($(du -h "$OUT_PATH" | cut -f1))" log "built $OUT_PATH ($(du -h "$OUT_PATH" | cut -f1))"
edge_built="${crate_dir}/target/${CARGO_TARGET}/release/fly-edge"
[ -x "$edge_built" ] || die "expected binary not found after build: $edge_built"
edge_out="$(dirname "$OUT_PATH")/fly-edge"
cp "$edge_built" "$edge_out"
chmod 0755 "$edge_out"
log "built $edge_out ($(du -h "$edge_out" | cut -f1))"
log "next: infra/build/package-release.sh VERSION $OUT_PATH <stage-dir> <bridge-dir> <out-dir>" log "next: infra/build/package-release.sh VERSION $OUT_PATH <stage-dir> <bridge-dir> <out-dir>"

View file

@ -11,9 +11,6 @@
# #
# Output: OUT_DIR/flybrain-<version>.tar.gz, laid out as # Output: OUT_DIR/flybrain-<version>.tar.gz, laid out as
# flysim (the binary, mode 0755) # flysim (the binary, mode 0755)
# fly-edge (the feed-bus edge, mode 0755, when build-flysim.sh
# left one beside FLYSIM_BIN; flyedge.service stays
# inactive on a release without it)
# stage/... (apps/stage's build output) # stage/... (apps/stage's build output)
# bridge/... (services/bridge + node_modules) # bridge/... (services/bridge + node_modules)
# data/fafb-v783/... (the connectome, from the repo; FLY_DATASET points here) # data/fafb-v783/... (the connectome, from the repo; FLY_DATASET points here)
@ -75,13 +72,6 @@ mkdir -p "$release_dir"
cp "$FLYSIM_BIN" "${release_dir}/flysim" cp "$FLYSIM_BIN" "${release_dir}/flysim"
chmod 0755 "${release_dir}/flysim" chmod 0755 "${release_dir}/flysim"
EDGE_BIN="$(dirname "$FLYSIM_BIN")/fly-edge"
if [ -x "$EDGE_BIN" ]; then
cp "$EDGE_BIN" "${release_dir}/fly-edge"
chmod 0755 "${release_dir}/fly-edge"
else
log "no fly-edge beside $FLYSIM_BIN; packaging without it (FLY_FEED_VIA=bus unavailable in this release)"
fi
cp -a "$STAGE_DIR" "${release_dir}/stage" cp -a "$STAGE_DIR" "${release_dir}/stage"
cp -a "$BRIDGE_DIR" "${release_dir}/bridge" cp -a "$BRIDGE_DIR" "${release_dir}/bridge"

View file

@ -6,9 +6,6 @@ d /run/fly 0750 fly fly -
d /run/fly/pulse 0750 fly fly - d /run/fly/pulse 0750 fly fly -
d /run/fly/state 0750 fly fly - d /run/fly/state 0750 fly fly -
d /run/fly/wd 0750 fly fly - d /run/fly/wd 0750 fly fly -
# ADDED: the feed bus (FLY_FEED_VIA=bus, docs/design/flybus.md): flysim's
# router socket and artifact store. flysim also creates it, 0700, on start.
d /run/fly/bus 0700 fly fly -
d /var/lib/fly 0750 fly fly - d /var/lib/fly 0750 fly fly -
d /var/lib/fly/chrome 0700 fly fly - d /var/lib/fly/chrome 0700 fly fly -
d /srv/fly/state 0750 fly fly - d /srv/fly/state 0750 fly fly -

View file

@ -20,8 +20,7 @@ FLY_ROM=".../Pokemon Red (U) [S][BF].gb" FLY_MACRO_BRAIN=data/fafb-v783 \
cargo run --release -p flysim --example palette_bench cargo run --release -p flysim --example palette_bench
``` ```
Both arms are the sim loop's own frame order (`flysim::frame::LegacyFrame` since 2026-09-23; Both arms are the sim loop's own frame order over the real connectome (`data/fafb-v783`), the real
before that `NeuralAgent::tick`'s, one frame behind the stream) over the real connectome (`data/fafb-v783`), the real
Game Boy readout preset with nothing overridden, the real Pokémon adapter paying the real reward Game Boy readout preset with nothing overridden, the real Pokémon adapter paying the real reward
catalog, and the real ratchet on the adapter's own recovery policy. The only difference between catalog, and the real ratchet on the adapter's own recovery policy. The only difference between
them is `flysim::macros::MacroLayer`, built from the configuration the way `Sim::boot` builds it, them is `flysim::macros::MacroLayer`, built from the configuration the way `Sim::boot` builds it,

File diff suppressed because it is too large Load diff

View file

@ -231,59 +231,6 @@ auto-reset (`docs/design/flysim.md` section 8: "no automatic fresh start, ever")
is deliberate — a silent reset would be indistinguishable from real progress on stream. is deliberate — a silent reset would be indistinguishable from real progress on stream.
A deliberate reset means moving `/srv/fly/state` aside by hand. A deliberate reset means moving `/srv/fly/state` aside by hand.
## Restart the run from a rung
When the run has to go back to an earlier milestone rather than start over — the operator's
decision of 2026-09-22 was "restart the live run from an early checkpoint instead of from
scratch". `FLY_RESET_STATE=1` is the wrong tool: it archives the durable state and the next start
warms up a fresh fly, losing everything the brain has learned.
`infra/bin/fly-reset-to-milestone <N>` promotes `milestone-<N>.checkpoint` to being what both
stores restore, with the ratchet's attempts and recoveries back at zero. It copies every file in
both stores to `/srv/fly/state.reset-<UTC>` first, so it is reversible by hand. It refuses while
flysim is running, and refuses a rung this run never reached.
The whole sequence, in order. Claim the container in the host's agent claim log first, like any
other work on it.
```
CTID=<release-ctid>
N=9 # the rung to restart from
# 1. what rungs exist at all
pct exec $CTID -- ls -1 /srv/fly/state/milestone-*.checkpoint
# 2. stop flysim (it owns both stores; a reset underneath it is overwritten within the minute)
pct exec $CTID -- systemctl stop flysim.service
# 3. the reset. Prints what it did, one line per step.
pct exec $CTID -- /opt/fly/bin/fly-reset-to-milestone $N
# 4. deploy. Two cases:
# (a) the running release already wrote that checkpoint -> nothing to deploy, skip to 5.
# (b) the new build bumps the ADAPTER VERSION and nothing else -> name the checkpoint's
# adapter so the gate and flysim both migrate instead of refusing:
FLY_ACCEPT_ADAPTERS=pokered-unique8-v6 infra/05-deploy.sh <release-env> <release-tarball>
# The gate logs "the adapter version is the only difference, and it is named; the run is KEPT
# and migrated", and writes FLY_ACCEPT_ADAPTERS into /etc/fly/fly.env so flysim applies the
# same rule at restore. Anything else about the string differing is still a refusal.
# 5. start
pct exec $CTID -- systemctl start flysim.service
# 6. verify: the rank is the rung, and the restore came from the generation the tool wrote
pct exec $CTID -- curl -s http://127.0.0.1:7401/status | jq '.milestone.rank, .game.badges, .checkpoint'
pct exec $CTID -- journalctl -u flysim -n 40 --no-pager | grep -E 'restored|migration|compatibility'
```
Step 6 is the one that must be read rather than assumed. The rank is recomputed by the adapter
from the restored game state, not taken from the ratchet, so a rank that is *not* N means the
milestone archive was taken somewhere other than where its name says — stop and look before
starting a stream on it.
To undo: stop flysim, move the contents of `/srv/fly/state.reset-<UTC>/durable` back into
`/srv/fly/state`, delete the generation the tool wrote, and start again.
## Restore from the backup host ## Restore from the backup host
``` ```
@ -599,20 +546,9 @@ pct exec <ctid> -- cat /run/fly/wd/loop.json | jq .
| `fly_loop_repeats` | how many times that block repeats at the end of the 10-brain-minute window | | `fly_loop_repeats` | how many times that block repeats at the end of the 10-brain-minute window |
| `fly_loop_distinct_macros` | distinct macro names started in the window | | `fly_loop_distinct_macros` | distinct macro names started in the window |
| `fly_places_delta` | growth in `game.uniqueLocations` since the previous probe (`-1` = no previous probe) | | `fly_places_delta` | growth in `game.uniqueLocations` since the previous probe (`-1` = no previous probe) |
| `fly_loop_refused` | macro presses refused in the window: a bound button pressed, nothing run |
| `fly_loop_blocked` | macros that ended `blocked` or `timeout` in the window |
| `fly_loop_done` | macros that ended `done` in the window |
| `fly_loop_rewards` | reward events in the window |
The flag needs **both** halves: at most 3 distinct macro names with the block repeating 20+ The flag needs **both** halves: at most 3 distinct macro names with the block repeating 20+
times, one macro at 95%+ of the window's decisions, 90%+ of 20+ decisions ending refused, times, or one macro at 95%+ of the window — **and** no growth in the exploration count. A
blocked or timed out (`stalled`), or decisions with no `done` among them on two probes in a row
(`zero-progress`), or 100+ decisions with no reward event among them on two probes in a row
(`unrewarded`, row 58: `GO OBJECTIVE` in and `GO OUT` out of one door, diluted by eight other
names, every macro `done`) — **and** no growth in the exploration count. A decision is a `start` or a
`refused`: a refused press starts nothing, which is why counting starts alone read row 57's
pad (`GO ROUTE refused` ~740 times in ten brain minutes, `macros-traps.md`) as one start and
one name. A
repeating macro over ground that keeps growing is a walk longer than the 600-frame cap, not a repeating macro over ground that keeps growing is a walk longer than the 600-frame cap, not a
trap (`macros-traps.md`: `GO FRONTIER` x19 across 93 tiles), and the watchdog is deliberately trap (`macros-traps.md`: `GO FRONTIER` x19 across 93 tiles), and the watchdog is deliberately
quiet about it. The thresholds are `WD_LOOP_*` in `infra/bin/fly-watchdog`; the 3-name ceiling quiet about it. The thresholds are `WD_LOOP_*` in `infra/bin/fly-watchdog`; the 3-name ceiling

22
infra/env/example.env vendored
View file

@ -312,33 +312,11 @@ CHAT_DENY_LIST=/srv/fly/chat-deny.txt
# "palette" and "plan" are the two modes section 12 replaced; flysim still reads # "palette" and "plan" are the two modes section 12 replaced; flysim still reads
# either as "macros", with a warning, for one release. # either as "macros", with a warning, for one release.
FLY_MACRO_MODE=raw FLY_MACRO_MODE=raw
# --- feed path ----------------------------------------------------------------
# Who serves ws://127.0.0.1:7400/feed (docs/design/flybus.md, "Feed over the
# bus"). direct: flysim binds it, as always. bus: flysim publishes every
# snapshot on its embedded feed bus (/run/fly/bus) and flyedge.service serves
# the same bytes; enable that unit by hand (its header has the steps).
# Watchdog check 2 follows this setting to the edge's counters by itself.
FLY_FEED_VIA=direct
# How long a macro leaves a target alone after a walk to it aborted "blocked" or # How long a macro leaves a target alone after a walk to it aborted "blocked" or
# "timeout" (macros.md section 12.1). Session state, so a restart offers every # "timeout" (macros.md section 12.1). Session state, so a restart offers every
# target once more. Unset means the default, 10. # target once more. Unset means the default, 10.
# FLY_MACRO_BLOCKED_MINUTES=10 # FLY_MACRO_BLOCKED_MINUTES=10
# --- restoring across an adapter version ------------------------------------
# Adapter version strings whose checkpoints this build may migrate, comma- or
# space-separated (docs/design/flysim.md, "Restoring across an adapter
# version"). Unset -- the default, and what every deploy before 2026-09-22 did
# -- migrates nothing: a build whose compatibility string differs from the live
# state's is refused by 05-deploy's gate and by flysim at restore.
#
# It applies only when the ADAPTER segment is the only difference between the
# two strings AND the new build's adapter declares a migration from that one. A
# dataset, kernel, plasticity, emulator or state-format difference is still a
# refusal. Set it for the one deploy that needs it and leave it out afterwards;
# 05-deploy writes it into /etc/fly/fly.env only while it is set. The v0.6.0
# deploy (pokered-unique8-v7, the engagement rewards) is the one that needs:
# FLY_ACCEPT_ADAPTERS=pokered-unique8-v6
# --- push mode -------------------------------------------------------------- # --- push mode --------------------------------------------------------------
# local: flypush.service stays disabled, everything else identical to prod. # local: flypush.service stays disabled, everything else identical to prod.
# twitch: flypush.service is enabled by 07-enable.sh. # twitch: flypush.service is enabled by 07-enable.sh.

View file

@ -154,20 +154,6 @@ require_release_tag() {
# (An earlier, eight-cpu version of this same live hotfix — CPUSET= # (An earlier, eight-cpu version of this same live hotfix — CPUSET=
# 1,3,5,7,9,11,13,15, ENCODER_CORES=2 (the default) — gave flysim=1,3,5,7, # 1,3,5,7,9,11,13,15, ENCODER_CORES=2 (the default) — gave flysim=1,3,5,7,
# page=9,11, flycast=13,15; infra/tests/lint.sh checks both shapes.) # page=9,11, flycast=13,15; infra/tests/lint.sh checks both shapes.)
# feed_via_normalize VALUE — print FLY_FEED_VIA lowercased (empty means "direct"), or
# return 1 for anything but direct|bus. flysim itself reads the value case-insensitively
# and refuses anything else at boot, which on a container is a restart loop; 05-deploy.sh
# refuses it at deploy instead and writes the lowercased word, so watchdog check 2 and
# flysim can never read the same line two ways (docs/design/flybus.md).
feed_via_normalize() {
local via
via="$(printf '%s' "${1:-direct}" | tr '[:upper:]' '[:lower:]')"
case "$via" in
direct|bus) printf '%s\n' "$via" ;;
*) return 1 ;;
esac
}
cpuset_partition() { cpuset_partition() {
local cpuset="$1" rayon_threads="$2" encoder_cores="${3:-2}" local cpuset="$1" rayon_threads="$2" encoder_cores="${3:-2}"
local sim_cpus remainder remainder_count page_count page_cpus encoder_cpus local sim_cpus remainder remainder_count page_count page_cpus encoder_cpus

View file

@ -429,140 +429,6 @@ else
fi fi
rm -rf "$lint_tmp" rm -rf "$lint_tmp"
# ---------------------------------------------------------------------------
# 3b2. The feed bus edge (docs/design/flybus.md, "Feed over the bus").
#
# flyedge.service is off unless the operator switches a container to
# FLY_FEED_VIA=bus by hand, and when it is on it must follow flysim, which
# owns the router. What would break that is statically visible: the unit
# ending up in fly.target or 07-enable's list, losing its ordering on
# flysim, or the deploy no longer writing the default. Watchdog check 2's
# choice of /metrics is driven for real against a fixture fly.env.
# ---------------------------------------------------------------------------
echo "--- flyedge.service: off by default, after and bound to flysim ---"
EDGE_UNIT="$INFRA_DIR/units/flyedge.service"
if [ ! -f "$EDGE_UNIT" ]; then
fail "units/flyedge.service is missing"
else
grep -qE '^After=.*\bflysim\.service\b' "$EDGE_UNIT" \
&& pass "flyedge.service orders itself After=flysim.service" \
|| fail "flyedge.service must be After=flysim.service: flysim owns the feed router"
grep -qE '^Requires=.*\bflysim\.service\b' "$EDGE_UNIT" \
&& pass "flyedge.service Requires=flysim.service" \
|| fail "flyedge.service must Require flysim.service, so a stop or restart of flysim takes the edge with it"
grep -qE '^ExecStart=/opt/fly/current/fly-edge$' "$EDGE_UNIT" \
&& pass "flyedge.service runs the release's fly-edge" \
|| fail "flyedge.service ExecStart must be /opt/fly/current/fly-edge"
grep -qE '^ConditionPathExists=/opt/fly/current/fly-edge$' "$EDGE_UNIT" \
&& pass "flyedge.service stays inactive on a release without fly-edge" \
|| fail "flyedge.service needs ConditionPathExists=/opt/fly/current/fly-edge (a release before it has none)"
grep -qE '^Environment=FLY_EDGE_METRICS_ADDR=127\.0\.0\.1:' "$EDGE_UNIT" \
&& pass "flyedge.service keeps its metrics on loopback" \
|| fail "flyedge.service FLY_EDGE_METRICS_ADDR must be a 127.0.0.1 address"
fi
# Every unit a target's Wants=/Requires= names, with backslash continuations joined and
# comments dropped: fly.target spreads both lists over several physical lines, and the
# continuation line is exactly where a new unit would be added.
target_pulls() {
awk '
/^[[:space:]]*[#;]/ { next }
{
line = $0
cont = sub(/\\[[:space:]]*$/, "", line)
buf = buf line
if (cont) next
if (buf ~ /^[[:space:]]*(Wants|Requires)=/) { sub(/^[^=]*=/, "", buf); print buf }
buf = ""
}
' "$1" | tr -s ' \t' '\n' | grep -v '^$' || true
}
if target_pulls "$INFRA_DIR/units/fly.target" | grep -qx 'flyedge.service'; then
fail "fly.target pulls flyedge.service in; it must stay off until the operator enables it"
else
pass "fly.target does not pull flyedge.service in"
fi
# The parser itself: a unit named only on a continuation line must be found, a commented one
# must not, and the real fly.target must still yield flysim.service.
tp_fixture="$(mktemp "${TMPDIR:-/tmp}/fly-lint-target.XXXXXX")"
cat > "$tp_fixture" <<'TPTARGET'
[Unit]
Wants=network-online.target xvfb.service \
flysim.service flyedge.service
# Requires=commented.service
Requires=xvfb.service \
pulse.service
TPTARGET
tp_units="$(target_pulls "$tp_fixture")"
if printf '%s\n' "$tp_units" | grep -qx 'flyedge.service' \
&& printf '%s\n' "$tp_units" | grep -qx 'pulse.service' \
&& ! printf '%s\n' "$tp_units" | grep -qx 'commented.service' \
&& target_pulls "$INFRA_DIR/units/fly.target" | grep -qx 'flysim.service'; then
pass "target_pulls reads continuation lines and skips comments (fixture + fly.target)"
else
fail "target_pulls missed a continuation line or read a comment: $(echo "$tp_units" | tr '\n' ' ')"
fi
rm -f "$tp_fixture"
if grep -E '^(ALWAYS_ON_UNITS|APP_UNITS)=' "$INFRA_DIR/07-enable.sh" "$INFRA_DIR/verify.sh" | grep -q 'flyedge'; then
fail "07-enable.sh or verify.sh lists flyedge.service as always-on"
else
pass "07-enable.sh and verify.sh leave flyedge.service alone"
fi
if grep -qF 'FLY_FEED_VIA_EFFECTIVE="$(feed_via_normalize "${FLY_FEED_VIA:-}")"' "$INFRA_DIR/05-deploy.sh" \
&& grep -qF 'echo "FLY_FEED_VIA=${FLY_FEED_VIA_EFFECTIVE}"' "$INFRA_DIR/05-deploy.sh"; then
pass "05-deploy.sh validates FLY_FEED_VIA and writes the normalized value"
else
fail "05-deploy.sh must run FLY_FEED_VIA through feed_via_normalize and write FLY_FEED_VIA_EFFECTIVE"
fi
# shellcheck source=../lib/common.sh
fv_out="$(bash -c '. "$1/lib/common.sh"
for v in "" direct DIRECT bus Bus BUS; do printf "%s=%s " "${v:-empty}" "$(feed_via_normalize "$v")"; done
for v in buss "bus " direct,bus; do feed_via_normalize "$v" >/dev/null && printf "ACCEPTED:%s " "$v"; done; true' _ "$INFRA_DIR" 2>&1)"
if [ "$fv_out" = "empty=direct direct=direct DIRECT=direct bus=bus Bus=bus BUS=bus " ]; then
pass "feed_via_normalize: direct|bus in any case, empty is direct, anything else refused"
else
fail "feed_via_normalize: got '$fv_out'"
fi
if grep -qE '^[[:space:]]*for u in flysim .*\bflyedge\b.*; do$' "$INFRA_DIR/05-deploy.sh"; then
pass "05-deploy.sh writes a cpuset drop-in for flyedge.service"
else
fail "05-deploy.sh cpuset loop must include flyedge (the page's CPUs, never flysim's)"
fi
if grep -qE '^Environment=FLY_FEED_VIA' "$INFRA_DIR/units/flysim.service"; then
fail "flysim.service pins FLY_FEED_VIA; it belongs to fly.env so a box can be switched by deploy"
else
pass "flysim.service leaves FLY_FEED_VIA to fly.env"
fi
echo "--- fly-watchdog check 2: the feed counters follow FLY_FEED_VIA ---"
if ! tail -n1 "$INFRA_DIR/bin/fly-watchdog" | grep -qE '^main "\$@"$'; then
fail "fly-watchdog: expected the last line to be 'main \"\$@\"' — the check-2 fixture strips it"
else
fe_fixture="$(mktemp -d "${TMPDIR:-/tmp}/fly-lint-edge.XXXXXX")"
sed '$d' "$INFRA_DIR/bin/fly-watchdog" > "$fe_fixture/wd.sh"
feed_url_case() {
local label="$1" env_line="$2" override="$3" want="$4" got
printf '%s\n' "$env_line" > "$fe_fixture/fly.env"
got="$(FLY_ENV_FILE="$fe_fixture/fly.env" FLY_FEED_METRICS_URL="$override" \
FLY_METRICS_URL=http://sim FLY_EDGE_METRICS_URL=http://edge \
WD_RUN_DIR="$fe_fixture/run" WD_STATE_DIR="$fe_fixture/state" \
TEXTFILE_DIR="$fe_fixture/textfile" \
bash -c "source '$fe_fixture/wd.sh'; feed_metrics_url" 2>&1 || true)"
if [ "$got" = "$want" ]; then
pass "check 2 feed metrics: $label -> $got"
else
fail "check 2 feed metrics: $label: got '$got', want '$want'"
fi
}
feed_url_case "direct" "FLY_FEED_VIA=direct" "" "http://sim"
feed_url_case "no FLY_FEED_VIA line (a fly.env before it)" "FLY_GAME=pokemon-red" "" "http://sim"
feed_url_case "bus" "FLY_FEED_VIA=bus" "" "http://edge"
feed_url_case "Bus (flysim lowercases)" "FLY_FEED_VIA=Bus" "" "http://edge"
feed_url_case "BUS" "FLY_FEED_VIA=BUS" "" "http://edge"
feed_url_case "quoted bus" 'FLY_FEED_VIA="bus"' "" "http://edge"
feed_url_case "explicit override wins" "FLY_FEED_VIA=bus" "http://other" "http://other"
rm -rf "$fe_fixture"
fi
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# 3c. lib/common.sh cpuset_partition — the three-way cpuset split used by # 3c. lib/common.sh cpuset_partition — the three-way cpuset split used by
# 05-deploy.sh section 3b (flysim / page-capture / flycast). Run as its own # 05-deploy.sh section 3b (flysim / page-capture / flycast). Run as its own
@ -1023,26 +889,6 @@ LPCAT
printf '{"id":999999,"wallMs":1758' printf '{"id":999999,"wallMs":1758'
} > "$out" } > "$out"
} }
# lp_outcomes FILE OUTCOME — like lp_events, but each name on stdin is one
# decision that ended OUTCOME: `refused` writes the refusal alone (nothing
# started, which is what a refused press is), anything else a start and
# that outcome. Row 57's shape is `GO ROUTE refused` every 800 brain ms.
lp_outcomes() {
local out="$1" outcome="$2" id=0 ms=0 nm
{
while IFS= read -r nm; do
if [ "$outcome" != "refused" ]; then
id=$(( id + 1 ))
printf '{"id":%d,"wallMs":%d,"brainMs":%d,"kind":"macro","label":"%s start","value":3}\n' \
"$id" "$(( 1758000000000 + id ))" "$ms" "$nm"
fi
id=$(( id + 1 ))
printf '{"id":%d,"wallMs":%d,"brainMs":%d,"kind":"macro","label":"%s %s","value":3}\n' \
"$id" "$(( 1758000000000 + id ))" "$ms" "$nm" "$outcome"
ms=$(( ms + 800 ))
done
} > "$out"
}
# lp_status FILE PLACES — the /status.json fields check 10 reads. `places` # lp_status FILE PLACES — the /status.json fields check 10 reads. `places`
# is `game.uniqueLocations`; there is no `places` field in the contract. # is `game.uniqueLocations`; there is no `places` field in the contract.
lp_status() { lp_status() {
@ -1155,95 +1001,10 @@ LPCAT
fail "check 10: expected the flag to clear on places growth, got flagged=${lp_flagged} then suspected=$(lp_metric fly_loop_suspected) places_delta=$(lp_metric fly_places_delta), journal: $(cat "$lp_fixture/journal.log")" fail "check 10: expected the flag to clear on places growth, got flagged=${lp_flagged} then suspected=$(lp_metric fly_loop_suspected) places_delta=$(lp_metric fly_places_delta), journal: $(cat "$lp_fixture/journal.log")"
fi fi
# (5) Row 57: a pad of one button that refuses every hold. One start in the
# window and one name, so the sequence and dominance rules over starts
# alone never fired; the outcomes say it is a stall.
lp_reset
{ lp_cycle 700 "GO ROUTE" | lp_outcomes "$lp_fixture/refused.jsonl" refused
echo "GO ROUTE" | lp_outcomes "$lp_fixture/blocked.jsonl" blocked
cat "$lp_fixture/refused.jsonl" "$lp_fixture/blocked.jsonl"; } > "$lp_fixture/events.jsonl"
lp_status "$lp_fixture/status.json" 1846
lp_pass
lp_first="$(lp_metric fly_loop_suspected)"
lp_pass
if [ "$lp_first" = "0" ] \
&& [ "$(lp_metric fly_loop_suspected)" = "1" ] \
&& [ "$(lp_metric fly_loop_refused)" = "700" ] \
&& [ "$(lp_metric fly_loop_blocked)" = "1" ] \
&& [ "$(lp_metric fly_loop_done)" = "0" ] \
&& grep -q 'loop suspected (stalled): \[GO ROUTE\]' "$lp_fixture/journal.log"; then
pass "check 10: a pad whose one button is refused every hold flags as stalled (700 refused, 1 blocked, 0 done)"
else
fail "check 10: the row-57 refusal log gave first=${lp_first} suspected=$(lp_metric fly_loop_suspected) refused=$(lp_metric fly_loop_refused) blocked=$(lp_metric fly_loop_blocked) done=$(lp_metric fly_loop_done), journal: $(cat "$lp_fixture/journal.log")"
fi
if lp_report="$(jq -e -r '[.reason, (.window.macroStarts|tostring), (.window.decisions|tostring), (.window.outcomes.refused|tostring), (.window.outcomes.done|tostring), .action] | join(" ")' "$lp_fixture/run/loop.json" 2>/dev/null)" \
&& [ "$lp_report" = "stalled 1 701 700 0 none" ]; then
pass "check 10: loop.json carries the decisions and every outcome, not only the starts"
else
fail "check 10: loop.json read back as '${lp_report:-UNREADABLE}' — expected 'stalled 1 701 700 0 none'"
fi
# (6) Zero progress: a handful of decisions, every one blocked, too few for
# the stall rule's floor. One probe of it is not enough; two in a row are.
lp_reset
lp_cycle 3 "GO OBJECTIVE" "GO FRONTIER" | lp_outcomes "$lp_fixture/events.jsonl" blocked
lp_status "$lp_fixture/status.json" 1846
lp_pass
lp_pass
lp_first="$(lp_metric fly_loop_suspected)"
lp_pass
if [ "$lp_first" = "0" ] && [ "$(lp_metric fly_loop_suspected)" = "1" ] \
&& grep -q 'loop suspected (zero-progress)' "$lp_fixture/journal.log"; then
pass "check 10: decisions that complete nothing over two probes with no new ground flag as zero-progress"
else
fail "check 10: the zero-progress case gave first=${lp_first} then suspected=$(lp_metric fly_loop_suspected), journal: $(cat "$lp_fixture/journal.log")"
fi
# (7) Row 58: the gym door, in and out. GO OBJECTIVE / GO OUT diluted by
# eight other names, every macro `done`, no reward event, no new ground --
# neither the four-name sequence rule, dominance nor zero-progress fires.
# Two probes of it flag; the same window with one reward in it does not.
lp_reset
lp_cycle 20 "GO OBJECTIVE" "GO OUT" "GO OUT" "GO ITEM" "GO OBJECTIVE" "GO OUT" \
"GO FRONTIER" "YES" "NO" "GO ROUTE" "NEXT" "TALK" "GO SHOP" \
| lp_outcomes "$lp_fixture/events.jsonl" "done"
lp_status "$lp_fixture/status.json" 1892
lp_pass
lp_pass
lp_first="$(lp_metric fly_loop_suspected)"
lp_pass
if [ "$lp_first" = "0" ] && [ "$(lp_metric fly_loop_suspected)" = "1" ] \
&& [ "$(lp_metric fly_loop_rewards)" = "0" ] \
&& [ "$(lp_metric fly_loop_distinct_macros)" = "10" ] \
&& grep -q 'loop suspected (unrewarded): 260 decisions and no reward event' "$lp_fixture/journal.log"; then
pass "check 10: an undo pair diluted by eight other names, all done, no reward over two probes flags as unrewarded"
else
fail "check 10: the row-58 log gave first=${lp_first} then suspected=$(lp_metric fly_loop_suspected) rewards=$(lp_metric fly_loop_rewards) distinct=$(lp_metric fly_loop_distinct_macros), journal: $(cat "$lp_fixture/journal.log")"
fi
if lp_report="$(jq -e -r '[.reason, (.window.decisions|tostring), (.window.rewards|tostring), .action] | join(" ")' "$lp_fixture/run/loop.json" 2>/dev/null)" \
&& [ "$lp_report" = "unrewarded 260 0 none" ]; then
pass "check 10: loop.json carries the reward events in the window"
else
fail "check 10: loop.json read back as '${lp_report:-UNREADABLE}' — expected 'unrewarded 260 0 none'"
fi
lp_reset
{ cat "$lp_fixture/events.jsonl"
printf '{"id":999998,"wallMs":1758000999998,"brainMs":150000,"kind":"reward","label":"WILD KO 54:1:1","value":0.1,"rewardKind":"wildwin"}\n'; } \
> "$lp_fixture/events-rewarded.jsonl"
mv -f "$lp_fixture/events-rewarded.jsonl" "$lp_fixture/events.jsonl"
lp_pass
lp_pass
lp_pass
if [ "$(lp_metric fly_loop_suspected)" = "0" ] && [ "$(lp_metric fly_loop_rewards)" = "1" ]; then
pass "check 10: the same busy window with one reward in it does not flag"
else
fail "check 10: a rewarded busy window gave suspected=$(lp_metric fly_loop_suspected) rewards=$(lp_metric fly_loop_rewards)"
fi
# The ethos, asserted rather than reviewed: over every case above, check 10 # The ethos, asserted rather than reviewed: over every case above, check 10
# restarted nothing. It reports; a human or a review agent decides. # restarted nothing. It reports; a human or a review agent decides.
if [ ! -s "$lp_fixture/systemctl.log" ]; then if [ ! -s "$lp_fixture/systemctl.log" ]; then
pass "check 10: never acts — no unit was restarted across any of the eight cases" pass "check 10: never acts — no unit was restarted across any of the four cases"
else else
fail "check 10 ACTED, which it must never do: $(cat "$lp_fixture/systemctl.log")" fail "check 10 ACTED, which it must never do: $(cat "$lp_fixture/systemctl.log")"
fi fi

View file

@ -1,56 +0,0 @@
# infra/units/flyedge.service — pushed to /etc/systemd/system/flyedge.service.
#
# The feed WebSocket served from flysim's feed bus (docs/design/flybus.md,
# "Feed over the bus"; services/flysim/crates/fly-edge). DISABLED BY DEFAULT:
# it is in no target's Wants=/Requires= and 07-enable.sh does not enable it.
# With FLY_FEED_VIA=direct (the default, written into /etc/fly/fly.env by
# 05-deploy.sh) flysim binds 127.0.0.1:7400 itself and this unit has nothing
# to do. To move the feed onto the bus on one container:
#
# 1. FLY_FEED_VIA=bus in the env file, then 05-deploy.sh (rewrites fly.env);
# 2. systemctl enable --now flyedge.service; systemctl restart flysim.service
# (flysim stops binding :7400, the edge binds it once the first snapshot
# is on the bus);
# 3. nothing for the watchdog: check 2 reads FLY_FEED_VIA from fly.env and
# follows the feed counters to this unit's loopback /metrics.
#
# Back: FLY_FEED_VIA=direct, deploy, systemctl disable --now flyedge.service,
# restart flysim.
#
# Ordering (docs/design/flybus.md, amendment "Feed store lifecycle"): flysim
# owns the router and its store under /run/fly/bus, so it starts first and
# the edge follows it. Requires= makes an explicit stop or restart of flysim
# (the unstick rule's `systemctl restart flysim.service` included) stop or
# restart the edge with it. A crash-restart of flysim is covered by the edge
# itself: it drops its clients, unbinds :7400 and reconnects every 500 ms,
# so nothing here has to be restarted by hand. The edge holds no state; the
# store is flysim's and a new router removes the previous one's directory.
[Unit]
Description=flyedge: the feed WebSocket served from flysim's feed bus
After=flysim.service
Requires=flysim.service
# A release that predates fly-edge has no binary; stay cleanly inactive
# rather than restart-looping (the flybridge.service header explains why a
# Condition, not a start limit).
ConditionPathExists=/opt/fly/current/fly-edge
[Service]
Type=simple
User=fly
# FLY_FEED_VIA, FLY_BUS_DIR and the rest of flysim's configuration: the edge
# reads the same file so the two cannot disagree about the port or the bus.
EnvironmentFile=/etc/fly/fly.env
Environment=FLY_FEED_BIND=127.0.0.1:7400
Environment=FLY_BUS_DIR=/run/fly/bus
# Its own read-only /metrics and /healthz, for watchdog check 2 in bus mode.
# Loopback only: nothing off the container needs the edge's counters.
Environment=FLY_EDGE_METRICS_ADDR=127.0.0.1:9102
ExecStart=/opt/fly/current/fly-edge
Restart=always
RestartSec=2
# A few snapshots in flight and a WebSocket per client; the store itself is
# flysim's (tmpfs, bounded by feedbus::limits at 32 MiB).
MemoryMax=256M
[Install]
WantedBy=fly.target

View file

@ -30,10 +30,6 @@ WatchdogSec=30
User=fly User=fly
EnvironmentFile=/etc/fly/fly.env EnvironmentFile=/etc/fly/fly.env
Environment=FLY_FEED_BIND=127.0.0.1:7400 Environment=FLY_FEED_BIND=127.0.0.1:7400
# Used only with FLY_FEED_VIA=bus (fly.env; default direct): the embedded
# feed router's socket and artifact store, on tmpfs. flyedge.service names
# the same directory. docs/design/flybus.md, "Feed over the bus".
Environment=FLY_BUS_DIR=/run/fly/bus
Environment=FLY_CONTROL_BIND=127.0.0.1:7401 Environment=FLY_CONTROL_BIND=127.0.0.1:7401
Environment=FLY_METRICS_ADDR=0.0.0.0:9101 Environment=FLY_METRICS_ADDR=0.0.0.0:9101
Environment=FLY_STATE_HOT=/run/fly/state Environment=FLY_STATE_HOT=/run/fly/state

19
package-lock.json generated
View file

@ -14,7 +14,6 @@
"apps/stage": { "apps/stage": {
"name": "@flybrain/stage", "name": "@flybrain/stage",
"version": "0.1.1", "version": "0.1.1",
"license": "Apache-2.0",
"dependencies": { "dependencies": {
"@flybrain/brain": "*", "@flybrain/brain": "*",
"@flybrain/feed": "*", "@flybrain/feed": "*",
@ -893,10 +892,6 @@
"resolved": "packages/feed", "resolved": "packages/feed",
"link": true "link": true
}, },
"node_modules/@flybrain/session-types": {
"resolved": "packages/session-types",
"link": true
},
"node_modules/@flybrain/stage": { "node_modules/@flybrain/stage": {
"resolved": "apps/stage", "resolved": "apps/stage",
"link": true "link": true
@ -3671,7 +3666,6 @@
"packages/brain": { "packages/brain": {
"name": "@flybrain/brain", "name": "@flybrain/brain",
"version": "0.1.1", "version": "0.1.1",
"license": "Apache-2.0",
"devDependencies": { "devDependencies": {
"@types/node": "22.17.0", "@types/node": "22.17.0",
"@types/three": "0.178.1", "@types/three": "0.178.1",
@ -3691,7 +3685,6 @@
"packages/feed": { "packages/feed": {
"name": "@flybrain/feed", "name": "@flybrain/feed",
"version": "0.1.0", "version": "0.1.0",
"license": "Apache-2.0",
"dependencies": { "dependencies": {
"ws": "8.21.3" "ws": "8.21.3"
}, },
@ -3703,21 +3696,9 @@
"typescript": "5.9.2" "typescript": "5.9.2"
} }
}, },
"packages/session-types": {
"name": "@flybrain/session-types",
"version": "0.1.0",
"license": "Apache-2.0",
"devDependencies": {
"@flybrain/brain": "*",
"@types/node": "22.17.0",
"tsx": "4.20.3",
"typescript": "5.9.2"
}
},
"services/bridge": { "services/bridge": {
"name": "@flybrain/bridge", "name": "@flybrain/bridge",
"version": "0.1.0", "version": "0.1.0",
"license": "Apache-2.0",
"dependencies": { "dependencies": {
"@flybrain/feed": "0.1.0", "@flybrain/feed": "0.1.0",
"@twurple/api": "8.1.4", "@twurple/api": "8.1.4",

View file

@ -1,78 +0,0 @@
# @flybrain/session-types
The session framework contracts in TypeScript: types, validation, canonical JSON (RFC 8785)
and canonical digests.
The other half of [`services/flysim/crates/fly-session-types`](../../services/flysim/crates/fly-session-types).
Same rules, same canonical bytes, same digests, and the same fixture corpus: this package
loads the crate's `fixtures/` directory rather than keeping a copy, so a case written once
holds both languages to it. Nothing here opens a socket; it reads, validates and hashes.
This is the internal session path (`docs/design/session-framework/`). The public feed and
control contracts are unchanged and still live in [`@flybrain/feed`](../feed).
## Modules
| Module | Contents |
| --- | --- |
| `canonical` | `canonicalize`, `digestOf`, `parseStrict`, `requireEnvelopeFit`, `rejectBusIdentities` |
| `scalar` | `Id`, `U64`, `Digest`, `Scope`, `RationalNs` with checked arithmetic, and the four identities as branded types |
| `reader` | `Reader`, which reads one object field by field and then refuses any field it did not read |
| `common` | `readScope`, `readSchemaRef`, `readTypedValue`, `operationKeyDigest`, `bodyDigest` |
| `media` | View and audio descriptors and refs, and the `State.*` payloads |
| `workers` | The closed enums and every Agent/Environment/Worker method payload |
| `rpc` | `SessionRpcRequest`, the success and failure replies, `ErrorCode`, `MutationCertainty` |
| `publishing` | `SessionDescriptor`, `CommittedSnapshot` |
| `trace` | The step-v1 section 8 record and the behaviour-only comparator |
| `seed` | `seed-derivation-v1` |
| `checkpoint` | The `FLYSESS1` envelope layout |
| `extensions` | `Environment.SaveSlot`/`RestoreSlot` and `Agent.Rollback` payloads (2026-09-23) |
| `gameboy` | The legacy Game Boy composition: registered schemas, profile, composition declaration |
| `fixtures` | Loading the shared corpus |
## Reading a payload
Every reader takes `unknown`, validates, and hands back a value whose fields are exactly the
ones it read. A payload with an unknown or misspelled field fails instead of silently
defaulting, and a round trip through a reader is the test that no field is dropped.
```ts
import { canonicalize, digestOf, readScope, readPrepareParams, bodyDigest } from '@flybrain/session-types';
const scope = readScope(payload.scope);
const params = readPrepareParams(payload.params);
const digest = bodyDigest('Agent.Prepare', scope, params); // the ipc-v1 section 5 comparison
```
Rules that need another value in hand are separate functions, because a payload cannot check
them alone: `validatePortControlAgainst`, `validateBatch`, `validateSensoryInputAgainst`,
`validateObservationAgainst`, `validateStepResultAgainst`, `validateSnapshotAgainst`,
`validateTelemetryRoles`, `validateRemainder`, `validateCommitAgainstScope`.
## Canonical JSON
Three rules make the two implementations agree byte for byte:
- object keys sort by UTF-16 code unit, which is what comparing JavaScript strings does;
- numbers print with `String(number)`, the ECMAScript algorithm RFC 8785 requires;
- a number is canonicalizable when it is finite and, if integral, no larger in magnitude than
`Number.MAX_SAFE_INTEGER`. Larger integers are refused rather than rounded: every counter
and clock in these contracts is a `U64` decimal string. The rule is on the value, not on how
it was written, because `JSON.parse` cannot tell `1e21` from the same digits written out.
`parseStrict` is a small recursive-descent parser rather than a wrapper around `JSON.parse`,
which keeps the last of two duplicate keys instead of failing.
Digests use `node:crypto`. This package is contract tooling for services and tests, not
browser code; the presentation layer consumes the public feed package instead.
## Tests
```sh
npm test --workspace @flybrain/session-types
npm run typecheck --workspace @flybrain/session-types
```
Nine files, all fixture-driven. The one that says the most about the two implementations is in
`tests/checkpoint.test.ts`: a `FLYSESS1` envelope written here is byte-identical to the one the
Rust crate wrote into the fixture.

View file

@ -1,23 +0,0 @@
{
"name": "@flybrain/session-types",
"version": "0.1.0",
"description": "TypeScript types, validation, canonical JSON (RFC 8785) and canonical digests for the session framework contracts, sharing the fixture corpus of services/flysim/crates/fly-session-types.",
"license": "Apache-2.0",
"private": true,
"type": "module",
"main": "./src/index.ts",
"types": "./src/index.ts",
"exports": {
".": "./src/index.ts"
},
"scripts": {
"test": "node --import tsx --test tests/**/*.test.ts",
"typecheck": "tsc -p tsconfig.json --pretty false"
},
"devDependencies": {
"@flybrain/brain": "*",
"@types/node": "22.17.0",
"tsx": "4.20.3",
"typescript": "5.9.2"
}
}

View file

@ -1,381 +0,0 @@
/**
* Canonical JSON (RFC 8785), strict parsing and canonical digests.
*
* The Rust crate `services/flysim/crates/fly-session-types` is the other half of this
* contract; `fixtures/valid.json` records the canonical bytes and digest of every accepted
* payload, and both languages assert against it.
*
* Three rules make the two agree:
*
* - object keys sort by UTF-16 code unit, which is what comparing JavaScript strings does;
* - numbers print with `String(number)`, the ECMAScript algorithm RFC 8785 requires;
* - a number is canonicalizable when it is finite and, if integral, no larger in magnitude
* than `Number.MAX_SAFE_INTEGER`. Larger integers are refused rather than rounded: every
* counter and clock in these contracts is a `U64` decimal string. The rule is on the value,
* not on how it was written, because `JSON.parse` cannot tell `1e21` from the same digits
* written out.
*/
import { createHash } from 'node:crypto';
/** The largest JSON envelope, in bytes (bus-v1 section 4). */
export const MAX_ENVELOPE_BYTES = 65_536;
/** Thrown by everything in this package. One error type, like the bus's `WireError`. */
export class ContractError extends Error {
constructor(message: string) {
super(message);
this.name = 'ContractError';
}
}
export function fail(message: string): never {
throw new ContractError(message);
}
/** A JSON value, as strictly parsed. */
export type Json = null | boolean | number | string | Json[] | { [key: string]: Json };
/** `String(number)` for a canonicalizable number. */
function numberToString(value: number): string {
if (!Number.isFinite(value)) {
fail(`canonical JSON: ${String(value)} is not a finite number`);
}
if (Number.isInteger(value) && Math.abs(value) > Number.MAX_SAFE_INTEGER) {
fail(`canonical JSON: ${String(value)} is an integral value outside the exact double range`);
}
// String(-0) is already "0".
return String(value);
}
function writeString(out: string[], value: string): void {
out.push('"');
for (const character of value) {
switch (character) {
case '"':
out.push('\\"');
break;
case '\\':
out.push('\\\\');
break;
case '\b':
out.push('\\b');
break;
case '\t':
out.push('\\t');
break;
case '\n':
out.push('\\n');
break;
case '\f':
out.push('\\f');
break;
case '\r':
out.push('\\r');
break;
default: {
const point = character.codePointAt(0) ?? 0;
if (point < 0x20) {
out.push(`\\u${point.toString(16).padStart(4, '0')}`);
} else {
out.push(character);
}
}
}
}
out.push('"');
}
function write(out: string[], value: unknown): void {
if (value === null) {
out.push('null');
return;
}
switch (typeof value) {
case 'boolean':
out.push(value ? 'true' : 'false');
return;
case 'number':
out.push(numberToString(value));
return;
case 'string':
writeString(out, value);
return;
case 'object':
break;
default:
fail(`canonical JSON: ${typeof value} is not a JSON value`);
}
if (Array.isArray(value)) {
out.push('[');
value.forEach((item, index) => {
if (index > 0) out.push(',');
write(out, item);
});
out.push(']');
return;
}
const entries = Object.entries(value as Record<string, unknown>);
for (const [key, item] of entries) {
if (item === undefined) fail(`canonical JSON: ${key} is undefined, which is not a JSON value`);
}
// Comparing JavaScript strings compares UTF-16 code units, which is the order RFC 8785
// section 3.2.3 specifies.
entries.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0));
out.push('{');
entries.forEach(([key, item], index) => {
if (index > 0) out.push(',');
writeString(out, key);
out.push(':');
write(out, item);
});
out.push('}');
}
/** The canonical JSON text of `value`. */
export function canonicalize(value: unknown): string {
const out: string[] = [];
write(out, value);
return out.join('');
}
/** Lowercase hex SHA-256 of `bytes`. */
export function sha256Hex(bytes: Uint8Array | string): string {
return createHash('sha256')
.update(typeof bytes === 'string' ? Buffer.from(bytes, 'utf8') : bytes)
.digest('hex');
}
/** The canonical digest of a JSON value: SHA-256 over its canonical JSON bytes. */
export function digestOf(value: unknown): string {
return sha256Hex(canonicalize(value));
}
/**
* Parses JSON strictly: duplicate keys at any depth, invalid UTF-8, `NaN`/`Infinity`,
* trailing bytes and control characters inside strings are all refused.
*
* `JSON.parse` keeps the last of two duplicate keys instead of failing, so this is a small
* recursive-descent parser rather than a wrapper around it.
*/
export function parseStrict(input: Uint8Array | string): Json {
let text: string;
if (typeof input === 'string') {
text = input;
} else {
try {
text = new TextDecoder('utf-8', { fatal: true }).decode(input);
} catch {
return fail('invalid UTF-8');
}
}
const parser = new Parser(text);
const value = parser.value();
parser.skipWhitespace();
if (!parser.atEnd()) fail('invalid JSON: trailing data');
return value;
}
class Parser {
private index = 0;
constructor(private readonly text: string) {}
atEnd(): boolean {
return this.index >= this.text.length;
}
skipWhitespace(): void {
while (this.index < this.text.length && ' \t\n\r'.includes(this.text[this.index] as string)) {
this.index += 1;
}
}
value(): Json {
this.skipWhitespace();
const character = this.text[this.index];
if (character === undefined) fail('invalid JSON: unexpected end of input');
switch (character) {
case '{':
return this.object();
case '[':
return this.array();
case '"':
return this.string();
case 't':
this.literal('true');
return true;
case 'f':
this.literal('false');
return false;
case 'n':
this.literal('null');
return null;
default:
return this.number();
}
}
private literal(word: string): void {
if (!this.text.startsWith(word, this.index)) fail(`invalid JSON: expected ${word}`);
this.index += word.length;
}
private object(): Json {
this.index += 1;
const out: { [key: string]: Json } = {};
this.skipWhitespace();
if (this.text[this.index] === '}') {
this.index += 1;
return out;
}
for (;;) {
this.skipWhitespace();
if (this.text[this.index] !== '"') fail('invalid JSON: expected a key');
const key = this.string();
if (Object.prototype.hasOwnProperty.call(out, key)) {
fail(`invalid JSON: duplicate key ${JSON.stringify(key)}`);
}
this.skipWhitespace();
if (this.text[this.index] !== ':') fail('invalid JSON: expected :');
this.index += 1;
out[key] = this.value();
this.skipWhitespace();
const next = this.text[this.index];
if (next === ',') {
this.index += 1;
continue;
}
if (next === '}') {
this.index += 1;
return out;
}
fail('invalid JSON: expected , or }');
}
}
private array(): Json {
this.index += 1;
const out: Json[] = [];
this.skipWhitespace();
if (this.text[this.index] === ']') {
this.index += 1;
return out;
}
for (;;) {
out.push(this.value());
this.skipWhitespace();
const next = this.text[this.index];
if (next === ',') {
this.index += 1;
continue;
}
if (next === ']') {
this.index += 1;
return out;
}
fail('invalid JSON: expected , or ]');
}
}
private string(): string {
this.index += 1;
let out = '';
for (;;) {
const character = this.text[this.index];
if (character === undefined) fail('invalid JSON: unterminated string');
this.index += 1;
if (character === '"') return out;
if (character === '\\') {
const escape = this.text[this.index];
this.index += 1;
switch (escape) {
case '"':
case '\\':
case '/':
out += escape;
break;
case 'b':
out += '\b';
break;
case 'f':
out += '\f';
break;
case 'n':
out += '\n';
break;
case 'r':
out += '\r';
break;
case 't':
out += '\t';
break;
case 'u': {
const hex = this.text.slice(this.index, this.index + 4);
if (!/^[0-9a-fA-F]{4}$/.test(hex)) fail('invalid JSON: bad \\u escape');
out += String.fromCharCode(Number.parseInt(hex, 16));
this.index += 4;
break;
}
default:
fail('invalid JSON: bad escape');
}
continue;
}
if (character.charCodeAt(0) < 0x20) fail('invalid JSON: control character in a string');
out += character;
}
}
private number(): number {
const match = /^-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][-+]?[0-9]+)?/.exec(
this.text.slice(this.index),
);
if (!match) fail('invalid JSON: expected a value');
this.index += match[0].length;
const value = Number(match[0]);
if (!Number.isFinite(value)) fail('invalid JSON: non-finite number');
return value;
}
}
/**
* Refuses a domain payload that does not fit the bus envelope ceiling. `envelopeOverhead` is
* what the surrounding envelope adds, so a payload that only fits without its envelope fails.
*/
export function requireEnvelopeFit(value: unknown, envelopeOverhead: number): number {
const total = canonicalize(value).length + envelopeOverhead;
if (total > MAX_ENVELOPE_BYTES) {
fail(`envelope: ${total} bytes exceeds the ${MAX_ENVELOPE_BYTES}-byte maximum`);
}
return total;
}
/** Keys that belong to the bus and never to a domain body (ipc-v1 section 5). */
export const BUS_ONLY_KEYS = [
'callId',
'deliveryId',
'ownerId',
'ownerIds',
'deliveryIds',
'requestDeliveryId',
'expectedIncarnation',
'serviceIncarnation',
'connectionId',
'topicSequence',
'subscriptionId',
] as const;
/** Fails if any bus-only key appears anywhere in `value`. */
export function rejectBusIdentities(value: unknown): void {
if (Array.isArray(value)) {
for (const item of value) rejectBusIdentities(item);
return;
}
if (value === null || typeof value !== 'object') return;
for (const [key, item] of Object.entries(value as Record<string, unknown>)) {
if ((BUS_ONLY_KEYS as readonly string[]).includes(key)) {
fail(`canonical body: "${key}" is a bus identity and never part of a domain body`);
}
rejectBusIdentities(item);
}
}

View file

@ -1,280 +0,0 @@
/**
* `FLYSESS1`: the envelope layout of
* `docs/design/session-framework/checkpoint-envelope-v1.md`.
*
* The layout half of the specification, not the store: writing generations, fsyncing and
* committing a manifest belong to the STATE-01 slice. `FLYSIM01` is a different format with a
* different magic and is not touched by any of this.
*/
import { createHash } from 'node:crypto';
import { type Json, canonicalize, fail, parseStrict } from './canonical';
import { requireUnique } from './reader';
import { isId } from './scalar';
export const MAGIC = 'FLYSESS1';
export const FOOTER_MAGIC = 'FLYSESSF';
export const VERSION = 1;
export const HEADER_BYTES = 32;
export const TABLE_ENTRY_BYTES = 112;
export const NAME_BYTES = 64;
export const FOOTER_BYTES = 48;
export const ALIGNMENT = 8;
export const MAX_PAYLOADS = 64;
export interface PayloadEntry {
name: string;
offset: number;
byteLength: number;
digest: string;
}
export interface Layout {
manifestOffset: number;
manifestBytes: number;
tableOffset: number;
entries: PayloadEntry[];
footerOffset: number;
totalBytes: number;
}
export interface Envelope {
manifest: Json;
payloads: { name: string; bytes: Uint8Array }[];
layout: Layout;
}
function alignUp(value: number): number {
return Math.ceil(value / ALIGNMENT) * ALIGNMENT;
}
function sha256(bytes: Uint8Array): string {
return createHash('sha256').update(bytes).digest('hex');
}
export function layoutOf(
manifest: unknown,
payloads: readonly { name: string; bytes: Uint8Array }[],
): Layout {
if (payloads.length > MAX_PAYLOADS) fail('checkpoint envelope: at most 64 payloads');
requireUnique(
payloads.map((payload) => payload.name),
'checkpoint envelope: payload names',
);
for (const payload of payloads) {
if (!isId(payload.name)) {
fail(`checkpoint envelope: payload name "${payload.name}" is not an Id`);
}
}
const manifestBytes = new TextEncoder().encode(canonicalize(manifest)).length;
const manifestOffset = HEADER_BYTES;
const tableOffset = alignUp(manifestOffset + manifestBytes);
let offset = alignUp(tableOffset + payloads.length * TABLE_ENTRY_BYTES);
const entries: PayloadEntry[] = [];
for (const payload of payloads) {
entries.push({
name: payload.name,
offset,
byteLength: payload.bytes.length,
digest: sha256(payload.bytes),
});
offset = alignUp(offset + payload.bytes.length);
}
return {
manifestOffset,
manifestBytes,
tableOffset,
entries,
footerOffset: offset,
totalBytes: offset + FOOTER_BYTES,
};
}
export function encode(
manifest: unknown,
payloads: readonly { name: string; bytes: Uint8Array }[],
): Uint8Array {
const layout = layoutOf(manifest, payloads);
const manifestText = new TextEncoder().encode(canonicalize(manifest));
const out = Buffer.alloc(layout.footerOffset);
out.write(MAGIC, 0, 'ascii');
out.writeUInt32LE(VERSION, 8);
out.writeUInt32LE(HEADER_BYTES, 12);
out.writeUInt32LE(layout.manifestBytes, 16);
out.writeUInt32LE(payloads.length, 20);
out.writeUInt32LE(layout.tableOffset, 24);
out.writeUInt32LE(0, 28);
Buffer.from(manifestText).copy(out, layout.manifestOffset);
layout.entries.forEach((entry, index) => {
const base = layout.tableOffset + index * TABLE_ENTRY_BYTES;
out.write(entry.name, base, 'ascii');
out.writeBigUInt64LE(BigInt(entry.offset), base + NAME_BYTES);
out.writeBigUInt64LE(BigInt(entry.byteLength), base + NAME_BYTES + 8);
Buffer.from(entry.digest, 'hex').copy(out, base + NAME_BYTES + 16);
});
layout.entries.forEach((entry, index) => {
Buffer.from((payloads[index] as { bytes: Uint8Array }).bytes).copy(out, entry.offset);
});
const footer = Buffer.alloc(FOOTER_BYTES);
footer.writeBigUInt64LE(BigInt(layout.totalBytes), 0);
Buffer.from(sha256(out), 'hex').copy(footer, 8);
footer.write(FOOTER_MAGIC, 40, 'ascii');
return Buffer.concat([out, footer]);
}
/** Reads and fully validates one envelope. */
export function decode(input: Uint8Array): Envelope {
const bytes = Buffer.from(input);
if (bytes.length < HEADER_BYTES + FOOTER_BYTES) {
fail('checkpoint envelope: shorter than a header plus a footer');
}
if (bytes.subarray(0, 8).toString('ascii') !== MAGIC) {
fail('checkpoint envelope: wrong magic (FLYSIM01 is a different format)');
}
if (bytes.readUInt32LE(8) !== VERSION) fail('checkpoint envelope: unsupported version');
if (bytes.readUInt32LE(12) !== HEADER_BYTES) {
fail('checkpoint envelope: headerBytes must be 32');
}
if (bytes.readUInt32LE(28) !== 0) {
fail('checkpoint envelope: reserved header word must be zero');
}
const manifestBytes = bytes.readUInt32LE(16);
const payloadCount = bytes.readUInt32LE(20);
const tableOffset = bytes.readUInt32LE(24);
if (payloadCount > MAX_PAYLOADS) fail('checkpoint envelope: at most 64 payloads');
const footerOffset = bytes.length - FOOTER_BYTES;
if (bytes.subarray(footerOffset + 40).toString('ascii') !== FOOTER_MAGIC) {
fail('checkpoint envelope: missing footer magic');
}
if (bytes.readBigUInt64LE(footerOffset) !== BigInt(bytes.length)) {
fail('checkpoint envelope: footer length does not match the file');
}
const recorded = bytes.subarray(footerOffset + 8, footerOffset + 40).toString('hex');
if (recorded !== sha256(bytes.subarray(0, footerOffset))) {
fail('checkpoint envelope: footer digest does not match the contents');
}
const manifestEnd = HEADER_BYTES + manifestBytes;
if (manifestEnd > footerOffset) {
fail('checkpoint envelope: manifest runs past the payload area');
}
const manifestSlice = bytes.subarray(HEADER_BYTES, manifestEnd);
const manifest = parseStrict(manifestSlice);
if (canonicalize(manifest) !== manifestSlice.toString('utf8')) {
fail('checkpoint envelope: the manifest is not canonical JSON');
}
if (tableOffset !== alignUp(manifestEnd)) {
fail('checkpoint envelope: the payload table is not at its laid-out offset');
}
const tableEnd = tableOffset + payloadCount * TABLE_ENTRY_BYTES;
if (tableEnd > footerOffset) {
fail('checkpoint envelope: the payload table runs past the payload area');
}
const entries: PayloadEntry[] = [];
const payloads: { name: string; bytes: Uint8Array }[] = [];
let previousEnd = alignUp(tableEnd);
for (let index = 0; index < payloadCount; index += 1) {
const base = tableOffset + index * TABLE_ENTRY_BYTES;
const nameField = bytes.subarray(base, base + NAME_BYTES);
const terminator = nameField.indexOf(0);
const length = terminator === -1 ? NAME_BYTES : terminator;
if (nameField.subarray(length).some((byte) => byte !== 0)) {
fail('checkpoint envelope: a payload name has bytes after its terminator');
}
const name = nameField.subarray(0, length).toString('utf8');
if (!isId(name)) fail(`checkpoint envelope: payload name "${name}" is not an Id`);
const offset = Number(bytes.readBigUInt64LE(base + NAME_BYTES));
const byteLength = Number(bytes.readBigUInt64LE(base + NAME_BYTES + 8));
const digest = bytes.subarray(base + NAME_BYTES + 16, base + NAME_BYTES + 48).toString('hex');
if (offset !== previousEnd) {
fail(
`checkpoint envelope: payload "${name}" starts at ${offset}, not at its aligned ${previousEnd}`,
);
}
const end = offset + byteLength;
if (end > footerOffset) {
fail(`checkpoint envelope: payload "${name}" runs past the payload area`);
}
const payload = bytes.subarray(offset, end);
if (sha256(payload) !== digest) {
fail(`checkpoint envelope: payload "${name}" fails its digest`);
}
previousEnd = alignUp(end);
entries.push({ name, offset, byteLength, digest });
payloads.push({ name, bytes: Uint8Array.from(payload) });
}
requireUnique(
entries.map((entry) => entry.name),
'checkpoint envelope: payload names',
);
if (previousEnd !== footerOffset) {
fail('checkpoint envelope: padding between the last payload and the footer');
}
return {
manifest,
payloads,
layout: {
manifestOffset: HEADER_BYTES,
manifestBytes,
tableOffset,
entries,
footerOffset,
totalBytes: bytes.length,
},
};
}
/**
* The manifest fields state-media-v1 section 4 requires.
*
* `helperState` and `environment` join the list under the 2026-09-22 amendment to
* checkpoint-envelope-v1 section 3.
*/
export const REQUIRED_MANIFEST_FIELDS = [
'envelopeVersion',
'checkpointId',
'sourceScope',
'episodeId',
'worldTime',
'schedulerId',
'compositionDigest',
'portMap',
'compatibility',
'agents',
'coordinator',
'environment',
'helperState',
'payloads',
] as const;
/** Checks the required field set and that the manifest's payload table mirrors the envelope's. */
export function validateManifest(envelope: Envelope): void {
const manifest = envelope.manifest;
if (manifest === null || typeof manifest !== 'object' || Array.isArray(manifest)) {
fail('checkpoint manifest: must be an object');
}
const map = manifest as Record<string, Json>;
for (const field of REQUIRED_MANIFEST_FIELDS) {
if (!Object.prototype.hasOwnProperty.call(map, field)) {
fail(`checkpoint manifest: missing "${field}"`);
}
}
if (map.envelopeVersion !== VERSION) fail('checkpoint manifest: envelopeVersion must be 1');
const listed = map.payloads;
if (!Array.isArray(listed)) fail('checkpoint manifest: payloads must be an array');
if (listed.length !== envelope.layout.entries.length) {
fail('checkpoint manifest: payloads does not match the payload table');
}
listed.forEach((declared, index) => {
const entry = envelope.layout.entries[index] as PayloadEntry;
const record = declared as Record<string, Json>;
if (record.name !== entry.name) {
fail('checkpoint manifest: payload name does not match the table');
}
if (record.byteLength !== String(entry.byteLength)) {
fail(`checkpoint manifest: payload "${entry.name}" byteLength does not match the table`);
}
if (record.digest !== entry.digest) {
fail(`checkpoint manifest: payload "${entry.name}" digest does not match the table`);
}
});
}

View file

@ -1,114 +0,0 @@
/**
* `Scope`, `SchemaRef`, `TypedValue`, the operation key and the canonical body
* (ipc-v1 sections 2 and 5).
*/
import { canonicalize, digestOf, fail, rejectBusIdentities } from './canonical';
import { Reader } from './reader';
import {
MAX_TYPED_VALUE_BYTES,
type RationalNs,
type Scope,
type SchemaRef,
type TypedValue,
isId,
isMethod,
validateRational,
} from './scalar';
export function readScope(value: unknown): Scope {
const reader = new Reader(value, 'Scope');
const scope: Scope = {
sessionId: reader.id('sessionId'),
epoch: reader.id('epoch'),
step: reader.u64('step'),
};
reader.finish();
return scope;
}
export function readNullableScope(value: unknown): Scope | null {
return value === null ? null : readScope(value);
}
export function readSchemaRef(value: unknown): SchemaRef {
const reader = new Reader(value, 'SchemaRef');
const schema: SchemaRef = {
id: reader.id('id'),
version: reader.int('version', 1, 65_535),
digest: reader.digest('digest'),
};
reader.finish();
return schema;
}
export function readTypedValue(value: unknown): TypedValue {
const reader = new Reader(value, 'TypedValue');
const typed: TypedValue = {
schema: readSchemaRef(reader.value('schema')),
value: reader.object('value'),
};
reader.finish();
const length = canonicalize(typed).length;
if (length > MAX_TYPED_VALUE_BYTES) {
fail(
`TypedValue: ${length} bytes of canonical JSON exceeds the ${MAX_TYPED_VALUE_BYTES}-byte limit`,
);
}
return typed;
}
export function readNullableTypedValue(value: unknown): TypedValue | null {
return value === null ? null : readTypedValue(value);
}
export { validateRational };
/** `(sessionId, epoch, step, method, workerId)`: the operation key of a step mutation. */
export interface OperationKey {
scope: Scope;
method: string;
workerId: string;
}
export function operationKeyJson(key: OperationKey): Record<string, unknown> {
if (!isMethod(key.method)) {
fail('OperationKey: method must be 1..=128 printable ASCII characters');
}
if (!isId(key.workerId)) fail('OperationKey: workerId is not a valid id');
return { scope: key.scope, method: key.method, workerId: key.workerId };
}
export function operationKeyDigest(key: OperationKey): string {
return digestOf(operationKeyJson(key));
}
/** The canonical body of a domain operation: method, scope and validated params. */
export function canonicalBody(
method: string,
scope: Scope | null,
params: unknown,
): Record<string, unknown> {
if (!isMethod(method)) {
fail('canonical body: method must be 1..=128 printable ASCII characters');
}
if (params === null || typeof params !== 'object' || Array.isArray(params)) {
fail('canonical body: params must be an object');
}
rejectBusIdentities(params);
return { method, scope, params };
}
export function bodyDigest(method: string, scope: Scope | null, params: unknown): string {
return digestOf(canonicalBody(method, scope, params));
}
export function readRational(value: unknown): RationalNs {
const reader = new Reader(value, 'RationalNs');
const rational: RationalNs = {
numerator: reader.u64('numerator'),
denominator: reader.u64('denominator'),
};
reader.finish();
validateRational(rational);
return rational;
}

View file

@ -1,184 +0,0 @@
/**
* The extension methods of the 2026-09-23 amendments (RT-01a, workers-v1 section 7):
* `Environment.SaveSlot`, `Environment.RestoreSlot` and `Agent.Rollback`.
*
* The Rust twin is `fly-session-types/src/extensions.rs`. The shapes are generic; which
* composition may use them is a capability negotiated by `Worker.Hello`. Nothing console
* specific is in these payloads: the Game Boy lives in the registered schemas of `gameboy`.
*/
import { fail } from './canonical';
import { readTypedValue } from './common';
import { Reader, u64 } from './reader';
import type { Digest, Id, Scope, TypedValue, U64 } from './scalar';
import {
type AgentTelemetry,
type SensoryInput,
type WorldObservation,
readAgentTelemetry,
readSensoryInput,
readWorldObservation,
} from './workers';
export const SLOTS_CAPABILITY = 'gameboy-slots-v1';
export const ROLLBACK_CAPABILITY = 'legacy-ratchet-rollback-v1';
export const ROLLBACK_POLICY = 'legacy-ratchet-rollback-v1';
export const METHOD_SAVE_SLOT = 'Environment.SaveSlot';
export const METHOD_RESTORE_SLOT = 'Environment.RestoreSlot';
export const METHOD_AGENT_ROLLBACK = 'Agent.Rollback';
/** Slots one environment may hold. Not a stated bound; recorded in the schema set. */
export const MAX_SLOTS = 4;
function requirePolicy(policy: string, what: string): void {
if (policy !== ROLLBACK_POLICY) {
fail(`${what}: policy must be ${ROLLBACK_POLICY}, the only rollback policy defined`);
}
}
export interface SaveSlotParams {
slotId: Id;
}
export function readSaveSlotParams(value: unknown): SaveSlotParams {
const reader = new Reader(value, 'SaveSlotParams');
const params: SaveSlotParams = { slotId: reader.id('slotId') };
reader.finish();
return params;
}
export interface SaveSlotResult {
slotId: Id;
boundary: U64;
stateDigest: Digest;
byteLength: U64;
}
export function readSaveSlotResult(value: unknown): SaveSlotResult {
const reader = new Reader(value, 'SaveSlotResult');
const result: SaveSlotResult = {
slotId: reader.id('slotId'),
boundary: reader.u64('boundary'),
stateDigest: reader.digest('stateDigest'),
byteLength: reader.u64('byteLength'),
};
reader.finish();
if (u64(result.byteLength) === 0n) fail('SaveSlotResult: byteLength must be positive');
return result;
}
/** The slot records the committed boundary the call was scoped to. */
export function validateSaveSlotAgainstScope(result: SaveSlotResult, scope: Scope): void {
if (result.boundary !== scope.step) {
fail(`SaveSlotResult: boundary ${result.boundary} must be the scoped committed step ${scope.step}`);
}
}
export interface RestoreSlotParams {
slotId: Id;
priorEpoch: Id;
policy: Id;
}
export function readRestoreSlotParams(value: unknown): RestoreSlotParams {
const reader = new Reader(value, 'RestoreSlotParams');
const params: RestoreSlotParams = {
slotId: reader.id('slotId'),
priorEpoch: reader.id('priorEpoch'),
policy: reader.id('policy'),
};
reader.finish();
requirePolicy(params.policy, 'RestoreSlotParams');
return params;
}
/** A rollback always moves to a new epoch. */
export function validateRestoreSlotAgainstScope(params: RestoreSlotParams, scope: Scope): void {
if (params.priorEpoch === scope.epoch) {
fail('RestoreSlotParams: priorEpoch must differ from the scoped (new) epoch');
}
}
export interface RestoreSlotResult {
slotId: Id;
committedStep: U64;
observation: WorldObservation;
}
export function readRestoreSlotResult(value: unknown): RestoreSlotResult {
const reader = new Reader(value, 'RestoreSlotResult');
const result: RestoreSlotResult = {
slotId: reader.id('slotId'),
committedStep: reader.u64('committedStep'),
observation: readWorldObservation(reader.value('observation')),
};
reader.finish();
if (result.observation.boundary !== result.committedStep) {
fail('RestoreSlotResult: the observation boundary must be the committed step');
}
if (result.observation.audio.length !== 0) {
fail('RestoreSlotResult: a restored slot ran no transition and carries no audio chunk');
}
return result;
}
export interface AgentRollbackParams {
agentId: Id;
priorEpoch: Id;
policy: Id;
input: SensoryInput;
decisionContext: TypedValue;
}
export function readAgentRollbackParams(value: unknown): AgentRollbackParams {
const reader = new Reader(value, 'AgentRollbackParams');
const params: AgentRollbackParams = {
agentId: reader.id('agentId'),
priorEpoch: reader.id('priorEpoch'),
policy: reader.id('policy'),
input: readSensoryInput(reader.value('input')),
decisionContext: readTypedValue(reader.value('decisionContext')),
};
reader.finish();
requirePolicy(params.policy, 'AgentRollbackParams');
return params;
}
/** A new epoch, and the installed input is the restored boundary: the scoped step. */
export function validateAgentRollbackAgainstScope(params: AgentRollbackParams, scope: Scope): void {
if (params.priorEpoch === scope.epoch) {
fail('AgentRollbackParams: priorEpoch must differ from the scoped (new) epoch');
}
if (params.input.boundary !== scope.step) {
fail(`AgentRollbackParams: input.boundary ${params.input.boundary} must be the scoped step ${scope.step}`);
}
}
export interface AgentRollbackResult {
agentId: Id;
committedStep: U64;
decisionContextDigest: Digest;
telemetry: AgentTelemetry;
}
export function readAgentRollbackResult(value: unknown): AgentRollbackResult {
const reader = new Reader(value, 'AgentRollbackResult');
const result: AgentRollbackResult = {
agentId: reader.id('agentId'),
committedStep: reader.u64('committedStep'),
decisionContextDigest: reader.digest('decisionContextDigest'),
telemetry: readAgentTelemetry(reader.value('telemetry')),
};
reader.finish();
return result;
}
/** A rollback runs no tick: the acknowledged step is the scoped one. */
export function validateAgentRollbackResultAgainstScope(
result: AgentRollbackResult,
scope: Scope,
): void {
if (result.committedStep !== scope.step) {
fail(
`AgentRollbackResult: committedStep ${result.committedStep} must be the scoped step ${scope.step}; a rollback runs no tick`,
);
}
}

View file

@ -1,62 +0,0 @@
/**
* Loading the fixture corpus, which lives with the Rust crate:
* `services/flysim/crates/fly-session-types/fixtures`.
*
* One corpus, two implementations. A case written once holds both languages to it.
*/
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { fileURLToPath } from 'node:url';
import { type Json, fail, parseStrict } from './canonical';
const here = fileURLToPath(new URL('.', import.meta.url));
/** The fixture directory. */
export const FIXTURE_DIR = join(
here,
'../../../services/flysim/crates/fly-session-types/fixtures',
);
export function loadBytes(name: string): Uint8Array {
return new Uint8Array(readFileSync(join(FIXTURE_DIR, name)));
}
/** Reads one fixture file, parsed strictly. */
export function load(name: string): Json {
return parseStrict(loadBytes(name));
}
export function section(file: Json, key: string): Json[] {
const value = (file as Record<string, Json>)[key];
if (!Array.isArray(value) || value.length === 0) {
fail(`fixture: ${key} must be a nonempty array`);
}
return value;
}
export function cases(file: Json): Json[] {
return section(file, 'cases');
}
/** A string field of one case. */
export function field(value: Json, key: string): string {
const found = (value as Record<string, Json>)[key];
if (typeof found !== 'string') fail(`fixture case: missing string field "${key}"`);
return found;
}
export function optionalField(value: Json, key: string): string | undefined {
const found = (value as Record<string, Json>)[key];
return typeof found === 'string' ? found : undefined;
}
export function member(value: Json, key: string): Json {
const found = (value as Record<string, Json>)[key];
if (found === undefined) fail(`fixture case: missing field "${key}"`);
return found;
}
export function decodeBase64(text: string): Uint8Array {
return new Uint8Array(Buffer.from(text, 'base64'));
}

View file

@ -1,560 +0,0 @@
/**
* The legacy Game Boy composition's registered schemas and declarations (PROF-02a,
* `docs/design/session-framework/legacy-gameboy-v1.md`).
*
* The Rust twin is `fly-session-types/src/gameboy.rs`, which renders every declaration and
* digest into `fixtures/gameboy-legacy.json`. This side keeps the references as constants and
* its tests recompute each digest from that file with this package's own canonical JSON, so a
* drift in either language fails.
*
* Nothing here is a generic session type: these values travel inside `TypedValue`s or are
* documents an `AssetRef` or the composition digest names.
*/
import { digestOf, fail } from './canonical';
import { readRational, readSchemaRef } from './common';
import { Reader, readArtifactRef, requireUnique, u64 } from './reader';
import type { ArtifactRef, Digest, Id, RationalNs, SchemaRef, TypedValue } from './scalar';
import { isDigest } from './scalar';
import { type AssetRef, readAssetRef } from './workers';
import { MAX_SLOTS, ROLLBACK_POLICY, SLOTS_CAPABILITY } from './extensions';
export const PROFILE_ID = 'gameboy-legacy-fafb-v783-v1';
export const DATASET_ID = 'fafb-v783';
export const FINGERPRINT_SCHEMA = 1;
/** Today's schema-1 fingerprint of `data/fafb-v783`: seven SHA-256 digests joined with ':'. */
export const FAFB_V783_FINGERPRINT = [
'75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3',
'1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e',
'63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5',
'f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7',
'ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62',
'b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634',
'dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc',
].join(':');
export const KERNEL_VERSION = 'lif-1ms-f64-v2';
export const PLASTICITY_VERSION = 'fly-kc-mbon-rstdp-v2';
export const WARMUP_MS = 2500;
export const STIMULUS_REWARD_PULSE = 'reward-pulse';
export const EXCEPTION_MACRO_ROLES = 'macro-roles-outside-fingerprint';
export const PROFILE_FORMAT = 'fly-profile-v1';
export const VIEW_ID = 'lcd';
export const VIEW_WIDTH = 160;
export const VIEW_HEIGHT = 144;
export const GAMEBOY_BUTTONS = ['up', 'down', 'left', 'right', 'a', 'b', 'start', 'select'] as const;
export const MEMORY_IMAGE_BYTES = 65_536n;
export const EXECUTOR_ID = 'pokered-macros-v1';
export const RESTORE_SEMANTICS = 'legacy-transient-reset';
export const CHECKPOINT_FORMAT_OF_RECORD = 'FLYSIM01';
export const SCHEDULER = 'lockstep-v1';
export const SETUP_FRAMES = 1;
export const MAX_MACRO_CHANNELS = 64;
export const MAX_COMPATIBILITY_BYTES = 1024;
export const MACRO_MODES = ['raw', 'macros'] as const;
export const ROLLBACK_TRIGGERS = ['stall', 'game-over'] as const;
/** One Game Boy frame, 70224 cycles at 4194304 Hz: `8572265625/512` ns exactly. */
export const STEP_DURATION: RationalNs = { numerator: '8572265625', denominator: '512' };
/** One model tick, 1 ms. */
export const TICK_DURATION: RationalNs = { numerator: '1000000', denominator: '1' };
/** The registered payload schema references, digests over their canonical declarations. */
export const READOUT_CONTEXT_SCHEMA: SchemaRef = {
id: 'gameboy-readout-context-v1',
version: 1,
digest: '78a5312f8608a1399e7a16549a1b95a43b87137618d59815b9b06c1bb184014d',
};
export const CHANNELS_SCHEMA: SchemaRef = {
id: 'gameboy-channels-v1',
version: 1,
digest: '28bd89bfa41ec73fb2785959a64d9975b4a932e10cd9e51dc4bc29020c823595',
};
export const JOYPAD_SCHEMA: SchemaRef = {
id: 'gameboy-joypad-v1',
version: 1,
digest: '1bde5fa114b99824ad608fba4ea85706cb0cebc6123a778dc1e5f89791d2a05e',
};
export const MEMORY_INSPECTION_SCHEMA: SchemaRef = {
id: 'gameboy-memory-inspection-v1',
version: 1,
digest: 'd6cb62248bfdac2ffdf00290ffbebf9a101b1fe28f7be766d24db28ede8da3e6',
};
export const ROLLBACK_REQUEST_SCHEMA: SchemaRef = {
id: 'legacy-ratchet-rollback-v1',
version: 1,
digest: '0610f899a4746e5991a07dbc3c847ada15d7c68cedf0664244a3c1d87c175c70',
};
export const PAYLOAD_SCHEMAS: readonly SchemaRef[] = [
READOUT_CONTEXT_SCHEMA,
CHANNELS_SCHEMA,
JOYPAD_SCHEMA,
MEMORY_INSPECTION_SCHEMA,
ROLLBACK_REQUEST_SCHEMA,
];
/** The legacy profile document's AssetRef digest: SHA-256 of its canonical JSON. */
export const PROFILE_DIGEST = '41e5d1ac62ab23f1b2d7252d52faac08b269c85e6b4c9ed7a370c74032c60878';
/** `ChannelName`: a decoder channel or rate-role name. Not an Id: legacy names carry '_'. */
export function isChannelName(value: unknown): value is string {
return typeof value === 'string' && /^[a-z][a-z0-9_]{0,63}$/.test(value);
}
function sameSchema(found: SchemaRef, expected: SchemaRef, what: string): void {
if (found.id !== expected.id || found.version !== expected.version || found.digest !== expected.digest) {
fail(`${what} must be the registered ${expected.id} reference`);
}
}
function exact(found: unknown, expected: unknown, what: string): void {
if (found !== expected) fail(`${what} must be ${JSON.stringify(expected)}`);
}
function channelList(reader: Reader, key: string, high: number): string[] {
const items = reader.list(key, 0, high, (item) => {
if (!isChannelName(item)) fail('every entry must be a channel name');
return item;
});
requireUnique(items, key);
return items;
}
function uniqueIds(reader: Reader, key: string, low: number, high: number): Id[] {
const ids = reader.idList(key, low, high);
requireUnique(ids, key);
return ids;
}
function typedAs<T>(value: TypedValue, schema: SchemaRef, read: (v: unknown) => T): T {
sameSchema(value.schema, schema, 'TypedValue.schema');
return read(value.value);
}
// gameboy-readout-context-v1 ---------------------------------------------------------------
export interface GameboyLocation {
area: number;
x: number;
y: number;
}
export interface GameboyReadoutContext {
boot: boolean;
bound: string[];
location: GameboyLocation | null;
}
export function readGameboyReadoutContext(value: unknown): GameboyReadoutContext {
const reader = new Reader(value, 'GameboyReadoutContext');
const boot = reader.boolean('boot');
const bound = channelList(reader, 'bound', MAX_MACRO_CHANNELS);
const raw = reader.value('location');
let location: GameboyLocation | null = null;
if (raw !== null) {
const l = new Reader(raw, 'GameboyReadoutContext.location');
location = {
area: l.int('area', 0, 4_294_967_295),
x: l.int('x', 0, 4_294_967_295),
y: l.int('y', 0, 4_294_967_295),
};
l.finish();
}
reader.finish();
return { boot, bound, location };
}
export function readGameboyReadoutContextTyped(value: TypedValue): GameboyReadoutContext {
return typedAs(value, READOUT_CONTEXT_SCHEMA, readGameboyReadoutContext);
}
/** `bound` is a subset of the composition's macro channels, in their order. */
export function validateReadoutContextAgainst(
context: GameboyReadoutContext,
macroChannels: readonly string[],
): void {
let cursor = 0;
for (const channel of context.bound) {
const offset = macroChannels.slice(cursor).indexOf(channel);
if (offset < 0) {
fail(
`GameboyReadoutContext: bound channel "${channel}" is not a macro channel of the composition, or is out of order`,
);
}
cursor += offset + 1;
}
}
// gameboy-channels-v1 ----------------------------------------------------------------------
export interface GameboyChannelsDecision {
buttons: { id: (typeof GAMEBOY_BUTTONS)[number]; down: boolean }[];
macro: string | null;
}
export function readGameboyChannelsDecision(value: unknown): GameboyChannelsDecision {
const reader = new Reader(value, 'GameboyChannelsDecision');
const buttons = reader.list('buttons', 8, 8, (item) => {
const b = new Reader(item, 'GameboyChannelsDecision.buttons');
const entry = { id: b.id('id'), down: b.boolean('down') };
b.finish();
return entry;
});
buttons.forEach((button, index) => {
if (button.id !== GAMEBOY_BUTTONS[index]) {
fail(`GameboyChannelsDecision: buttons must list ${GAMEBOY_BUTTONS.join(',')} in that order`);
}
});
const macro = reader.value('macro');
if (macro !== null && !isChannelName(macro)) {
fail('GameboyChannelsDecision: macro must be null or a channel name');
}
reader.finish();
return {
buttons: buttons as GameboyChannelsDecision['buttons'],
macro: macro as string | null,
};
}
export function readGameboyChannelsDecisionTyped(value: TypedValue): GameboyChannelsDecision {
return typedAs(value, CHANNELS_SCHEMA, readGameboyChannelsDecision);
}
/** The joypad mask, bit `i` for `GAMEBOY_BUTTONS[i]`. */
export function channelsMask(decision: GameboyChannelsDecision): number {
return decision.buttons.reduce((mask, button, bit) => (button.down ? mask | (1 << bit) : mask), 0);
}
/** The macro group only ever activates a bound channel. */
export function validateDecisionAgainst(
decision: GameboyChannelsDecision,
context: GameboyReadoutContext,
): void {
if (decision.macro !== null && !context.bound.includes(decision.macro)) {
fail(`GameboyChannelsDecision: macro "${decision.macro}" is not bound in the decision context`);
}
}
// gameboy-memory-inspection-v1 -------------------------------------------------------------
export interface GameboyMemoryInspection {
memory: ArtifactRef;
romDigest: Digest;
}
export function readGameboyMemoryInspection(value: unknown): GameboyMemoryInspection {
const reader = new Reader(value, 'GameboyMemoryInspection');
const inspection: GameboyMemoryInspection = {
memory: readArtifactRef(reader.value('memory')),
romDigest: reader.digest('romDigest'),
};
reader.finish();
if (u64(inspection.memory.byteLength) !== MEMORY_IMAGE_BYTES) {
fail(`GameboyMemoryInspection: the memory image is exactly ${MEMORY_IMAGE_BYTES} bytes`);
}
return inspection;
}
export function readGameboyMemoryInspectionTyped(value: TypedValue): GameboyMemoryInspection {
return typedAs(value, MEMORY_INSPECTION_SCHEMA, readGameboyMemoryInspection);
}
/** The ROM the executor reads is the content the environment runs. */
export function validateInspectionAgainst(
inspection: GameboyMemoryInspection,
contentDigest: Digest,
rom: AssetRef,
): void {
if (inspection.romDigest !== contentDigest || rom.digest !== contentDigest) {
fail(
'GameboyMemoryInspection: romDigest, the environment contentDigest and the executor rom must agree',
);
}
}
// legacy-ratchet-rollback-v1 ---------------------------------------------------------------
export interface LegacyRatchetRollbackRequest {
slotId: Id;
trigger: (typeof ROLLBACK_TRIGGERS)[number];
}
export function readLegacyRatchetRollbackRequest(value: unknown): LegacyRatchetRollbackRequest {
const reader = new Reader(value, 'LegacyRatchetRollbackRequest');
const request: LegacyRatchetRollbackRequest = {
slotId: reader.id('slotId'),
trigger: reader.enumeration('trigger', ROLLBACK_TRIGGERS),
};
reader.finish();
return request;
}
export function readLegacyRatchetRollbackRequestTyped(
value: TypedValue,
): LegacyRatchetRollbackRequest {
return typedAs(value, ROLLBACK_REQUEST_SCHEMA, readLegacyRatchetRollbackRequest);
}
// The legacy profile ------------------------------------------------------------------------
export interface LegacyGameboyProfile {
profileId: string;
datasetId: string;
fingerprintSchema: number;
datasetFingerprint: string;
kernelVersion: string;
plasticityVersion: string;
tickDuration: RationalNs;
warmupMs: number;
view: { viewId: Id; width: number; height: number };
supportedStimuli: Id[];
readoutContextSchema: SchemaRef;
decisionSchema: SchemaRef;
legacyExceptions: Id[];
}
function sameRational(found: RationalNs, expected: RationalNs, what: string): void {
if (found.numerator !== expected.numerator || found.denominator !== expected.denominator) {
fail(`${what} must be ${expected.numerator}/${expected.denominator}`);
}
}
export function readLegacyGameboyProfile(value: unknown): LegacyGameboyProfile {
const reader = new Reader(value, 'LegacyGameboyProfile');
const viewReader = (raw: unknown) => {
const v = new Reader(raw, 'LegacyGameboyProfile.view');
const view = {
viewId: v.id('viewId'),
width: v.int('width', VIEW_WIDTH, VIEW_WIDTH),
height: v.int('height', VIEW_HEIGHT, VIEW_HEIGHT),
};
v.finish();
return view;
};
const profile: LegacyGameboyProfile = {
profileId: reader.string('profileId'),
datasetId: reader.string('datasetId'),
fingerprintSchema: reader.int('fingerprintSchema', FINGERPRINT_SCHEMA, FINGERPRINT_SCHEMA),
datasetFingerprint: reader.string('datasetFingerprint'),
kernelVersion: reader.string('kernelVersion'),
plasticityVersion: reader.string('plasticityVersion'),
tickDuration: readRational(reader.value('tickDuration')),
warmupMs: reader.int('warmupMs', WARMUP_MS, WARMUP_MS),
view: viewReader(reader.value('view')),
supportedStimuli: uniqueIds(reader, 'supportedStimuli', 1, 1),
readoutContextSchema: readSchemaRef(reader.value('readoutContextSchema')),
decisionSchema: readSchemaRef(reader.value('decisionSchema')),
legacyExceptions: uniqueIds(reader, 'legacyExceptions', 1, 1),
};
reader.finish();
exact(profile.profileId, PROFILE_ID, 'profileId');
exact(profile.datasetId, DATASET_ID, 'datasetId');
exact(profile.kernelVersion, KERNEL_VERSION, 'kernelVersion');
exact(profile.plasticityVersion, PLASTICITY_VERSION, 'plasticityVersion');
sameRational(profile.tickDuration, TICK_DURATION, 'LegacyGameboyProfile: tickDuration');
exact(profile.view.viewId, VIEW_ID, 'view.viewId');
exact(profile.supportedStimuli[0], STIMULUS_REWARD_PULSE, 'supportedStimuli[0]');
exact(profile.legacyExceptions[0], EXCEPTION_MACRO_ROLES, 'legacyExceptions[0]');
if (profile.datasetFingerprint !== FAFB_V783_FINGERPRINT) {
fail(
"LegacyGameboyProfile: datasetFingerprint must be today's fafb-v783 schema-1 fingerprint; another fingerprint is another profile",
);
}
sameSchema(profile.readoutContextSchema, READOUT_CONTEXT_SCHEMA, 'readoutContextSchema');
sameSchema(profile.decisionSchema, CHANNELS_SCHEMA, 'decisionSchema');
return profile;
}
// The legacy composition --------------------------------------------------------------------
export interface LegacyGameboyComposition {
compositionId: Id;
scheduler: string;
profile: AssetRef;
executor: {
id: string;
rom: AssetRef;
adapter: Id;
symbolProvenance: string;
mode: (typeof MACRO_MODES)[number];
macroChannels: string[];
};
decoderConfigDigest: Digest;
environment: {
extensions: Id[];
slots: Id[];
stepDuration: RationalNs;
inspectionSchema: SchemaRef;
controllerSchema: SchemaRef;
setupFrames: number;
audio: { sampleRate: number; channels: number };
};
episodePolicy: string;
restore: string;
checkpointFormatOfRecord: string;
flysimCompatibility: string;
}
export function readLegacyGameboyComposition(value: unknown): LegacyGameboyComposition {
const reader = new Reader(value, 'LegacyGameboyComposition');
const compositionId = reader.id('compositionId');
const scheduler = reader.string('scheduler');
const profile = readAssetRef(reader.value('profile'));
const e = new Reader(reader.value('executor'), 'LegacyGameboyComposition.executor');
const executor = {
id: e.string('id'),
rom: readAssetRef(e.value('rom')),
adapter: e.id('adapter'),
symbolProvenance: e.boundedString('symbolProvenance', 64),
mode: e.enumeration('mode', MACRO_MODES),
macroChannels: channelList(e, 'macroChannels', MAX_MACRO_CHANNELS),
};
e.finish();
const decoderConfigDigest = reader.digest('decoderConfigDigest');
const n = new Reader(reader.value('environment'), 'LegacyGameboyComposition.environment');
const extensions = uniqueIds(n, 'extensions', 1, 1);
const slots = uniqueIds(n, 'slots', 1, MAX_SLOTS);
const stepDuration = readRational(n.value('stepDuration'));
const inspectionSchema = readSchemaRef(n.value('inspectionSchema'));
const controllerSchema = readSchemaRef(n.value('controllerSchema'));
const setupFrames = n.int('setupFrames', SETUP_FRAMES, SETUP_FRAMES);
const a = new Reader(n.value('audio'), 'environment.audio');
const audio = { sampleRate: a.int('sampleRate', 8000, 192_000), channels: a.int('channels', 2, 2) };
a.finish();
n.finish();
const composition: LegacyGameboyComposition = {
compositionId,
scheduler,
profile,
executor,
decoderConfigDigest,
environment: {
extensions,
slots,
stepDuration,
inspectionSchema,
controllerSchema,
setupFrames,
audio,
},
episodePolicy: reader.string('episodePolicy'),
restore: reader.string('restore'),
checkpointFormatOfRecord: reader.string('checkpointFormatOfRecord'),
flysimCompatibility: reader.string('flysimCompatibility'),
};
reader.finish();
exact(scheduler, SCHEDULER, 'scheduler');
exact(executor.id, EXECUTOR_ID, 'executor.id');
exact(extensions[0], SLOTS_CAPABILITY, 'environment.extensions[0]');
sameRational(stepDuration, STEP_DURATION, 'LegacyGameboyComposition: environment.stepDuration');
sameSchema(inspectionSchema, MEMORY_INSPECTION_SCHEMA, 'environment.inspectionSchema');
sameSchema(controllerSchema, JOYPAD_SCHEMA, 'environment.controllerSchema');
exact(composition.episodePolicy, ROLLBACK_POLICY, 'episodePolicy');
exact(composition.restore, RESTORE_SEMANTICS, 'restore');
exact(composition.checkpointFormatOfRecord, CHECKPOINT_FORMAT_OF_RECORD, 'checkpointFormatOfRecord');
if (profile.format !== PROFILE_FORMAT || profile.digest !== PROFILE_DIGEST) {
fail(
'LegacyGameboyComposition: profile must name the legacy profile document (format fly-profile-v1, its digest)',
);
}
if (executor.mode === 'raw' && executor.macroChannels.length !== 0) {
fail('LegacyGameboyComposition: raw mode deals no macro channels');
}
if (executor.mode === 'macros' && executor.macroChannels.length === 0) {
fail('LegacyGameboyComposition: macros mode needs its macro channels');
}
const text = composition.flysimCompatibility;
if (text.length === 0 || Buffer.byteLength(text, 'utf8') > MAX_COMPATIBILITY_BYTES) {
fail('LegacyGameboyComposition: flysimCompatibility must be 1..=1024 bytes');
}
const segments = text.split('/');
const agrees =
segments.length >= 6 &&
segments[0] === KERNEL_VERSION &&
segments[1] === executor.adapter &&
segments[2] === FAFB_V783_FINGERPRINT &&
segments[3] === PLASTICITY_VERSION &&
segments[5] === `pokered:${executor.symbolProvenance}`;
if (!agrees) {
fail(
"LegacyGameboyComposition: flysimCompatibility's kernel, adapter, fingerprint, plasticity and pokered segments must agree with the declaration",
);
}
return composition;
}
/** SHA-256 of the canonical declaration: the `declaration=` line of the composition digest. */
export function compositionDeclarationDigest(composition: LegacyGameboyComposition): Digest {
const digest = digestOf(composition);
if (!isDigest(digest)) fail('digest');
return digest;
}
// The decoder configuration digest ------------------------------------------------------------
/** The name of the canonical decoder-configuration form (legacy-gameboy-v1 section 12). */
export const DECODER_CONFIG_FORM = 'gameboy-decoder-config-v1';
interface GroupLike {
channels: Record<string, string>;
decisionMs: number;
holdMs: number;
hysteresis: number;
fatigueGain: number;
fatigueDecay: number;
blockedFatigue: number;
blockedMs: number;
}
/** Structurally the oracle's `DecoderConfig` (`packages/brain/src/readout/decoder.ts`). */
export interface DecoderConfigLike {
exclusive?: GroupLike;
macros?: GroupLike;
pulses: {
channel: string;
role: string;
holdMs: number;
cooldownMs: number;
threshold: number;
boot?: { cooldownMs: number; threshold: number };
throttleGroup?: string;
}[];
clearLockoutMs: number;
}
function groupForm(group: GroupLike | undefined): unknown {
if (!group) return null;
return {
// Channel order breaks argmax ties, so it is part of the identity: an array, not a map,
// because canonical JSON sorts object keys.
channels: Object.entries(group.channels).map(([channel, role]) => ({ channel, role })),
decisionMs: group.decisionMs,
holdMs: group.holdMs,
hysteresis: group.hysteresis,
fatigueGain: group.fatigueGain,
fatigueDecay: group.fatigueDecay,
blockedFatigue: group.blockedFatigue,
blockedMs: group.blockedMs,
};
}
/** The canonical form `decoderConfigDigest` is taken over. */
export function decoderConfigForm(config: DecoderConfigLike): unknown {
return {
form: DECODER_CONFIG_FORM,
exclusive: groupForm(config.exclusive),
macros: groupForm(config.macros),
pulses: config.pulses.map((pulse) => ({
channel: pulse.channel,
role: pulse.role,
holdMs: pulse.holdMs,
cooldownMs: pulse.cooldownMs,
threshold: pulse.threshold,
boot: pulse.boot ? { cooldownMs: pulse.boot.cooldownMs, threshold: pulse.boot.threshold } : null,
throttleGroup: pulse.throttleGroup ?? null,
})),
clearLockoutMs: config.clearLockoutMs,
};
}
/** `LegacyGameboyComposition.decoderConfigDigest`: SHA-256 of the canonical form. */
export function decoderConfigDigest(config: DecoderConfigLike): Digest {
return digestOf(decoderConfigForm(config));
}

View file

@ -1,21 +0,0 @@
/**
* `@flybrain/session-types`: the session framework contracts in TypeScript.
*
* The other half of `services/flysim/crates/fly-session-types`. Same rules, same canonical
* JSON, same digests, same fixtures. Nothing here opens a socket: it reads, validates and
* hashes payloads.
*/
export * from './canonical';
export * from './scalar';
export * from './reader';
export * from './common';
export * from './media';
export * from './workers';
export * from './rpc';
export * from './publishing';
export * from './trace';
export * from './extensions';
export * as seed from './seed';
export * as checkpoint from './checkpoint';
export * as gameboy from './gameboy';
export * as fixtures from './fixtures';

View file

@ -1,264 +0,0 @@
/** Native observation media (state-media-v1 section 2) and the `State.*` payloads (section 5). */
import { fail } from './canonical';
import { readRational, readScope } from './common';
import { Reader, readArtifactRef, requireUnique, u64 } from './reader';
import { type ArtifactRef, type Digest, type Id, type Scope, type U64, isDigest } from './scalar';
/** Max views per sensory input (workers-v1 section 1), and per descriptor list. */
export const MAX_VIEWS = 8;
export const MAX_VIEW_DIMENSION = 4096;
export const MAX_PIXEL_ASPECT = 65_535;
export const MAX_OBSERVATION_DELAY_STEPS = 8;
export const MAX_SAMPLE_FRAMES = 192_000;
/** Not a stated bound; this crate's choice, published in the schema set. */
export const MAX_AUDIO_STREAMS = 8;
export interface ViewDescriptor {
viewId: Id;
width: number;
height: number;
format: 'rgba8';
rowStride: number;
pixelAspect: { numerator: number; denominator: number };
observationDelaySteps: number;
}
export interface ViewRef {
viewId: Id;
producedStep: U64;
pixels: ArtifactRef;
}
export interface AudioDescriptor {
streamId: Id;
sampleRate: number;
channels: number;
format: 'f32le-interleaved';
}
export interface AudioRef {
streamId: Id;
firstSample: U64;
sampleFrames: number;
samples: ArtifactRef;
discontinuity: boolean;
}
export function readViewDescriptor(value: unknown): ViewDescriptor {
const reader = new Reader(value, 'ViewDescriptor');
const viewId = reader.id('viewId');
const width = reader.int('width', 1, MAX_VIEW_DIMENSION);
const height = reader.int('height', 1, MAX_VIEW_DIMENSION);
const format = reader.constant('format', 'rgba8');
const rowStride = reader.int('rowStride', 1, MAX_VIEW_DIMENSION * 4);
const aspectReader = new Reader(reader.value('pixelAspect'), 'ViewDescriptor.pixelAspect');
const pixelAspect = {
numerator: aspectReader.int('numerator', 1, MAX_PIXEL_ASPECT),
denominator: aspectReader.int('denominator', 1, MAX_PIXEL_ASPECT),
};
aspectReader.finish();
const observationDelaySteps = reader.int(
'observationDelaySteps',
0,
MAX_OBSERVATION_DELAY_STEPS,
);
reader.finish();
if (rowStride !== width * 4) {
fail('ViewDescriptor: rowStride must be exactly 4 x width (no padded rows in v1)');
}
return { viewId, width, height, format, rowStride, pixelAspect, observationDelaySteps };
}
/** The exact byte length of one frame of this view. */
export function frameBytes(descriptor: ViewDescriptor): number {
return descriptor.rowStride * descriptor.height;
}
/** `max(0, boundary - observationDelaySteps)` (state-media-v1 section 2). */
export function requiredProducedStep(descriptor: ViewDescriptor, boundary: bigint): bigint {
const delay = BigInt(descriptor.observationDelaySteps);
return boundary > delay ? boundary - delay : 0n;
}
export function readViewRef(value: unknown): ViewRef {
const reader = new Reader(value, 'ViewRef');
const view: ViewRef = {
viewId: reader.id('viewId'),
producedStep: reader.u64('producedStep'),
pixels: readArtifactRef(reader.value('pixels')),
};
reader.finish();
if (u64(view.pixels.byteLength) === 0n) {
fail('ViewRef: pixels must have a positive byte length');
}
return view;
}
export function readViewList(reader: Reader, key: string): ViewRef[] {
const views = reader.list(key, 0, MAX_VIEWS, readViewRef);
requireUnique(
views.map((view) => view.viewId),
key,
);
return views;
}
export function readAudioDescriptor(value: unknown): AudioDescriptor {
const reader = new Reader(value, 'AudioDescriptor');
const descriptor: AudioDescriptor = {
streamId: reader.id('streamId'),
sampleRate: reader.int('sampleRate', 8_000, 192_000),
channels: reader.int('channels', 1, 8),
format: reader.constant('format', 'f32le-interleaved'),
};
reader.finish();
return descriptor;
}
export function readAudioRef(value: unknown): AudioRef {
const reader = new Reader(value, 'AudioRef');
const chunk: AudioRef = {
streamId: reader.id('streamId'),
firstSample: reader.u64('firstSample'),
sampleFrames: reader.int('sampleFrames', 0, MAX_SAMPLE_FRAMES),
samples: readArtifactRef(reader.value('samples')),
discontinuity: reader.boolean('discontinuity'),
};
reader.finish();
if (u64(chunk.firstSample) + BigInt(chunk.sampleFrames) > 18446744073709551615n) {
fail('AudioRef: firstSample + sampleFrames overflows U64');
}
return chunk;
}
export function readAudioList(reader: Reader, key: string): AudioRef[] {
const audio = reader.list(key, 0, MAX_AUDIO_STREAMS, readAudioRef);
requireUnique(
audio.map((chunk) => chunk.streamId),
key,
);
return audio;
}
/** Byte shape and producing boundary against the descriptor that declared this view. */
export function validateViewAgainst(
view: ViewRef,
descriptor: ViewDescriptor,
boundary: bigint | null,
): void {
if (view.viewId !== descriptor.viewId) {
fail(`ViewRef: viewId "${view.viewId}" does not match descriptor "${descriptor.viewId}"`);
}
if (u64(view.pixels.byteLength) !== BigInt(frameBytes(descriptor))) {
fail(
`ViewRef ${view.viewId}: artifact is ${view.pixels.byteLength} bytes, rowStride x height is ${frameBytes(descriptor)}`,
);
}
if (boundary !== null) {
const expected = requiredProducedStep(descriptor, boundary);
if (u64(view.producedStep) !== expected) {
fail(
`ViewRef ${view.viewId}: producedStep ${view.producedStep} must be max(0, ${boundary} - ${descriptor.observationDelaySteps}) = ${expected}`,
);
}
}
}
export function validateAudioAgainst(chunk: AudioRef, descriptor: AudioDescriptor): void {
if (chunk.streamId !== descriptor.streamId) {
fail(`AudioRef: streamId "${chunk.streamId}" does not match the descriptor`);
}
const expected = BigInt(chunk.sampleFrames) * BigInt(descriptor.channels) * 4n;
if (u64(chunk.samples.byteLength) !== expected) {
fail(
`AudioRef ${chunk.streamId}: artifact is ${chunk.samples.byteLength} bytes, sampleFrames x channels x 4 is ${expected}`,
);
}
}
// ---------------------------------------------------------------------------------- State.*
export interface CaptureParams {
checkpointId: Id;
}
export interface CaptureResult {
checkpointId: Id;
boundary: U64;
compatibilityDigest: Digest;
payload: ArtifactRef;
}
export interface StageRestoreParams {
checkpointId: Id;
sourceScope: Scope;
compatibilityDigest: Digest;
payload: ArtifactRef;
}
export interface StageRestoreResult {
checkpointId: Id;
restoreToken: Id;
}
export interface ActivateRestoreParams {
restoreToken: Id;
}
function checkpointPayload(reader: Reader, key: string): ArtifactRef {
const reference = readArtifactRef(reader.value(key));
if (!isDigest(reference.digest)) {
fail(`${key}: a checkpoint payload must carry a content digest`);
}
return reference;
}
export function readCaptureParams(value: unknown): CaptureParams {
const reader = new Reader(value, 'CaptureParams');
const params = { checkpointId: reader.id('checkpointId') };
reader.finish();
return params;
}
export function readCaptureResult(value: unknown): CaptureResult {
const reader = new Reader(value, 'CaptureResult');
const result: CaptureResult = {
checkpointId: reader.id('checkpointId'),
boundary: reader.u64('boundary'),
compatibilityDigest: reader.digest('compatibilityDigest'),
payload: checkpointPayload(reader, 'payload'),
};
reader.finish();
return result;
}
export function readStageRestoreParams(value: unknown): StageRestoreParams {
const reader = new Reader(value, 'StageRestoreParams');
const params: StageRestoreParams = {
checkpointId: reader.id('checkpointId'),
sourceScope: readScope(reader.value('sourceScope')),
compatibilityDigest: reader.digest('compatibilityDigest'),
payload: checkpointPayload(reader, 'payload'),
};
reader.finish();
return params;
}
export function readStageRestoreResult(value: unknown): StageRestoreResult {
const reader = new Reader(value, 'StageRestoreResult');
const result: StageRestoreResult = {
checkpointId: reader.id('checkpointId'),
restoreToken: reader.id('restoreToken'),
};
reader.finish();
return result;
}
export function readActivateRestoreParams(value: unknown): ActivateRestoreParams {
const reader = new Reader(value, 'ActivateRestoreParams');
const params = { restoreToken: reader.id('restoreToken') };
reader.finish();
return params;
}
export { readRational };

View file

@ -1,231 +0,0 @@
/** The publication types of publishing-v1 section 3. */
import { fail } from './canonical';
import { readRational, readScope, readSchemaRef, readTypedValue, readNullableTypedValue } from './common';
import {
type AudioRef,
MAX_VIEWS,
type ViewRef,
readAudioList,
readViewList,
} from './media';
import { Reader, requireUnique, u64 } from './reader';
import { type Digest, type Id, type RationalNs, type SchemaRef, type Scope, type TypedValue, type U64 } from './scalar';
import {
type AgentTelemetry,
type AssetRef,
type EnvironmentDescriptor,
MAX_AGENTS,
MAX_RATE_ROLES,
MAX_SUPPORTED_STIMULI,
type PortControl,
findPort,
readAgentTelemetry,
readAssetRef,
readEnvironmentDescriptor,
readPortControl,
validatePortControlAgainst,
validateTelemetryRoles,
} from './workers';
export { MAX_SUPPORTED_STIMULI } from './workers';
/** Not stated by a document; this crate's choices, published in the schema set. */
export const MAX_ASSETS = 64;
export const MAX_SNAPSHOT_EVENTS = 64;
export interface AgentDescriptor {
agentId: Id;
portId: Id;
profileDigest: Digest;
datasetDigest: Digest;
indexDigest: Digest;
neuronCount: U64;
rateRoles: Id[];
supportedStimuli: Id[];
}
export interface SessionDescriptor {
sessionId: Id;
revision: U64;
compositionDigest: Digest;
schedulerId: 'lockstep-v1';
environment: EnvironmentDescriptor;
taskSchema: SchemaRef;
agents: AgentDescriptor[];
assets: AssetRef[];
}
export interface SnapshotAgent {
agentId: Id;
telemetry: AgentTelemetry;
selectedDecision: TypedValue | null;
appliedControls: PortControl | null;
}
export interface CommittedSnapshot {
descriptorRevision: U64;
publisherIncarnation: Id;
scope: Scope;
episodeId: Id;
sequence: U64;
worldTime: RationalNs;
agents: SnapshotAgent[];
progress: TypedValue;
media: { views: ViewRef[]; audio: AudioRef[] };
eventIds: Id[];
}
export function readSessionDescriptor(value: unknown): SessionDescriptor {
const reader = new Reader(value, 'SessionDescriptor');
const descriptor: SessionDescriptor = {
sessionId: reader.id('sessionId'),
revision: reader.u64('revision'),
compositionDigest: reader.digest('compositionDigest'),
schedulerId: reader.constant('schedulerId', 'lockstep-v1'),
environment: readEnvironmentDescriptor(reader.value('environment')),
taskSchema: readSchemaRef(reader.value('taskSchema')),
agents: reader.list('agents', 1, MAX_AGENTS, (item) => {
const agent = new Reader(item, 'SessionDescriptor.agents');
const entry: AgentDescriptor = {
agentId: agent.id('agentId'),
portId: agent.id('portId'),
profileDigest: agent.digest('profileDigest'),
datasetDigest: agent.digest('datasetDigest'),
indexDigest: agent.digest('indexDigest'),
neuronCount: agent.u64('neuronCount'),
rateRoles: agent.idList('rateRoles', 0, MAX_RATE_ROLES),
supportedStimuli: agent.idList('supportedStimuli', 0, MAX_SUPPORTED_STIMULI),
};
agent.finish();
requireUnique(entry.rateRoles, 'SessionDescriptor.agents rateRoles');
requireUnique(entry.supportedStimuli, 'SessionDescriptor.agents supportedStimuli');
return entry;
}),
assets: reader.list('assets', 0, MAX_ASSETS, readAssetRef),
};
reader.finish();
requireUnique(
descriptor.agents.map((agent) => agent.agentId),
'SessionDescriptor.agents agentId',
);
requireUnique(
descriptor.agents.map((agent) => agent.portId),
'SessionDescriptor.agents portId',
);
requireUnique(
descriptor.assets.map((asset) => asset.id),
'SessionDescriptor.assets',
);
for (const agent of descriptor.agents) {
if (!findPort(descriptor.environment, agent.portId)) {
fail(
`SessionDescriptor: agent "${agent.agentId}" is bound to port "${agent.portId}", which the environment does not declare`,
);
}
}
return descriptor;
}
export function readCommittedSnapshot(value: unknown): CommittedSnapshot {
const reader = new Reader(value, 'CommittedSnapshot');
const descriptorRevision = reader.u64('descriptorRevision');
const publisherIncarnation = reader.id('publisherIncarnation');
const scope = readScope(reader.value('scope'));
const episodeId = reader.id('episodeId');
const sequence = reader.u64('sequence');
const worldTime = readRational(reader.value('worldTime'));
const agents = reader.list('agents', 1, MAX_AGENTS, (item) => {
const agent = new Reader(item, 'CommittedSnapshot.agents');
const controls = agent.value('appliedControls');
const entry: SnapshotAgent = {
agentId: agent.id('agentId'),
telemetry: readAgentTelemetry(agent.value('telemetry')),
selectedDecision: readNullableTypedValue(agent.value('selectedDecision')),
appliedControls: controls === null ? null : readPortControl(controls),
};
agent.finish();
return entry;
});
const progress = readTypedValue(reader.value('progress'));
const mediaReader = new Reader(reader.value('media'), 'CommittedSnapshot.media');
const media = {
views: readViewList(mediaReader, 'views'),
audio: readAudioList(mediaReader, 'audio'),
};
mediaReader.finish();
const eventIds = reader.idList('eventIds', 0, MAX_SNAPSHOT_EVENTS);
reader.finish();
requireUnique(
agents.map((agent) => agent.agentId),
'CommittedSnapshot.agents',
);
requireUnique(
media.views.map((view) => view.viewId),
'CommittedSnapshot.media.views',
);
requireUnique(eventIds, 'CommittedSnapshot.eventIds');
if (media.views.length > MAX_VIEWS) fail('CommittedSnapshot: at most 8 views');
const atBoundaryZero = u64(scope.step) === 0n;
for (const agent of agents) {
// "Decisions/controls describe the transition ending at that boundary, null at initial
// boundary 0." (publishing-v1 section 3, and its 2026-09-22 amendment for a boundary that
// was installed rather than produced.)
if (atBoundaryZero && (agent.selectedDecision !== null || agent.appliedControls !== null)) {
fail('CommittedSnapshot: at boundary 0 selectedDecision and appliedControls are null');
}
if ((agent.selectedDecision === null) !== (agent.appliedControls === null)) {
fail(
'CommittedSnapshot: selectedDecision and appliedControls are null together or present together',
);
}
}
// A boundary is produced by a transition or installed by one, and the whole snapshot says
// which: every agent carries the transition that ended here, or none does.
if (agents.some((a) => (a.selectedDecision === null) !== (agents[0].selectedDecision === null))) {
fail(
'CommittedSnapshot: either every agent carries the transition that ended here, or none does',
);
}
return {
descriptorRevision,
publisherIncarnation,
scope,
episodeId,
sequence,
worldTime,
agents,
progress,
media,
eventIds,
};
}
/** Descriptor agreement: revision, session, agent set and each agent's assigned port. */
export function validateSnapshotAgainst(
snapshot: CommittedSnapshot,
descriptor: SessionDescriptor,
): void {
if (snapshot.descriptorRevision !== descriptor.revision) {
fail('CommittedSnapshot: descriptorRevision does not match the descriptor');
}
if (snapshot.scope.sessionId !== descriptor.sessionId) {
fail('CommittedSnapshot: sessionId does not match the descriptor');
}
for (const agent of snapshot.agents) {
const declared = descriptor.agents.find((candidate) => candidate.agentId === agent.agentId);
if (!declared) {
fail(`CommittedSnapshot: agent "${agent.agentId}" is not in the descriptor`);
}
validateTelemetryRoles(agent.telemetry, declared.rateRoles);
if (agent.appliedControls !== null) {
if (agent.appliedControls.portId !== declared.portId) {
fail(
`CommittedSnapshot: agent "${agent.agentId}" controls port "${agent.appliedControls.portId}", not its assigned "${declared.portId}"`,
);
}
const port = findPort(descriptor.environment, declared.portId);
if (!port) fail('CommittedSnapshot: assigned port is not declared');
validatePortControlAgainst(agent.appliedControls, port.controls);
}
}
}

View file

@ -1,231 +0,0 @@
/**
* Reading one JSON object field by field, then refusing any field that was not read.
*
* The Rust crate's `flybus::wire::Fields` does the same job; keeping the two shaped alike is
* what lets the fixture corpus hold both languages to the same rules.
*/
import { ContractError, canonicalize, fail } from './canonical';
import {
type Digest,
type Id,
type U64,
type ArtifactRef,
isDigest,
isId,
parseU64,
requireU64,
} from './scalar';
export class Reader {
private readonly map: Record<string, unknown>;
private readonly seen = new Set<string>();
constructor(
value: unknown,
private readonly what: string,
) {
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
fail(`${what} must be an object`);
}
this.map = value as Record<string, unknown>;
}
value(key: string): unknown {
this.seen.add(key);
if (!Object.prototype.hasOwnProperty.call(this.map, key)) {
fail(`${this.what}: missing field "${key}"`);
}
return this.map[key];
}
string(key: string): string {
const value = this.value(key);
if (typeof value !== 'string') fail(`${this.what}: ${key} must be a string`);
return value;
}
id(key: string): Id {
const value = this.string(key);
if (!isId(value)) fail(`${this.what}: ${key} is not a valid id`);
return value;
}
nullableId(key: string): Id | null {
const value = this.value(key);
if (value === null) return null;
return this.id(key);
}
digest(key: string): Digest {
const value = this.string(key);
if (!isDigest(value)) fail(`${this.what}: ${key} must be 64 lowercase hex digits`);
return value;
}
u64(key: string): U64 {
return requireU64(this.value(key), `${this.what}: ${key}`);
}
int(key: string, low: number, high: number): number {
const value = this.value(key);
if (typeof value !== 'number' || !Number.isInteger(value) || value < low || value > high) {
fail(`${this.what}: ${key} must be an integer in ${low}..=${high}`);
}
return value;
}
finite(key: string): number {
const value = this.value(key);
if (typeof value !== 'number' || !Number.isFinite(value)) {
fail(`${this.what}: ${key} must be a finite JSON number`);
}
if (Number.isInteger(value) && Math.abs(value) > Number.MAX_SAFE_INTEGER) {
fail(`${this.what}: ${key} is outside the exact double range`);
}
return value;
}
finiteIn(key: string, low: number, high: number): number {
const value = this.finite(key);
if (value < low || value > high) fail(`${this.what}: ${key} must be in [${low}, ${high}]`);
return value;
}
boolean(key: string): boolean {
const value = this.value(key);
if (typeof value !== 'boolean') fail(`${this.what}: ${key} must be a boolean`);
return value;
}
constantTrue(key: string): true {
if (!this.boolean(key)) fail(`${this.what}: ${key} must be true`);
return true;
}
object(key: string): Record<string, unknown> {
const value = this.value(key);
if (value === null || typeof value !== 'object' || Array.isArray(value)) {
fail(`${this.what}: ${key} must be an object`);
}
return value as Record<string, unknown>;
}
array(key: string, low: number, high: number): unknown[] {
const value = this.value(key);
if (!Array.isArray(value) || value.length < low || value.length > high) {
fail(`${this.what}: ${key} must be an array of ${low}..=${high} items`);
}
return value;
}
list<T>(key: string, low: number, high: number, read: (item: unknown) => T): T[] {
return this.array(key, low, high).map((item, index) => {
try {
return read(item);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new ContractError(`${this.what}: ${key}[${index}]: ${message}`);
}
});
}
idList(key: string, low: number, high: number): Id[] {
return this.list(key, low, high, (item) => {
if (!isId(item)) fail('every entry must be an id');
return item;
});
}
enumeration<T extends string>(key: string, allowed: readonly T[]): T {
const value = this.string(key);
if (!(allowed as readonly string[]).includes(value)) {
fail(`${this.what}: ${key} must be one of ${allowed.join(', ')}`);
}
return value as T;
}
constant<T extends string>(key: string, expected: T): T {
const value = this.string(key);
if (value !== expected) fail(`${this.what}: ${key} must be "${expected}"`);
return expected;
}
boundedString(key: string, maxCodePoints: number): string {
const value = this.string(key);
if ([...value].length > maxCodePoints) {
fail(`${this.what}: ${key} must be at most ${maxCodePoints} code points`);
}
return value;
}
nullableBoundedString(key: string, maxCodePoints: number): string | null {
return this.value(key) === null ? null : this.boundedString(key, maxCodePoints);
}
/** Refuses fields that were not read. */
finish(): void {
for (const key of Object.keys(this.map)) {
if (!this.seen.has(key)) fail(`${this.what}: unknown field "${key}"`);
}
}
}
/** Fails on the first repeated key, naming it. */
export function requireUnique(keys: readonly string[], what: string): void {
const seen = new Set<string>();
for (const key of keys) {
if (seen.has(key)) fail(`${what}: duplicate "${key}"`);
seen.add(key);
}
}
/** Fails unless `actual` is exactly `expected`, in that order. */
export function requireSameOrder(
actual: readonly string[],
expected: readonly string[],
what: string,
): void {
if (actual.length !== expected.length || actual.some((key, index) => key !== expected[index])) {
fail(
`${what}: must list [${expected.join(', ')}] in that order, found [${actual.join(', ')}]`,
);
}
}
/** A bus `ArtifactRef`, read with the bus's own rules. */
export function readArtifactRef(value: unknown): ArtifactRef {
const reader = new Reader(value, 'ArtifactRef');
const storeId = reader.id('storeId');
const artifactId = reader.id('artifactId');
const generation = reader.u64('generation');
const byteLength = reader.u64('byteLength');
const contentType = reader.string('contentType');
const digestValue = reader.value('digest');
reader.finish();
if (contentType.length < 1 || contentType.length > 127) {
fail('ArtifactRef: contentType must be 1..=127 printable ASCII characters');
}
if (digestValue !== null && !isDigest(digestValue)) {
fail('ArtifactRef: digest must be null or 64 lowercase hex digits');
}
return {
storeId,
artifactId,
generation,
byteLength,
contentType,
digest: digestValue as ArtifactRef['digest'],
};
}
/** The canonical JSON byte length of a value. */
export function canonicalLength(value: unknown): number {
return canonicalize(value).length;
}
/** `parseU64` that throws, for places that have already validated the string. */
export function u64(value: U64): bigint {
const parsed = parseU64(value);
if (parsed === undefined) fail(`${value} is not a canonical U64 string`);
return parsed;
}

View file

@ -1,140 +0,0 @@
/** The domain request/reply envelope of ipc-v1 section 3 and the error codes of section 7. */
import { fail, rejectBusIdentities } from './canonical';
import { bodyDigest, readNullableScope } from './common';
import { Reader } from './reader';
import { type DomainRequestId, type Id, type Scope, domainRequestId } from './scalar';
import { MAX_MESSAGE_CODE_POINTS } from './workers';
export const ERROR_CODES = [
'INVALID_ARGUMENT',
'UNSUPPORTED',
'IDENTITY_MISMATCH',
'STALE_EPOCH',
'STALE_STEP',
'FUTURE_STEP',
'INVALID_PHASE',
'CONFLICT',
'IN_PROGRESS',
'BUSY',
'BUFFER_INVALID',
'RESULT_EXPIRED',
'INCOMPATIBLE_STATE',
'BACKEND_FAILURE',
'INTERNAL',
] as const;
export type ErrorCode = (typeof ERROR_CODES)[number];
export const MUTATION_CERTAINTIES = ['none', 'applied', 'unknown'] as const;
export type MutationCertainty = (typeof MUTATION_CERTAINTIES)[number];
/** The codes raised strictly before any mutation, so their certainty is `none`. */
export const BEFORE_MUTATION: readonly ErrorCode[] = [
'INVALID_ARGUMENT',
'UNSUPPORTED',
'IDENTITY_MISMATCH',
'STALE_EPOCH',
'STALE_STEP',
'FUTURE_STEP',
'INVALID_PHASE',
'CONFLICT',
'IN_PROGRESS',
'BUSY',
'BUFFER_INVALID',
'INCOMPATIBLE_STATE',
];
export interface SessionRpcRequest {
requestId: DomainRequestId;
scope: Scope | null;
params: Record<string, unknown>;
}
export interface SessionRpcSuccess {
type: 'result';
requestId: DomainRequestId;
workerId: Id;
incarnationId: Id;
scope: Scope | null;
result: Record<string, unknown>;
}
export interface SessionRpcFailure {
type: 'error';
requestId: DomainRequestId;
workerId: Id;
incarnationId: Id;
scope: Scope | null;
error: { code: ErrorCode; message: string; mutation: MutationCertainty };
}
export type SessionRpcOutcome = SessionRpcSuccess | SessionRpcFailure;
export function readSessionRpcRequest(value: unknown): SessionRpcRequest {
const reader = new Reader(value, 'SessionRpcRequest');
const request: SessionRpcRequest = {
requestId: domainRequestId(reader.string('requestId')),
scope: readNullableScope(reader.value('scope')),
params: reader.object('params'),
};
reader.finish();
rejectBusIdentities(request.params);
return request;
}
/** The canonical body digest of this request under `method` (ipc-v1 section 5). */
export function requestBodyDigest(request: SessionRpcRequest, method: string): string {
return bodyDigest(method, request.scope, request.params);
}
export function readSessionRpcSuccess(value: unknown): SessionRpcSuccess {
const reader = new Reader(value, 'SessionRpcSuccess');
const success: SessionRpcSuccess = {
type: reader.constant('type', 'result'),
requestId: domainRequestId(reader.string('requestId')),
workerId: reader.id('workerId'),
incarnationId: reader.id('incarnationId'),
scope: readNullableScope(reader.value('scope')),
result: reader.object('result'),
};
reader.finish();
return success;
}
export function readSessionRpcFailure(value: unknown): SessionRpcFailure {
const reader = new Reader(value, 'SessionRpcFailure');
const type = reader.constant('type', 'error');
const requestId = domainRequestId(reader.string('requestId'));
const workerId = reader.id('workerId');
const incarnationId = reader.id('incarnationId');
const scope = readNullableScope(reader.value('scope'));
const errorReader = new Reader(reader.value('error'), 'SessionRpcFailure.error');
const error = {
code: errorReader.enumeration('code', ERROR_CODES),
message: errorReader.boundedString('message', MAX_MESSAGE_CODE_POINTS),
mutation: errorReader.enumeration('mutation', MUTATION_CERTAINTIES),
};
errorReader.finish();
reader.finish();
if (BEFORE_MUTATION.includes(error.code) && error.mutation !== 'none') {
fail(`SessionRpcFailure: ${error.code} is raised before mutation, so mutation is "none"`);
}
return { type, requestId, workerId, incarnationId, scope, error };
}
export function readSessionRpcOutcome(value: unknown): SessionRpcOutcome {
const type = (value as { type?: unknown } | null)?.type;
if (type === 'result') return readSessionRpcSuccess(value);
if (type === 'error') return readSessionRpcFailure(value);
return fail('SessionRpcOutcome: type must be "result" or "error"');
}
/** Replies echo the original scope (ipc-v1 section 3). */
export function echoes(outcome: SessionRpcOutcome, request: SessionRpcRequest): boolean {
const sameScope =
outcome.scope === null || request.scope === null
? outcome.scope === request.scope
: outcome.scope.sessionId === request.scope.sessionId &&
outcome.scope.epoch === request.scope.epoch &&
outcome.scope.step === request.scope.step;
return outcome.requestId === request.requestId && sameScope;
}

View file

@ -1,262 +0,0 @@
/**
* The domain scalars of ipc-v1 section 2, and the four identities that must never be confused.
*
* `Id`, `U64` and `Digest` are the bus encodings (bus-v1 section 3 defers to ipc-v1 for them),
* and `tests/encodings.test.ts` pins the same edge cases the Rust crate pins.
*/
import { fail } from './canonical';
/** `^[a-z0-9][a-z0-9._-]{0,63}$`. */
export type Id = string;
/** `"0"` or `[1-9][0-9]*`, at most 18446744073709551615. A counter, never a JSON number. */
export type U64 = string;
/** 64 lowercase hexadecimal digits (SHA-256). */
export type Digest = string;
const ID = /^[a-z0-9][a-z0-9._-]{0,63}$/;
const U64_TEXT = /^(0|[1-9][0-9]*)$/;
const DIGEST = /^[0-9a-f]{64}$/;
/** The largest U64, as a bigint. */
export const U64_MAX = 18446744073709551615n;
export function isId(value: unknown): value is Id {
return typeof value === 'string' && ID.test(value);
}
export function isDigest(value: unknown): value is Digest {
return typeof value === 'string' && DIGEST.test(value);
}
/** The value a `U64` string denotes, or `undefined` if it is not canonical. */
export function parseU64(value: unknown): bigint | undefined {
if (typeof value !== 'string' || !U64_TEXT.test(value)) return undefined;
const parsed = BigInt(value);
return parsed <= U64_MAX ? parsed : undefined;
}
export function requireU64(value: unknown, what: string): U64 {
if (parseU64(value) === undefined) fail(`${what} is not a canonical U64 string`);
return value as U64;
}
/** RPC method: 1..=128 printable ASCII characters (bus-v1 section 5). */
export function isMethod(value: unknown): boolean {
return (
typeof value === 'string' &&
value.length >= 1 &&
value.length <= 128 &&
[...value].every((character) => {
const point = character.codePointAt(0) ?? 0;
return point >= 0x20 && point <= 0x7e;
})
);
}
// ---------------------------------------------------------------------------------------------
// The four identities
declare const brand: unique symbol;
/** A branded string: assignable only through its own parser. */
type Branded<Name extends string> = string & { readonly [brand]: Name };
/** A bus RPC correlation id, `call-<U64>`. Not a domain operation id. */
export type BusCallId = Branded<'BusCallId'>;
/** A domain operation id, `req-<U64>`. A safe retry keeps it and gets a new `BusCallId`. */
export type DomainRequestId = Branded<'DomainRequestId'>;
/** A delivery (`dlv-<U64>`) or explicit-hold (`own-<U64>`) owner token. Connection-private. */
export type OwnerToken = Branded<'OwnerToken'>;
export type OwnerKind = 'delivery' | 'hold';
function serial(prefix: string, value: unknown): bigint | undefined {
if (typeof value !== 'string' || !value.startsWith(`${prefix}-`)) return undefined;
return parseU64(value.slice(prefix.length + 1));
}
export function isBusCallId(value: unknown): value is BusCallId {
return serial('call', value) !== undefined;
}
export function isDomainRequestId(value: unknown): value is DomainRequestId {
return serial('req', value) !== undefined;
}
export function ownerTokenKind(value: unknown): OwnerKind | undefined {
if (serial('dlv', value) !== undefined) return 'delivery';
if (serial('own', value) !== undefined) return 'hold';
return undefined;
}
export function busCallId(value: unknown): BusCallId {
if (!isBusCallId(value)) fail('a bus callId must be canonical call-<U64>');
return value;
}
export function domainRequestId(value: unknown): DomainRequestId {
if (!isDomainRequestId(value)) fail('a domain requestId must be canonical req-<U64>');
return value;
}
export function ownerToken(value: unknown): OwnerToken {
if (ownerTokenKind(value) === undefined) {
fail('an owner token must be canonical dlv-<U64> or own-<U64>');
}
return value as OwnerToken;
}
/** The naming half of a bus `ArtifactRef`: what identifies the bytes. */
export interface ArtifactIdentity {
storeId: Id;
artifactId: Id;
generation: U64;
}
/** A transient bus artifact reference (bus-v1 section 4). Never an `AssetRef`. */
export interface ArtifactRef {
storeId: Id;
artifactId: Id;
generation: U64;
byteLength: U64;
contentType: string;
digest: Digest | null;
}
export function artifactIdentity(reference: ArtifactRef): ArtifactIdentity {
return {
storeId: reference.storeId,
artifactId: reference.artifactId,
generation: reference.generation,
};
}
// ---------------------------------------------------------------------------------------------
// RationalNs
/** A nanosecond rational: reduced, positive denominator, zero encoded `0/1`. */
export interface RationalNs {
numerator: U64;
denominator: U64;
}
export const RATIONAL_ZERO: RationalNs = { numerator: '0', denominator: '1' };
function gcd(a: bigint, b: bigint): bigint {
let left = a;
let right = b;
while (right !== 0n) {
const rest = left % right;
left = right;
right = rest;
}
return left;
}
function parts(value: RationalNs, what: string): [bigint, bigint] {
const numerator = parseU64(value.numerator);
const denominator = parseU64(value.denominator);
if (numerator === undefined || denominator === undefined) {
fail(`${what}: numerator and denominator are U64 strings`);
}
return [numerator, denominator];
}
/** The canonical-form rules: positive denominator, `0/1` zero, reduced fraction. */
export function validateRational(value: RationalNs, what = 'RationalNs'): void {
const [numerator, denominator] = parts(value, what);
if (denominator === 0n) fail(`${what}: denominator must be positive`);
if (numerator === 0n && denominator !== 1n) fail(`${what}: zero is encoded 0/1`);
if (numerator !== 0n && gcd(numerator, denominator) !== 1n) {
fail(`${what}: fraction must be reduced`);
}
}
/** Reduces, then validates: the constructor for arithmetic results. */
export function reduced(numerator: bigint, denominator: bigint): RationalNs {
if (denominator <= 0n) fail('RationalNs: denominator must be positive');
let n = numerator;
let d = denominator;
if (n === 0n) {
d = 1n;
} else {
const divisor = gcd(n, d);
n /= divisor;
d /= divisor;
}
if (n > U64_MAX || d > U64_MAX) fail('RationalNs: reduced value does not fit U64');
return { numerator: n.toString(), denominator: d.toString() };
}
export function isRationalZero(value: RationalNs): boolean {
return parseU64(value.numerator) === 0n;
}
export function requirePositiveRational(value: RationalNs, what: string): void {
if (isRationalZero(value)) fail(`${what}: duration must be positive`);
}
export function addRational(left: RationalNs, right: RationalNs): RationalNs {
const [ln, ld] = parts(left, 'RationalNs');
const [rn, rd] = parts(right, 'RationalNs');
return reduced(ln * rd + rn * ld, ld * rd);
}
export function subtractRational(left: RationalNs, right: RationalNs): RationalNs {
const [ln, ld] = parts(left, 'RationalNs');
const [rn, rd] = parts(right, 'RationalNs');
const a = ln * rd;
const b = rn * ld;
if (b > a) fail('RationalNs: subtraction would be negative');
return reduced(a - b, ld * rd);
}
export function multiplyRational(value: RationalNs, factor: bigint): RationalNs {
const [numerator, denominator] = parts(value, 'RationalNs');
return reduced(numerator * factor, denominator);
}
export function compareRational(left: RationalNs, right: RationalNs): -1 | 0 | 1 {
const [ln, ld] = parts(left, 'RationalNs');
const [rn, rd] = parts(right, 'RationalNs');
const a = ln * rd;
const b = rn * ld;
return a < b ? -1 : a > b ? 1 : 0;
}
/**
* The step-v1 section 5 accumulator: `ticks = floor(value / tick)` and the remainder
* `value - ticks * tick`, which is always `>= 0` and `< tick`.
*/
export function divideFloor(value: RationalNs, tick: RationalNs): { ticks: U64; remainder: RationalNs } {
requirePositiveRational(tick, 'RationalNs divideFloor tick');
const [vn, vd] = parts(value, 'RationalNs');
const [tn, td] = parts(tick, 'RationalNs');
const ticks = (vn * td) / (vd * tn);
if (ticks > U64_MAX) fail('RationalNs: tick count does not fit U64');
const remainder = subtractRational(value, multiplyRational(tick, ticks));
return { ticks: ticks.toString(), remainder };
}
// ---------------------------------------------------------------------------------------------
// Scope, SchemaRef, TypedValue
/** The simulation timeline identity. Never a bus route or store incarnation. */
export interface Scope {
sessionId: Id;
epoch: Id;
step: U64;
}
export interface SchemaRef {
id: Id;
version: number;
digest: Digest;
}
/** A schema identity plus an object, capped at 32 KiB of canonical JSON. */
export interface TypedValue {
schema: SchemaRef;
value: Record<string, unknown>;
}
/** The canonical-JSON size limit of one `TypedValue`. */
export const MAX_TYPED_VALUE_BYTES = 32 * 1024;

View file

@ -1,57 +0,0 @@
/**
* `seed-derivation-v1`: independent per-agent seeds from one recorded master seed.
*
* The specification is `docs/design/session-framework/seed-derivation-v1.md`, and
* `fixtures/seed-vectors.json` its test vectors, which the Rust crate reproduces.
*/
import { createHash } from 'node:crypto';
import { fail } from './canonical';
import { requireUnique } from './reader';
import { isId, parseU64 } from './scalar';
export const ALGORITHM = 'seed-derivation-v1';
export const PREFIX = 'flybrain/seed-derivation-v1';
/** The exact bytes hashed: prefix, master seed and agent id, each followed by one newline. */
export function material(masterSeed: bigint, agentId: string): Uint8Array {
if (!isId(agentId)) fail('seed derivation: agentId is not a valid id');
if (masterSeed < 0n || masterSeed > 18446744073709551615n) {
fail('seed derivation: the master seed is a U64');
}
return new TextEncoder().encode(`${PREFIX}\n${masterSeed.toString()}\n${agentId}\n`);
}
export function materialDigest(masterSeed: bigint, agentId: string): string {
return createHash('sha256').update(material(masterSeed, agentId)).digest('hex');
}
/**
* The signed 32-bit seed `Agent.Initialize` takes for `agentId`: the first nonzero big-endian
* `u32` lane of the digest, as a two's-complement `i32`.
*/
export function agentSeed(masterSeed: bigint, agentId: string): number {
let bytes = Buffer.from(material(masterSeed, agentId));
for (let round = 0; round < 4; round += 1) {
if (round > 0) bytes = Buffer.concat([bytes, Buffer.from(`${round}\n`, 'utf8')]);
const digest = createHash('sha256').update(bytes).digest();
for (let offset = 0; offset < digest.length; offset += 4) {
const word = digest.readUInt32BE(offset);
if (word !== 0) return word | 0;
}
}
return fail('seed derivation: every lane of four digests was zero');
}
/** The seeds of a whole composition, in the order the agent ids are given. */
export function compositionSeeds(masterSeed: bigint, agentIds: readonly string[]): number[] {
requireUnique(agentIds, 'seed derivation: agentIds');
return agentIds.map((agentId) => agentSeed(masterSeed, agentId));
}
/** Parses a master seed from its `U64` decimal string. */
export function masterSeed(text: string): bigint {
const parsed = parseU64(text);
if (parsed === undefined) fail('seed derivation: the master seed is a canonical U64 string');
return parsed;
}

View file

@ -1,365 +0,0 @@
/**
* The trace format of step-v1 section 8, split into behaviour and operational metadata.
*
* `behaviourEquals` compares the first half only, which is the comparison section 8 asks for:
* sequential, concurrent and reversed runs must agree "excluding wall time, request ids and
* other explicitly operational metadata".
*/
import { canonicalize, digestOf, fail } from './canonical';
import { readRational, readScope } from './common';
import { MAX_VIEWS } from './media';
import { Reader, requireUnique, u64 } from './reader';
import {
type BusCallId,
type Digest,
type DomainRequestId,
type Id,
type OwnerToken,
type RationalNs,
type Scope,
type U64,
busCallId,
domainRequestId,
ownerToken,
} from './scalar';
import { MAX_AGENTS, MAX_RATE_ROLES } from './workers';
import { MAX_SLOTS } from './extensions';
/** Boundary actions at the reached boundary (amendment of 2026-09-23, RT-01a). */
export const BOUNDARY_ACTION_KINDS = ['save-slot', 'rollback'] as const;
export type BoundaryActionKind = (typeof BOUNDARY_ACTION_KINDS)[number];
/** Every slot at most once, plus one rollback. */
export const MAX_BOUNDARY_ACTIONS = MAX_SLOTS + 1;
export interface BoundaryAction {
kind: BoundaryActionKind;
slotId: Id;
stateDigest: Digest | null;
}
/** A checkpoint capture at the reached boundary and how many boundary actions preceded it. */
export interface TraceCapture {
checkpointId: Id;
afterActions: number;
}
export interface TraceAgent {
agentId: Id;
profileDigest: Digest;
ticksAdvanced: U64;
brainTicks: U64;
remainder: RationalNs;
decisionDigest: Digest;
committedStep: U64;
}
export interface TraceObservation {
viewId: Id;
producedStep: U64;
}
export interface TraceBehaviour {
scope: Scope;
agents: TraceAgent[];
batchId: Id;
controlDigest: Digest;
acknowledgedBoundary: U64;
observationBoundaries: TraceObservation[];
outcomeIds: Id[];
eventIds: Id[];
publishedBoundary: U64;
boundaryActions: BoundaryAction[];
}
export interface TraceRequest {
agentId: Id;
requestId: DomainRequestId;
}
export interface TraceOperational {
wallTimeNs: U64;
prepareRequestIds: TraceRequest[];
advanceRequestId: DomainRequestId;
commitRequestIds: TraceRequest[];
busCallIds: BusCallId[];
deliveryIds: OwnerToken[];
captures: TraceCapture[];
}
export interface TransitionTrace {
behaviour: TraceBehaviour;
operational: TraceOperational;
}
export function readTraceBehaviour(value: unknown): TraceBehaviour {
const reader = new Reader(value, 'TraceBehaviour');
const scope = readScope(reader.value('scope'));
const agents = reader.list('agents', 1, MAX_AGENTS, (item) => {
const agent = new Reader(item, 'TraceBehaviour.agents');
const entry: TraceAgent = {
agentId: agent.id('agentId'),
profileDigest: agent.digest('profileDigest'),
ticksAdvanced: agent.u64('ticksAdvanced'),
brainTicks: agent.u64('brainTicks'),
remainder: readRational(agent.value('remainder')),
decisionDigest: agent.digest('decisionDigest'),
committedStep: agent.u64('committedStep'),
};
agent.finish();
return entry;
});
const batchId = reader.id('batchId');
const controlDigest = reader.digest('controlDigest');
const acknowledgedBoundary = reader.u64('acknowledgedBoundary');
const observationBoundaries = reader.list(
'observationBoundaries',
0,
MAX_VIEWS * 2,
(item) => {
const observation = new Reader(item, 'TraceBehaviour.observationBoundaries');
const entry: TraceObservation = {
viewId: observation.id('viewId'),
producedStep: observation.u64('producedStep'),
};
observation.finish();
return entry;
},
);
const outcomeIds = reader.idList('outcomeIds', 0, MAX_RATE_ROLES);
const eventIds = reader.idList('eventIds', 0, MAX_RATE_ROLES);
const publishedBoundary = reader.u64('publishedBoundary');
const boundaryActions = reader.list('boundaryActions', 0, MAX_BOUNDARY_ACTIONS, (item) => {
const action = new Reader(item, 'TraceBehaviour.boundaryActions');
const entry: BoundaryAction = {
kind: action.enumeration('kind', BOUNDARY_ACTION_KINDS),
slotId: action.id('slotId'),
stateDigest: action.value('stateDigest') === null ? null : action.digest('stateDigest'),
};
action.finish();
return entry;
});
reader.finish();
validateBoundaryActions(boundaryActions);
requireUnique(
agents.map((agent) => agent.agentId),
'TraceBehaviour.agents',
);
requireUnique(
observationBoundaries.map((observation) => observation.viewId),
'TraceBehaviour.observationBoundaries',
);
requireUnique(eventIds, 'TraceBehaviour.eventIds');
requireUnique(outcomeIds, 'TraceBehaviour.outcomeIds');
const next = u64(scope.step) + 1n;
for (const agent of agents) {
if (u64(agent.committedStep) !== next) {
fail("TraceBehaviour: every commit acknowledgment is the transition's next boundary");
}
}
if (u64(acknowledgedBoundary) !== next) {
fail('TraceBehaviour: the acknowledged boundary is scope.step + 1');
}
if (u64(publishedBoundary) !== u64(acknowledgedBoundary)) {
fail('TraceBehaviour: the published boundary is the boundary every agent committed');
}
return {
scope,
agents,
batchId,
controlDigest,
acknowledgedBoundary,
observationBoundaries,
outcomeIds,
eventIds,
publishedBoundary,
boundaryActions,
};
}
/** Slot saves first, each slot once with its digest; at most one rollback, last, no digest. */
function validateBoundaryActions(actions: readonly BoundaryAction[]): void {
let rolledBack = false;
const saved: string[] = [];
for (const action of actions) {
if (rolledBack) fail('TraceBehaviour: nothing follows a rollback at the same boundary');
if (action.kind === 'save-slot') {
if (action.stateDigest === null) fail('TraceBehaviour: a slot save records its state digest');
if (saved.includes(action.slotId)) {
fail('TraceBehaviour: a slot is saved at most once per boundary');
}
saved.push(action.slotId);
} else {
if (action.stateDigest !== null) fail('TraceBehaviour: a rollback records no state digest');
rolledBack = true;
}
}
}
/** How many leading boundary actions are slot saves. */
export function slotSaves(behaviour: TraceBehaviour): number {
let count = 0;
for (const action of behaviour.boundaryActions) {
if (action.kind !== 'save-slot') break;
count += 1;
}
return count;
}
export function readTraceOperational(value: unknown): TraceOperational {
const reader = new Reader(value, 'TraceOperational');
const readRequests = (item: unknown): TraceRequest => {
const request = new Reader(item, 'TraceOperational request');
const entry: TraceRequest = {
agentId: request.id('agentId'),
requestId: domainRequestId(request.string('requestId')),
};
request.finish();
return entry;
};
const operational: TraceOperational = {
wallTimeNs: reader.u64('wallTimeNs'),
prepareRequestIds: reader.list('prepareRequestIds', 1, MAX_AGENTS, readRequests),
advanceRequestId: domainRequestId(reader.string('advanceRequestId')),
commitRequestIds: reader.list('commitRequestIds', 1, MAX_AGENTS, readRequests),
busCallIds: reader.list('busCallIds', 0, 64, busCallId),
deliveryIds: reader.list('deliveryIds', 0, 64, ownerToken),
captures: reader.list('captures', 0, MAX_BOUNDARY_ACTIONS + 1, (item) => {
const capture = new Reader(item, 'TraceOperational.captures');
const entry: TraceCapture = {
checkpointId: capture.id('checkpointId'),
afterActions: capture.int('afterActions', 0, MAX_BOUNDARY_ACTIONS),
};
capture.finish();
return entry;
}),
};
reader.finish();
requireUnique(
operational.prepareRequestIds.map((request) => request.agentId),
'TraceOperational.prepareRequestIds',
);
requireUnique(
operational.commitRequestIds.map((request) => request.agentId),
'TraceOperational.commitRequestIds',
);
requireUnique(operational.busCallIds, 'TraceOperational.busCallIds');
requireUnique(operational.deliveryIds, 'TraceOperational.deliveryIds');
requireUnique(
operational.captures.map((capture) => capture.checkpointId),
'TraceOperational.captures',
);
let last = 0;
for (const capture of operational.captures) {
if (capture.afterActions < last) {
fail('TraceOperational: captures are recorded in the order they were taken');
}
last = capture.afterActions;
}
return operational;
}
export function readTransitionTrace(value: unknown): TransitionTrace {
const reader = new Reader(value, 'TransitionTrace');
const trace: TransitionTrace = {
behaviour: readTraceBehaviour(reader.value('behaviour')),
operational: readTraceOperational(reader.value('operational')),
};
reader.finish();
const agents = trace.behaviour.agents.map((agent) => agent.agentId);
for (const phase of [trace.operational.prepareRequestIds, trace.operational.commitRequestIds]) {
for (const request of phase) {
if (!agents.includes(request.agentId)) {
fail(
`TransitionTrace: request recorded for "${request.agentId}", which is not in the transition`,
);
}
}
}
// A slot save due at a boundary completes before any capture there (legacy-gameboy-v1 16).
const saves = slotSaves(trace.behaviour);
for (const capture of trace.operational.captures) {
if (capture.afterActions < saves) {
fail(
`TransitionTrace: capture "${capture.checkpointId}" was taken before this boundary's slot saves completed`,
);
}
if (capture.afterActions > trace.behaviour.boundaryActions.length) {
fail(
`TransitionTrace: capture "${capture.checkpointId}" counts more boundary actions than were applied`,
);
}
}
return trace;
}
/** Sorts the order-free collections, so completion order cannot change the comparison. */
export function normalizeBehaviour(behaviour: TraceBehaviour): TraceBehaviour {
return {
...behaviour,
agents: [...behaviour.agents].sort((left, right) =>
left.agentId < right.agentId ? -1 : left.agentId > right.agentId ? 1 : 0,
),
observationBoundaries: [...behaviour.observationBoundaries].sort((left, right) =>
left.viewId < right.viewId ? -1 : left.viewId > right.viewId ? 1 : 0,
),
};
}
export function behaviourDigest(behaviour: TraceBehaviour): string {
return digestOf(normalizeBehaviour(behaviour));
}
export function behaviourEquals(left: TransitionTrace, right: TransitionTrace): boolean {
return (
canonicalize(normalizeBehaviour(left.behaviour)) ===
canonicalize(normalizeBehaviour(right.behaviour))
);
}
/** The behaviour fields that differ, named. Empty when `behaviourEquals` holds. */
export function behaviourDiff(left: TransitionTrace, right: TransitionTrace): string[] {
const a = normalizeBehaviour(left.behaviour);
const b = normalizeBehaviour(right.behaviour);
const out: string[] = [];
const differs = (first: unknown, second: unknown): boolean =>
canonicalize(first) !== canonicalize(second);
if (differs(a.scope, b.scope)) out.push(`scope: ${canonicalize(a.scope)} vs ${canonicalize(b.scope)}`);
if (a.batchId !== b.batchId) out.push(`batchId: ${a.batchId} vs ${b.batchId}`);
if (a.controlDigest !== b.controlDigest) out.push('controlDigest differs');
if (a.acknowledgedBoundary !== b.acknowledgedBoundary) {
out.push(`acknowledgedBoundary: ${a.acknowledgedBoundary} vs ${b.acknowledgedBoundary}`);
}
if (a.publishedBoundary !== b.publishedBoundary) {
out.push(`publishedBoundary: ${a.publishedBoundary} vs ${b.publishedBoundary}`);
}
if (differs(a.observationBoundaries, b.observationBoundaries)) {
out.push('observationBoundaries differ');
}
if (differs(a.outcomeIds, b.outcomeIds)) out.push('outcomeIds differ');
if (differs(a.eventIds, b.eventIds)) out.push('eventIds differ');
if (differs(a.boundaryActions, b.boundaryActions)) out.push('boundaryActions differ');
const idsA = a.agents.map((agent) => agent.agentId);
const idsB = b.agents.map((agent) => agent.agentId);
if (differs(idsA, idsB)) {
out.push(`agents: [${idsA.join(', ')}] vs [${idsB.join(', ')}]`);
} else {
a.agents.forEach((agent, index) => {
if (differs(agent, b.agents[index])) {
out.push(`agent ${agent.agentId}: behaviour differs`);
}
});
}
return out;
}
/** Two whole runs agree on behaviour, transition by transition. */
export function runsEqual(
left: readonly TransitionTrace[],
right: readonly TransitionTrace[],
): boolean {
return (
left.length === right.length &&
left.every((trace, index) => behaviourEquals(trace, right[index] as TransitionTrace))
);
}

File diff suppressed because it is too large Load diff

View file

@ -1,150 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import {
MAX_ENVELOPE_BYTES,
canonicalize,
digestOf,
parseStrict,
requireEnvelopeFit,
} from '../src/canonical';
import { bodyDigest, operationKeyDigest, readScope } from '../src/common';
import * as fixtures from '../src/fixtures';
const ASTRAL = String.fromCodePoint(0x10400);
const FULLWIDTH_A = String.fromCodePoint(0xff21);
const E_ACUTE = String.fromCodePoint(0xe9);
test('object keys are sorted by UTF-16 code unit', () => {
const value: Record<string, number> = { b: 1, a: 2, A: 3 };
value[E_ACUTE] = 4;
value[ASTRAL] = 5;
value[FULLWIDTH_A] = 6;
assert.equal(
canonicalize(value),
`{"A":3,"a":2,"b":1,"${E_ACUTE}":4,"${ASTRAL}":5,"${FULLWIDTH_A}":6}`,
'an astral key, whose leading surrogate is D801, sorts before U+FF21',
);
});
test('numbers print the way ECMAScript prints them', () => {
const file = fixtures.load('boundaries.json');
for (const item of fixtures.section(file, 'doubles')) {
const record = item as Record<string, unknown>;
const value = record.value as number;
if (record.accept === true) {
assert.equal(canonicalize(value), record.canonical, `${value} prints as its canonical form`);
} else {
assert.throws(() => canonicalize(value), `${value} must be refused`);
}
}
});
test('strings are escaped the way JSON.stringify escapes them', () => {
const bell = String.fromCharCode(7);
const del = String.fromCharCode(0x7f);
const value = { s: ['q"', 'b\\', 't\t', 'n\n', bell, del].join(' ') };
assert.equal(
canonicalize(value),
JSON.stringify(value),
'for a single-key object the two agree exactly, escape for escape',
);
assert.ok(canonicalize(value).includes('\\u0007'), 'a control character uses lowercase \\u');
assert.ok(canonicalize(value).includes(del), 'DEL is not an escape in JSON');
});
test('canonical form does not depend on the input formatting', () => {
const compact = '{"b":[1,2,{"y":true,"x":null}],"a":"z"}';
const pretty = '{\n "a" : "z",\n "b": [ 1, 2, { "x": null, "y": true } ]\n}';
assert.equal(canonicalize(parseStrict(compact)), canonicalize(parseStrict(pretty)));
assert.equal(digestOf(parseStrict(compact)), digestOf(parseStrict(pretty)));
});
test('duplicate keys and invalid UTF-8 never parse', () => {
assert.throws(() => parseStrict('{"a":1,"a":2}'), /duplicate key/);
assert.throws(() => parseStrict('{"a":{"b":1,"b":2}}'), /duplicate key/);
assert.throws(() => parseStrict(new Uint8Array([0x7b, 0x22, 0xff, 0x22, 0x7d])), /UTF-8/);
assert.throws(() => parseStrict('{"a":1} {"b":2}'), /trailing data/);
assert.throws(() => parseStrict('{"a":NaN}'));
assert.throws(() => parseStrict('{"a":Infinity}'));
assert.throws(() => parseStrict('{"a":1'));
assert.throws(() => parseStrict(''));
});
test('an envelope over 64 KiB is refused', () => {
assert.throws(() => requireEnvelopeFit({ pad: 'a'.repeat(MAX_ENVELOPE_BYTES) }, 0));
const small = { pad: 'a' };
const length = canonicalize(small).length;
assert.equal(requireEnvelopeFit(small, MAX_ENVELOPE_BYTES - length), MAX_ENVELOPE_BYTES);
assert.throws(() => requireEnvelopeFit(small, MAX_ENVELOPE_BYTES - length + 1));
});
test('operation keys match the fixture and separate the operations they should', () => {
const file = fixtures.load('operations.json');
const digests: [string, string][] = [];
for (const item of fixtures.section(file, 'keys')) {
const name = fixtures.field(item, 'name');
const digest = operationKeyDigest({
scope: readScope(fixtures.member(item, 'scope')),
method: fixtures.field(item, 'method'),
workerId: fixtures.field(item, 'workerId'),
});
assert.equal(digest, fixtures.field(item, 'digest'), `${name}: operation key digest`);
digests.push([name, digest]);
}
digests.forEach(([name, digest], index) => {
for (const [otherName, other] of digests.slice(index + 1)) {
assert.notEqual(digest, other, `${name} and ${otherName} are different operations`);
}
});
});
test('canonical bodies match the fixture', () => {
const file = fixtures.load('operations.json');
for (const item of fixtures.section(file, 'bodies')) {
const scopeValue = fixtures.member(item, 'scope');
const digest = bodyDigest(
fixtures.field(item, 'method'),
scopeValue === null ? null : readScope(scopeValue),
fixtures.member(item, 'params'),
);
assert.equal(digest, fixtures.field(item, 'digest'), fixtures.field(item, 'name'));
}
});
test('operation pairs agree with the fixture about sameness', () => {
const file = fixtures.load('operations.json');
for (const item of fixtures.section(file, 'pairs')) {
const name = fixtures.field(item, 'name');
const reason = fixtures.field(item, 'reason');
const worker = fixtures.field(item, 'workerId');
const rightWorker = fixtures.optionalField(item, 'rightWorkerId') ?? worker;
const side = (key: string, workerId: string): [string, string] => {
const value = fixtures.member(item, key);
const method = fixtures.field(value, 'method');
const scope = readScope(fixtures.member(value, 'scope'));
const params = fixtures.member(value, 'params');
return [operationKeyDigest({ scope, method, workerId }), bodyDigest(method, scope, params)];
};
const [leftKey, leftBody] = side('left', worker);
const [rightKey, rightBody] = side('right', rightWorker);
const record = item as Record<string, unknown>;
assert.equal(leftKey === rightKey, record.sameKey, `${name}: key sameness. ${reason}`);
assert.equal(leftBody === rightBody, record.sameBody, `${name}: body sameness. ${reason}`);
}
});
test('a domain body can never carry a bus identity', () => {
const file = fixtures.load('operations.json');
for (const item of fixtures.section(file, 'rejected')) {
assert.throws(
() =>
bodyDigest(
fixtures.field(item, 'method'),
readScope(fixtures.member(item, 'scope')),
fixtures.member(item, 'params'),
),
`${fixtures.field(item, 'name')} must be refused`,
);
}
});

View file

@ -1,126 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { canonicalize, parseStrict } from '../src/canonical';
import * as checkpoint from '../src/checkpoint';
import * as fixtures from '../src/fixtures';
function envelopeBytes(): Uint8Array {
const file = fixtures.load('checkpoint-envelope.json');
const envelope = fixtures.member(file, 'envelope');
return fixtures.decodeBase64(fixtures.field(envelope, 'base64'));
}
test('the fixture envelope decodes to its recorded layout', () => {
const file = fixtures.load('checkpoint-envelope.json');
const bytes = envelopeBytes();
const envelope = checkpoint.decode(bytes);
checkpoint.validateManifest(envelope);
const layout = fixtures.member(fixtures.member(file, 'envelope'), 'layout') as Record<
string,
unknown
>;
assert.equal(Buffer.from(bytes.subarray(0, 8)).toString('ascii'), checkpoint.MAGIC);
assert.equal(String(bytes.length), layout.totalBytes);
assert.equal(String(envelope.layout.tableOffset), layout.tableOffset);
assert.equal(envelope.layout.manifestBytes, layout.manifestBytes);
const entries = layout.entries as Record<string, unknown>[];
assert.equal(envelope.layout.entries.length, entries.length);
envelope.layout.entries.forEach((entry, index) => {
const recorded = entries[index]!;
assert.equal(entry.name, recorded.name);
assert.equal(String(entry.offset), recorded.offset);
assert.equal(String(entry.byteLength), recorded.byteLength);
assert.equal(entry.digest, recorded.digest);
assert.equal(entry.offset % 8, 0, 'payloads start on an eight-byte boundary');
});
for (const payload of fixtures.section(file, 'payloads')) {
const name = fixtures.field(payload, 'name');
const expected = fixtures.decodeBase64(fixtures.field(payload, 'base64'));
const found = envelope.payloads.find((candidate) => candidate.name === name);
assert.ok(found, `payload ${name} must be present`);
assert.deepEqual(found.bytes, expected, `payload ${name} must come back byte for byte`);
}
assert.equal(canonicalize(envelope.manifest), canonicalize(fixtures.member(file, 'manifest')));
});
test('every recorded corruption is refused', () => {
const file = fixtures.load('checkpoint-envelope.json');
const bytes = envelopeBytes();
for (const item of fixtures.section(file, 'corruption')) {
const name = fixtures.field(item, 'name');
const offset = (item as Record<string, unknown>).offset as number;
const corrupted = Uint8Array.from(bytes);
corrupted[offset] = (corrupted[offset]! ^ 0x01) & 0xff;
assert.throws(() => checkpoint.decode(corrupted), `${name} must be refused`);
}
assert.throws(() => checkpoint.decode(bytes.subarray(0, bytes.length - 1)));
assert.throws(() => checkpoint.decode(bytes.subarray(0, 8)));
});
test('a FLYSIM01 envelope is not read as a session checkpoint', () => {
const manifest = Buffer.from('{"schemaVersion":2,"chunks":["agent"]}', 'utf8');
const length = Buffer.alloc(4);
length.writeUInt32LE(manifest.length, 0);
const chunkLength = Buffer.alloc(4);
chunkLength.writeUInt32LE(64, 0);
const legacy = Buffer.concat([
Buffer.from('FLYSIM01', 'ascii'),
length,
manifest,
chunkLength,
Buffer.alloc(64),
Buffer.alloc(4), // the CRC32 footer
]);
assert.throws(() => checkpoint.decode(legacy), /magic/);
});
test('the layout is deterministic and the manifest is canonical', () => {
const manifest = parseStrict('{"b":2,"a":1}');
const payloads = [
{ name: 'one', bytes: new TextEncoder().encode('first') },
{ name: 'two', bytes: new Uint8Array(9) },
];
const bytes = checkpoint.encode(manifest, payloads);
assert.deepEqual(bytes, checkpoint.encode(manifest, payloads));
const envelope = checkpoint.decode(bytes);
const start = checkpoint.HEADER_BYTES;
const end = start + envelope.layout.manifestBytes;
assert.equal(Buffer.from(bytes.subarray(start, end)).toString('utf8'), '{"a":1,"b":2}');
assert.throws(() =>
checkpoint.encode(manifest, [
{ name: 'one', bytes: new Uint8Array() },
{ name: 'one', bytes: new Uint8Array() },
]),
);
assert.throws(() => checkpoint.encode(manifest, [{ name: 'One', bytes: new Uint8Array() }]));
});
test('an envelope written here is read by the same rules the Rust crate wrote its fixture with', () => {
const file = fixtures.load('checkpoint-envelope.json');
const manifest = fixtures.member(file, 'manifest');
const payloads = fixtures
.section(file, 'payloads')
.map((payload) => ({
name: fixtures.field(payload, 'name'),
bytes: fixtures.decodeBase64(fixtures.field(payload, 'base64')),
}));
assert.deepEqual(
Uint8Array.from(checkpoint.encode(manifest, payloads)),
Uint8Array.from(envelopeBytes()),
'the two implementations produce the same bytes for the same inputs',
);
});
test('a manifest missing a required field is not a complete checkpoint', () => {
const file = fixtures.load('checkpoint-envelope.json');
const full = fixtures.member(file, 'manifest') as Record<string, unknown>;
for (const field of checkpoint.REQUIRED_MANIFEST_FIELDS) {
const manifest = { ...full };
delete manifest[field];
const envelope = checkpoint.decode(checkpoint.encode(manifest, []));
assert.throws(() => checkpoint.validateManifest(envelope), `without ${field}`);
}
});

View file

@ -1,85 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as fixtures from '../src/fixtures';
import { readCommittedSnapshot, readSessionDescriptor, validateSnapshotAgainst } from '../src/publishing';
import {
findPort,
readEnvironmentDescriptor,
readPortControl,
readSensoryInput,
readStepResult,
readWorldObservation,
validateBatch,
validateObservationAgainst,
validatePortControlAgainst,
validateSensoryInputAgainst,
validateStepResultAgainst,
} from '../src/workers';
import { requiredProducedStep, frameBytes } from '../src/media';
test('every descriptor check lands the way the fixture says', () => {
const file = fixtures.load('descriptor-checks.json');
const descriptor = readEnvironmentDescriptor(fixtures.member(file, 'descriptor'));
const delayed = readEnvironmentDescriptor(fixtures.member(file, 'delayedDescriptor'));
const session = readSessionDescriptor(fixtures.member(file, 'sessionDescriptor'));
const previous = readWorldObservation(fixtures.member(file, 'stepResultPrevious'));
for (const item of fixtures.cases(file)) {
const name = fixtures.field(item, 'name');
const kind = fixtures.field(item, 'kind');
const reason = fixtures.optionalField(item, 'reason') ?? '';
const expectAccept = fixtures.field(item, 'expect') === 'accept';
const value = fixtures.member(item, 'value');
const attempt = () => {
switch (kind) {
case 'portControl': {
const control = readPortControl(value);
const port = findPort(descriptor, control.portId);
if (!port) throw new Error('no such port');
validatePortControlAgainst(control, port.controls);
return;
}
case 'advanceControls': {
const controls = (value as unknown[]).map(readPortControl);
validateBatch(descriptor, controls);
return;
}
case 'sensoryInput':
validateSensoryInputAgainst(readSensoryInput(value), descriptor.views);
return;
case 'sensoryInputDelayed':
validateSensoryInputAgainst(readSensoryInput(value), delayed.views);
return;
case 'worldObservation':
validateObservationAgainst(readWorldObservation(value), descriptor);
return;
case 'stepResult':
validateStepResultAgainst(readStepResult(value), descriptor, previous);
return;
case 'snapshot':
validateSnapshotAgainst(readCommittedSnapshot(value), session);
return;
default:
throw new Error(`unknown descriptor check kind "${kind}"`);
}
};
if (expectAccept) {
assert.doesNotThrow(attempt, `${name} must be accepted. ${reason}`);
} else {
assert.throws(attempt, `${name} must be refused. ${reason}`);
}
}
});
test('the required producing boundary saturates at zero', () => {
const file = fixtures.load('descriptor-checks.json');
const delayed = readEnvironmentDescriptor(fixtures.member(file, 'delayedDescriptor'));
const view = delayed.views[0]!;
assert.equal(view.observationDelaySteps, 2);
assert.equal(requiredProducedStep(view, 0n), 0n);
assert.equal(requiredProducedStep(view, 1n), 0n);
assert.equal(requiredProducedStep(view, 2n), 0n);
assert.equal(requiredProducedStep(view, 3n), 1n);
assert.equal(frameBytes(view), 160 * 4 * 144);
});

View file

@ -1,70 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as fixtures from '../src/fixtures';
import { readArtifactRef } from '../src/reader';
import {
artifactIdentity,
isBusCallId,
isDigest,
isDomainRequestId,
isId,
ownerTokenKind,
parseU64,
} from '../src/scalar';
import { readAssetRef } from '../src/workers';
test('U64 boundaries reject from the fixture', () => {
const file = fixtures.load('boundaries.json');
for (const item of fixtures.section(file, 'u64')) {
const record = item as Record<string, unknown>;
assert.equal(
parseU64(record.text) !== undefined,
record.accept,
`${String(record.text)}: ${String(record.reason)}`,
);
}
});
test('the Id and Digest encodings are the ones the bus uses', () => {
for (const id of ['a', 'fly-a', '0', 'a.b_c-d', 'a'.repeat(64)]) {
assert.ok(isId(id), `${id} is an Id`);
}
for (const id of ['', 'A', '-a', '.a', 'a b', 'fly/a', 'a'.repeat(65)]) {
assert.ok(!isId(id), `${id} is not an Id`);
}
assert.ok(isDigest('a'.repeat(64)));
assert.ok(!isDigest('A'.repeat(64)), 'digests are lowercase');
assert.ok(!isDigest('a'.repeat(63)), 'digests are 64 hex digits');
assert.ok(!isDigest('g'.repeat(64)), 'digests are hexadecimal');
});
test('the four identities never accept each other spellings', () => {
const file = fixtures.load('identities.json');
for (const item of fixtures.cases(file)) {
const record = item as Record<string, unknown>;
const text = record.text as string;
assert.equal(isBusCallId(text), record.busCallId, `busCallId ${text}`);
assert.equal(isDomainRequestId(text), record.domainRequestId, `domainRequestId ${text}`);
assert.equal(ownerTokenKind(text) ?? null, record.ownerToken, `owner token ${text}`);
const accepted = [
isBusCallId(text),
isDomainRequestId(text),
ownerTokenKind(text) !== undefined,
].filter(Boolean).length;
assert.ok(accepted <= 1, `${text} is accepted by more than one identity type`);
}
});
test('an artifact identity is the naming half of an ArtifactRef, and an asset is neither', () => {
const file = fixtures.load('identities.json');
const artifact = fixtures.member(file, 'artifact');
const reference = readArtifactRef(fixtures.member(artifact, 'ref'));
assert.deepEqual(artifactIdentity(reference), fixtures.member(artifact, 'identity'));
const asset = readAssetRef(fixtures.member(file, 'asset'));
assert.notEqual(
asset.id,
reference.artifactId,
'the fixture asset and artifact are deliberately different things',
);
});

View file

@ -1,176 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { canonicalize, digestOf, sha256Hex } from '../src/canonical';
import {
readAgentRollbackParams,
readAgentRollbackResult,
readRestoreSlotParams,
validateAgentRollbackAgainstScope,
validateAgentRollbackResultAgainstScope,
validateRestoreSlotAgainstScope,
} from '../src/extensions';
import * as fixtures from '../src/fixtures';
import * as gameboy from '../src/gameboy';
import { addRational, divideFloor, RATIONAL_ZERO, type Scope } from '../src/scalar';
import { EPISODE_REQUEST_KINDS, readEpisodeRequest } from '../src/workers';
const legacy = () => fixtures.load('gameboy-legacy.json') as Record<string, any>;
test('every registered schema reference is the digest of its declaration, in this language', () => {
const file = legacy();
const declared = new Map<string, unknown>(
(file.extensionSet.payloadSchemas as Record<string, any>[]).map((entry) => [
entry.declaration.id as string,
entry.declaration,
]),
);
for (const schema of gameboy.PAYLOAD_SCHEMAS) {
assert.deepEqual(file.schemaRefs[schema.id], schema, `${schema.id}: the constant is the fixture`);
assert.equal(digestOf(declared.get(schema.id)), schema.digest, `${schema.id}: recomputed here`);
}
assert.equal(digestOf(file.extensionSet), file.extensionSetDigest);
});
test('the legacy profile document is the one profile and its digest is the constant', () => {
const file = legacy();
const document = file.profile.document;
const profile = gameboy.readLegacyGameboyProfile(document);
assert.equal(canonicalize(profile), file.profile.canonical, 'reading keeps every field');
assert.equal(sha256Hex(file.profile.canonical), gameboy.PROFILE_DIGEST);
assert.equal(file.profile.assetRef.digest, gameboy.PROFILE_DIGEST);
assert.equal(file.profile.assetRef.byteLength, String(file.profile.canonical.length));
assert.equal(profile.kernelVersion, 'lif-1ms-f64-v2');
assert.equal(profile.plasticityVersion, 'fly-kc-mbon-rstdp-v2');
assert.equal(profile.datasetFingerprint.split(':').length, 7);
});
test('the frame clock is exact and matches the legacy f64 accumulator', () => {
const legacyMsPerFrame = 1000 / (4_194_304 / 70_224);
assert.equal(legacyMsPerFrame, 548_625 / 32_768, 'the legacy constant is dyadic, so exact');
const frames = legacy().clock.frames as { ticks: string; remainder: unknown }[];
let exact = RATIONAL_ZERO;
let float = 0;
for (let frame = 0; frame < 20_000; frame += 1) {
exact = addRational(exact, gameboy.STEP_DURATION);
const { ticks, remainder } = divideFloor(exact, gameboy.TICK_DURATION);
exact = remainder;
float += legacyMsPerFrame;
const steps = Math.floor(float);
float -= steps;
assert.equal(ticks, String(steps), `frame ${frame}: tick counts agree`);
// The f64 remainder is a multiple of 2^-15 ms; compare it as an exact fraction of ns.
const scaled = float * 32_768;
assert.equal(scaled, Math.floor(scaled), `frame ${frame}: dyadic`);
assert.equal(
BigInt(remainder.numerator) * 32_768n,
BigInt(scaled) * 1_000_000n * BigInt(remainder.denominator),
`frame ${frame}: remainders agree`,
);
if (frame < frames.length) {
assert.equal(frames[frame]!.ticks, ticks);
assert.deepEqual(frames[frame]!.remainder, remainder);
}
}
});
test('the example composition digest is the recorded one, and the decoder moves it', () => {
const file = legacy();
const composition = gameboy.readLegacyGameboyComposition(file.composition.example);
assert.equal(gameboy.compositionDeclarationDigest(composition), file.composition.digest);
const other = structuredClone(composition);
other.decoderConfigDigest = sha256Hex('another decoder');
assert.notEqual(gameboy.compositionDeclarationDigest(other), file.composition.digest);
assert.equal(other.flysimCompatibility, composition.flysimCompatibility);
});
test('bound is an ordered subset, and the macro winner is always bound', () => {
const channels = ['macro_go_objective', 'macro_talk', 'macro_next'];
const context = gameboy.readGameboyReadoutContext({
boot: false,
bound: ['macro_go_objective', 'macro_next'],
location: null,
});
gameboy.validateReadoutContextAgainst(context, channels);
assert.throws(() =>
gameboy.validateReadoutContextAgainst({ ...context, bound: ['macro_next', 'macro_go_objective'] }, channels),
);
const decision = gameboy.readGameboyChannelsDecision({
buttons: gameboy.GAMEBOY_BUTTONS.map((id) => ({ id, down: id === 'up' || id === 'a' })),
macro: 'macro_next',
});
assert.equal(gameboy.channelsMask(decision), 0x11);
gameboy.validateDecisionAgainst(decision, context);
assert.throws(() => gameboy.validateDecisionAgainst({ ...decision, macro: 'macro_talk' }, context));
});
test('typed values are read only under their registered schema', () => {
const value = { boot: true, bound: [], location: null };
gameboy.readGameboyReadoutContextTyped({ schema: gameboy.READOUT_CONTEXT_SCHEMA, value });
assert.throws(() => gameboy.readGameboyReadoutContextTyped({ schema: gameboy.CHANNELS_SCHEMA, value }));
assert.throws(() =>
gameboy.readGameboyReadoutContextTyped({
schema: { ...gameboy.READOUT_CONTEXT_SCHEMA, digest: sha256Hex('x') },
value,
}),
);
});
test('a rollback request and the extension methods check what they must', () => {
assert.deepEqual([...EPISODE_REQUEST_KINDS], ['terminal', 'rollback']);
const request = readEpisodeRequest({
kind: 'rollback',
reason: 'stall',
outcome: { schema: gameboy.ROLLBACK_REQUEST_SCHEMA, value: { slotId: 'best', trigger: 'stall' } },
});
assert.equal(gameboy.readLegacyRatchetRollbackRequestTyped(request.outcome).slotId, 'best');
const newEpoch: Scope = { sessionId: 'live', epoch: 'epoch-8', step: '4101' };
const sameEpoch: Scope = { sessionId: 'live', epoch: 'epoch-7', step: '4101' };
const restore = readRestoreSlotParams({
slotId: 'best',
priorEpoch: 'epoch-7',
policy: 'legacy-ratchet-rollback-v1',
});
validateRestoreSlotAgainstScope(restore, newEpoch);
assert.throws(() => validateRestoreSlotAgainstScope(restore, sameEpoch));
const cases = fixtures.cases(fixtures.load('valid.json')) as Record<string, any>[];
const find = (name: string) => cases.find((item) => item.name === name)!.value;
const rollback = readAgentRollbackParams(find('agent rollback params'));
validateAgentRollbackAgainstScope(rollback, newEpoch);
assert.throws(() => validateAgentRollbackAgainstScope(rollback, { ...newEpoch, step: '4102' }));
const result = readAgentRollbackResult(find('agent rollback result'));
validateAgentRollbackResultAgainstScope(result, newEpoch);
assert.throws(() => validateAgentRollbackResultAgainstScope(result, { ...newEpoch, step: '4100' }));
});
test('the decoderConfigDigest vectors are what the TypeScript oracle preset computes', async () => {
const { gameboyDecoderConfig } = await import('@flybrain/brain');
const file = fixtures.load('gameboy-decoder-config.json') as Record<string, any>;
const cases = file.cases as Record<string, any>[];
assert.deepEqual(
cases.map((item) => item.name),
['raw', 'macros'],
);
for (const item of cases) {
const form = gameboy.decoderConfigForm(gameboyDecoderConfig(item.macroChannels as string[]));
assert.deepEqual(form, item.form, `${item.name}: the oracle's form is the Rust twin's`);
assert.equal(canonicalize(form), item.canonical, `${item.name}: canonical bytes`);
assert.equal(
gameboy.decoderConfigDigest(gameboyDecoderConfig(item.macroChannels as string[])),
item.digest,
`${item.name}: digest`,
);
}
const reversed = [...(cases[1]!.macroChannels as string[])].reverse();
assert.notEqual(
gameboy.decoderConfigDigest(gameboyDecoderConfig(reversed)),
cases[1]!.digest,
'channel order is identity',
);
// The example composition declares the real macros-mode digest and channel set.
const example = legacy().composition.example;
assert.equal(example.decoderConfigDigest, cases[1]!.digest);
assert.deepEqual(example.executor.macroChannels, cases[1]!.macroChannels);
});

View file

@ -1,136 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { canonicalize, parseStrict, requireEnvelopeFit, sha256Hex } from '../src/canonical';
import { MAX_TYPED_VALUE_BYTES } from '../src/scalar';
import { readTypedValue } from '../src/common';
import * as fixtures from '../src/fixtures';
import { READERS, roundTrip } from './readers';
test('every valid case round trips and canonicalizes to its recorded bytes', () => {
const file = fixtures.load('valid.json');
const cases = fixtures.cases(file);
for (const item of cases) {
const name = fixtures.field(item, 'name');
const typeName = fixtures.field(item, 'type');
const value = fixtures.member(item, 'value');
let written: unknown;
try {
written = roundTrip(typeName, value);
} catch (error) {
assert.fail(`${name} (${typeName}) must be accepted: ${String(error)}`);
}
const canonicalIn = canonicalize(value);
assert.equal(
canonicalize(written),
canonicalIn,
`${name}: reading and writing must preserve every field`,
);
assert.equal(canonicalIn, fixtures.field(item, 'canonical'), `${name}: canonical JSON`);
assert.equal(sha256Hex(canonicalIn), fixtures.field(item, 'digest'), `${name}: digest`);
}
assert.ok(cases.length >= 70, 'the valid fixture should stay broad');
});
test('every type this package reads appears in the valid fixture', () => {
const covered = fixtures
.cases(fixtures.load('valid.json'))
.map((item) => fixtures.field(item, 'type'));
const missing = Object.keys(READERS).filter((typeName) => !covered.includes(typeName));
assert.deepEqual(missing, [], 'every readable type needs at least one accepted fixture');
});
test('every invalid case is refused', () => {
const file = fixtures.load('invalid.json');
const cases = fixtures.cases(file);
for (const item of cases) {
const name = fixtures.field(item, 'name');
const typeName = fixtures.field(item, 'type');
const reason = fixtures.field(item, 'reason');
assert.throws(
() => roundTrip(typeName, fixtures.member(item, 'value')),
`${name} (${typeName}) must be refused: ${reason}`,
);
}
assert.ok(cases.length >= 80, 'the invalid fixture should stay broad');
});
test('every raw byte case is refused before or during validation', () => {
for (const item of fixtures.cases(fixtures.load('raw.json'))) {
const name = fixtures.field(item, 'name');
const typeName = fixtures.field(item, 'type');
const reason = fixtures.field(item, 'reason');
const bytes = fixtures.decodeBase64(fixtures.field(item, 'base64'));
// parseStrict is the only door into the readers, so a byte sequence that does not parse
// never reaches validation.
assert.throws(
() => roundTrip(typeName, parseStrict(bytes)),
`${name} must be refused: ${reason}`,
);
}
});
test('generated boundary cases land on the right side of every limit', () => {
const file = fixtures.load('generated.json');
const padSchema = fixtures.member(file, 'padSchema');
for (const item of fixtures.cases(file)) {
const name = fixtures.field(item, 'name');
const kind = fixtures.field(item, 'kind');
const expectAccept = fixtures.field(item, 'expect') === 'accept';
const record = item as Record<string, unknown>;
const attempt = () => {
switch (kind) {
case 'padded-typed-value': {
const pad = 'a'.repeat(record.padCharacters as number);
readTypedValue({ schema: padSchema, value: { pad } });
return;
}
case 'padded-request': {
const pad = 'a'.repeat(record.padCharacters as number);
const total = record.envelopeTotal as number;
const body = roundTrip('SessionRpcRequest', {
requestId: 'req-1',
scope: null,
params: { pad },
});
requireEnvelopeFit(body, total - canonicalize(body).length);
return;
}
case 'error-message':
case 'error-message-astral': {
const character = kind === 'error-message' ? 'x' : '\u{10400}';
const message = character.repeat(record.codePoints as number);
roundTrip('SessionRpcFailure', {
type: 'error',
requestId: 'req-41',
workerId: 'fly-a',
incarnationId: 'inc-1',
scope: null,
error: { code: 'INTERNAL', message, mutation: 'unknown' },
});
return;
}
default:
throw new Error(`unknown generated case kind "${kind}"`);
}
};
if (expectAccept) {
assert.doesNotThrow(attempt, `${name} must be accepted`);
} else {
assert.throws(attempt, `${name} must be refused`);
}
}
});
test('a typed value at the cap is accepted and one byte more is not', () => {
const schema = { id: 'pad.v1', version: 1, digest: sha256Hex('pad.v1') };
const overhead = canonicalize({ schema, value: { pad: '' } }).length;
const atCap = readTypedValue({
schema,
value: { pad: 'a'.repeat(MAX_TYPED_VALUE_BYTES - overhead) },
});
assert.equal(canonicalize(atCap).length, MAX_TYPED_VALUE_BYTES);
assert.throws(() =>
readTypedValue({ schema, value: { pad: 'a'.repeat(MAX_TYPED_VALUE_BYTES - overhead + 1) } }),
);
});

View file

@ -1,98 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { readRational } from '../src/common';
import * as fixtures from '../src/fixtures';
import {
RATIONAL_ZERO,
U64_MAX,
addRational,
compareRational,
divideFloor,
multiplyRational,
reduced,
requirePositiveRational,
subtractRational,
validateRational,
} from '../src/scalar';
test('the accumulator produces the fixture tick counts and remainders', () => {
const file = fixtures.load('rational.json');
for (const item of fixtures.section(file, 'accumulator')) {
const name = fixtures.field(item, 'name');
const step = readRational(fixtures.member(item, 'stepDuration'));
const tick = readRational(fixtures.member(item, 'tickDuration'));
let accumulator = RATIONAL_ZERO;
let total = 0n;
fixtures.section(item, 'steps').forEach((expected, index) => {
accumulator = addRational(accumulator, step);
const { ticks, remainder } = divideFloor(accumulator, tick);
accumulator = remainder;
total += BigInt(ticks);
assert.equal(ticks, fixtures.field(expected, 'ticks'), `${name}: ticks at step ${index}`);
assert.deepEqual(
remainder,
readRational(fixtures.member(expected, 'remainder')),
`${name}: remainder at step ${index}`,
);
assert.ok(compareRational(remainder, tick) < 0, `${name}: remainder below one tick`);
});
assert.equal(total.toString(), fixtures.field(item, 'totalTicks'), `${name}: total ticks`);
}
});
test('checked arithmetic reduces or refuses', () => {
const file = fixtures.load('rational.json');
for (const item of fixtures.section(file, 'add')) {
const left = readRational(fixtures.member(item, 'a'));
const right = readRational(fixtures.member(item, 'b'));
const record = item as Record<string, unknown>;
if (record.sum !== undefined) {
assert.deepEqual(addRational(left, right), readRational(record.sum as never));
} else {
assert.throws(() => addRational(left, right));
}
}
for (const item of fixtures.section(file, 'subtract')) {
const left = readRational(fixtures.member(item, 'a'));
const right = readRational(fixtures.member(item, 'b'));
const record = item as Record<string, unknown>;
if (record.difference !== undefined) {
assert.deepEqual(subtractRational(left, right), readRational(record.difference as never));
} else {
assert.throws(() => subtractRational(left, right));
}
}
for (const item of fixtures.section(file, 'multiply')) {
const value = readRational(fixtures.member(item, 'a'));
const factor = BigInt(fixtures.field(item, 'k'));
const record = item as Record<string, unknown>;
if (record.product !== undefined) {
assert.deepEqual(multiplyRational(value, factor), readRational(record.product as never));
} else {
assert.throws(() => multiplyRational(value, factor));
}
}
for (const item of fixtures.section(file, 'compare')) {
const left = readRational(fixtures.member(item, 'a'));
const right = readRational(fixtures.member(item, 'b'));
const expected = { less: -1, equal: 0, greater: 1 }[fixtures.field(item, 'ordering')];
assert.equal(compareRational(left, right), expected);
}
});
test('zero has exactly one encoding and durations must be positive', () => {
validateRational(RATIONAL_ZERO);
assert.throws(() => validateRational({ numerator: '0', denominator: '2' }), /0\/1/);
assert.throws(() => validateRational({ numerator: '1', denominator: '0' }), /positive/);
assert.throws(() => validateRational({ numerator: '2', denominator: '4' }), /reduced/);
assert.throws(() => requirePositiveRational(RATIONAL_ZERO, 'worldTime'));
assert.throws(() => divideFloor(RATIONAL_ZERO, RATIONAL_ZERO), /positive/);
});
test('reduction refuses a result that does not fit U64', () => {
const big = { numerator: U64_MAX.toString(), denominator: '1' };
assert.throws(() => multiplyRational(big, 2n), /does not fit U64/);
assert.throws(() => addRational(big, big), /does not fit U64/);
assert.deepEqual(reduced(U64_MAX * 2n, 2n), big);
});

View file

@ -1,150 +0,0 @@
/** One place that knows how to read every type named by a fixture. */
import type { Json } from '../src/canonical';
import {
readRational,
readSchemaRef,
readScope,
readTypedValue,
} from '../src/common';
import {
readActivateRestoreParams,
readAudioDescriptor,
readAudioRef,
readCaptureParams,
readCaptureResult,
readStageRestoreParams,
readStageRestoreResult,
readViewDescriptor,
readViewRef,
} from '../src/media';
import {
readAgentRollbackParams,
readAgentRollbackResult,
readRestoreSlotParams,
readRestoreSlotResult,
readSaveSlotParams,
readSaveSlotResult,
} from '../src/extensions';
import {
readGameboyChannelsDecision,
readGameboyMemoryInspection,
readGameboyReadoutContext,
readLegacyGameboyComposition,
readLegacyGameboyProfile,
readLegacyRatchetRollbackRequest,
} from '../src/gameboy';
import { readCommittedSnapshot, readSessionDescriptor } from '../src/publishing';
import {
readSessionRpcFailure,
readSessionRpcRequest,
readSessionRpcSuccess,
} from '../src/rpc';
import {
readTraceBehaviour,
readTraceOperational,
readTransitionTrace,
} from '../src/trace';
import {
readAcknowledgeParams,
readAcknowledgeResult,
readActivateRestoreResult,
readAdvanceParams,
readAgentCommitResult,
readAgentInitializeParams,
readAgentInitializeResult,
readAgentTelemetry,
readAssetRef,
readCommitParams,
readControllerSchema,
readEnvironmentDescriptor,
readEnvironmentInitializeParams,
readEnvironmentInitializeResult,
readEpisodeRequest,
readHelloParams,
readHelloResult,
readPortControl,
readPrepareParams,
readPreparedDecision,
readReward,
readSensoryInput,
readShutdownParams,
readShutdownResult,
readStatusResult,
readStepResult,
readStimulus,
readTaskEvent,
readWorldObservation,
} from '../src/workers';
/** Every type the fixtures name, and the reader that validates it. */
export const READERS: Record<string, (value: unknown) => unknown> = {
Scope: readScope,
RationalNs: readRational,
SchemaRef: readSchemaRef,
TypedValue: readTypedValue,
SessionRpcRequest: readSessionRpcRequest,
SessionRpcSuccess: readSessionRpcSuccess,
SessionRpcFailure: readSessionRpcFailure,
AssetRef: readAssetRef,
SensoryInput: readSensoryInput,
Stimulus: readStimulus,
Reward: readReward,
AgentTelemetry: readAgentTelemetry,
AgentInitializeParams: readAgentInitializeParams,
AgentInitializeResult: readAgentInitializeResult,
PrepareParams: readPrepareParams,
PreparedDecision: readPreparedDecision,
CommitParams: readCommitParams,
AgentCommitResult: readAgentCommitResult,
ControllerSchema: readControllerSchema,
PortControl: readPortControl,
EnvironmentDescriptor: readEnvironmentDescriptor,
EnvironmentInitializeParams: readEnvironmentInitializeParams,
EnvironmentInitializeResult: readEnvironmentInitializeResult,
WorldObservation: readWorldObservation,
AdvanceParams: readAdvanceParams,
StepResult: readStepResult,
HelloParams: readHelloParams,
HelloResult: readHelloResult,
StatusResult: readStatusResult,
AcknowledgeParams: readAcknowledgeParams,
AcknowledgeResult: readAcknowledgeResult,
ShutdownParams: readShutdownParams,
ShutdownResult: readShutdownResult,
TaskEvent: readTaskEvent,
EpisodeRequest: readEpisodeRequest,
ViewDescriptor: readViewDescriptor,
ViewRef: readViewRef,
AudioDescriptor: readAudioDescriptor,
AudioRef: readAudioRef,
CaptureParams: readCaptureParams,
CaptureResult: readCaptureResult,
StageRestoreParams: readStageRestoreParams,
StageRestoreResult: readStageRestoreResult,
ActivateRestoreParams: readActivateRestoreParams,
ActivateRestoreResult: readActivateRestoreResult,
SessionDescriptor: readSessionDescriptor,
CommittedSnapshot: readCommittedSnapshot,
TraceBehaviour: readTraceBehaviour,
TraceOperational: readTraceOperational,
TransitionTrace: readTransitionTrace,
SaveSlotParams: readSaveSlotParams,
SaveSlotResult: readSaveSlotResult,
RestoreSlotParams: readRestoreSlotParams,
RestoreSlotResult: readRestoreSlotResult,
AgentRollbackParams: readAgentRollbackParams,
AgentRollbackResult: readAgentRollbackResult,
GameboyReadoutContext: readGameboyReadoutContext,
GameboyChannelsDecision: readGameboyChannelsDecision,
GameboyMemoryInspection: readGameboyMemoryInspection,
LegacyRatchetRollbackRequest: readLegacyRatchetRollbackRequest,
LegacyGameboyProfile: readLegacyGameboyProfile,
LegacyGameboyComposition: readLegacyGameboyComposition,
};
/** Reads the value as `typeName` and hands back what the reader reconstructed. */
export function roundTrip(typeName: string, value: Json): unknown {
const reader = READERS[typeName];
if (!reader) throw new Error(`no fixture reader for type "${typeName}"`);
return reader(value);
}

View file

@ -1,103 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { canonicalize, digestOf, parseStrict, sha256Hex } from '../src/canonical';
import * as fixtures from '../src/fixtures';
/**
* The schema set is generated by the Rust crate; this side hashes the checked-in file with its
* own canonical JSON and digest, which is the cross-language half of `contractDigest`.
*/
test('the contract digest is the digest of the checked-in schema set', () => {
const bytes = fixtures.loadBytes('schema-set.json');
const text = Buffer.from(bytes).toString('utf8');
assert.ok(text.endsWith('\n'), 'the file is the canonical set plus one newline');
const canonical = text.slice(0, -1);
const recorded = fixtures.load('contract-digest.json') as Record<string, unknown>;
assert.equal(sha256Hex(canonical), recorded.contractDigest);
assert.equal(canonical.length, recorded.schemaSetBytes);
// ... and the file really is canonical JSON: reserializing it changes nothing.
assert.equal(canonicalize(parseStrict(canonical)), canonical);
assert.equal(digestOf(parseStrict(canonical)), recorded.contractDigest);
});
test('the contract digest survives reformatting and changes when a schema changes', () => {
const set = parseStrict(
Buffer.from(fixtures.loadBytes('schema-set.json')).toString('utf8').slice(0, -1),
) as Record<string, unknown>;
const recorded = fixtures.load('contract-digest.json') as Record<string, unknown>;
const pretty = parseStrict(JSON.stringify(set, null, 4));
assert.equal(digestOf(pretty), recorded.contractDigest, 'pretty printing is not a change');
const types = set.types as Record<string, unknown>[];
const renamed = structuredClone(set);
((renamed.types as Record<string, unknown>[])[0]!.fields as Record<string, unknown>[])[0]!.name =
'sessionIdentifier';
assert.notEqual(digestOf(renamed), recorded.contractDigest, 'a renamed field is a change');
const widened = structuredClone(set);
for (const limit of widened.limits as Record<string, unknown>[]) {
if (limit.name === 'maxAgents') limit.value = 8;
}
assert.notEqual(digestOf(widened), recorded.contractDigest, 'a widened bound is a change');
const dropped = structuredClone(set);
(dropped.types as unknown[]).pop();
assert.notEqual(digestOf(dropped), recorded.contractDigest, 'a dropped type is a change');
assert.ok(types.length >= 50, 'the schema set should stay broad');
});
test('the schema set publishes the limits this package enforces', async () => {
const set = parseStrict(
Buffer.from(fixtures.loadBytes('schema-set.json')).toString('utf8').slice(0, -1),
) as Record<string, unknown>;
const limits = new Map(
(set.limits as Record<string, unknown>[]).map((limit) => [
limit.name as string,
limit.value as number,
]),
);
const workers = await import('../src/workers');
const media = await import('../src/media');
const scalar = await import('../src/scalar');
const canonical = await import('../src/canonical');
assert.equal(limits.get('maxAgents'), workers.MAX_AGENTS);
assert.equal(limits.get('maxPorts'), workers.MAX_PORTS);
assert.equal(limits.get('maxRateRoles'), workers.MAX_RATE_ROLES);
assert.equal(limits.get('maxStimuliPerOperation'), workers.MAX_STIMULI);
assert.equal(limits.get('maxRewardsPerOperation'), workers.MAX_REWARDS);
assert.equal(limits.get('maxButtons'), workers.MAX_BUTTONS);
assert.equal(limits.get('maxAxes'), workers.MAX_AXES);
assert.equal(limits.get('maxAcknowledge'), workers.MAX_ACKNOWLEDGE);
assert.equal(limits.get('maxMessageCodePoints'), workers.MAX_MESSAGE_CODE_POINTS);
assert.equal(limits.get('maxViews'), media.MAX_VIEWS);
assert.equal(limits.get('maxViewDimension'), media.MAX_VIEW_DIMENSION);
assert.equal(limits.get('maxSampleFrames'), media.MAX_SAMPLE_FRAMES);
assert.equal(limits.get('maxAudioStreams'), media.MAX_AUDIO_STREAMS);
assert.equal(limits.get('maxTypedValueBytes'), scalar.MAX_TYPED_VALUE_BYTES);
assert.equal(limits.get('maxEnvelopeBytes'), canonical.MAX_ENVELOPE_BYTES);
const extensions = await import('../src/extensions');
assert.equal(limits.get('maxSlots'), extensions.MAX_SLOTS);
});
test('the closed enums this package knows are the ones the schema set declares', async () => {
const set = parseStrict(
Buffer.from(fixtures.loadBytes('schema-set.json')).toString('utf8').slice(0, -1),
) as Record<string, unknown>;
const enums = new Map(
(set.enums as Record<string, unknown>[]).map((entry) => [
entry.name as string,
entry.members as string[],
]),
);
const workers = await import('../src/workers');
const rpc = await import('../src/rpc');
assert.deepEqual(enums.get('ErrorCode'), [...rpc.ERROR_CODES]);
assert.deepEqual(enums.get('MutationCertainty'), [...rpc.MUTATION_CERTAINTIES]);
assert.deepEqual(enums.get('Role'), [...workers.ROLES]);
assert.deepEqual(enums.get('WorkerState'), [...workers.WORKER_STATES]);
assert.deepEqual(enums.get('Recovery'), [...workers.RECOVERY]);
assert.deepEqual(enums.get('Determinism'), [...workers.DETERMINISM]);
assert.deepEqual(enums.get('AxisRange'), [...workers.AXIS_RANGES]);
assert.deepEqual(enums.get('EpisodeRequestKind'), [...workers.EPISODE_REQUEST_KINDS]);
});

View file

@ -1,61 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as fixtures from '../src/fixtures';
import * as seed from '../src/seed';
test('every vector derives its recorded seed', () => {
const file = fixtures.load('seed-vectors.json');
assert.equal((file as Record<string, unknown>).algorithm, seed.ALGORITHM);
const vectors = fixtures.section(file, 'vectors');
for (const item of vectors) {
const master = seed.masterSeed(fixtures.field(item, 'masterSeed'));
const agentId = fixtures.field(item, 'agentId');
assert.equal(
Buffer.from(seed.material(master, agentId)).toString('utf8'),
fixtures.field(item, 'material'),
'the hashed material is part of the specification',
);
assert.equal(seed.materialDigest(master, agentId), fixtures.field(item, 'materialDigest'));
assert.equal(
seed.agentSeed(master, agentId),
(item as Record<string, unknown>).seed,
`seed for ${agentId} under master ${master}`,
);
}
assert.ok(vectors.length >= 20, 'keep the vector table broad');
});
test('one composition gets independent seeds', () => {
const file = fixtures.load('seed-vectors.json');
const composition = fixtures.member(file, 'composition');
const master = seed.masterSeed(fixtures.field(composition, 'masterSeed'));
const ids = fixtures.section(composition, 'agentIds') as string[];
const seeds = seed.compositionSeeds(master, ids);
assert.deepEqual(seeds, fixtures.section(composition, 'seeds'));
assert.equal(new Set(seeds).size, seeds.length, 'per-agent seeds are independent');
assert.ok(
seeds.every((value) => value !== 0),
'a zero seed would stall an xorshift generator',
);
});
test('a different master seed or agent id derives a different seed', () => {
assert.notEqual(seed.agentSeed(0n, 'fly-a'), seed.agentSeed(1n, 'fly-a'));
assert.notEqual(seed.agentSeed(0n, 'fly-a'), seed.agentSeed(0n, 'fly-b'));
assert.equal(seed.agentSeed(7n, 'fly-a'), seed.agentSeed(7n, 'fly-a'));
});
test('invalid inputs are refused rather than normalized', () => {
const file = fixtures.load('seed-vectors.json');
for (const item of fixtures.section(file, 'invalid')) {
const master = seed.masterSeed(fixtures.field(item, 'masterSeed'));
const agentId = fixtures.optionalField(item, 'agentId');
if (agentId !== undefined) {
assert.throws(() => seed.agentSeed(master, agentId), `${agentId} must be refused`);
} else {
const ids = fixtures.section(item, 'agentIds') as string[];
assert.throws(() => seed.compositionSeeds(master, ids));
}
}
});

View file

@ -1,80 +0,0 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import * as fixtures from '../src/fixtures';
import {
behaviourDiff,
behaviourDigest,
behaviourEquals,
readTransitionTrace,
runsEqual,
} from '../src/trace';
test('every variant compares the way the fixture says', () => {
const file = fixtures.load('traces.json');
const baseline = readTransitionTrace(fixtures.member(file, 'baseline'));
for (const item of fixtures.section(file, 'variants')) {
const name = fixtures.field(item, 'name');
const variant = readTransitionTrace(fixtures.member(item, 'trace'));
const expected = (item as Record<string, unknown>).behaviourEquals as boolean;
const diff = behaviourDiff(baseline, variant);
assert.equal(
behaviourEquals(baseline, variant),
expected,
`${name}: behaviour equality. differences: ${JSON.stringify(diff)}`,
);
assert.equal(diff.length === 0, expected, `${name}: the diff is empty exactly when equal`);
const needle = fixtures.optionalField(item, 'diffContains');
if (needle !== undefined) {
assert.ok(
diff.some((line) => line.includes(needle)),
`${name}: the diff should name ${needle}, got ${JSON.stringify(diff)}`,
);
}
if (expected) {
assert.equal(
behaviourDigest(baseline.behaviour),
behaviourDigest(variant.behaviour),
`${name}: equal behaviour has one digest`,
);
}
}
});
test('a whole run compares transition by transition', () => {
const file = fixtures.load('traces.json');
const baseline = readTransitionTrace(fixtures.member(file, 'baseline'));
const variants = fixtures.section(file, 'variants');
const reversed = readTransitionTrace(fixtures.member(variants[0] as never, 'trace'));
const changed = readTransitionTrace(
fixtures.member(
variants.find((item) => fixtures.field(item, 'name') === 'one extra neural tick') as never,
'trace',
),
);
assert.ok(runsEqual([baseline, baseline], [reversed, baseline]));
assert.ok(!runsEqual([baseline], [changed]));
assert.ok(!runsEqual([baseline], [baseline, baseline]));
});
test('operational metadata is recorded and excluded', () => {
const file = fixtures.load('traces.json');
const baseline = readTransitionTrace(fixtures.member(file, 'baseline'));
assert.equal(baseline.operational.busCallIds.length, 3);
assert.equal(baseline.operational.prepareRequestIds.length, 2);
assert.equal(baseline.operational.deliveryIds.length, 2);
const retried = readTransitionTrace(
fixtures.member(
fixtures
.section(file, 'variants')
.find(
(item) =>
fixtures.field(item, 'name') ===
'a safe retry with fresh bus callIds, delivery ids and wall time',
) as never,
'trace',
),
);
assert.notDeepEqual(baseline.operational, retried.operational);
assert.ok(behaviourEquals(baseline, retried));
});

View file

@ -1,19 +0,0 @@
{
"compilerOptions": {
"target": "ES2022",
"useDefineForClassFields": true,
"module": "ESNext",
"lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"],
"types": ["node"],
"skipLibCheck": true,
"moduleResolution": "Bundler",
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"resolveJsonModule": true
},
"include": ["src", "tests"]
}

View file

@ -416,46 +416,6 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "fly-edge"
version = "0.1.1"
dependencies = [
"anyhow",
"axum",
"clap",
"flate2",
"flybus",
"flysim",
"futures-util",
"serde_json",
"tempfile",
"tokio",
"tokio-tungstenite",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "fly-session"
version = "0.1.1"
dependencies = [
"fly-session-types",
"flybus",
"serde_json",
"tempfile",
"tokio",
]
[[package]]
name = "fly-session-types"
version = "0.1.1"
dependencies = [
"flybus",
"ryu-js",
"serde_json",
"sha2",
]
[[package]] [[package]]
name = "flybrain-core" name = "flybrain-core"
version = "0.1.1" version = "0.1.1"
@ -481,18 +441,6 @@ dependencies = [
"sha2", "sha2",
] ]
[[package]]
name = "flybus"
version = "0.1.1"
dependencies = [
"libc",
"serde",
"serde_json",
"sha2",
"tempfile",
"tokio",
]
[[package]] [[package]]
name = "flysim" name = "flysim"
version = "0.1.1" version = "0.1.1"
@ -500,15 +448,12 @@ dependencies = [
"anyhow", "anyhow",
"axum", "axum",
"clap", "clap",
"fly-session-types",
"flybrain-core", "flybrain-core",
"flybrain-gb", "flybrain-gb",
"flybus",
"futures-util", "futures-util",
"jsonschema", "jsonschema",
"serde", "serde",
"serde_json", "serde_json",
"sha2",
"tempfile", "tempfile",
"tokio", "tokio",
"tokio-tungstenite", "tokio-tungstenite",

View file

@ -1,14 +1,6 @@
[workspace] [workspace]
resolver = "3" resolver = "3"
members = [ members = ["crates/flybrain-core", "crates/flybrain-gb", "crates/flysim"]
"crates/fly-edge",
"crates/fly-session",
"crates/fly-session-types",
"crates/flybrain-core",
"crates/flybrain-gb",
"crates/flybus",
"crates/flysim",
]
[workspace.package] [workspace.package]
version = "0.1.1" version = "0.1.1"

View file

@ -1,36 +0,0 @@
[package]
name = "fly-edge"
version.workspace = true
edition = "2024"
rust-version.workspace = true
license.workspace = true
publish = false
description = "The feed WebSocket (:7400) served from flysim's feed bus (FLY_FEED_VIA=bus)."
[lib]
name = "fly_edge"
path = "src/lib.rs"
[[bin]]
name = "fly-edge"
path = "src/main.rs"
[dependencies]
# The feed server and the bus encoding are flysim's own modules (`feed`, `feedbus`,
# `snapshot`), so the edge writes the WebSocket bytes with the code flysim uses in direct mode.
flysim = { path = "../flysim" }
flybus = { path = "../flybus" }
anyhow = "1.0"
axum = { version = "0.8", features = ["ws"] }
clap = { version = "4.5", features = ["derive"] }
tokio = { version = "1", features = ["rt-multi-thread", "net", "sync", "time", "signal", "macros"] }
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
[dev-dependencies]
flate2 = { workspace = true }
futures-util = "0.3"
serde_json = { workspace = true }
tempfile = "3"
tokio-tungstenite = "0.29"

View file

@ -1,344 +0,0 @@
//! `fly-edge`: the feed WebSocket, served from flysim's feed bus.
//!
//! With `FLY_FEED_VIA=bus` flysim does not bind the feed port. It publishes every snapshot on an
//! embedded flybus router (`flysim::feedbus`), and this process subscribes and serves
//! `ws://<feed.bind>/feed` to the stage, the bridge and tests. The contract is still
//! `docs/feed-protocol.md`, byte for byte: the snapshots come off the bus as the same
//! `flysim::snapshot::Snapshot` values and are written by the same `flysim::feed` server, so the
//! per-client `hello`, `wants`, drop-oldest and idle cadence are flysim's own code.
//!
//! Lifecycle (`docs/design/flybus.md`, amendment "Feed store lifecycle"):
//!
//! - the feed port is bound only once the first snapshot has arrived, so before that a client
//! is refused exactly as it would be by a flysim that has not started;
//! - when the bus goes away (flysim stopped or restarted) the edge drops every client and unbinds
//! the port, again exactly what a stopped flysim looks like to the stage, then reconnects every
//! `retry` until a router answers. It never serves a stale snapshot as if it were live.
use std::net::SocketAddr;
use std::path::PathBuf;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::Duration;
use anyhow::{Context, Result, anyhow};
use axum::extract::State;
use axum::http::StatusCode;
use axum::response::IntoResponse;
use axum::routing::get;
use flybus::{Client, ClientConfig, SubscriptionConfig};
use flysim::feed::{self, FeedState};
use flysim::feedbus;
use flysim::metrics::{Metrics, metric};
use tokio::sync::{oneshot, watch};
/// What the edge needs to know. Built from flysim's own configuration, so both processes read
/// one environment file and cannot disagree about the port, the bus directory or the cadence.
#[derive(Debug, Clone)]
pub struct EdgeConfig {
/// `feed.bus_dir`: the router's socket and store root.
pub bus_dir: PathBuf,
/// `feed.bind`, the port flysim leaves alone in bus mode.
pub feed_bind: SocketAddr,
/// `1 / loop.idle_snapshot_hz`, the protocol's header-only cadence.
pub idle_period: Duration,
/// `FLY_EDGE_METRICS_ADDR`: `/metrics` and `/healthz` for the watchdog, when set.
pub metrics_bind: Option<SocketAddr>,
/// Delay between attempts to reach the bus.
pub retry: Duration,
}
impl EdgeConfig {
pub fn from_flysim(config: &flysim::config::Config, metrics_bind: Option<SocketAddr>) -> Self {
Self {
bus_dir: config.feed.bus_dir.clone(),
feed_bind: config.feed.bind,
idle_period: config.publish_periods().1,
metrics_bind,
retry: Duration::from_millis(500),
}
}
}
/// The edge's counters. `feed` is the same `Metrics` type flysim uses, so
/// `fly_frames_sent_total` and `fly_feed_clients` mean exactly what they mean there.
#[derive(Debug, Default)]
pub struct EdgeMetrics {
pub feed: Arc<Metrics>,
/// Snapshots taken off the bus and handed to the feed server.
pub snapshots: AtomicU64,
/// 1 while subscribed and serving.
pub connected: AtomicU64,
/// Times a serving session ended because the bus went away.
pub bus_lost: AtomicU64,
/// Publications that could not be turned back into a snapshot.
pub decode_failures: AtomicU64,
/// Sessions that reached the bus but could not bind the feed port.
pub bind_failures: AtomicU64,
}
impl EdgeMetrics {
pub fn render(&self) -> String {
let mut out = String::with_capacity(1_024);
let feed = &self.feed;
metric(
&mut out,
"fly_frames_sent_total",
"counter",
"Feed snapshots written to a client socket.",
Metrics::get(&feed.frames_sent),
);
metric(
&mut out,
"fly_feed_clients",
"gauge",
"Feed clients currently subscribed.",
feed.clients(),
);
metric(
&mut out,
"fly_feed_dropped_total",
"counter",
"Snapshots superseded before a slow client could be sent them.",
Metrics::get(&feed.feed_dropped),
);
metric(
&mut out,
"fly_edge_snapshots_total",
"counter",
"Snapshots taken off the feed bus.",
self.snapshots.load(Ordering::Relaxed),
);
metric(
&mut out,
"fly_edge_bus_connected",
"gauge",
"1 while the edge is subscribed to the feed bus and serving.",
self.connected.load(Ordering::Relaxed),
);
metric(
&mut out,
"fly_edge_bus_lost_total",
"counter",
"Serving sessions ended by the feed bus going away.",
self.bus_lost.load(Ordering::Relaxed),
);
metric(
&mut out,
"fly_edge_decode_failures_total",
"counter",
"Feed bus publications that did not decode to a snapshot.",
self.decode_failures.load(Ordering::Relaxed),
);
metric(
&mut out,
"fly_edge_bind_failures_total",
"counter",
"Times the bus was reachable but the feed port could not be bound.",
self.bind_failures.load(Ordering::Relaxed),
);
out
}
}
/// Serve until the process is stopped. Only a metrics listener that cannot bind is fatal;
/// everything about the bus is retried.
pub async fn run(config: EdgeConfig, metrics: Arc<EdgeMetrics>) -> Result<()> {
if let Some(addr) = config.metrics_bind {
let listener = tokio::net::TcpListener::bind(addr)
.await
.with_context(|| format!("binding the edge metrics listener on {addr}"))?;
let app = axum::Router::new()
.route("/metrics", get(prometheus))
.route("/healthz", get(healthz))
.with_state(Arc::clone(&metrics));
tokio::spawn(async move {
if let Err(error) = axum::serve(listener, app).await {
tracing::error!(%error, "the edge metrics listener stopped");
}
});
}
// One line per outage of each kind, not one per retry.
let mut last: Option<&'static str> = None;
loop {
let end = session(&config, &metrics).await;
match &end {
SessionEnd::BusLost => {
tracing::warn!("the feed bus went away; clients dropped, reconnecting");
}
SessionEnd::Unreachable(error) if last != Some(end.kind()) => {
tracing::info!(error = format!("{error:#}"), "waiting for the feed bus");
}
SessionEnd::BindFailed(error) if last != Some(end.kind()) => {
// The bus is fine; the port is not ours. Most likely flysim is still in direct
// mode and holds it (FLY_FEED_VIA is not bus), or another process does.
tracing::warn!(
error = format!("{error:#}"),
"the bus is up but the feed port cannot be bound; retrying"
);
}
_ => {}
}
last = match end {
SessionEnd::BusLost => None,
other => Some(other.kind()),
};
tokio::time::sleep(config.retry).await;
}
}
/// Why a [`session`] ended.
enum SessionEnd {
/// No router answered, or it closed before the first snapshot. Nothing was served.
Unreachable(anyhow::Error),
/// Subscribed and holding a snapshot, but the feed port could not be bound.
BindFailed(anyhow::Error),
/// A session that served has ended because the bus went away.
BusLost,
}
impl SessionEnd {
fn kind(&self) -> &'static str {
match self {
Self::Unreachable(_) => "unreachable",
Self::BindFailed(_) => "bind",
Self::BusLost => "lost",
}
}
}
async fn prometheus(State(metrics): State<Arc<EdgeMetrics>>) -> impl IntoResponse {
(
[(
axum::http::header::CONTENT_TYPE,
"text/plain; version=0.0.4",
)],
metrics.render(),
)
}
async fn healthz(State(metrics): State<Arc<EdgeMetrics>>) -> impl IntoResponse {
if metrics.connected.load(Ordering::Relaxed) == 1 {
(StatusCode::OK, "ok")
} else {
(StatusCode::SERVICE_UNAVAILABLE, "waiting for the feed bus")
}
}
/// One subscription's lifetime.
async fn session(config: &EdgeConfig, metrics: &EdgeMetrics) -> SessionEnd {
let (subscription, client, first) = match subscribe(config, metrics).await {
Ok(subscribed) => subscribed,
Err(error) => return SessionEnd::Unreachable(error),
};
let listener = match tokio::net::TcpListener::bind(config.feed_bind).await {
Ok(listener) => listener,
Err(error) => {
metrics.bind_failures.fetch_add(1, Ordering::Relaxed);
return SessionEnd::BindFailed(
anyhow::Error::new(error)
.context(format!("binding the feed listener on {}", config.feed_bind)),
);
}
};
serve(config, metrics, client, subscription, first, listener).await;
SessionEnd::BusLost
}
/// Connect, subscribe and wait for the first snapshot that decodes.
async fn subscribe(
config: &EdgeConfig,
metrics: &EdgeMetrics,
) -> Result<(flybus::Subscription, Client, flysim::snapshot::Snapshot)> {
let client = Client::connect_unix(
feedbus::socket_path(&config.bus_dir),
ClientConfig::new(feedbus::EDGE, feedbus::store_root(&config.bus_dir)),
)
.await
.map_err(|error| anyhow!("connecting to the feed bus: {error}"))?;
// One in flight: while a snapshot is being copied out, the next one waits in the single
// latest slot and anything newer replaces it. The edge is never more than one behind.
let mut subscription = client
.subscribe(
feedbus::TOPIC,
SubscriptionConfig::latest().in_flight(1).replay(true),
)
.await
.map_err(|error| anyhow!("subscribing to {}: {error}", feedbus::TOPIC))?;
let first = loop {
let message = subscription
.next()
.await
.ok_or_else(|| anyhow!("the feed bus closed before the first snapshot"))?;
match feedbus::receive(&message).await {
Ok(snapshot) => break snapshot,
Err(error) => {
metrics.decode_failures.fetch_add(1, Ordering::Relaxed);
tracing::warn!(%error, "a feed bus publication did not decode");
}
}
};
Ok((subscription, client, first))
}
/// Serve `listener` from `subscription` until the bus goes away.
async fn serve(
config: &EdgeConfig,
metrics: &EdgeMetrics,
client: Client,
mut subscription: flybus::Subscription,
first: flysim::snapshot::Snapshot,
listener: tokio::net::TcpListener,
) {
let (snapshots, receiver) = watch::channel(Arc::new(first));
metrics.snapshots.fetch_add(1, Ordering::Relaxed);
tracing::info!(feed = %config.feed_bind, bus = %config.bus_dir.display(), "serving the feed from the bus");
metrics.connected.store(1, Ordering::Relaxed);
let state = FeedState {
snapshots: receiver,
metrics: Arc::clone(&metrics.feed),
idle_period: config.idle_period,
};
let (stop, stopped) = oneshot::channel::<()>();
let server = tokio::spawn(async move {
let result = axum::serve(listener, feed::router(state))
.with_graceful_shutdown(async move {
let _ = stopped.await;
})
.await;
if let Err(error) = result {
tracing::error!(%error, "the feed listener stopped");
}
});
while let Some(message) = subscription.next().await {
match feedbus::receive(&message).await {
Ok(snapshot) => {
drop(message);
snapshots.send_replace(Arc::new(snapshot));
metrics.snapshots.fetch_add(1, Ordering::Relaxed);
}
Err(error) => {
metrics.decode_failures.fetch_add(1, Ordering::Relaxed);
tracing::warn!(%error, "a feed bus publication did not decode");
if client.closed().is_some() {
break;
}
}
}
}
// The bus is gone. Dropping the sender ends every client's pump (a closed stream, as when
// flysim itself stops), and the graceful shutdown unbinds the port.
metrics.connected.store(0, Ordering::Relaxed);
metrics.bus_lost.fetch_add(1, Ordering::Relaxed);
drop(snapshots);
let _ = stop.send(());
if tokio::time::timeout(Duration::from_secs(5), server)
.await
.is_err()
{
tracing::warn!("the feed listener took more than 5 s to stop");
}
}

View file

@ -1,81 +0,0 @@
//! `fly-edge`: serve the feed WebSocket from flysim's feed bus.
//!
//! ```sh
//! FLY_FEED_VIA=bus flysim &
//! fly-edge
//! ```
//!
//! Configured through the same environment as flysim (`FLY_FEED_BIND`, `FLY_BUS_DIR`,
//! `FLYSIM_LOOP_IDLE_SNAPSHOT_HZ`, or `--config flysim.toml`), plus `FLY_EDGE_METRICS_ADDR` for
//! its own `/metrics` and `/healthz`. `infra/units/flyedge.service` runs it with no arguments.
use std::sync::Arc;
use anyhow::{Context, Result};
use clap::Parser;
use fly_edge::{EdgeConfig, EdgeMetrics};
#[derive(Debug, Parser)]
#[command(
name = "fly-edge",
about = "The feed WebSocket, served from flysim's feed bus.",
version
)]
struct Args {
/// Path to `flysim.toml`, read for `[feed]` and `[loop]`. Environment overrides apply as
/// they do for flysim.
#[arg(long, value_name = "PATH")]
config: Option<std::path::PathBuf>,
}
fn main() -> Result<()> {
let args = Args::parse();
init_tracing();
let config = flysim::config::Config::load(args.config.as_deref())?;
let metrics_bind = match std::env::var("FLY_EDGE_METRICS_ADDR") {
Ok(value) if !value.is_empty() => Some(value.parse().with_context(|| {
format!("FLY_EDGE_METRICS_ADDR: {value:?} is not a host:port address")
})?),
_ => None,
};
let edge = EdgeConfig::from_flysim(&config, metrics_bind);
if config.feed.via != flysim::config::FeedVia::Bus {
tracing::warn!(
"FLY_FEED_VIA is not \"bus\": flysim serves the feed itself and binds {}; \
this edge will wait for a bus that is not there",
edge.feed_bind
);
}
tracing::info!(config = ?edge, "fly-edge starting");
let runtime = tokio::runtime::Builder::new_multi_thread()
.worker_threads(2)
.thread_name("fly-edge")
.enable_all()
.build()
.context("building the tokio runtime")?;
runtime.block_on(async move {
let metrics = Arc::new(EdgeMetrics::default());
let mut terminate =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
.context("installing the SIGTERM handler")?;
tokio::select! {
result = fly_edge::run(edge, metrics) => result,
_ = tokio::signal::ctrl_c() => { tracing::info!("SIGINT: shutting down"); Ok(()) }
_ = terminate.recv() => { tracing::info!("SIGTERM: shutting down"); Ok(()) }
}
})
}
/// Logs to stderr, like flysim, under `FLY_EDGE_LOG` (or `RUST_LOG`).
fn init_tracing() {
use tracing_subscriber::EnvFilter;
let filter = EnvFilter::try_from_env("FLY_EDGE_LOG")
.or_else(|_| EnvFilter::try_from_default_env())
.unwrap_or_else(|_| EnvFilter::new("info"));
tracing_subscriber::fmt()
.with_env_filter(filter)
.with_writer(std::io::stderr)
.with_target(false)
.init();
}

View file

@ -1,246 +0,0 @@
#![allow(dead_code)]
//! Shared pieces of the edge tests: the committed `.flyfeed` fixtures as snapshots, a feed
//! client, the two serving paths side by side, and a `.flyfeed` writer.
use std::io::Read as _;
use std::net::SocketAddr;
use std::path::{Path, PathBuf};
use std::sync::Arc;
use std::time::Duration;
use fly_edge::{EdgeConfig, EdgeMetrics};
use flysim::feed::FeedState;
use flysim::feedbus;
use flysim::metrics::Metrics;
use flysim::snapshot::{AttachmentKind, FeedHeader, Snapshot};
use futures_util::{SinkExt as _, StreamExt as _};
use tokio::sync::watch;
use tokio_tungstenite::tungstenite::Message as WsMessage;
pub fn repo_root() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../../..")
.canonicalize()
.expect("the repository root is above services/flysim/crates/fly-edge")
}
/// `u32 LE headerLength | header JSON | (u32 LE length | bytes)*`, split.
pub fn split(message: &[u8]) -> (&[u8], Vec<&[u8]>) {
let read = |at: usize| u32::from_le_bytes(message[at..at + 4].try_into().unwrap()) as usize;
let header_len = read(0);
let header = &message[4..4 + header_len];
let mut at = 4 + header_len;
let mut attachments = Vec::new();
while at < message.len() {
let len = read(at);
attachments.push(&message[at + 4..at + 4 + len]);
at += 4 + len;
}
(header, attachments)
}
/// A wire message back into the snapshot that produced it, or `None` when its header predates
/// fields the Rust producer always writes (the three oldest fixtures lack `game.scene`).
pub fn snapshot_of(message: &[u8]) -> Option<Snapshot> {
let (header, attachments) = split(message);
let header: FeedHeader = serde_json::from_slice(header).ok()?;
let mut snapshot = Snapshot {
header,
frame: Arc::new(Vec::new()),
audio: Arc::new(Vec::new()),
spikes: Arc::new(Vec::new()),
};
for (kind, bytes) in snapshot
.header
.attachments
.clone()
.into_iter()
.zip(attachments)
{
let bytes = Arc::new(bytes.to_vec());
match kind {
AttachmentKind::Frame => snapshot.frame = bytes,
AttachmentKind::Audio => snapshot.audio = bytes,
AttachmentKind::Spikes => snapshot.spikes = bytes,
}
}
Some(snapshot)
}
/// Every record of `apps/stage/public/fixtures/<name>.flyfeed.gz`, as wire messages.
pub fn fixture_messages(name: &str) -> Vec<Vec<u8>> {
let path = repo_root().join(format!("apps/stage/public/fixtures/{name}.flyfeed.gz"));
let gz = std::fs::read(&path).unwrap_or_else(|error| panic!("{}: {error}", path.display()));
let mut bytes = Vec::new();
flate2::read::GzDecoder::new(gz.as_slice())
.read_to_end(&mut bytes)
.unwrap();
assert_eq!(&bytes[..8], b"FLYFEED\0", "{name}");
let read = |at: usize| u32::from_le_bytes(bytes[at..at + 4].try_into().unwrap()) as usize;
assert_eq!(read(8), 1, "{name}: container version");
let mut at = 16 + read(12);
let mut out = Vec::new();
while at < bytes.len() {
let len = read(at);
out.push(bytes[at + 4..at + 4 + len].to_vec());
at += 4 + len;
}
out
}
/// A `.flyfeed` file of `messages` (`packages/feed/src/fixture.ts`).
pub fn encode_flyfeed(name: &str, source: &str, messages: &[Vec<u8>]) -> Vec<u8> {
let wall = |message: &Vec<u8>| -> u64 {
let header: serde_json::Value = serde_json::from_slice(split(message).0).unwrap();
header["wallMs"].as_u64().unwrap_or(0)
};
let duration = match (messages.first(), messages.last()) {
(Some(first), Some(last)) => wall(last).saturating_sub(wall(first)),
_ => 0,
};
let manifest = serde_json::json!({
"name": name,
"protocol": 1,
"recordedAt": "1970-01-01T00:00:00.000Z",
"source": source,
"snapshotCount": messages.len(),
"durationMs": duration,
"hz": 30,
"attachmentPolicy": {
"frame": { "stride": 1 },
"audio": { "stride": 1 },
"spikes": { "stride": 1 }
},
});
let manifest = serde_json::to_vec(&manifest).unwrap();
let mut out = b"FLYFEED\0".to_vec();
out.extend_from_slice(&1u32.to_le_bytes());
out.extend_from_slice(&(manifest.len() as u32).to_le_bytes());
out.extend_from_slice(&manifest);
for message in messages {
out.extend_from_slice(&(message.len() as u32).to_le_bytes());
out.extend_from_slice(message);
}
out
}
pub fn free_port() -> SocketAddr {
std::net::TcpListener::bind("127.0.0.1:0")
.unwrap()
.local_addr()
.unwrap()
}
pub type Ws =
tokio_tungstenite::WebSocketStream<tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>>;
/// Connect and say `hello`, retrying while the port is not bound yet (the edge binds only once
/// its first snapshot has arrived).
pub async fn connect(addr: SocketAddr, wants: &[&str]) -> Ws {
let url = format!("ws://{addr}/feed");
let deadline = tokio::time::Instant::now() + Duration::from_secs(20);
loop {
match tokio_tungstenite::connect_async(&url).await {
Ok((mut ws, _)) => {
let hello = serde_json::json!({ "protocol": 1, "client": "test", "wants": wants });
ws.send(WsMessage::Text(hello.to_string().into()))
.await
.unwrap();
return ws;
}
Err(error) => {
assert!(tokio::time::Instant::now() < deadline, "{url}: {error}");
tokio::time::sleep(Duration::from_millis(50)).await;
}
}
}
}
/// The next binary message, within `within`.
pub async fn next_binary(ws: &mut Ws, within: Duration) -> Vec<u8> {
let deadline = tokio::time::Instant::now() + within;
loop {
let message = tokio::time::timeout_at(deadline, ws.next())
.await
.expect("a snapshot in time")
.expect("the feed stays open")
.expect("a well-formed frame");
if let WsMessage::Binary(bytes) = message {
return bytes.to_vec();
}
}
}
pub fn seq_of(message: &[u8]) -> u64 {
let header: serde_json::Value = serde_json::from_slice(split(message).0).unwrap();
header["seq"].as_u64().unwrap()
}
/// The same watch slot served both ways at once: flysim's direct server on `direct`, and the
/// bus (router, publisher, edge) on `edge`. Owns its runtime-side tasks through the handles.
pub struct Paths {
pub snapshots: watch::Sender<Arc<Snapshot>>,
pub direct: SocketAddr,
pub edge: SocketAddr,
pub publisher_metrics: Arc<Metrics>,
pub edge_metrics: Arc<EdgeMetrics>,
pub bus_dir: tempfile::TempDir,
pub bus: feedbus::BusFeed,
}
/// Idle cadence long enough that no test sees a header repeated for idleness.
pub const NO_IDLE: Duration = Duration::from_secs(3_600);
/// Start both paths over `first`. `edge` false leaves the edge out (a test then plays its part).
pub async fn start(first: Snapshot, with_edge: bool) -> Paths {
let bus_dir = tempfile::tempdir().unwrap();
let (snapshots, receiver) = watch::channel(Arc::new(first));
let bus = feedbus::start_router(bus_dir.path()).await.unwrap();
let publisher_metrics = Arc::new(Metrics::default());
tokio::spawn(feedbus::run_publisher(
bus.router.clone(),
receiver.clone(),
Arc::clone(&publisher_metrics),
));
let direct = free_port();
let listener = tokio::net::TcpListener::bind(direct).await.unwrap();
let state = FeedState {
snapshots: receiver,
metrics: Arc::new(Metrics::default()),
idle_period: NO_IDLE,
};
tokio::spawn(async move { axum::serve(listener, flysim::feed::router(state)).await });
let edge = free_port();
let edge_metrics = Arc::new(EdgeMetrics::default());
if with_edge {
let config = EdgeConfig {
bus_dir: bus_dir.path().to_path_buf(),
feed_bind: edge,
idle_period: NO_IDLE,
metrics_bind: None,
retry: Duration::from_millis(50),
};
tokio::spawn(fly_edge::run(config, Arc::clone(&edge_metrics)));
}
Paths {
snapshots,
direct,
edge,
publisher_metrics,
edge_metrics,
bus_dir,
bus,
}
}
pub fn out_dir() -> Option<PathBuf> {
std::env::var_os("FLY_EDGE_PARITY_OUT").map(PathBuf::from)
}
pub fn write(path: &Path, bytes: &[u8]) {
std::fs::create_dir_all(path.parent().unwrap()).unwrap();
std::fs::write(path, bytes).unwrap();
}

View file

@ -1,301 +0,0 @@
//! Parity: a feed served through the bus and `fly-edge` is the feed flysim serves directly.
//!
//! The committed stage fixtures (`apps/stage/public/fixtures/*.flyfeed.gz`, the recordings the
//! stage's e2e suite plays) are fed snapshot by snapshot into one watch slot, served both ways at
//! once, and recorded by one client per path and per `wants` flavour: the stage's (everything),
//! the bridge's (nothing) and a frame-only one. The two recordings must match: headers equal
//! apart from wall-time fields and attachments byte-equal -- and in fact the whole messages are
//! byte-equal, because both are written by `Snapshot::encode` from equal snapshots. The edge's
//! attachments must also equal the fixture's own.
//!
//! `FLY_EDGE_PARITY_OUT=<dir>` also writes each pair of recordings as `.flyfeed` files, which
//! `packages/feed`'s `decodeFlyfeed` reads. `FLY_EDGE_PARITY_ALL=1` replays whole fixtures
//! instead of their first 400 snapshots.
mod common;
use std::time::Duration;
use common::*;
use serde_json::Value;
/// The fixtures whose headers carry every field the Rust producer writes. The three older ones
/// (`cold-open`, `steady`, `big-moment`) predate `game.scene` and cannot be a Rust `Snapshot`.
const FIXTURES: [&str; 4] = ["macros", "shop", "center", "bigpad"];
const WANTS: [(&str, &[&str]); 3] = [
("all", &["frame", "audio", "spikes"]),
("none", &[]),
("frame", &["frame"]),
];
/// The header with every `wallMs` removed, at any depth.
fn without_wall_time(header: &[u8]) -> Value {
fn strip(value: &mut Value) {
match value {
Value::Object(map) => {
map.remove("wallMs");
map.values_mut().for_each(strip);
}
Value::Array(items) => items.iter_mut().for_each(strip),
_ => {}
}
}
let mut value: Value = serde_json::from_slice(header).unwrap();
strip(&mut value);
value
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_edge_writes_the_bytes_the_direct_feed_writes_for_every_committed_fixture() {
let limit = if std::env::var_os("FLY_EDGE_PARITY_ALL").is_some() {
usize::MAX
} else {
400
};
for name in FIXTURES {
let snapshots: Vec<_> = fixture_messages(name)
.iter()
.take(limit)
.map(|message| {
(
message.clone(),
snapshot_of(message).unwrap_or_else(|| panic!("{name}")),
)
})
.collect();
assert!(
snapshots.len() >= 100,
"{name}: {} snapshots",
snapshots.len()
);
let paths = start(snapshots[0].1.clone(), true).await;
let mut clients = Vec::new();
for (flavour, wants) in WANTS {
let direct = connect(paths.direct, wants).await;
let edge = connect(paths.edge, wants).await;
clients.push((flavour, direct, edge, Vec::new(), Vec::new()));
}
// Lockstep: publish one snapshot, wait until every client has it. Nothing is superseded,
// so both recordings are complete and comparable message by message.
for (index, (_, snapshot)) in snapshots.iter().enumerate() {
if index > 0 {
paths
.snapshots
.send_replace(std::sync::Arc::new(snapshot.clone()));
}
for (_, direct, edge, direct_log, edge_log) in &mut clients {
for (ws, log) in [
(&mut *direct, &mut *direct_log),
(&mut *edge, &mut *edge_log),
] {
let message = next_binary(ws, Duration::from_secs(20)).await;
assert_eq!(seq_of(&message), snapshot.header.seq, "{name} #{index}");
log.push(message);
}
}
}
for (flavour, _, _, direct_log, edge_log) in &clients {
assert_eq!(direct_log.len(), snapshots.len());
assert_eq!(edge_log.len(), direct_log.len());
for (index, (direct, edge)) in direct_log.iter().zip(edge_log).enumerate() {
let (direct_header, direct_attachments) = split(direct);
let (edge_header, edge_attachments) = split(edge);
assert_eq!(
without_wall_time(direct_header),
without_wall_time(edge_header),
"{name}/{flavour} #{index}: headers"
);
assert_eq!(
direct_attachments, edge_attachments,
"{name}/{flavour} #{index}: attachments"
);
// The stronger fact: the whole message, wall times included, is the same bytes.
assert!(direct == edge, "{name}/{flavour} #{index}: messages differ");
if *flavour == "all" {
let (_, fixture_attachments) = split(&snapshots[index].0);
assert_eq!(
edge_attachments, fixture_attachments,
"{name} #{index}: vs the fixture"
);
}
}
if let Some(dir) = out_dir() {
write(
&dir.join(format!("{name}-{flavour}-direct.flyfeed")),
&encode_flyfeed(name, "flysim direct", direct_log),
);
write(
&dir.join(format!("{name}-{flavour}-edge.flyfeed")),
&encode_flyfeed(name, "flysim bus + fly-edge", edge_log),
);
}
}
let published = flysim::metrics::Metrics::get(&paths.publisher_metrics.bus_published);
assert!(
published >= snapshots.len() as u64,
"{name}: {published} published"
);
assert_eq!(
flysim::metrics::Metrics::get(&paths.publisher_metrics.bus_publish_failures),
0
);
eprintln!(
"{name}: {} snapshots x {} flavours identical on both paths",
snapshots.len(),
WANTS.len()
);
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_header_too_large_for_an_envelope_travels_as_an_artifact_and_arrives_intact() {
let mut snapshot = snapshot_of(&fixture_messages("macros")[10]).unwrap();
// Far past flybus's 65,536-byte envelope: 400 events of 200 characters.
for id in 0..400u64 {
snapshot.header.events.push(flysim::snapshot::FeedEvent {
id: 10_000 + id,
wall_ms: 1_757_000_000_000 + id,
brain_ms: 5.0,
kind: flysim::snapshot::FeedEventKind::System,
label: "x".repeat(200),
value: None,
reward_kind: None,
by: None,
});
}
assert!(serde_json::to_vec(&snapshot.header).unwrap().len() > 65_536);
let paths = start(snapshot.clone(), true).await;
let mut direct = connect(paths.direct, &["frame", "audio", "spikes"]).await;
let mut edge = connect(paths.edge, &["frame", "audio", "spikes"]).await;
let direct = next_binary(&mut direct, Duration::from_secs(20)).await;
let edge = next_binary(&mut edge, Duration::from_secs(20)).await;
assert!(direct == edge);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn the_edge_drops_its_clients_and_unbinds_when_the_bus_goes_away_then_comes_back() {
let snapshot = snapshot_of(&fixture_messages("shop")[5]).unwrap();
let paths = start(snapshot.clone(), true).await;
let mut edge = connect(paths.edge, &[]).await;
next_binary(&mut edge, Duration::from_secs(20)).await;
// flysim stopping is its router stopping.
let Paths {
snapshots,
edge: edge_addr,
edge_metrics,
bus_dir,
bus,
..
} = paths;
bus.router.shutdown();
drop(bus);
drop(snapshots);
let closed = tokio::time::timeout(Duration::from_secs(10), async {
use futures_util::StreamExt as _;
loop {
match edge.next().await {
None | Some(Err(_)) => break,
Some(Ok(tokio_tungstenite::tungstenite::Message::Close(_))) => break,
Some(Ok(_)) => continue,
}
}
})
.await;
assert!(closed.is_ok(), "the client was not dropped");
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while tokio::net::TcpStream::connect(edge_addr).await.is_ok() {
assert!(
std::time::Instant::now() < deadline,
"the feed port stayed bound"
);
tokio::time::sleep(Duration::from_millis(50)).await;
}
assert_eq!(
edge_metrics
.connected
.load(std::sync::atomic::Ordering::Relaxed),
0
);
// A new flysim on the same directory: the edge finds it and serves again.
let (snapshots, receiver) = tokio::sync::watch::channel(std::sync::Arc::new(snapshot.clone()));
let bus = flysim::feedbus::start_router(bus_dir.path()).await.unwrap();
tokio::spawn(flysim::feedbus::run_publisher(
bus.router.clone(),
receiver,
std::sync::Arc::new(flysim::metrics::Metrics::default()),
));
let mut edge = connect(edge_addr, &[]).await;
let message = next_binary(&mut edge, Duration::from_secs(20)).await;
assert_eq!(seq_of(&message), snapshot.header.seq);
assert_eq!(
edge_metrics
.bus_lost
.load(std::sync::atomic::Ordering::Relaxed),
1
);
drop(snapshots);
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn an_edge_whose_port_is_taken_keeps_retrying_and_serves_once_it_is_free() {
let snapshot = snapshot_of(&fixture_messages("center")[7]).unwrap();
// Someone else (flysim still in direct mode, say) holds the feed port before the edge starts.
let squatter = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
let port = squatter.local_addr().unwrap();
let bus_dir = tempfile::tempdir().unwrap();
let (snapshots, receiver) = tokio::sync::watch::channel(std::sync::Arc::new(snapshot.clone()));
let bus = flysim::feedbus::start_router(bus_dir.path()).await.unwrap();
tokio::spawn(flysim::feedbus::run_publisher(
bus.router.clone(),
receiver,
std::sync::Arc::new(flysim::metrics::Metrics::default()),
));
let metrics = std::sync::Arc::new(fly_edge::EdgeMetrics::default());
tokio::spawn(fly_edge::run(
fly_edge::EdgeConfig {
bus_dir: bus_dir.path().to_path_buf(),
feed_bind: port,
idle_period: NO_IDLE,
metrics_bind: None,
retry: Duration::from_millis(50),
},
std::sync::Arc::clone(&metrics),
));
// It reaches the bus, fails to bind, and says so rather than claiming to wait for the bus.
let deadline = std::time::Instant::now() + Duration::from_secs(10);
while metrics
.bind_failures
.load(std::sync::atomic::Ordering::Relaxed)
< 3
{
assert!(
std::time::Instant::now() < deadline,
"the edge never reached the bus"
);
tokio::time::sleep(Duration::from_millis(20)).await;
}
assert_eq!(
metrics.connected.load(std::sync::atomic::Ordering::Relaxed),
0
);
assert_eq!(
metrics.bus_lost.load(std::sync::atomic::Ordering::Relaxed),
0
);
drop(squatter);
let mut edge = connect(port, &[]).await;
let message = next_binary(&mut edge, Duration::from_secs(20)).await;
assert_eq!(seq_of(&message), snapshot.header.seq);
assert_eq!(
metrics.connected.load(std::sync::atomic::Ordering::Relaxed),
1
);
drop(snapshots);
drop(bus);
}

View file

@ -1,354 +0,0 @@
//! A slow or absent edge never slows the loop.
//!
//! A thread stands in for the sim loop: flysim's own `Pacer` at realtime speed and 60 Hz Game
//! Boy frames, publishing full-size snapshots (a real 92,160-byte frame, a 17,407-byte spike
//! bitset for 139,255 neurons, 12,800 bytes of audio) into the watch slot every second frame,
//! with `watch::Sender::send`, exactly as `Sim::publish` does. Around it, three kinds of bad
//! consumer:
//!
//! - the edge is up but three of its WebSocket clients never read, so their sockets fill;
//! - the edge's place on the bus is held by a client that opens every subscription it may
//! (4) and never releases a delivery on any of them (the "slow edge", at its worst);
//! - nobody is subscribed at all (the "absent edge").
//!
//! The gated tests assert the claim, and only the claim: the pacer reports no lag, no watch send
//! waits on a consumer, no publication is refused, the store stays bounded, and a healthy client
//! still reaches the newest snapshot. Those hold on a box at any load, because none of them is a
//! rate.
//!
//! How fast the loop's sleeps come back and how many snapshots a debug-build publisher gets
//! through measure the OS scheduler and the CPU left over, not the bus: a starved publisher
//! coalesces by design. Those bounds are in `the_three_scenarios_keep_their_rates`, which is
//! `#[ignore]`d; run it on a quiet box with
//! `cargo test --release -p fly-edge --test stall -- --ignored --nocapture`.
mod common;
use std::sync::Arc;
use std::sync::atomic::{AtomicU64, Ordering};
use std::time::{Duration, Instant};
use common::*;
use flybus::{Client, ClientConfig, SubscriptionConfig};
use flysim::feedbus;
use flysim::metrics::Metrics;
use flysim::pacing::Pacer;
use flysim::snapshot::{AttachmentKind, FeedStatus, Snapshot};
/// A full-size running snapshot from a real fixture frame.
fn full_snapshot() -> Snapshot {
let mut snapshot = snapshot_of(&fixture_messages("macros")[3]).unwrap();
assert_eq!(snapshot.frame.len(), flysim::snapshot::FRAME_BYTES);
snapshot.header.status = FeedStatus::Running;
snapshot.header.attachments = AttachmentKind::ALL.to_vec();
snapshot.spikes = Arc::new(vec![0b1010_0101; 139_255usize.div_ceil(8)]);
snapshot.audio = Arc::new(vec![7; 12_800]);
snapshot
}
struct LoopReport {
frames: u64,
published: u64,
lag_seconds: f64,
worst_send: Duration,
worst_shortfall: f64,
p99_shortfall: f64,
}
/// Run the stand-in loop for `seconds` on its own thread.
fn run_loop(
snapshots: tokio::sync::watch::Sender<Arc<Snapshot>>,
template: Snapshot,
seconds: f64,
) -> LoopReport {
std::thread::spawn(move || {
let frame_ms = flysim::config::GAMEBOY_MS_PER_FRAME;
let mut pacer = Pacer::new(frame_ms, 1.0, Instant::now());
let frames = (seconds * 1000.0 / frame_ms) as u64;
let mut worst_send = Duration::ZERO;
let mut shortfalls = Vec::with_capacity(frames as usize);
let mut seq = template.header.seq;
let mut published = 0;
for frame in 0..frames {
if frame % 2 == 0 {
let mut snapshot = template.clone();
seq += 1;
snapshot.header.seq = seq;
snapshot.header.frame = frame;
let started = Instant::now();
let _ = snapshots.send(Arc::new(snapshot));
worst_send = worst_send.max(started.elapsed());
published += 1;
}
let now = Instant::now();
shortfalls.push(pacer.shortfall_seconds(now));
let sleep = pacer.next_sleep(now);
if !sleep.is_zero() {
std::thread::sleep(sleep);
}
}
shortfalls.sort_by(f64::total_cmp);
LoopReport {
frames,
published,
lag_seconds: pacer.lag_seconds(),
worst_send,
worst_shortfall: *shortfalls.last().unwrap(),
p99_shortfall: shortfalls[shortfalls.len() * 99 / 100],
}
})
.join()
.unwrap()
}
fn print_report(report: &LoopReport, publisher: &Metrics, what: &str) {
eprintln!(
"{what}: {} frames, {} snapshots, lag {:.3} s, worst send {:?}, shortfall p99 {:.2} ms worst {:.2} ms, bus published {} failed {}",
report.frames,
report.published,
report.lag_seconds,
report.worst_send,
report.p99_shortfall * 1e3,
report.worst_shortfall * 1e3,
Metrics::get(&publisher.bus_published),
Metrics::get(&publisher.bus_publish_failures),
);
}
/// The claim: the loop is never held by the bus, whatever the load.
fn assert_unharmed(report: &LoopReport, publisher: &Metrics, what: &str) {
assert_eq!(report.lag_seconds, 0.0, "{what}: the pacer fell behind");
// A watch send is a lock and a swap. A send that waited on a consumer would be a whole
// stall, seconds; 50 ms leaves room for a preempted thread on a loaded box.
assert!(
report.worst_send < Duration::from_millis(50),
"{what}: a send took {:?}",
report.worst_send
);
// A slow or absent consumer is never a reason to refuse a latest publication.
assert_eq!(
Metrics::get(&publisher.bus_publish_failures),
0,
"{what}: a publication was refused"
);
// Coalescing is allowed, stopping is not.
assert!(
Metrics::get(&publisher.bus_published) >= 1,
"{what}: nothing reached the bus"
);
}
/// Rates: meaningful only on a quiet box (see the module comment).
fn assert_rates(report: &LoopReport, publisher: &Metrics, what: &str) {
// Sleep overshoot is absorbed by the next frame; under one frame at p99 means the loop kept
// its absolute deadlines.
assert!(
report.p99_shortfall < 0.016,
"{what}: p99 shortfall {:.2} ms",
report.p99_shortfall * 1e3
);
let published = Metrics::get(&publisher.bus_published);
assert!(
published * 2 >= report.published,
"{what}: only {published} of {} reached the bus",
report.published
);
}
const SECONDS: f64 = 6.0;
/// What a scenario leaves for the rate checks.
struct Outcome {
report: LoopReport,
publisher: Arc<Metrics>,
/// Snapshots the healthy client received, where there is one.
healthy_received: Option<u64>,
}
async fn stalled_clients() -> Outcome {
let template = full_snapshot();
let paths = start(template.clone(), true).await;
// Three stages that said hello and then stopped reading: their sockets fill and stay full.
let mut stalled = Vec::new();
for _ in 0..3 {
stalled.push(connect(paths.edge, &["frame", "audio", "spikes"]).await);
}
// One healthy stage, read continuously.
let mut healthy = connect(paths.edge, &["frame", "audio", "spikes"]).await;
let newest = Arc::new(AtomicU64::new(0));
let received = Arc::new(AtomicU64::new(0));
let reader = {
let (newest, received) = (Arc::clone(&newest), Arc::clone(&received));
tokio::spawn(async move {
loop {
let message = next_binary(&mut healthy, Duration::from_secs(120)).await;
newest.store(seq_of(&message), Ordering::Relaxed);
received.fetch_add(1, Ordering::Relaxed);
}
})
};
let snapshots = paths.snapshots.clone();
let report = tokio::task::spawn_blocking(move || run_loop(snapshots, template, SECONDS))
.await
.unwrap();
print_report(&report, &paths.publisher_metrics, "stalled clients");
assert_unharmed(&report, &paths.publisher_metrics, "stalled clients");
// The healthy client reaches the last snapshot published: the newest one always gets
// through, however many in between were coalesced.
let last = paths.snapshots.borrow().header.seq;
let deadline = Instant::now() + Duration::from_secs(60);
while newest.load(Ordering::Relaxed) < last {
assert!(
Instant::now() < deadline,
"healthy client stuck at {} of {last}",
newest.load(Ordering::Relaxed)
);
tokio::time::sleep(Duration::from_millis(20)).await;
}
// The stalled ones are still connected, not dropped for being slow.
assert_eq!(paths.edge_metrics.feed.clients(), 4);
reader.abort();
drop(stalled);
Outcome {
report,
publisher: Arc::clone(&paths.publisher_metrics),
healthy_received: Some(received.load(Ordering::Relaxed)),
}
}
async fn hoarding_subscriber() -> Outcome {
let template = full_snapshot();
let paths = start(template.clone(), false).await;
// The edge's seat, taken by a subscriber that keeps every delivery it gets.
let client = Client::connect_unix(
feedbus::socket_path(paths.bus_dir.path()),
ClientConfig::new(feedbus::EDGE, feedbus::store_root(paths.bus_dir.path())),
)
.await
.unwrap();
// Every subscription the seat may open, each keeping every delivery at the in-flight cap:
// the worst case the store has to hold (`feedbus::limits`, flybus.md "Feed sizing").
let seats = feedbus::limits().max_subscriptions_per_client;
let mut hoards = Vec::new();
for _ in 0..seats {
let mut subscription = client
.subscribe(
feedbus::TOPIC,
SubscriptionConfig::latest().in_flight(2).replay(true),
)
.await
.unwrap();
hoards.push(tokio::spawn(async move {
let mut kept = Vec::new();
while let Some(message) = subscription.next().await {
kept.push(message);
}
kept.len()
}));
}
assert!(
client
.subscribe(feedbus::TOPIC, SubscriptionConfig::latest())
.await
.is_err(),
"a subscription past max_subscriptions_per_client was admitted"
);
// And the seat is the only one: a second connection as the edge is refused.
assert!(
Client::connect_unix(
feedbus::socket_path(paths.bus_dir.path()),
ClientConfig::new(feedbus::EDGE, feedbus::store_root(paths.bus_dir.path())),
)
.await
.is_err(),
"a second client was admitted on edge.sock"
);
let snapshots = paths.snapshots.clone();
let report = tokio::task::spawn_blocking(move || run_loop(snapshots, template, SECONDS))
.await
.unwrap();
print_report(&report, &paths.publisher_metrics, "hoarding subscriber");
assert_unharmed(&report, &paths.publisher_metrics, "hoarding subscriber");
let stats = paths.bus.router.stats();
eprintln!(
"hoarding subscriber: store {} bytes, retained {} bytes",
stats.store_bytes, stats.retained_bytes
);
// Held: per subscription two in flight and one queued, plus one retained and whatever is
// mid-seal: 4 * 3 + 1 + 2 = 15 snapshots at most. Bounded, not growing with the number
// published.
assert!(
stats.store_bytes <= 15 * 122_367,
"store holds {} bytes",
stats.store_bytes
);
for hoard in hoards {
hoard.abort();
}
Outcome {
report,
publisher: Arc::clone(&paths.publisher_metrics),
healthy_received: None,
}
}
async fn absent_edge() -> Outcome {
let template = full_snapshot();
let paths = start(template.clone(), false).await;
let snapshots = paths.snapshots.clone();
let report = tokio::task::spawn_blocking(move || run_loop(snapshots, template, SECONDS))
.await
.unwrap();
print_report(&report, &paths.publisher_metrics, "absent edge");
assert_unharmed(&report, &paths.publisher_metrics, "absent edge");
let stats = paths.bus.router.stats();
assert!(
stats.store_bytes <= 3 * 122_367,
"store holds {} bytes",
stats.store_bytes
);
Outcome {
report,
publisher: Arc::clone(&paths.publisher_metrics),
healthy_received: None,
}
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn clients_of_the_edge_that_never_read_do_not_lag_the_loop_or_the_healthy_client() {
stalled_clients().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn a_bus_subscriber_that_never_releases_does_not_lag_the_loop_or_fill_the_store() {
hoarding_subscriber().await;
}
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
async fn an_absent_edge_costs_the_loop_nothing() {
absent_edge().await;
}
/// The same three scenarios, plus the rates. A measurement of the box as much as of the bus,
/// so not part of the workspace gate.
#[tokio::test(flavor = "multi_thread", worker_threads = 4)]
#[ignore = "timing: needs a quiet box; run with --release -- --ignored"]
async fn the_three_scenarios_keep_their_rates() {
for (what, outcome) in [
("stalled clients", stalled_clients().await),
("hoarding subscriber", hoarding_subscriber().await),
("absent edge", absent_edge().await),
] {
assert_rates(&outcome.report, &outcome.publisher, what);
if let Some(got) = outcome.healthy_received {
assert!(
got * 2 >= outcome.report.published,
"{what}: the healthy client got only {got} of {}",
outcome.report.published
);
}
}
}

View file

@ -1,16 +0,0 @@
[package]
name = "fly-session-types"
version.workspace = true
edition = "2024"
rust-version.workspace = true
license.workspace = true
publish = false
description = "Session domain scalars, closed enums, method payloads, canonical JSON digests and the step trace format (session-framework CONTRACT-01)."
[dependencies]
# The bus owns the Id/U64/Digest encodings, strict JSON and ArtifactRef; this crate reuses
# them rather than forking their semantics.
flybus = { path = "../flybus" }
ryu-js.workspace = true
serde_json = { workspace = true }
sha2 = { workspace = true }

View file

@ -1,107 +0,0 @@
# fly-session-types
The executable schemas of the session framework: domain scalars, closed enums, method
payloads, canonical JSON, canonical digests and the step trace format.
This crate is CONTRACT-01 of
[`docs/design/session-framework/implementation.md`](../../../../docs/design/session-framework/implementation.md).
It holds no transport, no worker, no coordinator and no store; it never opens a socket or a
file other than its own fixtures. The bus owns the wire
([`flybus`](../flybus)), and this crate owns what the messages mean.
## Layout
| Module | Contents |
| --- | --- |
| `scalar` | `Scope`, `RationalNs`, `SchemaRef`, `TypedValue`, the `DomainType` trait, and `BusCallId` / `DomainRequestId` / `ArtifactIdentity` / `OwnerToken` |
| `canonical` | RFC 8785 canonical JSON, SHA-256 digests, `OperationKey`, canonical bodies, the 64-KiB envelope check |
| `rpc` | `SessionRpcRequest`, `SessionRpcSuccess`, `SessionRpcFailure`, `ErrorCode`, `MutationCertainty` |
| `workers` | The closed enums and every Agent/Environment/Worker method payload of workers-v1 |
| `media` | `ViewDescriptor`, `ViewRef`, `AudioDescriptor`, `AudioRef` and the `State.*` payloads |
| `publishing` | `SessionDescriptor` and `CommittedSnapshot` |
| `trace` | `TraceBehaviour`, `TraceOperational`, `TransitionTrace` and the behaviour comparator |
| `schema` | The canonical schema set and `contract_digest()` |
| `seed` | `seed-derivation-v1` |
| `checkpoint` | The `FLYSESS1` envelope layout |
| `extensions` | The 2026-09-23 extension methods: `Environment.SaveSlot`/`RestoreSlot` (`gameboy-slots-v1`) and `Agent.Rollback` (`legacy-ratchet-rollback-v1`) |
| `gameboy` | The legacy Game Boy composition ([`legacy-gameboy-v1`](../../../../docs/design/session-framework/legacy-gameboy-v1.md)): registered payload schemas, the legacy profile, the composition declaration. Not part of `contractDigest` |
| `fixtures` | Loading `fixtures/`, shared with `packages/session-types` |
`Id`, `U64` and `Digest` are the bus encodings: `scalar` calls into `flybus::wire` instead of
restating them, and `tests/encodings.rs` pins that the two agree for every edge case.
## Reading and validating
Every type implements `DomainType`:
```rust
use fly_session_types::scalar::{DomainType, Scope};
let scope = Scope::from_json(&value)?; // reads, refusing unknown fields, then validates
scope.validate()?; // the cross-field rules, re-runnable
let json = scope.to_json(); // the canonical shape
```
Rules that need another value in hand are separate, because a payload cannot check them alone:
```rust
control.validate_against(&port.controls)?; // complete batch, descriptor order, ranges
input.validate_against(&descriptor.views)?; // max(0, boundary - observationDelaySteps)
result.validate_against(&descriptor, &previous)?; // exactly one stepDuration of world time
snapshot.validate_against(&session_descriptor)?; // revision, agent set, assigned ports
telemetry.validate_against_roles(&profile_roles)?; // rates in profile-defined order
```
## Digests
- `contract_digest()` is the SHA-256 of the canonical schema set (`schema::schema_set()`),
which is a declaration: type names, JSON field names, kinds, bounds and closed enums.
Reformatting this crate cannot change it; changing a field or a bound does.
- `canonical::body_digest(method, scope, params)` is the comparison ipc-v1 section 5 uses to
tell a safe replay from a `CONFLICT`. It refuses a body that carries a bus identity.
- `OperationKey` is `(sessionId, epoch, step, method, workerId)`, and deliberately not the
request id: a changed id for an existing key is the conflict to detect.
## Fixtures
`fixtures/` is loaded by these tests and by `packages/session-types`, so a case is written
once and holds both languages to it.
| File | Contents |
| --- | --- |
| `valid.json` | Payloads every implementation accepts, with their canonical JSON and digest |
| `invalid.json` | Payloads every implementation refuses, each with the rule it breaks |
| `raw.json` | Byte sequences refused before validation: duplicate keys, invalid UTF-8, `NaN`, trailing data |
| `generated.json` | Recipes for payloads too large to store: the 32-KiB and 64-KiB boundaries, 512-code-point messages |
| `boundaries.json` | The `U64` decimal-string and double boundaries |
| `rational.json` | Checked rational arithmetic and the 16, 17, 17 tick accumulator |
| `identities.json` | Which of the four identity types accepts which spelling |
| `descriptor-checks.json` | Rules that need a descriptor: batches, delays, byte shapes, descriptor agreement |
| `operations.json` | Operation keys, canonical bodies and the pairs that are or are not the same operation |
| `traces.json` | A baseline transition and the variants that must or must not compare equal |
| `schema-set.json`, `contract-digest.json` | The canonical schema set and its digest |
| `seed-vectors.json` | `seed-derivation-v1` test vectors |
| `checkpoint-envelope.json` | One `FLYSESS1` envelope, its layout and the corruptions a reader refuses |
| `gameboy-decoder-config.json` | The `decoderConfigDigest` vectors; written and checked by `flysim`'s `legacy_profile_identity` test (`FLY_UPDATE_FIXTURES=1` rewrites), reproduced by `@flybrain/session-types` from the oracle preset |
| `gameboy-legacy.json` | The legacy Game Boy extension set and digest, every registered `SchemaRef`, the legacy profile and its `AssetRef`, the frame clock, an example composition and its digest |
The derived files (`schema-set.json`, `contract-digest.json`, the `canonical`/`digest` fields
of `valid.json`, the digests in `operations.json`, `seed-vectors.json`,
`checkpoint-envelope.json` and `gameboy-legacy.json`) come from
`cargo run -p fly-session-types --example update_fixtures`;
`tests/schema_set.rs` fails if the checked-in files are stale.
## Tests
```sh
cargo test -p fly-session-types
cargo clippy -p fly-session-types --all-targets
```
## Bounds this crate chose
Every bound in the schema set names its source. Seven are marked `crate` because no document
states them: `maxAudioStreams` (8), `maxCapabilities` (32), `maxSupportedMajors` (8),
`maxSupportedStimuli` (64), `maxAssets` (64), `maxSnapshotEvents` (64) and `maxSlots` (4). They exist so an
unbounded array cannot fill an envelope, and they are in the digest, so widening one is a
contract change rather than a quiet edit.

View file

@ -1,402 +0,0 @@
//! Regenerates the derived fixture files.
//!
//! `cargo run -p fly-session-types --example update_fixtures`. `tests/fixtures_current.rs`
//! fails if the checked-in files differ from what this writes, so the digests in the
//! fixtures can never drift from the code that produced them.
use std::collections::BTreeMap;
use fly_session_types::gameboy::{self, LegacyComposition};
use fly_session_types::scalar::{DomainType, RationalNs, Scope};
use fly_session_types::workers::AssetRef;
use fly_session_types::{canonical, checkpoint, fixtures, schema, seed};
use serde_json::{Map, Value, json};
fn main() {
let dir = fixtures::dir();
for (name, contents) in derived() {
let path = dir.join(&name);
std::fs::write(&path, contents).expect("write fixture");
println!("wrote {}", path.display());
}
}
/// Every derived fixture, as `(file name, exact bytes)`.
pub fn derived() -> Vec<(String, String)> {
vec![
("schema-set.json".to_owned(), schema_set()),
("contract-digest.json".to_owned(), contract_digest()),
("valid.json".to_owned(), valid()),
("operations.json".to_owned(), operations()),
("seed-vectors.json".to_owned(), seed_vectors()),
("checkpoint-envelope.json".to_owned(), checkpoint_envelope()),
("gameboy-legacy.json".to_owned(), gameboy_legacy()),
]
}
/// An example legacy composition. The ROM digest is a placeholder -- the real one is computed
/// by the composition that runs, and no ROM identity belongs in a fixture. The macro channels and
/// the decoder digest are the real macros-mode vector of `gameboy-decoder-config.json`, which
/// `flysim`'s `legacy_profile_identity` test computes from `gameboy_decoder_config_with_macros`
/// and the TypeScript test from the oracle preset. The compatibility string is today's, byte for
/// byte, because its segments must agree with the declaration.
pub fn example_composition() -> LegacyComposition {
let pokered = "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b";
LegacyComposition {
composition_id: "pokered-live".to_owned(),
profile: gameboy::profile_asset_ref(),
executor: gameboy::ExecutorDeclaration {
rom: AssetRef {
id: "pokered-rom".to_owned(),
digest: canonical::sha256_hex(b"placeholder: the cartridge digest is the operator's"),
byte_length: 1_048_576,
format: "gb-rom".to_owned(),
},
adapter: "pokered-unique8-v7".to_owned(),
symbol_provenance: pokered.to_owned(),
mode: "macros".to_owned(),
macro_channels: decoder_vector("macros")["macroChannels"]
.as_array()
.expect("macroChannels")
.iter()
.map(|c| c.as_str().expect("channel").to_owned())
.collect(),
},
decoder_config_digest: decoder_vector("macros")["digest"]
.as_str()
.expect("digest")
.to_owned(),
environment: gameboy::EnvironmentDeclaration {
slots: vec!["best".to_owned()],
audio_sample_rate: 48_000,
},
flysim_compatibility: format!(
"{}/pokered-unique8-v7/{}/{}/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:{pokered}/statefmt:199616-x86_64-unknown-linux-gnu",
gameboy::KERNEL_VERSION,
gameboy::FAFB_V783_FINGERPRINT,
gameboy::PLASTICITY_VERSION,
),
}
}
/// One case of the (flysim-written) decoder-config vectors.
fn decoder_vector(name: &str) -> Value {
let file = fixtures::load("gameboy-decoder-config.json").expect("gameboy-decoder-config.json");
fixtures::cases(&file)
.expect("cases")
.iter()
.find(|c| c["name"] == Value::String(name.to_owned()))
.unwrap_or_else(|| panic!("decoder vector {name}"))
.clone()
}
/// The legacy Game Boy extension set, the profile document and its AssetRef, the clock
/// vector and an example composition with its digest.
fn gameboy_legacy() -> String {
let profile = gameboy::legacy_profile().to_json();
let schema_refs: Map<String, Value> = gameboy::PAYLOAD_SCHEMAS
.iter()
.map(|p| (p.id.to_owned(), p.schema_ref().to_json()))
.collect();
// The first frames from a zero remainder: the rational accumulator of step-v1 section 5,
// which the legacy f64 accumulator equals exactly (legacy-gameboy-v1 section 3).
let step = gameboy::step_duration();
let tick = gameboy::tick_duration();
let mut accumulator = RationalNs::ZERO;
let mut frames = Vec::new();
for _ in 0..12 {
accumulator = accumulator.checked_add(&step).expect("no overflow");
let (ticks, remainder) = accumulator.divide_floor(&tick).expect("positive tick");
accumulator = remainder;
frames.push(json!({"ticks": ticks.to_string(), "remainder": remainder.to_json()}));
}
let composition = example_composition();
write(&json!({
"description": "The legacy Game Boy composition (legacy-gameboy-v1): registered payload schemas with their SchemaRef digests, the one legacy profile document and its AssetRef, the frame clock, and an example composition declaration with its digest.",
"extensionSetDigest": gameboy::extension_set_digest(),
"extensionSet": gameboy::extension_set(),
"schemaRefs": schema_refs,
"profile": {
"document": profile,
"canonical": canonical::canonicalize(&profile).expect("canonicalizable"),
"assetRef": gameboy::profile_asset_ref().to_json(),
},
"clock": {
"stepDuration": step.to_json(),
"tickDuration": tick.to_json(),
"legacyMsPerFrame": "1000 / (4194304 / 70224) == 548625/32768 exactly",
"frames": frames,
},
"composition": {
"example": composition.to_json(),
"digest": composition.digest().expect("digest"),
"recipeLines": [
"fly-session/composition-v1",
"session=<sessionId>",
"epoch=<epoch>",
"contract=<contractDigest>",
"agent=<agentId> port=<portId> profile=<profileDigest> (one line per agent)",
"declaration=<this digest> (added by the 2026-09-23 amendment)",
],
},
}))
}
fn write(value: &Value) -> String {
let mut text = serde_json::to_string_pretty(value).expect("serializable");
text.push('\n');
text
}
fn schema_set() -> String {
// The rendered set is itself canonical JSON, so the file the TypeScript package hashes is
// byte for byte what the digest was taken over.
let mut text = schema::schema_set_json().expect("canonicalizable");
text.push('\n');
text
}
fn contract_digest() -> String {
let set = schema::schema_set_json().expect("canonicalizable");
write(&json!({
"description": "contractDigest is the SHA-256 of the canonical schema set in schema-set.json.",
"contractDigest": schema::contract_digest(),
"schemaSetVersion": schema::SCHEMA_SET_VERSION,
"schemaSetBytes": set.len(),
"types": schema::SCHEMAS.len(),
"enums": schema::ENUMS.len(),
"limits": schema::LIMITS.len(),
}))
}
fn valid() -> String {
let mut file = fixtures::load("valid.json").expect("valid.json");
let cases = file
.get_mut("cases")
.and_then(Value::as_array_mut)
.expect("cases");
for case in cases.iter_mut() {
let value = case.get("value").expect("value").clone();
let canonical = canonical::canonicalize(&value).expect("canonicalizable");
let digest = canonical::sha256_hex(canonical.as_bytes());
let map = case.as_object_mut().expect("case object");
map.insert("canonical".to_owned(), Value::String(canonical));
map.insert("digest".to_owned(), Value::String(digest));
}
write(&file)
}
fn operations() -> String {
let mut file = fixtures::load("operations.json").expect("operations.json");
let scope_of = |case: &Value| -> Option<Scope> {
match case.get("scope") {
Some(Value::Null) | None => None,
Some(v) => Some(Scope::from_json(v).expect("scope")),
}
};
for key in file
.get_mut("keys")
.and_then(Value::as_array_mut)
.expect("keys")
{
let scope = scope_of(key).expect("an operation key has a scope");
let method = key.get("method").and_then(Value::as_str).expect("method");
let worker = key.get("workerId").and_then(Value::as_str).expect("workerId");
let digest = canonical::OperationKey::new(scope, method, worker)
.expect("valid key")
.digest()
.expect("digest");
key.as_object_mut()
.expect("object")
.insert("digest".to_owned(), Value::String(digest));
}
for body in file
.get_mut("bodies")
.and_then(Value::as_array_mut)
.expect("bodies")
{
let scope = scope_of(body);
let method = body.get("method").and_then(Value::as_str).expect("method");
let params = body.get("params").expect("params").clone();
let digest =
canonical::body_digest(method, scope.as_ref(), &params).expect("canonical body");
body.as_object_mut()
.expect("object")
.insert("digest".to_owned(), Value::String(digest));
}
write(&file)
}
fn seed_vectors() -> String {
let master_seeds: [u64; 5] = [0, 1, 42, 9_223_372_036_854_775_808, u64::MAX];
let agents = ["fly-a", "fly-b", "fly-c", "fly-d"];
let mut vectors = Vec::new();
for master in master_seeds {
for agent in agents {
let material = seed::material(master, agent).expect("material");
vectors.push(json!({
"masterSeed": master.to_string(),
"agentId": agent,
"material": String::from_utf8(material).expect("utf-8"),
"materialDigest": seed::material_digest(master, agent).expect("digest"),
"seed": seed::agent_seed(master, agent).expect("seed"),
}));
}
}
let composition: Vec<Value> = seed::composition_seeds(
42,
&agents.iter().map(|a| (*a).to_owned()).collect::<Vec<_>>(),
)
.expect("composition")
.into_iter()
.map(Value::from)
.collect();
write(&json!({
"description": "seed-derivation-v1 test vectors. Both languages must reproduce every seed.",
"algorithm": seed::ALGORITHM,
"prefix": seed::PREFIX,
"materialTemplate": "<prefix>\\n<masterSeed>\\n<agentId>\\n",
"rule": "SHA-256 of the material, read as eight big-endian u32 lanes; the first nonzero lane is the seed as a two's-complement i32.",
"vectors": vectors,
"composition": {
"masterSeed": "42",
"agentIds": agents,
"seeds": composition,
"reason": "independent per-agent seeds from one recorded master seed and stable agent ids",
},
"invalid": [
{"masterSeed": "0", "agentId": "Fly-A", "reason": "an agent id is an Id: lowercase"},
{"masterSeed": "0", "agentId": "", "reason": "an agent id is 1..=64 characters"},
{"masterSeed": "0", "agentIds": ["fly-a", "fly-a"],
"reason": "a composition with a repeated agent id is refused rather than silently sharing a seed"},
],
}))
}
fn checkpoint_envelope() -> String {
let scope = Scope::new("demo", "epoch-1", 42).expect("scope");
let manifest = json!({
"envelopeVersion": checkpoint::VERSION,
"checkpointId": "ckpt-1",
"sourceScope": scope.to_json(),
"episodeId": "episode-1",
"worldTime": {"numerator": "700000000", "denominator": "1"},
"schedulerId": "lockstep-v1",
"compositionDigest": canonical::sha256_hex(b"composition"),
"portMap": [{"portId": "port-1", "agentId": "fly-a"}],
"compatibility": {
"backendDigest": canonical::sha256_hex(b"backend"),
"contentDigest": canonical::sha256_hex(b"content"),
"patchDigest": canonical::sha256_hex(b"patch"),
"controllerDigest": canonical::sha256_hex(b"controller"),
"parserDigest": canonical::sha256_hex(b"parser"),
"stateFormatId": "flysess-1",
},
"agents": [{
"agentId": "fly-a",
"profileDigest": canonical::sha256_hex(b"profile"),
"datasetDigest": canonical::sha256_hex(b"fafb-v783"),
"modelVersion": "lif-1ms-f64-v2",
"plasticityVersion": "fly-kc-mbon-rstdp-v2",
"seed": seed::agent_seed(42, "fly-a").expect("seed"),
"brainTicks": "2534",
"remainder": {"numerator": "1000000", "denominator": "3"},
"payload": "agent-fly-a",
}],
"coordinator": {
"taskLedger": "task-ledger",
"priorInspection": "prior-inspection",
"executorState": [{"agentId": "fly-a", "payload": "executor-fly-a"}],
"admissionState": null,
"eventWatermarks": {"lastSourceStep": "42", "issued": "7"},
},
"environment": {"workerId": "arena", "payload": "world"},
"helperState": [],
"payloads": payload_table(),
});
let bytes = checkpoint::encode(&manifest, &payloads()).expect("encode");
let envelope = checkpoint::decode(&bytes).expect("decode");
let entries: Vec<Value> = envelope
.layout
.entries
.iter()
.map(|entry| {
json!({
"name": entry.name,
"offset": entry.offset.to_string(),
"byteLength": entry.byte_length.to_string(),
"digest": checkpoint::hex(&entry.digest),
})
})
.collect();
let first_payload = envelope.layout.entries[0].offset;
write(&json!({
"description": "One FLYSESS1 envelope, its layout and the corruptions a reader must refuse.",
"magic": "FLYSESS1",
"footerMagic": "FLYSESSF",
"version": checkpoint::VERSION,
"manifest": manifest,
"payloads": payloads()
.iter()
.map(|(name, bytes)| json!({"name": name, "base64": fixtures::encode_base64(bytes)}))
.collect::<Vec<_>>(),
"envelope": {
"base64": fixtures::encode_base64(&bytes),
"byteLength": bytes.len(),
"layout": {
"headerBytes": checkpoint::HEADER_BYTES,
"manifestOffset": envelope.layout.manifest_offset.to_string(),
"manifestBytes": envelope.layout.manifest_bytes,
"tableOffset": envelope.layout.table_offset.to_string(),
"tableEntryBytes": checkpoint::TABLE_ENTRY_BYTES,
"entries": entries,
"footerOffset": envelope.layout.footer_offset.to_string(),
"footerBytes": checkpoint::FOOTER_BYTES,
"totalBytes": envelope.layout.total_bytes.to_string(),
},
},
"corruption": [
{"name": "a flipped magic byte", "offset": 0, "reason": "wrong magic"},
{"name": "an unsupported version", "offset": 8, "reason": "unsupported version"},
{"name": "a flipped manifest byte", "offset": checkpoint::HEADER_BYTES,
"reason": "the footer digest covers the manifest"},
{"name": "a flipped payload byte", "offset": first_payload,
"reason": "every payload carries its own digest"},
{"name": "a flipped footer digest byte", "offset": bytes.len() - 40,
"reason": "the footer digest must match the contents"},
{"name": "a flipped footer magic byte", "offset": bytes.len() - 8,
"reason": "a truncated file cannot look complete"},
],
}))
}
fn payloads() -> Vec<(String, Vec<u8>)> {
vec![
("agent-fly-a".to_owned(), b"agent state bytes".to_vec()),
("executor-fly-a".to_owned(), b"executor state".to_vec()),
("task-ledger".to_owned(), b"{\"rank\":10}".to_vec()),
("prior-inspection".to_owned(), b"{\"map\":40}".to_vec()),
("world".to_owned(), vec![0u8; 64]),
]
}
fn payload_table() -> Value {
let mut out = Vec::new();
for (name, bytes) in payloads() {
let mut entry = Map::new();
entry.insert("name".to_owned(), Value::String(name));
entry.insert(
"byteLength".to_owned(),
Value::String(bytes.len().to_string()),
);
entry.insert(
"digest".to_owned(),
Value::String(canonical::sha256_hex(&bytes)),
);
out.push(Value::Object(entry));
}
// A BTreeMap would sort the payload names; the table order is the write order, which is
// what the envelope records.
let _: BTreeMap<(), ()> = BTreeMap::new();
Value::Array(out)
}

View file

@ -1,177 +0,0 @@
{
"description": "The U64 decimal-string and double boundaries, shared by both languages.",
"u64": [
{
"text": "0",
"accept": true,
"reason": "zero is \"0\""
},
{
"text": "1",
"accept": true,
"reason": ""
},
{
"text": "18446744073709551615",
"accept": true,
"reason": "the U64 maximum"
},
{
"text": "18446744073709551616",
"accept": false,
"reason": "one past the maximum"
},
{
"text": "184467440737095516150",
"accept": false,
"reason": "far past the maximum"
},
{
"text": "00",
"accept": false,
"reason": "no leading zeros"
},
{
"text": "01",
"accept": false,
"reason": "no leading zeros"
},
{
"text": "",
"accept": false,
"reason": "empty"
},
{
"text": "-1",
"accept": false,
"reason": "unsigned"
},
{
"text": "+1",
"accept": false,
"reason": "no sign"
},
{
"text": "1.0",
"accept": false,
"reason": "integers only"
},
{
"text": "1e3",
"accept": false,
"reason": "decimal digits only"
},
{
"text": " 1",
"accept": false,
"reason": "no whitespace"
},
{
"text": "1 ",
"accept": false,
"reason": "no whitespace"
},
{
"text": "0x10",
"accept": false,
"reason": "decimal only"
},
{
"text": "9007199254740993",
"accept": true,
"reason": "a U64 string keeps precision a double would lose"
}
],
"doubles": [
{
"value": 0.0,
"canonical": "0",
"accept": true,
"reason": ""
},
{
"value": -0.0,
"canonical": "0",
"accept": true,
"reason": "JSON.stringify prints negative zero as 0"
},
{
"value": 1.0,
"canonical": "1",
"accept": true,
"reason": "an integral double prints without a fraction"
},
{
"value": -17.0,
"canonical": "-17",
"accept": true,
"reason": ""
},
{
"value": 0.1,
"canonical": "0.1",
"accept": true,
"reason": "the shortest round-tripping form"
},
{
"value": 0.30000000000000004,
"canonical": "0.30000000000000004",
"accept": true,
"reason": "shortest round-tripping form, not a rounded one"
},
{
"value": 1e-07,
"canonical": "1e-7",
"accept": true,
"reason": "ECMAScript switches to exponent notation below 1e-6"
},
{
"value": 5e-324,
"canonical": "5e-324",
"accept": true,
"reason": "the smallest subnormal double"
},
{
"value": 1234.5678,
"canonical": "1234.5678",
"accept": true,
"reason": "a fractional value of any magnitude is canonicalizable"
},
{
"value": 9007199254740991,
"canonical": "9007199254740991",
"accept": true,
"reason": "the largest exactly representable integer"
},
{
"value": -9007199254740991,
"canonical": "-9007199254740991",
"accept": true,
"reason": ""
},
{
"value": 9007199254740993,
"canonical": null,
"accept": false,
"reason": "an integral value past the exact range; counters are U64 strings"
},
{
"value": 1e+21,
"canonical": null,
"accept": false,
"reason": "integral and far past the exact range; JSON.parse cannot tell it from the same digits written out"
},
{
"value": 1.7976931348623157e+308,
"canonical": null,
"accept": false,
"reason": "integral and far past the exact range"
},
{
"value": 1.5e+20,
"canonical": null,
"accept": false,
"reason": "1.5e20 is integral as a double, so it falls under the same rule as 1e21"
}
]
}

View file

@ -1,199 +0,0 @@
{
"description": "One FLYSESS1 envelope, its layout and the corruptions a reader must refuse.",
"magic": "FLYSESS1",
"footerMagic": "FLYSESSF",
"version": 1,
"manifest": {
"envelopeVersion": 1,
"checkpointId": "ckpt-1",
"sourceScope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "42"
},
"episodeId": "episode-1",
"worldTime": {
"numerator": "700000000",
"denominator": "1"
},
"schedulerId": "lockstep-v1",
"compositionDigest": "730d725c8a59d3a7303def2bed041a577edb4255aabd4889ce12918311d952f0",
"portMap": [
{
"portId": "port-1",
"agentId": "fly-a"
}
],
"compatibility": {
"backendDigest": "10e08a419e850eba1ebba18fdd28eb7ec1b7e8baa9bcc3b973e2b8891ec726be",
"contentDigest": "ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73",
"patchDigest": "a4895eb44afc336fecbba6e520cd67e178dace0276655d102fceffa8e5f70570",
"controllerDigest": "c1472135b14c77c8bef98e73f70208325fa0dcf1e6bd668ae9b31a9cea295fe7",
"parserDigest": "b17d45121150928f2146af49e195eff1eef5d67325be273a733fb74acadaa342",
"stateFormatId": "flysess-1"
},
"agents": [
{
"agentId": "fly-a",
"profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0",
"datasetDigest": "6c0af1f0784ef63a393ee77d614e8246c625051360f3f1a48838374c5d355b52",
"modelVersion": "lif-1ms-f64-v2",
"plasticityVersion": "fly-kc-mbon-rstdp-v2",
"seed": -184946063,
"brainTicks": "2534",
"remainder": {
"numerator": "1000000",
"denominator": "3"
},
"payload": "agent-fly-a"
}
],
"coordinator": {
"taskLedger": "task-ledger",
"priorInspection": "prior-inspection",
"executorState": [
{
"agentId": "fly-a",
"payload": "executor-fly-a"
}
],
"admissionState": null,
"eventWatermarks": {
"lastSourceStep": "42",
"issued": "7"
}
},
"environment": {
"workerId": "arena",
"payload": "world"
},
"helperState": [],
"payloads": [
{
"name": "agent-fly-a",
"byteLength": "17",
"digest": "1321dffb0cdc6f9092cbf7fa2a5fc68bbed12c993d5ad398264012810ce9bf93"
},
{
"name": "executor-fly-a",
"byteLength": "14",
"digest": "3aee60df7e29efeba7f5f99fc5867647b36aebff1d5d3c838dbff323122e6462"
},
{
"name": "task-ledger",
"byteLength": "11",
"digest": "40b00ed2bbba901d68205ff71b04a44b9ee53c51cb3109aa2ceaa44f1c45727e"
},
{
"name": "prior-inspection",
"byteLength": "10",
"digest": "2c13b7b4d9a9916801ab9191c314f31b045e9b9c5b669a6c0474f0217ef75bf5"
},
{
"name": "world",
"byteLength": "64",
"digest": "f5a5fd42d16a20302798ef6ed309979b43003d2320d9f0e8ea9831a92759fb4b"
}
]
},
"payloads": [
{
"name": "agent-fly-a",
"base64": "YWdlbnQgc3RhdGUgYnl0ZXM="
},
{
"name": "executor-fly-a",
"base64": "ZXhlY3V0b3Igc3RhdGU="
},
{
"name": "task-ledger",
"base64": "eyJyYW5rIjoxMH0="
},
{
"name": "prior-inspection",
"base64": "eyJtYXAiOjQwfQ=="
},
{
"name": "world",
"base64": "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA=="
}
],
"envelope": {
"base64": "RkxZU0VTUzEBAAAAIAAAADAIAAAFAAAAUAgAAAAAAAB7ImFnZW50cyI6W3siYWdlbnRJZCI6ImZseS1hIiwiYnJhaW5UaWNrcyI6IjI1MzQiLCJkYXRhc2V0RGlnZXN0IjoiNmMwYWYxZjA3ODRlZjYzYTM5M2VlNzdkNjE0ZTgyNDZjNjI1MDUxMzYwZjNmMWE0ODgzODM3NGM1ZDM1NWI1MiIsIm1vZGVsVmVyc2lvbiI6ImxpZi0xbXMtZjY0LXYyIiwicGF5bG9hZCI6ImFnZW50LWZseS1hIiwicGxhc3RpY2l0eVZlcnNpb24iOiJmbHkta2MtbWJvbi1yc3RkcC12MiIsInByb2ZpbGVEaWdlc3QiOiIxOTAwZWFiNmMwMjg0ODNkNzEyNjU5OWVlNmY1MGRlMGQyNzkwN2I1YzY1ZmE5MDUyNDU4MGI0YjBmOTg1MmIwIiwicmVtYWluZGVyIjp7ImRlbm9taW5hdG9yIjoiMyIsIm51bWVyYXRvciI6IjEwMDAwMDAifSwic2VlZCI6LTE4NDk0NjA2M31dLCJjaGVja3BvaW50SWQiOiJja3B0LTEiLCJjb21wYXRpYmlsaXR5Ijp7ImJhY2tlbmREaWdlc3QiOiIxMGUwOGE0MTllODUwZWJhMWViYmExOGZkZDI4ZWI3ZWMxYjdlOGJhYTliY2MzYjk3M2UyYjg4OTFlYzcyNmJlIiwiY29udGVudERpZ2VzdCI6ImVkNzAwMmI0MzllOWFjODQ1ZjIyMzU3ZDgyMmJhYzE0NDQ3MzBmYmRiNjAxNmQzZWM5NDMyMjk3YjllYzlmNzMiLCJjb250cm9sbGVyRGlnZXN0IjoiYzE0NzIxMzViMTRjNzdjOGJlZjk4ZTczZjcwMjA4MzI1ZmEwZGNmMWU2YmQ2NjhhZTliMzFhOWNlYTI5NWZlNyIsInBhcnNlckRpZ2VzdCI6ImIxN2Q0NTEyMTE1MDkyOGYyMTQ2YWY0OWUxOTVlZmYxZWVmNWQ2NzMyNWJlMjczYTczM2ZiNzRhY2FkYWEzNDIiLCJwYXRjaERpZ2VzdCI6ImE0ODk1ZWI0NGFmYzMzNmZlY2JiYTZlNTIwY2Q2N2UxNzhkYWNlMDI3NjY1NWQxMDJmY2VmZmE4ZTVmNzA1NzAiLCJzdGF0ZUZvcm1hdElkIjoiZmx5c2Vzcy0xIn0sImNvbXBvc2l0aW9uRGlnZXN0IjoiNzMwZDcyNWM4YTU5ZDNhNzMwM2RlZjJiZWQwNDFhNTc3ZWRiNDI1NWFhYmQ0ODg5Y2UxMjkxODMxMWQ5NTJmMCIsImNvb3JkaW5hdG9yIjp7ImFkbWlzc2lvblN0YXRlIjpudWxsLCJldmVudFdhdGVybWFya3MiOnsiaXNzdWVkIjoiNyIsImxhc3RTb3VyY2VTdGVwIjoiNDIifSwiZXhlY3V0b3JTdGF0ZSI6W3siYWdlbnRJZCI6ImZseS1hIiwicGF5bG9hZCI6ImV4ZWN1dG9yLWZseS1hIn1dLCJwcmlvckluc3BlY3Rpb24iOiJwcmlvci1pbnNwZWN0aW9uIiwidGFza0xlZGdlciI6InRhc2stbGVkZ2VyIn0sImVudmVsb3BlVmVyc2lvbiI6MSwiZW52aXJvbm1lbnQiOnsicGF5bG9hZCI6IndvcmxkIiwid29ya2VySWQiOiJhcmVuYSJ9LCJlcGlzb2RlSWQiOiJlcGlzb2RlLTEiLCJoZWxwZXJTdGF0ZSI6W10sInBheWxvYWRzIjpbeyJieXRlTGVuZ3RoIjoiMTciLCJkaWdlc3QiOiIxMzIxZGZmYjBjZGM2ZjkwOTJjYmY3ZmEyYTVmYzY4YmJlZDEyYzk5M2Q1YWQzOTgyNjQwMTI4MTBjZTliZjkzIiwibmFtZSI6ImFnZW50LWZseS1hIn0seyJieXRlTGVuZ3RoIjoiMTQiLCJkaWdlc3QiOiIzYWVlNjBkZjdlMjllZmViYTdmNWY5OWZjNTg2NzY0N2IzNmFlYmZmMWQ1ZDNjODM4ZGJmZjMyMzEyMmU2NDYyIiwibmFtZSI6ImV4ZWN1dG9yLWZseS1hIn0seyJieXRlTGVuZ3RoIjoiMTEiLCJkaWdlc3QiOiI0MGIwMGVkMmJiYmE5MDFkNjgyMDVmZjcxYjA0YTQ0YjllZTUzYzUxY2IzMTA5YWEyY2VhYTQ0ZjFjNDU3MjdlIiwibmFtZSI6InRhc2stbGVkZ2VyIn0seyJieXRlTGVuZ3RoIjoiMTAiLCJkaWdlc3QiOiIyYzEzYjdiNGQ5YTk5MTY4MDFhYjkxOTFjMzE0ZjMxYjA0NWU5YjljNWI2NjlhNmMwNDc0ZjAyMTdlZjc1YmY1IiwibmFtZSI6InByaW9yLWluc3BlY3Rpb24ifSx7ImJ5dGVMZW5ndGgiOiI2NCIsImRpZ2VzdCI6ImY1YTVmZDQyZDE2YTIwMzAyNzk4ZWY2ZWQzMDk5NzliNDMwMDNkMjMyMGQ5ZjBlOGVhOTgzMWE5Mjc1OWZiNGIiLCJuYW1lIjoid29ybGQifV0sInBvcnRNYXAiOlt7ImFnZW50SWQiOiJmbHktYSIsInBvcnRJZCI6InBvcnQtMSJ9XSwic2NoZWR1bGVySWQiOiJsb2Nrc3RlcC12MSIsInNvdXJjZVNjb3BlIjp7ImVwb2NoIjoiZXBvY2gtMSIsInNlc3Npb25JZCI6ImRlbW8iLCJzdGVwIjoiNDIifSwid29ybGRUaW1lIjp7ImRlbm9taW5hdG9yIjoiMSIsIm51bWVyYXRvciI6IjcwMDAwMDAwMCJ9fWFnZW50LWZseS1hAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACACgAAAAAAABEAAAAAAAAAEyHf+wzcb5CSy/f6Kl/Gi77RLJk9WtOYJkASgQzpv5NleGVjdXRvci1mbHktYQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAmAoAAAAAAAAOAAAAAAAAADruYN9+Ke/rp/X5n8WGdkezauv/HV08g42/8yMSLmRidGFzay1sZWRnZXIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAKgKAAAAAAAACwAAAAAAAABAsA7Su7qQHWggX/cbBKRLnuU8UcsxCaos6qRPHEVyfnByaW9yLWluc3BlY3Rpb24AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAC4CgAAAAAAAAoAAAAAAAAALBO3tNmpkWgBq5GRwxTzGwRem5xbZppsBHTwIX73W/V3b3JsZAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAyAoAAAAAAABAAAAAAAAAAPWl/ULRaiAwJ5jvbtMJl5tDAD0jINnw6OqYMaknWftLYWdlbnQgc3RhdGUgYnl0ZXMAAAAAAAAAZXhlY3V0b3Igc3RhdGUAAHsicmFuayI6MTB9AAAAAAB7Im1hcCI6NDB9AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAADgLAAAAAAAAX4L9WkdX0MViD3h5YJgf7VQgwocWU4XJoKX6MUwl+8hGTFlTRVNTRg==",
"byteLength": 2872,
"layout": {
"headerBytes": 32,
"manifestOffset": "32",
"manifestBytes": 2096,
"tableOffset": "2128",
"tableEntryBytes": 112,
"entries": [
{
"name": "agent-fly-a",
"offset": "2688",
"byteLength": "17",
"digest": "1321dffb0cdc6f9092cbf7fa2a5fc68bbed12c993d5ad398264012810ce9bf93"
},
{
"name": "executor-fly-a",
"offset": "2712",
"byteLength": "14",
"digest": "3aee60df7e29efeba7f5f99fc5867647b36aebff1d5d3c838dbff323122e6462"
},
{
"name": "task-ledger",
"offset": "2728",
"byteLength": "11",
"digest": "40b00ed2bbba901d68205ff71b04a44b9ee53c51cb3109aa2ceaa44f1c45727e"
},
{
"name": "prior-inspection",
"offset": "2744",
"byteLength": "10",
"digest": "2c13b7b4d9a9916801ab9191c314f31b045e9b9c5b669a6c0474f0217ef75bf5"
},
{
"name": "world",
"offset": "2760",
"byteLength": "64",
"digest": "f5a5fd42d16a20302798ef6ed309979b43003d2320d9f0e8ea9831a92759fb4b"
}
],
"footerOffset": "2824",
"footerBytes": 48,
"totalBytes": "2872"
}
},
"corruption": [
{
"name": "a flipped magic byte",
"offset": 0,
"reason": "wrong magic"
},
{
"name": "an unsupported version",
"offset": 8,
"reason": "unsupported version"
},
{
"name": "a flipped manifest byte",
"offset": 32,
"reason": "the footer digest covers the manifest"
},
{
"name": "a flipped payload byte",
"offset": 2688,
"reason": "every payload carries its own digest"
},
{
"name": "a flipped footer digest byte",
"offset": 2832,
"reason": "the footer digest must match the contents"
},
{
"name": "a flipped footer magic byte",
"offset": 2864,
"reason": "a truncated file cannot look complete"
}
]
}

View file

@ -1,9 +0,0 @@
{
"description": "contractDigest is the SHA-256 of the canonical schema set in schema-set.json.",
"contractDigest": "a56e25e6289a6f9e6720ba5f727651e9e4e2c5cf7a68990fc1f8d37fe12f2006",
"schemaSetVersion": 1,
"schemaSetBytes": 30711,
"types": 60,
"enums": 12,
"limits": 27
}

View file

@ -1,335 +0,0 @@
{
"description": "decoderConfigDigest vectors (legacy-gameboy-v1 section 12): the canonical form of gameboy_decoder_config_with_macros / gameboyDecoderConfig for raw mode and the Pokemon Red macro group. Written by FLY_UPDATE_FIXTURES=1 cargo test -p flysim --test legacy_profile_identity; both languages must reproduce every form and digest.",
"cases": [
{
"name": "raw",
"macroChannels": [],
"form": {
"form": "gameboy-decoder-config-v1",
"exclusive": {
"channels": [
{
"channel": "up",
"role": "command_0"
},
{
"channel": "down",
"role": "command_1"
},
{
"channel": "left",
"role": "command_2"
},
{
"channel": "right",
"role": "command_3"
}
],
"decisionMs": 800.0,
"holdMs": 800.0,
"hysteresis": 1.05,
"fatigueGain": 0.08,
"fatigueDecay": 0.8,
"blockedFatigue": 0.35,
"blockedMs": 800.0
},
"macros": null,
"pulses": [
{
"channel": "a",
"role": "command_4",
"holdMs": 85.0,
"cooldownMs": 480.0,
"threshold": 1.0,
"boot": null,
"throttleGroup": null
},
{
"channel": "b",
"role": "command_5",
"holdMs": 85.0,
"cooldownMs": 480.0,
"threshold": 1.0,
"boot": null,
"throttleGroup": null
},
{
"channel": "start",
"role": "command_6",
"holdMs": 55.0,
"cooldownMs": 30000.0,
"threshold": 1.35,
"boot": {
"cooldownMs": 2500.0,
"threshold": 1.0
},
"throttleGroup": "system"
},
{
"channel": "select",
"role": "command_7",
"holdMs": 55.0,
"cooldownMs": 30000.0,
"threshold": 1.35,
"boot": {
"cooldownMs": 2500.0,
"threshold": 1.0
},
"throttleGroup": "system"
}
],
"clearLockoutMs": 480.0
},
"canonical": "{\"clearLockoutMs\":480,\"exclusive\":{\"blockedFatigue\":0.35,\"blockedMs\":800,\"channels\":[{\"channel\":\"up\",\"role\":\"command_0\"},{\"channel\":\"down\",\"role\":\"command_1\"},{\"channel\":\"left\",\"role\":\"command_2\"},{\"channel\":\"right\",\"role\":\"command_3\"}],\"decisionMs\":800,\"fatigueDecay\":0.8,\"fatigueGain\":0.08,\"holdMs\":800,\"hysteresis\":1.05},\"form\":\"gameboy-decoder-config-v1\",\"macros\":null,\"pulses\":[{\"boot\":null,\"channel\":\"a\",\"cooldownMs\":480,\"holdMs\":85,\"role\":\"command_4\",\"threshold\":1,\"throttleGroup\":null},{\"boot\":null,\"channel\":\"b\",\"cooldownMs\":480,\"holdMs\":85,\"role\":\"command_5\",\"threshold\":1,\"throttleGroup\":null},{\"boot\":{\"cooldownMs\":2500,\"threshold\":1},\"channel\":\"start\",\"cooldownMs\":30000,\"holdMs\":55,\"role\":\"command_6\",\"threshold\":1.35,\"throttleGroup\":\"system\"},{\"boot\":{\"cooldownMs\":2500,\"threshold\":1},\"channel\":\"select\",\"cooldownMs\":30000,\"holdMs\":55,\"role\":\"command_7\",\"threshold\":1.35,\"throttleGroup\":\"system\"}]}",
"digest": "6234e4a0363cfe82b8d515943c4f645a3960eda8fa5bf9465009061f80d50812"
},
{
"name": "macros",
"macroChannels": [
"macro_go_objective",
"macro_go_out",
"macro_go_warp",
"macro_go_route",
"macro_go_item",
"macro_go_npc",
"macro_go_frontier",
"macro_go_shop",
"macro_go_heal",
"macro_talk",
"macro_menu",
"macro_next",
"macro_yes",
"macro_no",
"macro_close",
"macro_confirm",
"macro_back",
"macro_move_1",
"macro_move_2",
"macro_move_3",
"macro_move_4",
"macro_switch",
"macro_item",
"macro_throw_ball",
"macro_run",
"macro_buy_potion",
"macro_buy_ball",
"macro_buy_antidote",
"macro_buy_repel",
"macro_heal",
"macro_leave"
],
"form": {
"form": "gameboy-decoder-config-v1",
"exclusive": {
"channels": [
{
"channel": "up",
"role": "command_0"
},
{
"channel": "down",
"role": "command_1"
},
{
"channel": "left",
"role": "command_2"
},
{
"channel": "right",
"role": "command_3"
}
],
"decisionMs": 800.0,
"holdMs": 800.0,
"hysteresis": 1.05,
"fatigueGain": 0.08,
"fatigueDecay": 0.8,
"blockedFatigue": 0.35,
"blockedMs": 800.0
},
"macros": {
"channels": [
{
"channel": "macro_go_objective",
"role": "macro_go_objective"
},
{
"channel": "macro_go_out",
"role": "macro_go_out"
},
{
"channel": "macro_go_warp",
"role": "macro_go_warp"
},
{
"channel": "macro_go_route",
"role": "macro_go_route"
},
{
"channel": "macro_go_item",
"role": "macro_go_item"
},
{
"channel": "macro_go_npc",
"role": "macro_go_npc"
},
{
"channel": "macro_go_frontier",
"role": "macro_go_frontier"
},
{
"channel": "macro_go_shop",
"role": "macro_go_shop"
},
{
"channel": "macro_go_heal",
"role": "macro_go_heal"
},
{
"channel": "macro_talk",
"role": "macro_talk"
},
{
"channel": "macro_menu",
"role": "macro_menu"
},
{
"channel": "macro_next",
"role": "macro_next"
},
{
"channel": "macro_yes",
"role": "macro_yes"
},
{
"channel": "macro_no",
"role": "macro_no"
},
{
"channel": "macro_close",
"role": "macro_close"
},
{
"channel": "macro_confirm",
"role": "macro_confirm"
},
{
"channel": "macro_back",
"role": "macro_back"
},
{
"channel": "macro_move_1",
"role": "macro_move_1"
},
{
"channel": "macro_move_2",
"role": "macro_move_2"
},
{
"channel": "macro_move_3",
"role": "macro_move_3"
},
{
"channel": "macro_move_4",
"role": "macro_move_4"
},
{
"channel": "macro_switch",
"role": "macro_switch"
},
{
"channel": "macro_item",
"role": "macro_item"
},
{
"channel": "macro_throw_ball",
"role": "macro_throw_ball"
},
{
"channel": "macro_run",
"role": "macro_run"
},
{
"channel": "macro_buy_potion",
"role": "macro_buy_potion"
},
{
"channel": "macro_buy_ball",
"role": "macro_buy_ball"
},
{
"channel": "macro_buy_antidote",
"role": "macro_buy_antidote"
},
{
"channel": "macro_buy_repel",
"role": "macro_buy_repel"
},
{
"channel": "macro_heal",
"role": "macro_heal"
},
{
"channel": "macro_leave",
"role": "macro_leave"
}
],
"decisionMs": 800.0,
"holdMs": 800.0,
"hysteresis": 1.05,
"fatigueGain": 0.08,
"fatigueDecay": 0.8,
"blockedFatigue": 0.35,
"blockedMs": 800.0
},
"pulses": [
{
"channel": "a",
"role": "command_4",
"holdMs": 85.0,
"cooldownMs": 480.0,
"threshold": 1.0,
"boot": null,
"throttleGroup": null
},
{
"channel": "b",
"role": "command_5",
"holdMs": 85.0,
"cooldownMs": 480.0,
"threshold": 1.0,
"boot": null,
"throttleGroup": null
},
{
"channel": "start",
"role": "command_6",
"holdMs": 55.0,
"cooldownMs": 30000.0,
"threshold": 1.35,
"boot": {
"cooldownMs": 2500.0,
"threshold": 1.0
},
"throttleGroup": "system"
},
{
"channel": "select",
"role": "command_7",
"holdMs": 55.0,
"cooldownMs": 30000.0,
"threshold": 1.35,
"boot": {
"cooldownMs": 2500.0,
"threshold": 1.0
},
"throttleGroup": "system"
}
],
"clearLockoutMs": 480.0
},
"canonical": "{\"clearLockoutMs\":480,\"exclusive\":{\"blockedFatigue\":0.35,\"blockedMs\":800,\"channels\":[{\"channel\":\"up\",\"role\":\"command_0\"},{\"channel\":\"down\",\"role\":\"command_1\"},{\"channel\":\"left\",\"role\":\"command_2\"},{\"channel\":\"right\",\"role\":\"command_3\"}],\"decisionMs\":800,\"fatigueDecay\":0.8,\"fatigueGain\":0.08,\"holdMs\":800,\"hysteresis\":1.05},\"form\":\"gameboy-decoder-config-v1\",\"macros\":{\"blockedFatigue\":0.35,\"blockedMs\":800,\"channels\":[{\"channel\":\"macro_go_objective\",\"role\":\"macro_go_objective\"},{\"channel\":\"macro_go_out\",\"role\":\"macro_go_out\"},{\"channel\":\"macro_go_warp\",\"role\":\"macro_go_warp\"},{\"channel\":\"macro_go_route\",\"role\":\"macro_go_route\"},{\"channel\":\"macro_go_item\",\"role\":\"macro_go_item\"},{\"channel\":\"macro_go_npc\",\"role\":\"macro_go_npc\"},{\"channel\":\"macro_go_frontier\",\"role\":\"macro_go_frontier\"},{\"channel\":\"macro_go_shop\",\"role\":\"macro_go_shop\"},{\"channel\":\"macro_go_heal\",\"role\":\"macro_go_heal\"},{\"channel\":\"macro_talk\",\"role\":\"macro_talk\"},{\"channel\":\"macro_menu\",\"role\":\"macro_menu\"},{\"channel\":\"macro_next\",\"role\":\"macro_next\"},{\"channel\":\"macro_yes\",\"role\":\"macro_yes\"},{\"channel\":\"macro_no\",\"role\":\"macro_no\"},{\"channel\":\"macro_close\",\"role\":\"macro_close\"},{\"channel\":\"macro_confirm\",\"role\":\"macro_confirm\"},{\"channel\":\"macro_back\",\"role\":\"macro_back\"},{\"channel\":\"macro_move_1\",\"role\":\"macro_move_1\"},{\"channel\":\"macro_move_2\",\"role\":\"macro_move_2\"},{\"channel\":\"macro_move_3\",\"role\":\"macro_move_3\"},{\"channel\":\"macro_move_4\",\"role\":\"macro_move_4\"},{\"channel\":\"macro_switch\",\"role\":\"macro_switch\"},{\"channel\":\"macro_item\",\"role\":\"macro_item\"},{\"channel\":\"macro_throw_ball\",\"role\":\"macro_throw_ball\"},{\"channel\":\"macro_run\",\"role\":\"macro_run\"},{\"channel\":\"macro_buy_potion\",\"role\":\"macro_buy_potion\"},{\"channel\":\"macro_buy_ball\",\"role\":\"macro_buy_ball\"},{\"channel\":\"macro_buy_antidote\",\"role\":\"macro_buy_antidote\"},{\"channel\":\"macro_buy_repel\",\"role\":\"macro_buy_repel\"},{\"channel\":\"macro_heal\",\"role\":\"macro_heal\"},{\"channel\":\"macro_leave\",\"role\":\"macro_leave\"}],\"decisionMs\":800,\"fatigueDecay\":0.8,\"fatigueGain\":0.08,\"holdMs\":800,\"hysteresis\":1.05},\"pulses\":[{\"boot\":null,\"channel\":\"a\",\"cooldownMs\":480,\"holdMs\":85,\"role\":\"command_4\",\"threshold\":1,\"throttleGroup\":null},{\"boot\":null,\"channel\":\"b\",\"cooldownMs\":480,\"holdMs\":85,\"role\":\"command_5\",\"threshold\":1,\"throttleGroup\":null},{\"boot\":{\"cooldownMs\":2500,\"threshold\":1},\"channel\":\"start\",\"cooldownMs\":30000,\"holdMs\":55,\"role\":\"command_6\",\"threshold\":1.35,\"throttleGroup\":\"system\"},{\"boot\":{\"cooldownMs\":2500,\"threshold\":1},\"channel\":\"select\",\"cooldownMs\":30000,\"holdMs\":55,\"role\":\"command_7\",\"threshold\":1.35,\"throttleGroup\":\"system\"}]}",
"digest": "82b6601f0390b742a67eca44ef935b49e16a73315db70f5644f1cbd21f7c8a52"
}
]
}

View file

@ -1,575 +0,0 @@
{
"description": "The legacy Game Boy composition (legacy-gameboy-v1): registered payload schemas with their SchemaRef digests, the one legacy profile document and its AssetRef, the frame clock, and an example composition declaration with its digest.",
"extensionSetDigest": "a35823ecf607a218b0c9f9e7ec86585d355cb311f8d7a3ada8aa793e1c9fe47c",
"extensionSet": {
"contract": "fly-session-types/legacy-gameboy",
"version": 1,
"scalars": {
"ChannelName": "^[a-z][a-z0-9_]{0,63}$: a decoder channel or rate-role name; not an Id, because the legacy role names carry '_'"
},
"enums": {
"MacroMode": [
"raw",
"macros"
],
"RollbackTrigger": [
"stall",
"game-over"
]
},
"payloadSchemas": [
{
"declaration": {
"registry": "fly-session-payload-schema-v1",
"id": "gameboy-channels-v1",
"version": 1,
"source": "legacy-gameboy-v1 6",
"fields": [
{
"name": "buttons",
"kind": "array<{id:Id,down:bool}>",
"required": true,
"constraint": "exactly up,down,left,right,a,b,start,select in that order"
},
{
"name": "macro",
"kind": "ChannelName|null",
"required": false,
"constraint": "the macro-group channel active in this decode; one of the context's bound"
}
]
},
"schemaRef": {
"id": "gameboy-channels-v1",
"version": 1,
"digest": "28bd89bfa41ec73fb2785959a64d9975b4a932e10cd9e51dc4bc29020c823595"
}
},
{
"declaration": {
"registry": "fly-session-payload-schema-v1",
"id": "gameboy-joypad-v1",
"version": 1,
"source": "legacy-gameboy-v1 7",
"fields": [
{
"name": "buttons",
"kind": "const",
"required": true,
"constraint": "up,down,left,right,a,b,start,select; no axes; one port"
}
]
},
"schemaRef": {
"id": "gameboy-joypad-v1",
"version": 1,
"digest": "1bde5fa114b99824ad608fba4ea85706cb0cebc6123a778dc1e5f89791d2a05e"
}
},
{
"declaration": {
"registry": "fly-session-payload-schema-v1",
"id": "gameboy-memory-inspection-v1",
"version": 1,
"source": "legacy-gameboy-v1 8",
"fields": [
{
"name": "memory",
"kind": "ArtifactRef",
"required": true,
"constraint": "listed attachment; byteLength 65536; the CPU address space $0000..=$FFFF at this boundary, read-only"
},
{
"name": "romDigest",
"kind": "Digest",
"required": true,
"constraint": "== EnvironmentDescriptor.contentDigest == the executor's rom AssetRef digest"
}
]
},
"schemaRef": {
"id": "gameboy-memory-inspection-v1",
"version": 1,
"digest": "d6cb62248bfdac2ffdf00290ffbebf9a101b1fe28f7be766d24db28ede8da3e6"
}
},
{
"declaration": {
"registry": "fly-session-payload-schema-v1",
"id": "gameboy-readout-context-v1",
"version": 1,
"source": "legacy-gameboy-v1 5",
"fields": [
{
"name": "boot",
"kind": "bool",
"required": true,
"constraint": "the adapter's boot gate after the last transition: permissive Start/Select variant"
},
{
"name": "bound",
"kind": "array<ChannelName>",
"required": true,
"constraint": "<= 64, unique, a subset of the composition's macroChannels in their order; [] in raw mode"
},
{
"name": "location",
"kind": "{area:int,x:int,y:int}|null",
"required": false,
"constraint": "each 0..=4294967295; the adapter's location after the last transition; null is no information"
}
]
},
"schemaRef": {
"id": "gameboy-readout-context-v1",
"version": 1,
"digest": "78a5312f8608a1399e7a16549a1b95a43b87137618d59815b9b06c1bb184014d"
}
},
{
"declaration": {
"registry": "fly-session-payload-schema-v1",
"id": "legacy-ratchet-rollback-v1",
"version": 1,
"source": "legacy-gameboy-v1 11",
"fields": [
{
"name": "slotId",
"kind": "Id",
"required": true,
"constraint": "one of the composition's declared slots, saved earlier"
},
{
"name": "trigger",
"kind": "RollbackTrigger",
"required": true,
"constraint": "stall or game-over"
}
]
},
"schemaRef": {
"id": "legacy-ratchet-rollback-v1",
"version": 1,
"digest": "0610f899a4746e5991a07dbc3c847ada15d7c68cedf0664244a3c1d87c175c70"
}
}
],
"declarations": [
{
"name": "LegacyGameboyComposition",
"source": "legacy-gameboy-v1 12",
"fields": [
{
"name": "compositionId",
"kind": "Id",
"required": true,
"constraint": ""
},
{
"name": "scheduler",
"kind": "const",
"required": true,
"constraint": "\"lockstep-v1\""
},
{
"name": "profile",
"kind": "AssetRef",
"required": true,
"constraint": "format fly-profile-v1; digest == the legacy profile's"
},
{
"name": "executor",
"kind": "{id:const,rom:AssetRef,adapter:Id,symbolProvenance:string,mode:MacroMode,macroChannels:array<ChannelName>}",
"required": true,
"constraint": "pokered-macros-v1; task and executor one object; macroChannels [] iff mode raw, else <= 64 unique in decoder order"
},
{
"name": "decoderConfigDigest",
"kind": "Digest",
"required": true,
"constraint": "SHA-256 of the canonical gameboy-decoder-config-v1 form of the effective decoder configuration"
},
{
"name": "environment",
"kind": "{extensions:array<Id>,slots:array<Id>,stepDuration:RationalNs,inspectionSchema:SchemaRef,controllerSchema:SchemaRef,setupFrames:const,audio:{sampleRate:int,channels:const}}",
"required": true,
"constraint": "[\"gameboy-slots-v1\"]; 1..=4 unique slots; 8572265625/512; memory inspection; joypad; 1 setup frame; 2 channels"
},
{
"name": "episodePolicy",
"kind": "const",
"required": true,
"constraint": "\"legacy-ratchet-rollback-v1\""
},
{
"name": "restore",
"kind": "const",
"required": true,
"constraint": "\"legacy-transient-reset\""
},
{
"name": "checkpointFormatOfRecord",
"kind": "const",
"required": true,
"constraint": "\"FLYSIM01\""
},
{
"name": "flysimCompatibility",
"kind": "string",
"required": true,
"constraint": "<= 1024 bytes; the FLYSIM01 string; its kernel, adapter, fingerprint, plasticity and pokered segments agree with this declaration"
}
]
},
{
"name": "LegacyGameboyProfile",
"source": "legacy-gameboy-v1 2",
"fields": [
{
"name": "profileId",
"kind": "const",
"required": true,
"constraint": "\"gameboy-legacy-fafb-v783-v1\""
},
{
"name": "datasetId",
"kind": "const",
"required": true,
"constraint": "\"fafb-v783\""
},
{
"name": "fingerprintSchema",
"kind": "const",
"required": true,
"constraint": "1"
},
{
"name": "datasetFingerprint",
"kind": "string",
"required": true,
"constraint": "today's schema-1 fingerprint, seven digests joined with ':'"
},
{
"name": "kernelVersion",
"kind": "const",
"required": true,
"constraint": "\"lif-1ms-f64-v2\""
},
{
"name": "plasticityVersion",
"kind": "const",
"required": true,
"constraint": "\"fly-kc-mbon-rstdp-v2\""
},
{
"name": "tickDuration",
"kind": "RationalNs",
"required": true,
"constraint": "1000000/1"
},
{
"name": "warmupMs",
"kind": "const",
"required": true,
"constraint": "2500"
},
{
"name": "view",
"kind": "{viewId:Id,width:int,height:int}",
"required": true,
"constraint": "lcd, 160, 144"
},
{
"name": "supportedStimuli",
"kind": "array<Id>",
"required": true,
"constraint": "[\"reward-pulse\"]"
},
{
"name": "readoutContextSchema",
"kind": "SchemaRef",
"required": true,
"constraint": "gameboy-readout-context-v1"
},
{
"name": "decisionSchema",
"kind": "SchemaRef",
"required": true,
"constraint": "gameboy-channels-v1"
},
{
"name": "legacyExceptions",
"kind": "array<Id>",
"required": true,
"constraint": "[\"macro-roles-outside-fingerprint\"]"
}
]
}
]
},
"schemaRefs": {
"gameboy-readout-context-v1": {
"id": "gameboy-readout-context-v1",
"version": 1,
"digest": "78a5312f8608a1399e7a16549a1b95a43b87137618d59815b9b06c1bb184014d"
},
"gameboy-channels-v1": {
"id": "gameboy-channels-v1",
"version": 1,
"digest": "28bd89bfa41ec73fb2785959a64d9975b4a932e10cd9e51dc4bc29020c823595"
},
"gameboy-joypad-v1": {
"id": "gameboy-joypad-v1",
"version": 1,
"digest": "1bde5fa114b99824ad608fba4ea85706cb0cebc6123a778dc1e5f89791d2a05e"
},
"gameboy-memory-inspection-v1": {
"id": "gameboy-memory-inspection-v1",
"version": 1,
"digest": "d6cb62248bfdac2ffdf00290ffbebf9a101b1fe28f7be766d24db28ede8da3e6"
},
"legacy-ratchet-rollback-v1": {
"id": "legacy-ratchet-rollback-v1",
"version": 1,
"digest": "0610f899a4746e5991a07dbc3c847ada15d7c68cedf0664244a3c1d87c175c70"
}
},
"profile": {
"document": {
"profileId": "gameboy-legacy-fafb-v783-v1",
"datasetId": "fafb-v783",
"fingerprintSchema": 1,
"datasetFingerprint": "75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc",
"kernelVersion": "lif-1ms-f64-v2",
"plasticityVersion": "fly-kc-mbon-rstdp-v2",
"tickDuration": {
"numerator": "1000000",
"denominator": "1"
},
"warmupMs": 2500,
"view": {
"viewId": "lcd",
"width": 160,
"height": 144
},
"supportedStimuli": [
"reward-pulse"
],
"readoutContextSchema": {
"id": "gameboy-readout-context-v1",
"version": 1,
"digest": "78a5312f8608a1399e7a16549a1b95a43b87137618d59815b9b06c1bb184014d"
},
"decisionSchema": {
"id": "gameboy-channels-v1",
"version": 1,
"digest": "28bd89bfa41ec73fb2785959a64d9975b4a932e10cd9e51dc4bc29020c823595"
},
"legacyExceptions": [
"macro-roles-outside-fingerprint"
]
},
"canonical": "{\"datasetFingerprint\":\"75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc\",\"datasetId\":\"fafb-v783\",\"decisionSchema\":{\"digest\":\"28bd89bfa41ec73fb2785959a64d9975b4a932e10cd9e51dc4bc29020c823595\",\"id\":\"gameboy-channels-v1\",\"version\":1},\"fingerprintSchema\":1,\"kernelVersion\":\"lif-1ms-f64-v2\",\"legacyExceptions\":[\"macro-roles-outside-fingerprint\"],\"plasticityVersion\":\"fly-kc-mbon-rstdp-v2\",\"profileId\":\"gameboy-legacy-fafb-v783-v1\",\"readoutContextSchema\":{\"digest\":\"78a5312f8608a1399e7a16549a1b95a43b87137618d59815b9b06c1bb184014d\",\"id\":\"gameboy-readout-context-v1\",\"version\":1},\"supportedStimuli\":[\"reward-pulse\"],\"tickDuration\":{\"denominator\":\"1\",\"numerator\":\"1000000\"},\"view\":{\"height\":144,\"viewId\":\"lcd\",\"width\":160},\"warmupMs\":2500}",
"assetRef": {
"id": "gameboy-legacy-fafb-v783-v1",
"digest": "41e5d1ac62ab23f1b2d7252d52faac08b269c85e6b4c9ed7a370c74032c60878",
"byteLength": "1137",
"format": "fly-profile-v1"
}
},
"clock": {
"stepDuration": {
"numerator": "8572265625",
"denominator": "512"
},
"tickDuration": {
"numerator": "1000000",
"denominator": "1"
},
"legacyMsPerFrame": "1000 / (4194304 / 70224) == 548625/32768 exactly",
"frames": [
{
"ticks": "16",
"remainder": {
"numerator": "380265625",
"denominator": "512"
}
},
{
"ticks": "17",
"remainder": {
"numerator": "124265625",
"denominator": "256"
}
},
{
"ticks": "17",
"remainder": {
"numerator": "116796875",
"denominator": "512"
}
},
{
"ticks": "16",
"remainder": {
"numerator": "124265625",
"denominator": "128"
}
},
{
"ticks": "17",
"remainder": {
"numerator": "365328125",
"denominator": "512"
}
},
{
"ticks": "17",
"remainder": {
"numerator": "116796875",
"denominator": "256"
}
},
{
"ticks": "17",
"remainder": {
"numerator": "101859375",
"denominator": "512"
}
},
{
"ticks": "16",
"remainder": {
"numerator": "60265625",
"denominator": "64"
}
},
{
"ticks": "17",
"remainder": {
"numerator": "350390625",
"denominator": "512"
}
},
{
"ticks": "17",
"remainder": {
"numerator": "109328125",
"denominator": "256"
}
},
{
"ticks": "17",
"remainder": {
"numerator": "86921875",
"denominator": "512"
}
},
{
"ticks": "16",
"remainder": {
"numerator": "116796875",
"denominator": "128"
}
}
]
},
"composition": {
"example": {
"compositionId": "pokered-live",
"scheduler": "lockstep-v1",
"profile": {
"id": "gameboy-legacy-fafb-v783-v1",
"digest": "41e5d1ac62ab23f1b2d7252d52faac08b269c85e6b4c9ed7a370c74032c60878",
"byteLength": "1137",
"format": "fly-profile-v1"
},
"executor": {
"id": "pokered-macros-v1",
"rom": {
"id": "pokered-rom",
"digest": "c840ea493f9bf41505f26cf5b1db26815dd588e7ec91d5fd6f1ad4d363dc4f20",
"byteLength": "1048576",
"format": "gb-rom"
},
"adapter": "pokered-unique8-v7",
"symbolProvenance": "0cd19d3b877b7dc66d12c7050bed9a7f38154d4b",
"mode": "macros",
"macroChannels": [
"macro_go_objective",
"macro_go_out",
"macro_go_warp",
"macro_go_route",
"macro_go_item",
"macro_go_npc",
"macro_go_frontier",
"macro_go_shop",
"macro_go_heal",
"macro_talk",
"macro_menu",
"macro_next",
"macro_yes",
"macro_no",
"macro_close",
"macro_confirm",
"macro_back",
"macro_move_1",
"macro_move_2",
"macro_move_3",
"macro_move_4",
"macro_switch",
"macro_item",
"macro_throw_ball",
"macro_run",
"macro_buy_potion",
"macro_buy_ball",
"macro_buy_antidote",
"macro_buy_repel",
"macro_heal",
"macro_leave"
]
},
"decoderConfigDigest": "82b6601f0390b742a67eca44ef935b49e16a73315db70f5644f1cbd21f7c8a52",
"environment": {
"extensions": [
"gameboy-slots-v1"
],
"slots": [
"best"
],
"stepDuration": {
"numerator": "8572265625",
"denominator": "512"
},
"inspectionSchema": {
"id": "gameboy-memory-inspection-v1",
"version": 1,
"digest": "d6cb62248bfdac2ffdf00290ffbebf9a101b1fe28f7be766d24db28ede8da3e6"
},
"controllerSchema": {
"id": "gameboy-joypad-v1",
"version": 1,
"digest": "1bde5fa114b99824ad608fba4ea85706cb0cebc6123a778dc1e5f89791d2a05e"
},
"setupFrames": 1,
"audio": {
"sampleRate": 48000,
"channels": 2
}
},
"episodePolicy": "legacy-ratchet-rollback-v1",
"restore": "legacy-transient-reset",
"checkpointFormatOfRecord": "FLYSIM01",
"flysimCompatibility": "lif-1ms-f64-v2/pokered-unique8-v7/75ba5d3536a2862fdb9f4ef1a76b96fe099a7201ac737f0cf53fe4c5ad4183f3:1657ba7716494c9db95a13b1527bc129226363f99b395774de1f76ff28571f0e:63b1acb26272edccdcfacf3c2ff58069e0cefaa84451429c989258bafaeae1d5:f567d7f07227e71c0df2ab4e6510f3a3f79e51b675095792e16d216d74b7b5b7:ece0b5e76d1884dd2f3ff6e0362bc284febe205ac1ce07fdab5009942ddffb62:b8c33144d4cec31c3ac4b6091ef1f4207f567c4f710f7c13fd2dc47c44c2b634:dbbafc044cd50aad7b792615988ff6d9991c846cc3d8b2eafc86b7c357b5eefc/fly-kc-mbon-rstdp-v2/binjgb:c60e138da5a795ebb55e56b11b7e90024e41112c/pokered:0cd19d3b877b7dc66d12c7050bed9a7f38154d4b/statefmt:199616-x86_64-unknown-linux-gnu"
},
"digest": "44916db0a0846d3338f50d4f24d9d4e0214fa239af7d15e4174213cd93b07e28",
"recipeLines": [
"fly-session/composition-v1",
"session=<sessionId>",
"epoch=<epoch>",
"contract=<contractDigest>",
"agent=<agentId> port=<portId> profile=<profileDigest> (one line per agent)",
"declaration=<this digest> (added by the 2026-09-23 amendment)"
]
}
}

View file

@ -1,74 +0,0 @@
{
"description": "Boundary cases both languages build from a recipe, because the payload is too large to store.",
"padSchema": {
"id": "pad.v1",
"version": 1,
"digest": "018689202f154300eeda48ccee8cc021c36672df03f7404097613f5898fdfdcc"
},
"cases": [
{
"name": "typed value exactly at the 32 KiB cap",
"kind": "padded-typed-value",
"padCharacters": 32635,
"expect": "accept",
"reason": "32768 bytes of canonical JSON is the limit, not one byte less"
},
{
"name": "typed value one byte over the cap",
"kind": "padded-typed-value",
"padCharacters": 32636,
"expect": "reject",
"reason": "a TypedValue is at most 32 KiB of canonical JSON"
},
{
"name": "request that fills the 64 KiB envelope exactly",
"kind": "padded-request",
"padCharacters": 60000,
"expect": "accept",
"reason": "65536 bytes including the envelope wrapper is admissible",
"envelopeTotal": 65536
},
{
"name": "request one byte past the envelope ceiling",
"kind": "padded-request",
"padCharacters": 60000,
"expect": "reject",
"reason": "the complete envelope must fit Flybus's 64-KiB maximum",
"envelopeTotal": 65537
},
{
"name": "error message of 512 code points",
"kind": "error-message",
"codePoints": 512,
"expect": "accept",
"reason": "messages are <= 512 code points"
},
{
"name": "error message of 513 code points",
"kind": "error-message",
"codePoints": 513,
"expect": "reject",
"reason": "messages are <= 512 code points"
},
{
"name": "error message of 512 astral code points",
"kind": "error-message-astral",
"codePoints": 512,
"expect": "accept",
"reason": "the bound counts code points, not UTF-16 units or bytes"
},
{
"name": "error message of 513 astral code points",
"kind": "error-message-astral",
"codePoints": 513,
"expect": "reject",
"reason": "the bound counts code points, not UTF-16 units or bytes"
}
],
"recipes": {
"padded-typed-value": "a TypedValue whose schema is padSchema and whose value is {\"pad\": <padCharacters> 'a' characters}; validate it",
"padded-request": "a SessionRpcRequest req-1 with scope null and params {\"pad\": <padCharacters> 'a' characters}; canonicalize it, then require the envelope to fit with an overhead of envelopeTotal minus that canonical length",
"error-message": "a SessionRpcFailure with code INTERNAL, mutation unknown and a message of <codePoints> 'x' characters",
"error-message-astral": "the same failure with a message of <codePoints> repetitions of U+10400, one code point and two UTF-16 units each"
}
}

View file

@ -1,111 +0,0 @@
{
"description": "Bus callId, domain requestId, artifact identity and delivery/hold owner tokens are four types, not four spellings of one string.",
"cases": [
{
"text": "call-0",
"busCallId": true,
"domainRequestId": false,
"ownerToken": null
},
{
"text": "call-102",
"busCallId": true,
"domainRequestId": false,
"ownerToken": null
},
{
"text": "req-41",
"busCallId": false,
"domainRequestId": true,
"ownerToken": null
},
{
"text": "dlv-7",
"busCallId": false,
"domainRequestId": false,
"ownerToken": "delivery"
},
{
"text": "own-9",
"busCallId": false,
"domainRequestId": false,
"ownerToken": "hold"
},
{
"text": "req-041",
"busCallId": false,
"domainRequestId": false,
"ownerToken": null
},
{
"text": "call-",
"busCallId": false,
"domainRequestId": false,
"ownerToken": null
},
{
"text": "call",
"busCallId": false,
"domainRequestId": false,
"ownerToken": null
},
{
"text": "callid-1",
"busCallId": false,
"domainRequestId": false,
"ownerToken": null
},
{
"text": "request-1",
"busCallId": false,
"domainRequestId": false,
"ownerToken": null
},
{
"text": "REQ-1",
"busCallId": false,
"domainRequestId": false,
"ownerToken": null
},
{
"text": "dlv-07",
"busCallId": false,
"domainRequestId": false,
"ownerToken": null
},
{
"text": "sub-1",
"busCallId": false,
"domainRequestId": false,
"ownerToken": null
},
{
"text": "svc-1",
"busCallId": false,
"domainRequestId": false,
"ownerToken": null
}
],
"artifact": {
"ref": {
"storeId": "store-1",
"artifactId": "frame-1",
"generation": "1",
"byteLength": "92160",
"contentType": "image/x-rgba8",
"digest": null
},
"identity": {
"storeId": "store-1",
"artifactId": "frame-1",
"generation": "1"
},
"reason": "the identity is the naming half of an ArtifactRef; byteLength, contentType and digest are not identity, and an AssetRef is not an artifact at all"
},
"asset": {
"id": "profile-fly-a",
"digest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0",
"byteLength": "4096",
"format": "flyprofile"
}
}

File diff suppressed because it is too large Load diff

View file

@ -1,667 +0,0 @@
{
"description": "ipc-v1 section 5: the operation key of a step mutation and the canonical body a duplicate is compared against.",
"keys": [
{
"name": "Agent.Prepare on fly-a at step 41",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"method": "Agent.Prepare",
"workerId": "fly-a",
"digest": "eda73d62bb5d616052997ab5e9d2c4ba5872fdefb44cf706910a18a24d39225f"
},
{
"name": "Agent.Prepare on fly-b at step 41",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"method": "Agent.Prepare",
"workerId": "fly-b",
"digest": "5a7fd15da236f3fd6343698a8cc5c4f14fa7d84683a819d5a28904d90f700c4e"
},
{
"name": "Agent.Commit on fly-a at step 41",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"method": "Agent.Commit",
"workerId": "fly-a",
"digest": "2a89a120e5594895d024b4ffc2a7d225bd27322cfd345ebd78e80ed7b271c680"
},
{
"name": "Agent.Prepare on fly-a at step 42",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "42"
},
"method": "Agent.Prepare",
"workerId": "fly-a",
"digest": "5bb184cab6c3c2d7567a7979d860a7e9b26ea52bdc92b139053b0a2f81a9ed4d"
},
{
"name": "Agent.Prepare on fly-a in another epoch",
"scope": {
"sessionId": "demo",
"epoch": "epoch-2",
"step": "41"
},
"method": "Agent.Prepare",
"workerId": "fly-a",
"digest": "fa1d9cf3936aaffe3cedd0e586d7763efca2d250e89dd85b849cf35fbd91a477"
},
{
"name": "Environment.Advance at step 41",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"method": "Environment.Advance",
"workerId": "world",
"digest": "c89c390aa78ef2895d9e2b86a6000160923dc54e3be75529e0289cd18b9088b1"
}
],
"bodies": [
{
"name": "Agent.Prepare body",
"method": "Agent.Prepare",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"params": {
"agentId": "fly-a",
"profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0",
"interval": {
"numerator": "50000000",
"denominator": "3"
},
"decisionContextDigest": "ea7792a26f405e2ae9c6f49ca93bbe6076ceac0a1fc53d83426c7d7f2d9377e4",
"preStepStimulations": [
{
"id": "stim-1",
"kindId": "sugar",
"durationMs": 50.0
}
]
},
"digest": "74c9d5f27b2cb06922cc3ca13d87c67faea0a6c73aefbbdb21ba8f52c6d33f60"
},
{
"name": "Environment.Advance body",
"method": "Environment.Advance",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"params": {
"batchId": "batch-41",
"controls": [
{
"portId": "port-1",
"buttons": [
{
"id": "a",
"down": true
},
{
"id": "b",
"down": false
},
{
"id": "start",
"down": false
},
{
"id": "select",
"down": false
},
{
"id": "up",
"down": false
},
{
"id": "down",
"down": false
},
{
"id": "left",
"down": false
},
{
"id": "right",
"down": false
}
],
"axes": [
{
"id": "stick-x",
"value": 0.0
},
{
"id": "trigger",
"value": 0.0
}
]
}
]
},
"digest": "5aaae9c083336460a2ce34fb50e191f7108444a953f09b952e97a48b7962ee79"
},
{
"name": "Worker.Hello body with no scope",
"method": "Worker.Hello",
"scope": null,
"params": {
"sessionId": "demo",
"expectedWorkerId": "fly-a",
"role": "agent",
"supportedMajors": [
1
]
},
"digest": "0c07677a45178d8680dddbeec56d4e09f6ccbfe5fe77d9d4ff2fb07b369febaf"
}
],
"pairs": [
{
"name": "the same operation retried on a new bus call",
"left": {
"method": "Environment.Advance",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"params": {
"batchId": "batch-41",
"controls": [
{
"portId": "port-1",
"buttons": [
{
"id": "a",
"down": true
},
{
"id": "b",
"down": false
},
{
"id": "start",
"down": false
},
{
"id": "select",
"down": false
},
{
"id": "up",
"down": false
},
{
"id": "down",
"down": false
},
{
"id": "left",
"down": false
},
{
"id": "right",
"down": false
}
],
"axes": [
{
"id": "stick-x",
"value": 0.0
},
{
"id": "trigger",
"value": 0.0
}
]
}
]
}
},
"right": {
"method": "Environment.Advance",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"params": {
"batchId": "batch-41",
"controls": [
{
"portId": "port-1",
"buttons": [
{
"id": "a",
"down": true
},
{
"id": "b",
"down": false
},
{
"id": "start",
"down": false
},
{
"id": "select",
"down": false
},
{
"id": "up",
"down": false
},
{
"id": "down",
"down": false
},
{
"id": "left",
"down": false
},
{
"id": "right",
"down": false
}
],
"axes": [
{
"id": "stick-x",
"value": 0.0
},
{
"id": "trigger",
"value": 0.0
}
]
}
]
}
},
"workerId": "world",
"sameKey": true,
"sameBody": true,
"reason": "a retry reuses the requestId and body; only the callId changes, and the callId is not in either digest"
},
{
"name": "the same batch id with altered controls",
"left": {
"method": "Environment.Advance",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"params": {
"batchId": "batch-41",
"controls": [
{
"portId": "port-1",
"buttons": [
{
"id": "a",
"down": true
},
{
"id": "b",
"down": false
},
{
"id": "start",
"down": false
},
{
"id": "select",
"down": false
},
{
"id": "up",
"down": false
},
{
"id": "down",
"down": false
},
{
"id": "left",
"down": false
},
{
"id": "right",
"down": false
}
],
"axes": [
{
"id": "stick-x",
"value": 0.0
},
{
"id": "trigger",
"value": 0.0
}
]
}
]
}
},
"right": {
"method": "Environment.Advance",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"params": {
"batchId": "batch-41",
"controls": [
{
"portId": "port-1",
"buttons": [
{
"id": "a",
"down": true
},
{
"id": "b",
"down": false
},
{
"id": "start",
"down": false
},
{
"id": "select",
"down": false
},
{
"id": "up",
"down": false
},
{
"id": "down",
"down": false
},
{
"id": "left",
"down": false
},
{
"id": "right",
"down": false
}
],
"axes": [
{
"id": "stick-x",
"value": 1.0
},
{
"id": "trigger",
"value": 0.0
}
]
}
]
}
},
"workerId": "world",
"sameKey": true,
"sameBody": false,
"reason": "same key, changed body: CONFLICT, never a second world mutation"
},
{
"name": "params written with their keys in another order",
"left": {
"method": "Agent.Prepare",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"params": {
"agentId": "fly-a",
"profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0",
"interval": {
"numerator": "50000000",
"denominator": "3"
},
"decisionContextDigest": "ea7792a26f405e2ae9c6f49ca93bbe6076ceac0a1fc53d83426c7d7f2d9377e4",
"preStepStimulations": [
{
"id": "stim-1",
"kindId": "sugar",
"durationMs": 50.0
}
]
}
},
"right": {
"method": "Agent.Prepare",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"params": {
"preStepStimulations": [
{
"id": "stim-1",
"kindId": "sugar",
"durationMs": 50.0
}
],
"decisionContextDigest": "ea7792a26f405e2ae9c6f49ca93bbe6076ceac0a1fc53d83426c7d7f2d9377e4",
"interval": {
"numerator": "50000000",
"denominator": "3"
},
"profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0",
"agentId": "fly-a"
}
},
"workerId": "fly-a",
"sameKey": true,
"sameBody": true,
"reason": "RFC 8785 sorts keys, so serialization order is not a body change"
},
{
"name": "the same body one step later",
"left": {
"method": "Agent.Prepare",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"params": {
"agentId": "fly-a",
"profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0",
"interval": {
"numerator": "50000000",
"denominator": "3"
},
"decisionContextDigest": "ea7792a26f405e2ae9c6f49ca93bbe6076ceac0a1fc53d83426c7d7f2d9377e4",
"preStepStimulations": [
{
"id": "stim-1",
"kindId": "sugar",
"durationMs": 50.0
}
]
}
},
"right": {
"method": "Agent.Prepare",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "42"
},
"params": {
"agentId": "fly-a",
"profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0",
"interval": {
"numerator": "50000000",
"denominator": "3"
},
"decisionContextDigest": "ea7792a26f405e2ae9c6f49ca93bbe6076ceac0a1fc53d83426c7d7f2d9377e4",
"preStepStimulations": [
{
"id": "stim-1",
"kindId": "sugar",
"durationMs": 50.0
}
]
}
},
"workerId": "fly-a",
"sameKey": false,
"sameBody": false,
"reason": "the step is part of both the key and the body"
},
{
"name": "the same body on another worker",
"left": {
"method": "Agent.Prepare",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"params": {
"agentId": "fly-a",
"profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0",
"interval": {
"numerator": "50000000",
"denominator": "3"
},
"decisionContextDigest": "ea7792a26f405e2ae9c6f49ca93bbe6076ceac0a1fc53d83426c7d7f2d9377e4",
"preStepStimulations": [
{
"id": "stim-1",
"kindId": "sugar",
"durationMs": 50.0
}
]
}
},
"right": {
"method": "Agent.Prepare",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"params": {
"agentId": "fly-a",
"profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0",
"interval": {
"numerator": "50000000",
"denominator": "3"
},
"decisionContextDigest": "ea7792a26f405e2ae9c6f49ca93bbe6076ceac0a1fc53d83426c7d7f2d9377e4",
"preStepStimulations": [
{
"id": "stim-1",
"kindId": "sugar",
"durationMs": 50.0
}
]
}
},
"workerId": "fly-a",
"rightWorkerId": "fly-b",
"sameKey": false,
"sameBody": true,
"reason": "the worker is part of the key, not of the body"
}
],
"rejected": [
{
"name": "a body carrying a bus callId",
"method": "Agent.Prepare",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"params": {
"callId": "call-2",
"agentId": "fly-a"
},
"reason": "the canonical body excludes bus callIds"
},
{
"name": "a body carrying a delivery id",
"method": "Agent.Prepare",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"params": {
"deliveryId": "dlv-7"
},
"reason": "the canonical body excludes deliveryIds"
},
{
"name": "a body carrying an owner token",
"method": "Agent.Commit",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"params": {
"nextInput": {
"frame": {
"ownerId": "own-3"
}
}
},
"reason": "the canonical body excludes owner tokens"
},
{
"name": "a body carrying a pinned service incarnation",
"method": "Agent.Prepare",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"params": {
"expectedIncarnation": "svc-1"
},
"reason": "route pinning is transport state, not domain state"
},
{
"name": "an empty method",
"method": "",
"scope": {
"sessionId": "demo",
"epoch": "epoch-1",
"step": "41"
},
"params": {},
"reason": "methods are 1..=128 printable ASCII characters"
}
]
}

View file

@ -1,273 +0,0 @@
{
"description": "Checked rational arithmetic and the step-v1 section 5 tick accumulator.",
"accumulator": [
{
"name": "a synthetic 60 Hz environment on a 1 ms model tick",
"stepDuration": {
"numerator": "50000000",
"denominator": "3"
},
"tickDuration": {
"numerator": "1000000",
"denominator": "1"
},
"steps": [
{
"ticks": "16",
"remainder": {
"numerator": "2000000",
"denominator": "3"
}
},
{
"ticks": "17",
"remainder": {
"numerator": "1000000",
"denominator": "3"
}
},
{
"ticks": "17",
"remainder": {
"numerator": "0",
"denominator": "1"
}
}
],
"totalTicks": "50",
"reason": "16, 17, 17 and a remainder of zero after three steps"
},
{
"name": "a whole millisecond cadence never accumulates a remainder",
"stepDuration": {
"numerator": "16000000",
"denominator": "1"
},
"tickDuration": {
"numerator": "1000000",
"denominator": "1"
},
"steps": [
{
"ticks": "16",
"remainder": {
"numerator": "0",
"denominator": "1"
}
},
{
"ticks": "16",
"remainder": {
"numerator": "0",
"denominator": "1"
}
}
],
"totalTicks": "32",
"reason": ""
},
{
"name": "a step shorter than one tick advances nothing and keeps the remainder",
"stepDuration": {
"numerator": "500000",
"denominator": "1"
},
"tickDuration": {
"numerator": "1000000",
"denominator": "1"
},
"steps": [
{
"ticks": "0",
"remainder": {
"numerator": "500000",
"denominator": "1"
}
},
{
"ticks": "1",
"remainder": {
"numerator": "0",
"denominator": "1"
}
}
],
"totalTicks": "1",
"reason": "the fractional period is carried, not rounded"
}
],
"add": [
{
"a": {
"numerator": "0",
"denominator": "1"
},
"b": {
"numerator": "50000000",
"denominator": "3"
},
"sum": {
"numerator": "50000000",
"denominator": "3"
}
},
{
"a": {
"numerator": "1",
"denominator": "3"
},
"b": {
"numerator": "1",
"denominator": "6"
},
"sum": {
"numerator": "1",
"denominator": "2"
}
},
{
"a": {
"numerator": "18446744073709551615",
"denominator": "1"
},
"b": {
"numerator": "1",
"denominator": "2"
},
"error": "reduced value does not fit U64"
},
{
"a": {
"numerator": "18446744073709551615",
"denominator": "18446744073709551614"
},
"b": {
"numerator": "18446744073709551613",
"denominator": "18446744073709551611"
},
"error": "the cross-multiplied numerators sum past 2^128",
"reason": "two reduced fractions near the U64 maximum: every multiplication fits u128, their sum does not, and the arithmetic must refuse rather than wrap"
}
],
"subtract": [
{
"a": {
"numerator": "50000000",
"denominator": "3"
},
"b": {
"numerator": "50000000",
"denominator": "3"
},
"difference": {
"numerator": "0",
"denominator": "1"
}
},
{
"a": {
"numerator": "1",
"denominator": "2"
},
"b": {
"numerator": "1",
"denominator": "3"
},
"difference": {
"numerator": "1",
"denominator": "6"
}
},
{
"a": {
"numerator": "0",
"denominator": "1"
},
"b": {
"numerator": "1",
"denominator": "2"
},
"error": "subtraction would be negative"
},
{
"a": {
"numerator": "18446744073709551615",
"denominator": "18446744073709551614"
},
"b": {
"numerator": "1",
"denominator": "18446744073709551611"
},
"error": "the reduced difference does not fit U64",
"reason": "large denominators reach the reduction limit rather than the subtraction one"
}
],
"multiply": [
{
"a": {
"numerator": "1000000",
"denominator": "1"
},
"k": "17",
"product": {
"numerator": "17000000",
"denominator": "1"
}
},
{
"a": {
"numerator": "0",
"denominator": "1"
},
"k": "1000",
"product": {
"numerator": "0",
"denominator": "1"
}
},
{
"a": {
"numerator": "18446744073709551615",
"denominator": "1"
},
"k": "2",
"error": "reduced value does not fit U64"
}
],
"compare": [
{
"a": {
"numerator": "0",
"denominator": "1"
},
"b": {
"numerator": "1000000",
"denominator": "1"
},
"ordering": "less"
},
{
"a": {
"numerator": "1",
"denominator": "3"
},
"b": {
"numerator": "1",
"denominator": "3"
},
"ordering": "equal",
"reason": "equal values compare equal; an unreduced 2/6 never reaches a comparison, because it never parses"
},
{
"a": {
"numerator": "50000000",
"denominator": "3"
},
"b": {
"numerator": "1000000",
"denominator": "1"
},
"ordering": "greater"
}
]
}

View file

@ -1,77 +0,0 @@
{
"description": "Byte sequences every implementation must refuse before validation.",
"cases": [
{
"name": "duplicate key at the top level",
"type": "Scope",
"base64": "eyJzZXNzaW9uSWQiOiJkZW1vIiwiZXBvY2giOiJlcG9jaC0xIiwic3RlcCI6IjEiLCJzdGVwIjoiMiJ9",
"reason": "duplicate JSON keys are refused at any depth"
},
{
"name": "duplicate key inside a nested object",
"type": "TypedValue",
"base64": "eyJzY2hlbWEiOnsiaWQiOiJhLnYxIiwidmVyc2lvbiI6MSwiZGlnZXN0IjoiYmJhNzk1MWMwNDY2NGNkNDliYjZlZWQxZGE2ZGMxYTRlMTdlOTg5ZTc4N2JmMmU3MmY1OTMzYTVjNjE3MTM3MiJ9LCJ2YWx1ZSI6eyJ4IjoxLCJ4IjoyfX0=",
"reason": "duplicate JSON keys are refused at any depth"
},
{
"name": "invalid UTF-8 in a string",
"type": "Scope",
"base64": "eyJzZXNzaW9uSWQiOiJkZf9tbyIsImVwb2NoIjoiZXBvY2gtMSIsInN0ZXAiOiIxIn0=",
"reason": "the envelope is UTF-8"
},
{
"name": "invalid UTF-8 in a key",
"type": "Scope",
"base64": "eyJzZXNzaW9u/0lkIjoiZGVtbyIsImVwb2NoIjoiZXBvY2gtMSIsInN0ZXAiOiIxIn0=",
"reason": "the envelope is UTF-8"
},
{
"name": "NaN literal",
"type": "Stimulus",
"base64": "eyJpZCI6InN0aW0tMSIsImtpbmRJZCI6InN1Z2FyIiwiZHVyYXRpb25NcyI6TmFOfQ==",
"reason": "NaN and Infinity are not JSON"
},
{
"name": "Infinity literal",
"type": "Stimulus",
"base64": "eyJpZCI6InN0aW0tMSIsImtpbmRJZCI6InN1Z2FyIiwiZHVyYXRpb25NcyI6SW5maW5pdHl9",
"reason": "NaN and Infinity are not JSON"
},
{
"name": "number that overflows a double",
"type": "Stimulus",
"base64": "eyJpZCI6InN0aW0tMSIsImtpbmRJZCI6InN1Z2FyIiwiZHVyYXRpb25NcyI6MWU5OTl9",
"reason": "a non-finite number never survives parsing"
},
{
"name": "integer past the exact double range",
"type": "Stimulus",
"base64": "eyJpZCI6InN0aW0tMSIsImtpbmRJZCI6InN1Z2FyIiwiZHVyYXRpb25NcyI6OTAwNzE5OTI1NDc0MDk5M30=",
"reason": "canonical JSON cannot encode it exactly; counters are U64 strings"
},
{
"name": "trailing data after the object",
"type": "Scope",
"base64": "eyJzZXNzaW9uSWQiOiJkZW1vIiwiZXBvY2giOiJlcG9jaC0xIiwic3RlcCI6IjEifSB7fQ==",
"reason": "one value per payload"
},
{
"name": "truncated object",
"type": "Scope",
"base64": "eyJzZXNzaW9uSWQiOiJkZW1vIiwiZXBvY2giOiJlcG9jaC0xIg==",
"reason": "partial JSON is refused"
},
{
"name": "empty payload",
"type": "Scope",
"base64": "",
"reason": "an empty payload is not a JSON object"
},
{
"name": "a bare array",
"type": "Scope",
"base64": "W10=",
"reason": "a payload is an object"
}
]
}

File diff suppressed because one or more lines are too long

View file

@ -1,185 +0,0 @@
{
"description": "seed-derivation-v1 test vectors. Both languages must reproduce every seed.",
"algorithm": "seed-derivation-v1",
"prefix": "flybrain/seed-derivation-v1",
"materialTemplate": "<prefix>\\n<masterSeed>\\n<agentId>\\n",
"rule": "SHA-256 of the material, read as eight big-endian u32 lanes; the first nonzero lane is the seed as a two's-complement i32.",
"vectors": [
{
"masterSeed": "0",
"agentId": "fly-a",
"material": "flybrain/seed-derivation-v1\n0\nfly-a\n",
"materialDigest": "6cf7c34a422f4cdd890c64d0ef5a072e4f6ddc2ee18e83ee24a5f2214f4960d1",
"seed": 1828176714
},
{
"masterSeed": "0",
"agentId": "fly-b",
"material": "flybrain/seed-derivation-v1\n0\nfly-b\n",
"materialDigest": "48a52f4069cfca001ba5ee6ae870dccb5a5bd59d61babf333a01749b6a8f4025",
"seed": 1218785088
},
{
"masterSeed": "0",
"agentId": "fly-c",
"material": "flybrain/seed-derivation-v1\n0\nfly-c\n",
"materialDigest": "53a83c2d0cfce5a3356aab3b0b73a270201a2dff7816168a4167c71a32d863fe",
"seed": 1403534381
},
{
"masterSeed": "0",
"agentId": "fly-d",
"material": "flybrain/seed-derivation-v1\n0\nfly-d\n",
"materialDigest": "f202513f32fe6070eee65ad72027490f9218b86dc8cf74e4e3563f02d532ea94",
"seed": -234729153
},
{
"masterSeed": "1",
"agentId": "fly-a",
"material": "flybrain/seed-derivation-v1\n1\nfly-a\n",
"materialDigest": "6eab9d6d6d002ffc0e2cdf2d64dd8226f90c391e45641512d14edbb244225698",
"seed": 1856740717
},
{
"masterSeed": "1",
"agentId": "fly-b",
"material": "flybrain/seed-derivation-v1\n1\nfly-b\n",
"materialDigest": "00115cb341d839490c5485462fd7eedd8b6baeda3748054fcba0d1558e471a6c",
"seed": 1137843
},
{
"masterSeed": "1",
"agentId": "fly-c",
"material": "flybrain/seed-derivation-v1\n1\nfly-c\n",
"materialDigest": "af97482074d6d36e5f5920aaa1d87f02365045f3712300dbd243584f2cb4a64c",
"seed": -1349040096
},
{
"masterSeed": "1",
"agentId": "fly-d",
"material": "flybrain/seed-derivation-v1\n1\nfly-d\n",
"materialDigest": "1081b24880040dcc4d442f88451513e4fbcf8dae9ccb79329dd289fabbc7938b",
"seed": 276935240
},
{
"masterSeed": "42",
"agentId": "fly-a",
"material": "flybrain/seed-derivation-v1\n42\nfly-a\n",
"materialDigest": "f4f9f271d53e94c38c6260b99840513f9eb70e5bb442d63345e217185e6ba9f6",
"seed": -184946063
},
{
"masterSeed": "42",
"agentId": "fly-b",
"material": "flybrain/seed-derivation-v1\n42\nfly-b\n",
"materialDigest": "1feb285e834138ec823ffea696fbe5fb3c211f349ce17140e25286d06c56ece8",
"seed": 535504990
},
{
"masterSeed": "42",
"agentId": "fly-c",
"material": "flybrain/seed-derivation-v1\n42\nfly-c\n",
"materialDigest": "b5a251ed4521f91eab1d8a6813880794c5c6706eafc137d8ebc169f743c60eed",
"seed": -1247653395
},
{
"masterSeed": "42",
"agentId": "fly-d",
"material": "flybrain/seed-derivation-v1\n42\nfly-d\n",
"materialDigest": "e36d6e40f27a4ffe473351d4bd01154da2efb3c3038d88f3aa48f77963d465c6",
"seed": -479367616
},
{
"masterSeed": "9223372036854775808",
"agentId": "fly-a",
"material": "flybrain/seed-derivation-v1\n9223372036854775808\nfly-a\n",
"materialDigest": "d59f513b6fdce1646ed13907302e0cb3ca4696738dd7df38e9581397508ec933",
"seed": -710979269
},
{
"masterSeed": "9223372036854775808",
"agentId": "fly-b",
"material": "flybrain/seed-derivation-v1\n9223372036854775808\nfly-b\n",
"materialDigest": "c8de8ca0279829c438fa19c07f05768cfe050e8c21bdaa0fe3c68f8048b4e1a3",
"seed": -924939104
},
{
"masterSeed": "9223372036854775808",
"agentId": "fly-c",
"material": "flybrain/seed-derivation-v1\n9223372036854775808\nfly-c\n",
"materialDigest": "354ad1ec388aa7be6136151d0f6281cc17279b62e95d458c96f392aaac40ee62",
"seed": 894095852
},
{
"masterSeed": "9223372036854775808",
"agentId": "fly-d",
"material": "flybrain/seed-derivation-v1\n9223372036854775808\nfly-d\n",
"materialDigest": "58e61deb4777b7702bdcd7edf5a5bcd0d51273699cc1051c7fbc9a5ffedd9e15",
"seed": 1491475947
},
{
"masterSeed": "18446744073709551615",
"agentId": "fly-a",
"material": "flybrain/seed-derivation-v1\n18446744073709551615\nfly-a\n",
"materialDigest": "f888eb95bcab276936fce5f72df3a0741e36da26722f43cc2a974932dfd42cd1",
"seed": -125244523
},
{
"masterSeed": "18446744073709551615",
"agentId": "fly-b",
"material": "flybrain/seed-derivation-v1\n18446744073709551615\nfly-b\n",
"materialDigest": "d343df049e8ef71617e9af7a321cf498d61083c50229f2d6852077670c86acbd",
"seed": -750526716
},
{
"masterSeed": "18446744073709551615",
"agentId": "fly-c",
"material": "flybrain/seed-derivation-v1\n18446744073709551615\nfly-c\n",
"materialDigest": "88016b220245b431a4eb510d31b643475bb2496130b65ae81b165acd5a899292",
"seed": -2013172958
},
{
"masterSeed": "18446744073709551615",
"agentId": "fly-d",
"material": "flybrain/seed-derivation-v1\n18446744073709551615\nfly-d\n",
"materialDigest": "d9b5bee718c87599c9ef0b3c16f361dadf714a33f7dd1209a21108f4df700b16",
"seed": -642400537
}
],
"composition": {
"masterSeed": "42",
"agentIds": [
"fly-a",
"fly-b",
"fly-c",
"fly-d"
],
"seeds": [
-184946063,
535504990,
-1247653395,
-479367616
],
"reason": "independent per-agent seeds from one recorded master seed and stable agent ids"
},
"invalid": [
{
"masterSeed": "0",
"agentId": "Fly-A",
"reason": "an agent id is an Id: lowercase"
},
{
"masterSeed": "0",
"agentId": "",
"reason": "an agent id is 1..=64 characters"
},
{
"masterSeed": "0",
"agentIds": [
"fly-a",
"fly-a"
],
"reason": "a composition with a repeated agent id is refused rather than silently sharing a seed"
}
]
}

File diff suppressed because it is too large Load diff

File diff suppressed because it is too large Load diff

Some files were not shown because too many files have changed in this diff Show more