Compare commits

..

No commits in common. "main" and "docs/session-framework" have entirely different histories.

240 changed files with 1399 additions and 94216 deletions

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

@ -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
@ -511,13 +474,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 +506,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
@ -722,47 +641,6 @@ is exactly the shape of mistake the cross-check below exists for.
both are the old map — so only the *id* is wrong, and the check that catches it is one byte: does 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 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 ### The survey, on two maps
`services/flysim/crates/flysim/tests/rom_map_grid.rs`, the method of `services/flysim/crates/flysim/tests/rom_map_grid.rs`, the method of
@ -787,211 +665,3 @@ answers that, and the executor's per-step moved check covers the rest), a warp t
step onto it, and a script that pushes the fly off a tile (a session ledger answers that). 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 water half of the tile-pair lists is deliberately absent: it is the list
`CheckForJumpingAndTilePairCollisions` uses while surfing, and the palette cannot surf. `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).

View file

@ -1003,653 +1003,6 @@ cursor inverted and it needs a WRAM reading rather than a pad change.
The decoder, the reward catalog, the adapter version, the roles and the compatibility string are The decoder, the reward catalog, the adapter version, the roles and the compatibility string are
untouched. untouched.
### 12.12 The nurse's box is a ring, and `YES` and `NEXT` are one press (2026-09-22, rung 10, row 41)
Rank 10 (PEWTER CITY), the fly in the Pewter Pokémon Center, and since the v0.4.4 restart the
macro starts were `YES` **2,142**, `TALK` 107, `GO FRONTIER` 26, `BACK` 24, with the event log's
tail `YES start/done` for ever. This is **row 41**, first measured in the rung-9 trap hunt (`YES`
1,278 of 1,295 starts on one tile of map `0x3a`) and named by 12.11 as the next trap. Reproduced
from the live checkpoint with the real cartridge and surveyed press by press
(`examples/scene_probe.rs`, `FLY_PROBE_CATCH=nurse`); `infra/docs/macros-traps.md` has the survey
whole.
- **The conversation is a ring of forty-six A presses, and the box is a *choice* on one of them.**
Welcome, "We heal your POKéMON back to perfect health!", the **YES/NO box**, "OK. We'll need your
POKéMON.", the machine, "Your POKéMON are fighting fit!", "We hope to see you again!", the box
closes for a single frame, and the next A press at a nurse two tiles away over her counter opens
the whole thing again. The party read **70/70 and healthy** on every frame of it, so not one of
those presses changed anything. That is section 12.2's rule at conversation scale: a macro whose
precondition is already satisfied where the fly stands is a trap.
- **The brief allowed three readings and the survey settles it as none of them.** The box open at
the checkpoint is not the prompt, it is the closing line, and `YES` there is an A press on plain
text -- forty-five of the forty-six frames are like that. `HEAL` is not in the loop at all: its
precondition already reads the live party, `party_needs_rest` answers `false`, and the button was
off the pad the whole time. And `HEAL`'s own wait is a *read* of the party, not a loop waiting
for a timer, so it cannot spin on a party that is already full. What was in the loop was the
**dialog pad**, dealt unconditionally, and `TALK` to get back into it.
- **On a box that is a choice, `NEXT` is `YES` under another name.** An A press at a two-option menu
confirms the option the cursor is on, and the cursor opens on YES, so `NEXT` and `YES` are one
press with two channels -- 12.10's rule about a pair of buttons, in a dialog rather than a
battle. `NEXT` is off any pad dealt for a readable YES/NO prompt; the pad is the box's own two
answers.
- **At the nurse's prompt the answer that changes something is the only one bound.** Hurt or
statused, `YES`; full and healthy, `NO`. Section 13 has read that byte for `HEAL` since the
errands existed; this is the same byte read for the box `HEAL` opens. Knowledge inside the macro
as a precondition, and nothing ranks the two: one of them simply is not there.
- **`TALK` is not offered at a nurse the party has no use for.** The nurse is an object with a
purpose rather than a person to chat with, and she is the one person in Red whose conversation
has a precondition the cartridge publishes. This is the door into the ring, and closing it is
what makes the rest of the section a backstop rather than the fix. Nobody else is narrowed: an
ordinary villager is `TALK`'s whatever the party reads.
- **The nurse enters the *talked* ledger on a completed heal or a declined prompt.** A completed
`HEAL` has had the conversation with its own presses, so `TALK` has nothing left to open, and the
reached window would otherwise expire in ten brain minutes and offer the ring again. A declined
prompt is 12.4's rule deliberately inverted, for one person: "the thing it said no to is still on
offer" is true of a villager with something to say and false of a service the party does not
need -- and the pad only ever offers `NO` at her prompt when the party is already full.
- **`TALK`'s precondition and `TALK`'s ledger entry were two different questions, which is why she
was never retired.** The precondition reaches over a counter, because
`IsSpriteOrSignInFrontOfPlayer` does; the entry was read one tile ahead. So `TALK` was *bound* at
the counter and *recorded* nothing at all, 107 times. A precondition and the ledger that answers
it have to be the same reading, and they are now.
- **The general rule: an answer that brings the same prompt straight back is excluded for the
blocked window.** Same map, same tile, same readable prompt, within one hold of the answer
finishing -- nothing moved and nothing was settled, so the press did nothing the next press will
not undo. It is 12.1's ledger doing for an answer what it does for a walk, with the same ten
brain minutes, keyed `TargetKey::Answer { at, yes }`. The exclusion *narrows* a pad and never
empties one: with both answers excluded both come back, because a box nothing can answer is a
screen nothing can leave.
- **What the reading rests on, and what it does not claim.** `docs/design/macros-wram.md` said
outright that there is no "a choice is open" flag, and there is not -- so `yes_no_prompt` is the
construction `text_box`'s `waiting` already makes: `wFontLoaded` plus the figure the game draws,
a border at (11, 6)-(19, 11) with the shared cursor parked at row 8, column 12, one item below
the first, watching A and B. **Both halves are needed**: the cursor bytes are not cleared when
the box closes, so all forty-six of the nurse's frames carry that geometry while the box itself
is drawn on exactly one. Red places a two-option menu where the script asking for it says, so a
prompt drawn somewhere else reads `false` and its dialog keeps the pad it has always had. That is
a named limit, not a gap being papered over.
**What the harness holds.** Unit: `HEAL` is off a full party's pad, including the rung-10 party's
own numbers; `TALK` is off a rested nurse's pad and on a hurt one's; the nurse's prompt deals one
answer and a plain box still deals three; a readable prompt that is not hers deals both answers and
no `NEXT`; an answer whose prompt reopens is excluded and an answer that settled the box is not; a
completed heal and a declined prompt both write the nurse into the talked ledger. ROM-gated from
the live checkpoint: the fly leaves map `0x3a`, `YES` starts stay under five, `NEXT` is on no pad
while a prompt is readable, and `TALK` is on no pad at a rested nurse.
The decoder, the reward catalog, the adapter version, the roles and the compatibility string are
untouched.
### 12.13 `UNKNOWN` is two states, and one of them has nothing to press (2026-09-22, rung 10, five and a half hours)
Live on v0.4.5, rank 10, the fly inside the Pewter museum with rung 11's BOULDER BADGE two doors
away: since the 11:30 restart the macro starts were `GO FRONTIER` **1,235**, `BACK` **678**,
`GO OBJECTIVE` 267, `GO OUT` 242, `YES` 169, and the previous review had already named the shape --
`BACK` pressed 189 times "in a text box" on map `0x02`, surrounded by `GO OBJECTIVE` and
`GO FRONTIER`. `infra/docs/macros-traps.md` has the reproduction; three things were wrong and all
three are here, and the first of them is not a text box at all.
- **`BACK` was dealt by `Scene::Unknown`, on frames with nothing drawn.** `BACK` is on no overworld
pad and on no dialog pad, so every one of those presses came from `Unknown` — and `Unknown` holds
two states under one name. One is a screen this crate cannot name: the Pokédex, the trainer card,
OPTION, where A and B are what leave it and 13.1 put them there on purpose. The other is a frame
of the **overworld** where the buttons are not reaching the player — a warp in flight, a scripted
push-back, the museum guide walking the fly through the door — which `scene::detect` calls
`Unknown` because its overworld branch needs `controllable`. On the second, `NEXT` and `BACK` are
an A and a B pressed into somebody else's script: they change nothing, they complete where the fly
stands, and that is 12.2's trap with no box to advance. So the pad is dealt on **whether a box is
drawn** (`wFontLoaded`, the reading `text_box` already makes), and a scripted overworld frame is an
empty pad the fly waits out. Nothing else can wait it out: the cartridge gives the buttons back by
itself, which is the difference between this and every other empty pad 13.1 enumerates.
### 12.14 A frontier no walk can reach is a fact about the map (2026-09-22, the same run)
The museum's ground floor is 98 walkable tiles, **62** of them reachable from the door and **39**
never stood on — and almost all of those 39 are behind the admission desk. `GO FRONTIER` aims at
ground the run has not stood on, the route search cannot reach any of it, so the macro refuses
`no route`, presses nothing and writes every goal to the blocked ledger (12.1). That ledger is a
**ten brain minute window**: it lapsed, all of it was a candidate again, and the refusal happened
again, once per hold, for hours.
A window is the right shape for a target somebody is standing in front of and the wrong shape for
ground the map has fenced off. So the refusal is remembered **per map** instead, with no window,
beside the pushed-tile ledger of row 37 — and it is *cleared* by the one event that can change the
answer: the fly standing somewhere on that map it has not stood on before, because a door opened, a
script carried it through, or somebody moved out of a doorway. Re-entering the map clears nothing;
that was the loop the window made. Session state like every other ledger, never checkpointed.
### 12.15 The ratchet's stall window cannot see a fly walking a road it has already covered
Two "Stuck" rollbacks fired on this rung inside half an hour, attempts 0 → 2, each one putting the
fly back where it had started. Both were the ratchet working exactly to contract: its stall window
is restarted by *exploration* — `progress.unique_locations`, ground the run has never stood on
(`docs/design/ladder.md`, and row 22) — and 120 brain seconds of safe overworld samples without one
new tile is a stall by definition. Entering a map for the first time already counts, because a new
map is a map's worth of tiles nobody has stood on; **re-entering** one does not, and two museum
floors and a covered town are exactly that.
What is plainly progress and is not ground: **being nearer the objective than this run has ever
been**, counted in hops over the same map graph `GO OBJECTIVE` walks (`geography::hops`). The macro
layer answers it, the sim loop passes it to the ratchet beside the coverage figure, and the ratchet
treats it exactly as it treats a rise in coverage — it restarts the window and does nothing else: no
budget spent, no snapshot taken, no trigger skipped. It can fire at most once per step of the road,
and nothing in the macro layer reads it back: no macro is ranked by it and no button is bound on it.
The checkpointed ratchet state does not move. The signal is a level on one sample, not a counter, so
there is nothing to serialise and nothing to drift across a restore.
### 12.16 What is still in the way of the badge, measured rather than fixed
With 12.13, 12.14 and the museum's two rows on the map graph, the ROM-gated run from the live
checkpoint reaches the gym's own interior on **15 macros** — against never, in five and a half live
hours. Two things it then does are worth naming, because neither is a bug and both cost the rung:
- **the town's errands come first, and they are session state.** Section 13 puts an unvisited mart
or Pokémon Center ahead of the rung's place for every map in that area, and the gym is in Pewter's
area like everything else. The ledger does not survive a restart, so the 11:30 restart re-armed
both errands and `GO OBJECTIVE` aimed at them before the leader. They are paid once and the run
goes on; the cost is minutes, not hours.
- **`GO FRONTIER` is still most of the run** — 1,247 starts in 55 brain minutes, 649 of them in
Pewter City itself. There the frontier is genuinely reachable, one tile at a time, because the
whole-map grid is refused on a walking fly (below) and the windowed frontier is the nearest
unstood tile on screen. It is covering ground rather than standing still, which is why it is a
residual and not a trap.
**The trap hunt does not improve.** Distinct tiles 193 -> 175 and flagged windows 59 -> 69, with
`BACK` in a text box 295 -> 0 and battle frames 6,948 -> 20,894. What the old cycle is replaced by
is a new one on the same five tiles -- `GO FRONTIER`, `GO HEAL`, `GO ROUTE`, x42, for seven and a
half brain minutes -- and then four brain minutes inside one battle, which the hunt's tile rule
flags as hard as it flags a stall. `infra/docs/macros-traps.md` has both arms whole and row 54 is
the next brief. The ethos check's "the trap hunt improves" does not hold for this branch; the
ROM-gated run does, and both are reported rather than one of them.
**The whole-map grid is refused while the fly is moving.** `pokemon_red::state::map_grid` checks its
decode against the screen buffer over the fly's own tile and its four neighbours, and on a frame
mid-step the two are a tile apart. Measured on Pewter City from the rung-10 checkpoint: standing
still it decodes on **118 of 120** frames, and the frame the survey caught disagreed on three tiles
by exactly one row in the direction of travel. A walk planned on such a frame is planned over the
ten-by-nine window of section 15's "before".
*Worked in 12.17*, and the guess above was the wrong way round: the survey found the coordinates
change at the **end** of the step, so it is the screen that is a tile ahead of `wYCoord` rather
than `wYCoord` ahead of the screen.
### 12.17 The coordinates change at the end of a step, and an errand arrives inside (2026-09-22, row 54)
Section 12.16's two residuals turned out to be one fact and one old rule that had been left off one
walk. Both were measured from the same rung-10 checkpoint, with
`FLY_PROBE_CATCH=step` in `examples/scene_probe.rs`; the bytes are in
`docs/design/macros-wram.md` section 9.
- **`wXCoord` and `wYCoord` change at the *end* of a step, not at its start.** Holding UP out of the
Pewter museum, `wYCoord` read 7 for frames 0 to 15 of a sixteen-frame step and 6 from frame 16,
while from frame 2 the screen buffer already held the view centred on (10, 6). The grid's
cross-check compared the decode of (10, 7) with the screen's reading of (10, 6) -- `$20` against
`$01` -- and refused, on fourteen frames of every sixteen. Pewter City decoded on **118 of 120**
standing frames and on **none** of the moving ones, so every walk the fly actually took was
re-planned over the ten-by-nine window: section 15's "before", and row 23's oscillation with it.
- **So the decode is read from the tile the screen is centred on.** Nothing in the pinned symbol
table says "a step is in progress" and a new address cannot be pinned without the disassembly
`gen_symbols.py` reads, so the anchor is *measured* rather than named: the screen is centred on
the fly's tile or on one of its four neighbours, and the one it is centred on is the one whose
whole neighbourhood agrees with the decode. The check keeps the property it exists for -- a wrong
stride, a wrong quadrant, a half-loaded map or the mid-warp tear agrees with **none** of the five,
because the whole neighbourhood has to agree under one anchor rather than each tile finding an
anchor of its own.
- **The tile a step is landing on is ground the run has covered.** The other half of the same fact:
for fifteen frames of every sixteen the stood ledger recorded the tile the fly had already left,
so the ground under it stayed *unstood*, `path::frontier` kept offering it, and `GO FRONTIER` was
dealt aiming one tile away -- a walk that reports `done` the instant the step it did not make
lands. A step that has begun always finishes, and the screen has already centred on it.
- **An errand arrives inside the building, facing the counter, never on the doormat outside it.**
`GO SHOP` and `GO HEAL` aim at a door, and a door's aim carries no press because the warp fires
when it is stepped on -- so an aim on the tile the fly is already standing on settles for
`SETTLE_FRAMES` and reports `done` with the world exactly as it was. Section 12.2's trap in its
own words, and `exit_goals` has excluded a settled goal underfoot since row 13: this was the one
walk that did not have the rule. A completed errand walk also writes the reached ledger, which
`goals_toward` does not filter, so the same button came back every hold: `GO HEAL` **204** starts
at a mean net of 0.0 tiles and a mean reach of 0.0.
- **An errand is paid by a building this run has already been inside.** `areaVisited` is session
state, so a restore re-armed every errand in the town and walked the fly back to a counter it had
already used -- section 13's own residual. `MacroState::map_visited` is the adapter's lifetime
answer to the same question and it does survive a restore, so both are asked and either pays.
Nothing here changes which button the fly presses. The decoder, the reward catalog, the adapter
version and the compatibility string are untouched.
### 12.18 A menu is up while its box is on screen, not while its cursor bytes say so (2026-09-22, row 50)
The largest thing left inside a battle after 12.17: `MOVE n` reported `blocked` **890 times in
1,431 macros** from the rung-9 forest checkpoint, `MOVE 4` **222 of 224**, and 82% of a fixed run's
frames were battle time with one battle running 30,809 of them. Row 50 called it "the move list
drawn and its cursor placeable but not accepting input". Half of that turned out to be wrong, and
finding out which half is the whole fix.
- **The cursor bytes outlive the list, so the list was not drawn at all.** `MoveSelectionMenu`
writes `wTopMenuItemY` 12 and `wTopMenuItemX` 5 and **nothing in the game clears them**. That is
the same fact 12.12 rested the YES/NO box on — "the cursor bytes survive the box closing" — and
the reason the *top-level* battle menu never had it is that `wTextBoxID` = `$0b` sits beside its
geometry and is written by somebody else. `SelectMenuItem` then decrements `wCurrentMenuItem` back
into a 0-based move slot on its way out, which lands inside the one-based range the accessor reads
as valid, so a turn spent on move 2, 3 or 4 leaves a *placeable* cursor behind it. Every frame of
the text, the animation, the damage and the enemy's reply read as the fly's own turn on an open
move list. The pad dealt `MOVE 1..4` and `BACK` on all of them, the roll landed on one, and the
cursor step pressed at a list nobody was reading until its budget ran out. That is section 12.2's
trap wearing 12.6's clothes: a macro whose precondition is satisfied where the fly stands.
- **So a menu is up while its box is on screen.** The reading is the figure `MoveSelectionMenu`
draws — a box at (4, 12) fourteen wide, with a horizontal run over its top-left corner and the
`┘` junction over (10, 12) — read whole, exactly as `text_box`'s `waiting` and `yes_no_prompt`
are. `docs/design/macros-wram.md` section 10 has the accessor.
- **It was surveyed by pressing, not by nominating a flag.** `examples/scene_probe.rs`,
`FLY_PROBE_CATCH=accept`: on every battle frame the emulator exports its state, one directional
pulse is issued, `wCurrentMenuItem` is read and the state goes straight back, so every frame has a
ground truth and the run is not perturbed by the measurement. By the cursor bytes alone a press
was honoured on **264 frames of 3,102**; by the cursor bytes and the box on **231 of 231**. Beside
it every byte of WRAM and HRAM was asked whether it separates the two classes, so a reading was
found rather than guessed — nothing separates once the box is in it, which is what "exact" means
here.
- **A frame whose list is not on screen is between turns**, whose pad is the one `NEXT` that
advances text (12.10). No pad gains or loses a button anywhere else: the top-level menu, the party
list, the bag and the forced switch keep exactly the rows 13.1 gives them, and which move the fly
uses is still the fly's.
- **The bag is the same trap and it is named rather than fixed.** `wListMenuID` = `ITEMLISTMENU`
outlives the bag as surely as the cursor bytes outlive the move list: over the frames the seam
calls an open battle bag, the same survey refused **449** presses against 36 honoured. The bag's
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 `ITEM` and `THROW BALL` still pay for it and that is
reported. A reading this crate cannot verify does not go in (`docs/design/ladder.md`).
The decoder, the reward catalog, the adapter version, the roles and the compatibility string are
untouched.
### 12.19 A mart's counter is four screens, and one of them is the clerk talking (2026-09-22, row 55)
Minutes after v0.4.7 went live the watchdog flagged the narrowest loop yet: map `0x38`, scene
`shop`, `sequence: [BUY ANTIDOTE], period 1, repeats 747, distinctMacros 1` over ten brain
minutes, with `BUY ANTIDOTE start` then `BUY ANTIDOTE blocked` every 0.8 s and **nothing else
starting at all**. Surveyed from the live checkpoint with a new probe mode
(`FLY_PROBE_CATCH=shop` in `examples/scene_probe.rs`); the bytes are in
`docs/design/macros-wram.md` section 7.1.
- **`wListMenuID` says the counter is open, not which of its screens is up.** The mart prints its
own text from inside `DisplayPokemartDialogue_`, which never calls the routine that clears that
byte, so it holds `PRICEDITEMLISTMENU` for the **whole visit**. The frame the stream sat on was
the clerk's "Here you are! Thank you!" box waiting for a press, and the seam called it the buy
list; the cursor bytes on it belong to a two-option box (`max` 1). So which screen is up is now
read from the figure the game draws -- the construction the dialogue box's `waiting` test and the
YES/NO prompt already use. The clerk talking is `ShopScreen::Talking`, it reports **no listing at
all**, and no purchase starts on it: a scene whose menu is not open offers what actually opens it,
which here is `CONFIRM` and `LEAVE`, and both are already on the shop's pad.
- **A mart's buy list scrolls, so an item's position in the stock is its cursor index only for the
first three entries.** Walked one 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 offset that names the scrolled
position is not a pinned address, so the fourth item of a counter and after have no index this
seam can aim at. Pewter's counter carries seven items and its ANTIDOTE is the **fourth**:
`BUY ANTIDOTE` aimed a cursor step at index 3 in a list reporting a max of 1 and returned
`Blocked` **on its own first frame, having pressed nothing** -- three of three attempts, zero
frames. A macro that cannot run is not on the pad, so the four purchases are bound on the rows
the cursor can reach, and Viridian's four-item counter is why this went unseen: its ANTIDOTE is
index 1.
- **A purchase has no target, so a blocked purchase records nothing.** Section 12.1's ledger is
keyed by what a walk set out for and a press sets out for nothing, so the button was dealt again
on the very next hold, for ever -- row 6's shape in a scene with no walk in it. The fix is the
precondition rather than a new ledger: the question the ledger would have answered is a fact about
the counter, and the pad can ask it before the fly presses.
The loop was also **the fly's own choice landing on the one button it could afford**: the wallet
read 104, so of Pewter's stock only the Antidote was under it, and `BUY ANTIDOTE` was the only
purchase bound. Nothing about the choice changes here. What changes is that the button is not
offered, and the two presses that leave a counter are.
Nothing here changes which button the fly presses. The decoder, the reward catalog, the adapter
version and the compatibility string are untouched.
### 12.20 A two-option box is the one the cartridge drew, and a `NO` inside a conversation declines nothing (2026-09-23, row 56)
Live on the release box: map 54 (**Pewter Gym**), scene `dialog`, rank 10, **thirty-plus brain
minutes of zero progress** with the explore and wild-win counters frozen, **747 macro starts in ten
brain minutes**, and a mix of `YES` 64 / `NO` 62 / `NEXT` 59 / `TALK` 6 with **no walk macro dealt
at all**. The watchdog did not flag it: four distinct macros is exactly its threshold. Surveyed
from the live checkpoint with a new probe mode (`examples/scene_probe.rs`,
`FLY_PROBE_CATCH=dialog`), which walks the conversation one raw pulse at a time and prints, per
frame, what the seam makes of it beside **every complete `TextBoxBorder` the cartridge actually
drew**. `infra/docs/macros-traps.md` has the survey whole.
The shape is **row 41's ring one town over**: the gym guide's conversation is fifty-two presses --
"Hiya! I can tell you have what it takes to become a POKeMON champ!", "Let me take you to the
top!" with a YES/NO box, the type-matchup tutorial, "matches could be made easier!" -- the box
closes for a frame, and the next A press at the guide two tiles away opens the whole thing again.
Nothing in it changes the world. Two things kept the fly walking it, and both are readings rather
than pads.
- **The box was drawn where this crate was not looking.** 12.12 read the border at
(11, 6)-(19, 11), because that is where a Pokemon Center's script puts it, and said so in its own
residual: "Red places a two-option menu where the script asking for it says, so a prompt drawn
elsewhere reads `false` and its dialog keeps the pad it has always had". The guide's box is at
**(14, 7)-(19, 11)** with the cursor at column 15. Over 260 surveyed presses the box was drawn on
**10 frames** and `yes_no_prompt` answered `false` on **all 260** -- so the pad was
`NEXT, YES, NO` on a frame that was a *choice*, which is 12.10's forbidden pair (an A press at a
two-option menu confirms the option the cursor is on, and that is what `YES` is), and the
reopened-prompt exclusion of 12.12 never armed, because it only judges an answer to a prompt this
crate can read. **The whole of 12.12 was inert in that gym.**
- **So the figure is found rather than pinned.** One fact about `DisplayTwoOptionMenu` rather than
about any one script: the cursor goes in the box's **first interior column**, so the border's
left edge is one column to the left of `wTopMenuItemX` -- true of both boxes surveyed. The *top*
obeys no such rule, because the nurse's box begins two rows above the first item and the guide's
one, so the top is found by looking up for the border's own corner and the figure is then read
**whole**, exactly as `waiting` and the move list are. `docs/design/macros-wram.md` section 11
has the accessor. Measured over both checkpoints, 400 frames: the reading is true on the 14
frames a two-option box is drawn and false on the other 386, and `wTextBoxID` = `TWO_OPTION_MENU`
agrees with it exactly -- which is recorded as a third reading and **not** put in the accessor,
because no survey here covers Red's other two-option menus.
- **And a `NO` pressed inside a conversation declines nothing.** 12.4's rule -- "the fly said no, so
whatever it said no to is still on offer" -- took the pending `TALK` off the moment any `NO`
finished. A `NO`'s B press advances a plain text box exactly as `NEXT`'s A does, about a third of
the fifty-two presses that walk the guide's ring are `NO`, so the talked ledger **never learned
the conversation had happened**: `TALK` was on the overworld pad every hold and was the ring's
own door. In the twenty-brain-minute reproduction `TALK` started **25** times on that one map.
- **Which of the two a `NO` was is decided where it can be seen: by whether the box closes on it.**
The decision moves to the frame the text goes away, which is where the talked entry is written
anyway, and the reading is the answer still standing there -- `pending_answer`, armed only by an
answer to a prompt this crate can read and alive for one hold (12.12). A declining `NO` still
standing when the box closes is a `NO` the box closed *on*, and the offer stands; anything else
is a conversation walked through to its end, and the person is retired. The nurse's own declined
heal is unchanged: it writes her into the ledger by 12.12's named inversion, one person wide.
- **What the pad does instead, which is the point.** With the guide retired, the gym's overworld pad
is `GO OBJECTIVE`, `GO OUT`, `GO FRONTIER`, `GO HEAL` -- the walks -- and the fly is out of the
room in 0.37 brain minutes against never in twenty. Nothing new is on any pad and nothing is
ranked: `TALK` goes off a person this run has already had the conversation with, which is the
ledger 9.2 added doing exactly what it was added for, and `NEXT` goes off a readable prompt,
which is 12.10.
**What the harness holds.** Unit: a two-option box reads as a prompt at both surveyed geometries
and at neither without its border, its font flag or its two-option cursor; a box the cursor is not
parked in is not the cursor's box; the pads the two frames are dealt (`YES, NO` against
`NEXT, YES, NO`); a declined offer leaves the thing on offer; and a `NO` deeper inside a
conversation leaves the conversation counted and `TALK` off the pad. ROM-gated from the live
checkpoint: the fly leaves map 54, `NEXT` is on no pad while a readable prompt is open, and **no
readable prompt is answered more than four times for one person in a session**, against 439
answers at one person in the twenty-minute reproduction.
The decoder, the reward catalog, the adapter version, the roles and the compatibility string are
untouched.
### 12.21 A button refused from here is not dealt again from here, a last resort walks where it can, and an escort walls the tile it fired on (2026-09-23, row 57)
Live on v0.5.3, rank 10, map 2 (**Pewter City**), scene `overworld`: the pad was **`GO ROUTE` and
nothing else**, and it was refused about **740 times per ten brain minutes for more than two
hours**, with one `GO ROUTE start` / `blocked` pair every ten brain minutes, no button pressed and
the exploration count frozen. The watchdog read one start and one name, and never flagged it. The
`refused` event's `value 3.0` is the slot, not a reason (row 55); the feed carries no reason.
The ledgers that dealt that pad are session state and a restore starts them empty, so the trap
does not come back from the checkpoint by itself: a twenty-brain-minute hunt with the real brain
from the live frame covers 356 tiles. It was reproduced by **earning** them -- a new probe mode
(`examples/scene_probe.rs`, `FLY_PROBE_CATCH=route`) drives the real palette from the checkpoint and
can seed each ledger -- and three facts came out, each measured on the cartridge.
- **The pushed ledger walled the tile a walk set out from, not the tile the script fired on.**
Pewter City's youngster takes the joypad on four tiles by the road east
(`PewterCityPlayerLeavingEastCoords`) and walks the fly to the gym until Brock is beaten. `GO
ROUTE` aims east every time, because Route 3 is the one connection the run has not crossed, so it
is escorted every time -- and row 37's ledger, which has **no window**, recorded the macro's
starting tile. Measured from the ratchet's rollback snapshot: one `GO ROUTE` from the town's
south entrance walked 26 tiles to (37, 18), was escorted, and walled **(18, 35)**, the south
entrance. Row 37's rule is right for a press (the fly is standing on the tile the script fires
on) and wrong for a walk. Walks start wherever the last one ended, so the walls accumulate
until the fly stands in a pocket no route leaves.
- **In the pocket every walk refuses `no route`**, and each refusal is recorded where it belongs:
`GO FRONTIER`'s marks the map exhausted (12.14, no window, cleared only by new ground, and there
was none for hours), and `GO OBJECTIVE`'s only goal, the gym's door, goes to the blocked ledger.
With every person and sign already talked to and the errands paid, nothing else is left.
- **The last resort is the one list that ignores the blocked ledger**, by design ("a target the
ledger is resting is still the only place to go", 13.1). So `ways` dealt `GO ROUTE` at the gym's
door, the route search refused it, the refusal wrote the door to a ledger the dealer does not
read, and the button was dealt again on the next hold. The same refusal **re-stamped the door's
window every hold**, which is why `GO OBJECTIVE` never came back either. Once per window the
road east lapsed, `GO ROUTE` walked it, and it was excluded again.
Seeded with that pocket -- three pushed tiles sealing the strip by the road from the town, the
frontier mark, everything talked to, the road east resting -- the cartridge deals exactly the live
pad: `GO ROUTE`, refused `no route` **746 holds running** on one tile over ten brain minutes.
**The fix, all three parts inside the macros:**
- **A walk walls the tile it last stood the fly on**, which is where the cartridge took over; every
other macro keeps the tile it started on. The same walk now walls (37, 18), one of the four
tiles the youngster fires on.
- **A refusal that taught the blocked ledger nothing is recorded with the tile the fly stood on,
and the dealer does not deal that button from that tile for the blocked window.** The dealer
asks the cheap question and `start` the real one, and the blocked ledger closes that gap for
every list but a last resort; this closes it for all of them without a route search in the
dealer, which runs every frame. Only a last resort deals goals the ledger is already resting, so
a `no route` whose every goal was resting already -- and any `precondition` refusal, which writes
nothing -- is the one remembered; a refusal that writes a new exclusion changes the next deal
by itself. (Measured: holding *every* refusal against the tile kept the real brain, which
pressed `GO ROUTE` first while the door was still the second tier's answer, from ever reaching
the last resort below.) It is a fact about *here*: the button is dealt again the moment the fly stands
on any other tile, or when the window closes. A pad with nothing left that can run is empty and
the fly waits, which is section 13.1's honest answer.
- **A last resort that cannot reach the objective's door takes a way out it can reach.** The
narrowing to "the ways toward the objective" is a preference the dealer cannot check, and in the
pocket it chose the gym's door beyond the fence while the road east was three tiles away,
resting in its window (which a last resort ignores). When `start`'s route search cannot reach
the preferred ways it tries the rest of the last resort, nearest reachable first, as every walk
chooses. The pad does not change; only where the pressed macro walks.
In the rebuilt pocket the fly now leaves on **frame 517** (0.14 brain minutes) -- walked east, met
by the youngster, carried to the gym -- against frame 36,325 on the base, when the road's window
lapsed; `GO ROUTE` is refused **once** there against 746 holds running.
Nothing presses for the fly and nothing is ranked: one button leaves a pad it could not run from,
one wall moves to the tile that earned it, and one walk goes where it can. The decoder, the reward catalog, the adapter
version, the roles and the compatibility string are untouched.
### 12.22 The rung's people are in the room when the screen does not show them (2026-09-23, row 58)
Live on v0.5.3, rank 10, for twenty-five minutes: `GO OBJECTIVE` into the Pewter Gym, `GO OUT`
straight back out, with `GO ITEM`, `GO FRONTIER`, `YES` and `NO` mixed in. Per ten brain minutes
about 93 `GO OUT`, 47 `GO OBJECTIVE`, 200 starts in all, every one `done`, **no reward event of any
kind**, the exploration count frozen at 1,892. Check 10 saw ten distinct names and said nothing.
Surveyed from the live checkpoint with the route probe (`FLY_PROBE_CATCH=route`,
`FLY_PROBE_CATCH_MAP=54`), which reads the room on the fly's Nth arrival.
- **The objective saw the room through the screen.** `objective_targets` read `npcs`, which is
what the cartridge *draws*, and `CheckSpriteAvailability` writes `$ff` into the image index of
every sprite outside a window of the player's coordinate. From the doormat at (4, 13) that window
holds the guide at (7, 10) and nobody else: BROCK at (4, 1) and the Jr. Trainer at (3, 6) are not
drawn. With the guide talked to (12.20), the rung's list was empty, so `GO OBJECTIVE` had nothing
to aim at inside and 12.5's rule -- the ways out are withheld while the rung's person is in the
room -- let `GO OUT` onto the pad. Outside, `GO OBJECTIVE` aimed at the gym's door. The pair
undoes itself in about a second, and nothing on either side of the door earns anything.
- **So the rung reads the people the cartridge hides only for being off the screen.** The window
is a function of `wYCoord`, `wXCoord` and the sprite's own biased coordinates, all already read,
so a sprite whose `$ff` falls outside it is one the cartridge would hide for that reason whatever
else were true, and a sprite the cartridge is not updating does not move
(`state::offscreen_npcs`). A `$ff` *inside* the window, or on a scripted mover, is not the
screen's and is not reported. Only the rung reads the list: a sprite outside the window may also
be a toggleable object switched off, which reads the same, so `GO NPC`, `TALK` and objects keep
what is drawn.
- **Facing any of the rung's people is the arrival.** 12.5 left out only the one ahead, which was
enough for one target; a gym names three, and in front of BROCK `GO OBJECTIVE` still had the
trainer to walk to. A fly facing a person the rung is waiting on has nothing left for a walk to
do, and `TALK` is the press.
Three frames the seam read as the fly's own were the cartridge's, and each wrote a ledger entry that
emptied the room again once the first fix let the fly into it:
- **A warp's tear.** `wCurMap` changes thirty-two frames before the header, the coordinates and the
warp table follow it, while the screen fades, and no joypad bit is set until the fade is over.
The seam read "map 54 at (16, 17)" -- Pewter City's doormat under the gym's id -- as an overworld
and dealt it a pad; a walk started there planned over the wrong map, and what it aimed at went
into the blocked ledger under the gym's id (live: `GO OUT` started and finished in 0.05 s). The
driver now reads a tear as the map byte having changed while the fly still stands on a warp of
the loaded table that leads to the map the byte names, deals it as `Unknown` with an empty pad,
and records no ground from it. Teleport pads -- Saffron Gym, two Silph Co. floors -- do not change
the map byte, so they are never a tear; a tear is bounded at ninety frames all the same.
- **A battle's transition.** Between a trainer's challenge closing and the battle screen there are
219 frames with every joypad and script bit clear. The pad was dealt, a walk toward BROCK pressed
into the animation and gave up after three refused steps -- BROCK blocked for ten brain minutes --
and the Jr. Trainer's conversation read as over, so the trainer the fly then lost to was
"talked to" for the session. `wCurOpponent` is set when a battle is decided and cleared by
`EndOfBattle` with `wIsInBattle`; it is not in the generated table and is derived as the byte
between two that are, both neighbours checked in a test (`macros-wram.md` section 12), and
`controllable` reads it.
- **A trainer walking up.** 12.4 reads a macro the cartridge ended by taking the joypad as a
refusal and wrote the target blocked and the tile pushed at once. A trainer who sees the fly
takes the joypad the same way. The entries now wait until the cartridge gives the joypad back:
in the overworld it was a refusal and is written as before; a battle teaches the ledgers nothing.
Nothing is ranked, nothing presses for the fly, and no button is added to any pad: `GO OBJECTIVE`
has a person to walk to where it had none, `GO OUT` is withheld by 12.5's own rule, and three
frames that were never the fly's deal nothing. The decoder, the reward catalog, the adapter
version, the roles and the compatibility string are untouched.
### 12.23 A move the cartridge answers with nothing is not dealt beside one it does not (2026-09-23, row 60)
Live on v0.5.5, early game after the reset to milestone 1: Route 1, Squirtle L5 (TACKLE, TAIL
WHIP) against a wild Pidgey, "Nothing happened!" on the screen. Since the reset `MOVE 2` 183
times and `MOVE 1` once; the last reward 25 brain minutes before the checkpoint, one wild win in
the whole run. Check 10 flagged `unrewarded` (1,600 decisions, no reward event, no new ground on
two probes), which is right, and it is unchanged.
- **The pad was at fault, not only the choice.** `MOVE n`'s precondition was "the slot holds a
move with PP", so TAIL WHIP stayed on the pad after it had walked the Pidgey's DEFENSE to the
point the cartridge refuses it. `StatModifierDownEffect` answers "Nothing happened!" when the
stage is already -6 **or the stat itself is already 1**, restoring the stage. The checkpoint is
the frame Squirtle fainted to a Pidgey L3 at DEFENSE -6 (stat 2); in the next battle the stat
reached 1 at -5. From there the pad dealt `MOVE 1, MOVE 2, RUN` and the fly pressed `MOVE 2`
until Squirtle fainted, woke at home and walked back: every battle lost, one wild win in the
run. The readout's favourite being `MOVE 2` is the fly's; a button that can do nothing at all
being on the pad is a macro that knows nothing about its own effect, section 12.2's trap.
- **What a move does is the cartridge's, read the same way for every move.** `state::move_data`
reads the move's row of `Moves` from the cartridge image (`$0E:$4000`, each row checked against
its own id) and `state::move_without_effect` answers the refusals the effect routines make on
bytes already in WRAM: a stat stage at its limit or a stat at 1 or 999, Mist or a substitute in
front of a stat-lowering move, a sleep, poison or paralysis move against a target that already has
a status, is Poison type, or is Ground type to an Electric move. No move is named; a miss is a
roll and is not answered. `macros-wram.md` section 13 has the bytes.
- **It is PP's rule.** A move the cartridge answers with nothing is not dealt beside one it does
not, exactly as a spent move is not (12.6, 12.8), and `MOVE 1` over the menu stops being FIGHT's
backstop only in that case. When no move would do anything the moves stay as PP deals them:
taking the last ones away would leave an open list whose only button is `BACK`, 12.11's pair,
and a turn that ends on "Nothing happened!" still ends. RUN, ITEM and SWITCH are untouched.
Nothing is ranked, weighted or pressed for the fly: a button leaves the pad while it cannot change
anything and comes back when it can (a new battle resets the stages). The decoder, the reward
catalog, the adapter version, the roles and the compatibility string are untouched.
### 12.24 The map graph is the disassembly's, piece by piece (2026-09-23, row 59)
Opened pre-emptively: the row-58 review carried the route survey past the Boulder Badge and the
fly walked Pewter City (39, 17) to Route 3 (0, 9) and back from about frame 68,000, `GO OBJECTIVE`
done on Route 3 538 times and `GO ROUTE` done on Pewter 537. Reproduced from a rank-11 checkpoint
the survey writes (`FLY_PROBE_SAVE_RANK=11`): 509 and 508, never on Route 4. The live fly got
through Route 3 anyway and met the next half on v0.6.0 at 22:20 UTC: rank 12, MT. MOON, the
objective Cerulean, on Route 4 per ten minutes `GO ROUTE` 215, `GO OBJECTIVE` 113, `GO OUT` 103,
two new tiles, in and out of the Pokécenter and the cave mouth. Route 4 was one node with Cerulean
off its east edge, which the mountain cuts off from the cave mouth's side. From the live
checkpoint the route survey on `main` walks it 930 times in 72,000 frames.
**The geography table disagreed with the headers.** Checked row by row against
`data/maps/headers/*.asm` and `data/maps/objects/*.asm` at the pinned commit:
- Route 4 is **north** of Route 3, not east (`Route3.asm`: `connection north, Route4`); Route 3's
top edge is the road to Mt. Moon's Pokécenter. Mt. Moon's doors are both **on Route 4**:
(18, 5) into the first floor and (24, 5) into B1F, whose (27, 3) is the way out. Route 3 has no
warps. So Route 3's north edge named no map, and nothing on Route 3 was the way to the rung.
- Route 14 / 15 and Route 24 / 25 had the right neighbour in the wrong column (west and east,
not south and north). Route 24's east edge is Nugget Bridge's far end, rung 16's road.
**Four maps are pieces the player cannot walk between** (12.7's rule, measured by flooding every
tile of every map on the graph from the blocks, blockset, collision list, tile-pair walls and
ledges): Route 2 as before; **Route 4**, cut by the mountain into the cave mouth's side and
Cerulean's side; **Mt. Moon B1F**, four chambers of two ladders each; **B2F**, one large piece and
two small ones. The one road through is 1F (5, 5), B1F (21, 17), B2F (5, 7), B1F (27, 3); the
other two ladders on 1F lead to dead ends.
A split row is now any number of pieces, each with its doors (the warp index and tile) and what is
one step from it, a whole map or another map's piece. Three rules keep it honest:
- **Which piece the fly is in** is what its walk can reach on the decoded grid (section 15): the
piece whose doors it reaches, when exactly one piece's are. The grid has no ledges, so where it
reaches none (Route 4 below the ledges) the nearest door answers. Flooded over every tile of
the four maps' ground in the disassembly, the rule names the right piece for all of them.
- **Which piece a door lands in** is the cartridge's own answer: a warp names the destination
warp it arrives at (`wWarpEntries` byte 2), and each piece lists its warps. An edge lands in
the piece that lists the map it is stepped off.
- **The hop is a piece**, and an exit is toward the objective only if it lands in that piece. On
1F three ladders go down to B1F and one of them is the road.
**A connection nobody can walk across is not a road.** Four header connections have no tile where
both sides are land: Pallet Town / Route 21, Cinnabar / Route 20, Route 20 / 19 (sea) and Route 22 /
23 (the League's fence). They keep their name and offer no exit and no hop. Without this the road
from Pallet Town to Cerulean was by sea, and a fly that whited out in Mt. Moon, which the survey's
did, walked into Pallet's shore every two seconds.
**One seam frame, found on the way.** Route 3's first trainer closes his challenge onto five frames
of plain overworld before `StartTrainerBattle` decides the battle (`home/trainers.asm`: it runs
after `DisplayTextID`'s close-down). Row 58's pending push-back was decided on the first of them
and walled (11, 6), the one gap between Route 3's west end and the rest of the road, for the
session. A push-back is now a refusal only once the overworld has been the fly's for thirty frames
running; a battle inside them drops it.
From the badge, the route survey reaches Route 4 at frame 70,356 and Mt. Moon at 70,707 (rung 12),
no pushed tile; after whiting out in the cave it walks the land road back from Pallet Town. The
ROM test on the stub rotation reaches Mt. Moon in 56.7 brain minutes with 4 Pewter / Route 3
crossings; the base makes 3,391 in 80.4 and never stands on Route 4. From the live 2026-09-22
rank-11 checkpoint the branch reaches Mt. Moon too, where the base ends fenced on Route 3. From
the live Route 4 checkpoint the branch is in the cave on frame 279 and stays on the road; on the
stub rotation Route 4's west doors are crossed 13 times in twenty brain minutes (whiteouts and
walks back included) against 56 on `main`.
Nothing is ranked and nothing presses for the fly: a table of maps says what the headers say, a
split map has the pieces its ground has, and a frame that was the cartridge's is not read as the
fly's. The decoder, the reward catalog, the adapter version, the roles and the compatibility
string are untouched.
### 12.25 A trainer's challenge is the cartridge's until its battle is over (2026-09-23, row 61)
Live on v0.5.5, rung 9, for twenty minutes: `GO OBJECTIVE` into Viridian Forest's south gate (map
50, `$32`), `GO OUT` straight back onto Route 2, `GO WARP` back from the forest, `GO OBJECTIVE
blocked` in the forest, no reward. Reproduced with the route survey from the live checkpoint
(uniform choice per hold, xorshift seed 7), which walks the same ring for twenty brain minutes.
- **The ring's cause was a wall, not the gate.** The forest's only road to its north gate is a
two-wide corridor at x = 1-2; a Bug Catcher stands on (2, 18) facing west. A walk up the
corridor steps onto (1, 18), the trainer takes the joypad, and row 58's held push-back waits for
the joypad to come back. `DisplayEnemyTrainerTextAndStartBattle` clears `wJoyIgnore` before the
challenge text and `StartTrainerBattle` writes `wCurOpponent` only after that text's close-down:
**five frames** with no box, no script bit and `wCurOpponent` zero, which the seam read as the
fly's overworld. The push was written there; (1, 18) went into the pushed ledger, which has no
window, and from then on every walk to the north gate had no road. `GO OBJECTIVE` walked to the
nearest reachable tile, a dead end at (6, 1), and was blocked; `GO WARP`'s last tier took the
south gate, whose `GO OUT` is Route 2, whose `GO OBJECTIVE` is the gate.
- **The fact is `wStatusFlags7` bit 3, `BIT_TRAINER_BATTLE`**: set by `CheckFightingMapTrainers`
on the "!", cleared at `.battleOccurred` after every battle (before the blackout check). It
covers more than the five frames: the "!" bubble runs about sixty frames before `wJoyIgnore` is
set, and they read as the fly's overworld too -- about sixty-six free-looking frames per
engagement, measured. An overworld frame with the bit set is `Unknown` in the macros' own
scene, with no text box, so the pad is empty, no ground is recorded and no held entry is
decided on it. A trainer talked to by the fly never sets it; its `wCurOpponent` is written
inside the text.
- **On main the five frames are already row 59's** (12.24: a held push-back is written only after
thirty frames of overworld), and that alone keeps (1, 18) clear. This row is the cartridge-fact
layer under it: the pad is empty through the bubble as well, no ground is recorded, and it does
not depend on the gap staying under thirty frames.
- **The macros' reading only.** `controllable` and `scene::detect` are shared with the reward
adapter and do not change; `PokeState`'s `scene` and `scripted` read the bit beside them. In
macros mode the feed's `game.scene` is the palette's, so it reads `unknown` on those frames,
which is what the contract says of a frame the cartridge is driving.
- **The gates were modelled right.** Both forest gates are on the graph and `next_hop` answers
the forest from the south gate and Route 2 from the north one. The south gate's `GO OUT` is the
"a room has to be leavable" tier, a way back that is the fly's choice: with the corridor open,
the survey seed that walked into the gate twelve times still earned the badge, on both arms.
Nothing is ranked or pressed for the fly: frames that were never the fly's deal nothing. The
decoder, the reward catalog, the adapter version, the roles and the compatibility string are
untouched.
## 13. Shops and Pokémon Centers (the operator, 2026-09-17: "refactor the shop macros. make it a ## 13. Shops and Pokémon Centers (the operator, 2026-09-17: "refactor the shop macros. make it a
## priority to visit the shop at least once per area; make shop macros item purchases. same ## priority to visit the shop at least once per area; make shop macros item purchases. same
## for the Pokécenter. heal should be a macro.") ## for the Pokécenter. heal should be a macro.")
@ -1672,9 +1025,7 @@ Knowledge inside macros, never in the choice; the pad still lists buttons and th
marks nothing visited; the errand was paid on entering. marks nothing visited; the errand was paid on entering.
- **Center scene.** `HEAL` is a macro: walk to the counter, face the nurse, talk, answer YES, - **Center scene.** `HEAL` is a macro: walk to the counter, face the nurse, talk, answer YES,
wait for the heal animation to end (read the party HP back to full), close the box. On the wait for the heal animation to end (read the party HP back to full), close the box. On the
pad only when at least one party member is not at full HP or has a status — verified against the pad only when at least one party member is not at full HP or has a status. `LEAVE` walks out.
live party on the cartridge (**12.12**), which is also what takes `TALK` at the nurse off the pad
and what picks the one answer her YES/NO box is dealt. `LEAVE` walks out.
- **Ladder.** Unchanged; no reward for shopping or healing (rewards are the adapter's, - **Ladder.** Unchanged; no reward for shopping or healing (rewards are the adapter's,
untouched). untouched).
- **Screen.** Two new channel tags; the cells and the MACROS rate row take them as they come. - **Screen.** Two new channel tags; the cells and the MACROS rate row take them as they come.
@ -1724,18 +1075,17 @@ observe is not a precondition, it is a guess.
| Overworld, inside a Pokémon Center | the indoor pad plus HEAL | new. A centre is a sub-state of the overworld, not a `Scene`: pokered has no "a Pokémon Center is open" byte, so the only honest observable is the map id, and a new `Scene` would be a new `game.scene` on the wire | | Overworld, inside a Pokémon Center | the indoor pad plus HEAL | new. A centre is a sub-state of the overworld, not a `Scene`: pokered has no "a Pokémon Center is open" byte, so the only honest observable is the map id, and a new `Scene` would be a new `game.scene` on the wire |
| Overworld, inside a mart | the indoor pad | the counter is a `Shop`; the mart's *floor* is an ordinary interior, with the one exception below | | Overworld, inside a mart | the indoor pad | the counter is a `Shop`; the mart's *floor* is an ordinary interior, with the one exception below |
| Overworld, inside a mart or a centre, counter unfaced | GO SHOP or GO HEAL, TALK when facing the counter | new, and it is the one place a pad is deliberately *narrow*. The errand is paid on entering and never offered again, so a walk that leaves the building spends the one visit the area gets — measured: the fly reached the mart in 1.7 brain minutes and `GO OBJECTIVE` walked it straight back out over the doormat. While the counter is unfaced nothing on the pad leaves (row 34b) | | Overworld, inside a mart or a centre, counter unfaced | GO SHOP or GO HEAL, TALK when facing the counter | new, and it is the one place a pad is deliberately *narrow*. The errand is paid on entering and never offered again, so a walk that leaves the building spends the one visit the area gets — measured: the fly reached the mart in 1.7 brain minutes and `GO OBJECTIVE` walked it straight back out over the doormat. While the counter is unfaced nothing on the pad leaves (row 34b) |
| Overworld, inside a mart or a centre, counter faced | the indoor pad, plus HEAL in a centre | the suppression is released by facing the counter, by talking to it, or by a walk to it failing. Since **12.12** `TALK` is not on it at a *nurse* the party has no use for: her conversation is a service whose need the cartridge publishes, and a ring of text that ends where it began is section 12.2's trap | | Overworld, inside a mart or a centre, counter faced | the indoor pad, plus HEAL in a centre | the suppression is released by facing the counter, by talking to it, or by a walk to it failing |
| Dialog, a plain text box | NEXT, YES, NO | A and B both advance a plain box, so all three are dealt for one — what it buys is the fly being able to answer *no*. Forty-five of the nurse's forty-six frames are this row (**12.12**) | | Dialog | NEXT, YES, NO | unchanged. There is no "a choice is open" flag (`macros-wram.md`), and A and B both advance a plain box, so all three are dealt for every box — what it buys is the fly being able to answer *no* |
| Dialog, a readable YES/NO box (**the box the cartridge drew**, 12.20) | YES, NO — or **one of them** at a Pokémon Center's nurse | **new, 12.12.** `NEXT` is off it: an A press at a two-option menu confirms the option the cursor is on, which is what `YES` is, so the two are one press under two names (12.10). At the nurse's own prompt the bound answer is the one that changes something — `YES` with a hurt or statused party, `NO` with a full one. An answer whose prompt comes straight back is excluded for the blocked window, and the exclusion never empties the pad |
| Menu (the start menu) | CLOSE, CONFIRM, BACK | unchanged as a *scene*, and since **12.11** nothing on any other pad opens it: the fly reaches it with the **raw** START button, which still reaches the cartridge in macros mode, and moves its cursor with the raw D-pad. A SAVE or a POKéDEX button would be a macro per start-menu entry and is not asked for -- which is precisely why `MENU` had nothing behind it | | Menu (the start menu) | CLOSE, CONFIRM, BACK | unchanged as a *scene*, and since **12.11** nothing on any other pad opens it: the fly reaches it with the **raw** START button, which still reaches the cartridge in macros mode, and moves its cursor with the raw D-pad. A SAVE or a POKéDEX button would be a macro per start-menu entry and is not asked for -- which is precisely why `MENU` had nothing behind it |
| Menu (the bag, an elevator, the party list outside a battle) | CLOSE, CONFIRM, BACK | unchanged | | Menu (the bag, an elevator, the party list outside a battle) | CLOSE, CONFIRM, BACK | unchanged |
| Unknown (the Pokédex, the trainer card, OPTION, a naming screen, a mid-warp frame) | NEXT, **BACK** | **BACK added** (row 9): B is what leaves the first three, and A leaves none of them | | Unknown (the Pokédex, the trainer card, OPTION, a naming screen, a mid-warp frame) | NEXT, **BACK** | **BACK added** (row 9): B is what leaves the first three, and A leaves none of them |
| Battle, own turn, main menu | MOVE 1..4, SWITCH, ITEM, THROW BALL, RUN (whose cursor indices are FIGHT 0, **ITEM 1, PKMN 2**, RUN 3 -- two columns, 12.11) | four move buttons for `ATTACK` (section 14); THROW BALL added, and gated on the species since 12.9; **RUN gated**, below. No `BACK`: the four entries are the answers to this menu. **`NEXT` removed by 12.10** — an A press here confirms FIGHT and reopens the list the move list's `BACK` just closed, and `MOVE 1` is the backstop instead, bound here whatever the battler reads as | | Battle, own turn, main menu | MOVE 1..4, SWITCH, ITEM, THROW BALL, RUN (whose cursor indices are FIGHT 0, **ITEM 1, PKMN 2**, RUN 3 -- two columns, 12.11) | four move buttons for `ATTACK` (section 14); THROW BALL added, and gated on the species since 12.9; **RUN gated**, below. No `BACK`: the four entries are the answers to this menu. **`NEXT` removed by 12.10** — an A press here confirms FIGHT and reopens the list the move list's `BACK` just closed, and `MOVE 1` is the backstop instead, bound here whatever the battler reads as |
| Battle, own turn, move list (**the box on screen**, 12.18) | MOVE 1..4, BACK -- or **MOVE 1 alone** | as above, plus **12.11**: `BACK` is dealt here only while `wBattleMon*` reads, because a list that binds no `MOVE n` has a pad whose one button closes the list `MOVE 1` underneath had just opened. With nothing readable the pad is `MOVE 1` and its script confirms where the cursor stands | | Battle, own turn, move list | MOVE 1..4, BACK -- or **MOVE 1 alone** | as above, plus **12.11**: `BACK` is dealt here only while `wBattleMon*` reads, because a list that binds no `MOVE n` has a pad whose one button closes the list `MOVE 1` underneath had just opened. With nothing readable the pad is `MOVE 1` and its script confirms where the cursor stands |
| Battle, own turn, party list | SWITCH, BACK | unchanged | | Battle, own turn, party list | SWITCH, BACK | unchanged |
| Battle, own turn, the bag | ITEM, THROW BALL, **BACK** | the bag reports a *cursor* (`macros-wram.md` 7.1), and since **12.10** it is the own turn, because a cursor accepting input is one. Its pad is the list's own answers; `NEXT` and `CONFIRM` are both off it, being the same blind A press that *uses* whatever the cursor holds | | Battle, own turn, the bag | ITEM, THROW BALL, **BACK** | the bag reports a *cursor* (`macros-wram.md` 7.1), and since **12.10** it is the own turn, because a cursor accepting input is one. Its pad is the list's own answers; `NEXT` and `CONFIRM` are both off it, being the same blind A press that *uses* whatever the cursor holds |
| Battle, forced switch | SWITCH, NEXT | unchanged (row 8). The one arm that keeps `NEXT` with a cursor up, because it cannot be cancelled and has no `BACK` to undo it | | Battle, forced switch | SWITCH, NEXT | unchanged (row 8). The one arm that keeps `NEXT` with a cursor up, because it cannot be cancelled and has no `BACK` to undo it |
| Battle, between turns | NEXT | since **12.18** this row is most of a battle, and correctly so: a frame whose move list is remembered rather than drawn lands here. `BACK` was added here for the bag and **taken back out by 12.9**: on a frame of battle text there is no list to leave, and a `BACK` that changes nothing is the trap of section 12.2. Since **12.10** the bag is not on this row at all, so `NEXT` here is only ever the A that advances text | | Battle, between turns | NEXT | `BACK` was added here for the bag and **taken back out by 12.9**: on a frame of battle text there is no list to leave, and a `BACK` that changes nothing is the trap of section 12.2. Since **12.10** the bag is not on this row at all, so `NEXT` here is only ever the A that advances text |
| Shop | BUY POTION, BUY BALL, BUY ANTIDOTE, BUY REPEL, CONFIRM, LEAVE | two purchases to four; CONFIRM added | | Shop | BUY POTION, BUY BALL, BUY ANTIDOTE, BUY REPEL, CONFIRM, LEAVE | two purchases to four; CONFIRM added |
| PC | **CONFIRM**, LEAVE | **CONFIRM added**: a list the fly opened is one it can answer rather than only close. Depositing and withdrawing are still not in the vocabulary (row 17) | | PC | **CONFIRM**, LEAVE | **CONFIRM added**: a list the fly opened is one it can answer rather than only close. Depositing and withdrawing are still not in the vocabulary (row 17) |
| Title | nothing | unchanged, by contract: the readout's boot variant applies | | Title | nothing | unchanged, by contract: the readout's boot variant applies |
@ -1770,7 +1120,6 @@ measured where it cannot.
| cause | closed by | | cause | closed by |
| --- | --- | | --- | --- |
| a dialog whose one answer the reopen ledger excludes | **not a cause** (12.12): the exclusion narrows a pad and never empties one, so a box with both answers excluded is dealt both again. A box nothing can answer is a screen nothing can leave |
| a scene that binds nothing at all | the table above: every playable scene but the overworld has at least one unconditional button (`NEXT` in a dialog and in a battle, `CLOSE` in a menu, `LEAVE` in a shop and a PC). The overworld's was `MENU`, and **12.11** took it off rather than keep a button whose only effect is a screen its own scene closes again; what stands in its place is the row below | | a scene that binds nothing at all | the table above: every playable scene but the overworld has at least one unconditional button (`NEXT` in a dialog and in a battle, `CLOSE` in a menu, `LEAVE` in a shop and a PC). The overworld's was `MENU`, and **12.11** took it off rather than keep a button whose only effect is a screen its own scene closes again; what stands in its place is the row below |
| an overworld map with no way out at all | **not closed, and named**: with `MENU` gone this is a genuinely empty pad. No map in Red is that -- an interior has its front door or its staircase, an outdoor map has its connections -- so it is asserted as a residual in the pad-empty sweep rather than covered, and `game.padEmptyMs` reports it | | an overworld map with no way out at all | **not closed, and named**: with `MENU` gone this is a genuinely empty pad. No map in Red is that -- an interior has its front door or its staircase, an outdoor map has its connections -- so it is asserted as a residual in the pad-empty sweep rather than covered, and `game.padEmptyMs` reports it |
| an *indoors* overworld where every ledger excludes everything and the blocked window is resting the one door | **fixed** (12.11): `ways`' last resort is a room's too, not only `GO ROUTE`'s. With nothing else on this map worth walking to, the exits of that kind come back ignoring the blocked window, the one toward the objective preferred. This is the rung-10 museum: its only way out is a *passage*, so tier 3 was built out of the excluded list and emptied with it | | an *indoors* overworld where every ledger excludes everything and the blocked window is resting the one door | **fixed** (12.11): `ways`' last resort is a room's too, not only `GO ROUTE`'s. With nothing else on this map worth walking to, the exits of that kind come back ignoring the blocked window, the one toward the objective preferred. This is the rung-10 museum: its only way out is a *passage*, so tier 3 was built out of the excluded list and emptied with it |
@ -1780,7 +1129,6 @@ measured where it cannot.
| a restore, before the first `observe` | not a cause: the loop calls `observe` once at the end of boot and once after a ratchet recovery, so the first frame is decided on a real palette | | a restore, before the first `observe` | not a cause: the loop calls `observe` once at the end of boot and once after a ratchet recovery, so the first frame is decided on a real palette |
| the title screen, and raw mode | not an empty pad by contract: the readout's boot variant applies and no palette is dealt | | the title screen, and raw mode | not an empty pad by contract: the readout's boot variant applies and no palette is dealt |
| a scene the detector cannot name | `Unknown` deals `NEXT` and `BACK`; a screen neither press leaves (the naming screen, row 14) is a genuine stall and still needs START, which is a contract change | | a scene the detector cannot name | `Unknown` deals `NEXT` and `BACK`; a screen neither press leaves (the naming screen, row 14) is a genuine stall and still needs START, which is a contract change |
| an `Unknown` frame that is the **overworld with the cartridge driving** -- a warp in flight, a scripted push-back, a guide walking the fly through a door | **empty on purpose** (12.13). There is no box to advance and no screen to leave, so an A or a B press is a press into somebody else's script: it changes nothing and completes where the fly stands. This is the one empty pad that ends itself -- the cartridge gives the buttons back within a few frames -- and `game.padEmptyMs` reports it like any other |
| the fly's own turn where the seam cannot read the battler, now that `NEXT` is off that row (12.10) | **fixed**: `MOVE 1` is bound over the top-level menu whatever `wBattleMon*` reads as, because FIGHT is one of that menu's four entries and always opens | | the fly's own turn where the seam cannot read the battler, now that `NEXT` is off that row (12.10) | **fixed**: `MOVE 1` is bound over the top-level menu whatever `wBattleMon*` reads as, because FIGHT is one of that menu's four entries and always opens |
And because "closed where a macro can close it" is not "closed": And because "closed where a macro can close it" is not "closed":

View file

@ -68,15 +68,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`
@ -117,16 +108,6 @@ profile-mismatch fixtures above -- is later and gates DATA-01, not the session p
- **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`

View file

@ -548,14 +548,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 +605,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

View file

@ -33,27 +33,6 @@ retroactively to existing [public feed](../../feed-protocol.md),
5. [Session media/state](state-media-v1.md) — observation timing and coherent recovery. 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. 6. [Application/presentation boundary](publishing-v1.md) — snapshots, flexible data and effects.
7. [Implementation guide](implementation.md) — sequenced build tasks and acceptance tests. 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 For context: [modular-session analysis](../malecns-modular-sessions.md) and
[Melee audit](../melee-framework-audit.md). Each contract owns its named subject; step ordering [Melee audit](../melee-framework-audit.md). Each contract owns its named subject; step ordering
@ -135,19 +114,6 @@ versions, historical arithmetic/fingerprints and FLYSIM01 reader. New identities
readout/executor/task/scheduler semantics. New public feed v2 is an application/presentation 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. 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 ## 5. Reuse criterion
Adding a third environment/application requires a backend, task/profile, composition and Adding a third environment/application requires a backend, task/profile, composition and

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

@ -43,7 +43,7 @@ Illustrative Rust surface (not yet implemented):
```rust ```rust
let bus = Client::connect(config).await?; let bus = Client::connect(config).await?;
let service = bus.register("agent.fly-a", service_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 reply = bus.call(target, "Agent.Prepare", payload, attachments, budget).await?;
let subscription = bus.subscribe("session.demo.snapshots", subscription_config).await?; let subscription = bus.subscribe("session.demo.snapshots", subscription_config).await?;
bus.publish("session.demo.snapshots", payload, attachments).await?; bus.publish("session.demo.snapshots", payload, attachments).await?;
@ -422,8 +422,7 @@ Configure limits explicitly; these defaults are a prototype starting point, not
| Bounded subscription queued / in-flight deliveries | 64 / 16 | | Bounded subscription queued / in-flight deliveries | 64 / 16 |
| Active owners per client | 256 | | Active owners per client | 256 |
| Total artifact storage / per object | 512 MiB / 128 MiB | | Total artifact storage / per object | 512 MiB / 128 MiB |
| Per-client ordinary bounded queued envelope bytes | 1 MiB | | Per-client ordinary queued envelope bytes | 1 MiB |
| Latest subscription slots | subscriptions × 64 KiB |
| Reserved management/reply lane | 128 frames and 1 MiB per client | | Reserved management/reply lane | 128 frames and 1 MiB per client |
Reserve an owner allowance for lifecycle/results separately from ordinary telemetry; memory Reserve an owner allowance for lifecycle/results separately from ordinary telemetry; memory
@ -439,8 +438,7 @@ bounded; rejected callers choose their own retry/fail/pause policy.
Transport errors include `INVALID_ENVELOPE`, `VERSION_MISMATCH`, `NOT_AUTHORIZED`, Transport errors include `INVALID_ENVELOPE`, `VERSION_MISMATCH`, `NOT_AUTHORIZED`,
`NO_SERVICE`, `TARGET_CHANGED`, `BACKPRESSURE`, `CALL_GONE`, `ARTIFACT_UNSEALED`, `NO_SERVICE`, `TARGET_CHANGED`, `BACKPRESSURE`, `CALL_GONE`, `ARTIFACT_UNSEALED`,
`ARTIFACT_GONE`, `OWNER_INVALID`, `QUOTA_EXCEEDED`, `STORE_FAILURE`, `ROUTER_LOST`, and the `ARTIFACT_GONE`, `OWNER_INVALID`, `QUOTA_EXCEEDED`, `STORE_FAILURE`, `ROUTER_LOST`.
three of section 12.
Before admission use dispatch:not-dispatched. Once dispatch might have occurred, report Before admission use dispatch:not-dispatched. Once dispatch might have occurred, report
unknown/dispatched conservatively; a caller-side timeout must not imply no mutation. unknown/dispatched conservatively; a caller-side timeout must not imply no mutation.
@ -486,40 +484,3 @@ pipeline may itself exchange large artifacts through this same bus if useful.
The first executable example should show a counter RPC, a pub/sub observer, and a frame 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. 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. 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

@ -160,22 +160,6 @@ delayed rendering retains its handle. Distinguish AssetRef from transient Artifa
**Depends on:** SESSION-02, MEDIA-01 and the profile/identity foundation in the broader backlog. **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; **Implement:** adapter over existing LIF, plasticity, retina and fixed readout primitives;
reference-first composition/goldens; independently seeded agent state and shared immutable data. 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. Avoid using the old whole-frame `tick` wrapper if it changes the specified phase ordering.
@ -188,31 +172,6 @@ dispatch order and varying worker count preserves results. Keep 64-role limits e
**Depends on:** AGENT-01 and environment/task extraction in the broader backlog. **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 **Implement:** binjgb environment, task-local memory inspector and identity/existing action
adapter. Keep `legacy-gameboy-v1` separately routed with exact old ordering/hash semantics. adapter. Keep `legacy-gameboy-v1` separately routed with exact old ordering/hash semantics.

View file

@ -94,14 +94,10 @@ interface HelloResult {
workerId: Id; incarnationId: Id; role: "agent" | "environment" | "coordinator"; workerId: Id; incarnationId: Id; role: "agent" | "environment" | "coordinator";
buildDigest: Digest; contractDigest: Digest; buildDigest: Digest; contractDigest: Digest;
capabilities: Id[]; capabilities: Id[];
limits: { maxAgents: number; maxPorts: number; workerThreads: number }; limits: { maxAgents: number; maxPorts: 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 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 session authority to the expected coordinator identity/incarnation during negotiation and
initialization. Wrong worker/role, no common major or missing required capability refuses initialization. Wrong worker/role, no common major or missing required capability refuses
@ -158,11 +154,6 @@ Lifecycle/capture replies are retained until Worker.Acknowledge:
not another consumer's bus delivery. Already released/unknown IDs are ignored. Serial not another consumer's bus delivery. Already released/unknown IDs are ignored. Serial
watermarks reject reuse after acknowledgment without an unbounded tombstone list. 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 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 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; separate finite retention. Caches containing big artifacts consume bus owner/byte budgets;

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

@ -42,20 +42,6 @@ Example addresses (chosen by composition, not recognized by router code):
| `app.pokemon.cues` | Pub/sub: application narrative/presentation events under declared delivery policy | | `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 | | `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 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 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. application/session query contract or buffer a bounded number of snapshots, not infer shape.
@ -91,16 +77,6 @@ interface CommittedSnapshot {
} }
``` ```
**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 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 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 and never claim an uncommitted future boundary. Every transient media reference is a declared

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

@ -65,38 +65,11 @@ the sample position relative to the episode's configured audio origin, with inte
firstSample/sampleRate. Crash restore preserves sample position under a new epoch; first firstSample/sampleRate. Crash restore preserves sample position under a new epoch; first
chunk marks discontinuity. Within an epoch, chunks cannot overlap or go backwards. 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 The environment provides **native game output**. Sensor transformations belong to the agent
profile. Resizing for viewers, overlays, composition, audio mixing/resampling, encoding, profile. Resizing for viewers, overlays, composition, audio mixing/resampling, encoding,
browser delivery and streaming belong to the application/presentation layer. No bus or browser delivery and streaming belong to the application/presentation layer. No bus or
generic session configuration assumes a 1080p show or Twitch output. 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 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 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 reasonable to measure before introducing codecs or pooled GPU buffers. Native dimensions
@ -127,28 +100,6 @@ If it violates configured resource policy, disconnect/restart that observer inst
live data or silently skipping simulation input. Global store exhaustion is an explicit fault 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. 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. 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 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 unlink; physical pages disappear when all OS mappings close. Pooled reuse is deferred until
@ -169,19 +120,6 @@ The manifest records:
or reproducible reconstruction inputs, admission state and event watermarks. or reproducible reconstruction inputs, admission state and event watermarks.
- Payload names, lengths and hashes, including external-helper state required for exact resume. - 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 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 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, readable. Persist payload **bytes and durable content identity**, not transient bus storeId,
@ -235,40 +173,11 @@ restored time. It cannot advance gameplay to manufacture it. Capture/reconstruct
covers render/inspection state and any pending sensor pipeline. Agent state agrees with it; 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. 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 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 provide externally atomic resume, advertise episode-restart, not exact-checkpoint. After all
activation acknowledgments, install the coordinator's staged task/executor/admission state 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. 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 ## 6. Durable commit, router failure and recovery
Write payload/envelope temporary generation, fsync, rename, fsync directory, then atomically Write payload/envelope temporary generation, fsync, rename, fsync directory, then atomically
@ -301,14 +210,3 @@ state and retained/fresh brain components are explicit. Gain retention, eligibil
clearing, calibration and first sensory input are part of the policy, tested independently. 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 Legacy Pokémon ratchet behavior remains in the legacy composition. Shared competitive worlds
never restore one player's environment independently of the other players. 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

@ -5,13 +5,6 @@ arrows below are RPCs through the same [Flybus router](bus-v1.md); the router it
implements the barrier. Read [architecture](README.md) and [session RPC](ipc-v1.md) first. Method payloads are in implements the barrier. Read [architecture](README.md) and [session RPC](ipc-v1.md) first. Method payloads are in
[worker interfaces](workers-v1.md). [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 ## 1. Committed boundary
At `Ready(epoch, k)`: At `Ready(epoch, k)`:
@ -37,7 +30,6 @@ Starting → Ready(k) → Preparing(k) → Applying(k) → Observing(k+1)
Ready(k) → Paused(k) → Ready(k) Ready(k) → Paused(k) → Ready(k)
Ready(k) / Paused(k) → Capturing(k) → same boundary 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) any unresolved partial failure → Failed → Restoring(new epoch) → Paused(k)
terminal episode → Paused(k) → Resetting(new epoch) → Ready(0) terminal episode → Paused(k) → Resetting(new epoch) → Ready(0)
``` ```
@ -46,25 +38,6 @@ terminal episode → Paused(k) → Resetting(new epoch) → Ready(0)
transition carry `scope.step=k`; result fields identify `nextStep=k+1` where applicable. 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. 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 ## 3. Transaction sequence
### Phase A: prepare all agents concurrently ### Phase A: prepare all agents concurrently
@ -96,12 +69,6 @@ After every PreparedDecision arrives:
2. Run each task-local action executor once, in sorted agent-ID order, against coherent current 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. game state, task progress/objectives and clock from this boundary.
Direct-control profiles use an identity executor. Macro profiles are explicit extensions. 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 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. ports. Uncontrolled ports are configured neutral before the epoch, not supplied ad hoc.
4. Send exactly one `Environment.Advance(scope=k, batchId, controls)`. 4. Send exactly one `Environment.Advance(scope=k, batchId, controls)`.
@ -122,17 +89,6 @@ controls. It returns scoped rewards/stimulation, next decision contexts, progres
an optional episode request. Commit its ledger update in memory and retain the result for 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. 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 ### Phase D: commit all agent outcomes concurrently
Send `Agent.Commit(scope=k)` with that agent's next sensory observation and routed outcomes. Send `Agent.Commit(scope=k)` with that agent's next sensory observation and routed outcomes.
@ -208,15 +164,6 @@ Example: a synthetic 60-Hz environment with a 1-ms model tick produces 16,17,17
over three steps, totaling 50. A real backend's measured/declared emulated cadence may 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. 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 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 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. not skip world steps, drop neural ticks, or let one agent advance more slowly than another.
@ -246,39 +193,6 @@ exactly what is retained, cleared, warmed or recalibrated. No worker independent
Changing port assignment, agent membership, model/profile, cadence or task schema requires 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. 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 ## 7. Failure rules
| Failure point | Required response | | Failure point | Required response |
@ -306,19 +220,6 @@ The synthetic integration test must record, for every transition:
- Observation producing boundaries and task event/outcome IDs in order. - Observation producing boundaries and task event/outcome IDs in order.
- All Commit acknowledgments and published committed boundary. - 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 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 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, explicitly operational metadata. Delayed/lost/duplicate messages must not add a neural tick,

View file

@ -22,9 +22,6 @@ rpc.result. Large inputs/outputs use owned bus attachments, never another worker
| `State.Capture` | Coordinator → agent/environment | Immutable snapshot of committed boundary | | `State.Capture` | Coordinator → agent/environment | Immutable snapshot of committed boundary |
| `State.StageRestore` | Coordinator → agent/environment | Validate replacement state under new epoch | | `State.StageRestore` | Coordinator → agent/environment | Validate replacement state under new epoch |
| `State.ActivateRestore` | Coordinator → agent/environment | Install staged state; remain quiescent | | `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 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. message consumption are bus operations managed by the SDK, not Worker/Coordinator methods.
@ -54,8 +51,6 @@ interface AgentTelemetry {
rates: { roleId: Id; hz: number }[]; rates: { roleId: Id; hz: number }[];
learning: { enabled: boolean; updates: U64; changed: U64; signal: 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 `AssetRef` names persistent content in a preprovisioned local registry; it is not an arbitrary path or
@ -80,15 +75,6 @@ finite; shipped positive-only task profiles reject negatives. Empty rewards do n
different numerical rule. `id`/`eventId` is unique within its outcome or command namespace; different numerical rule. `id`/`eventId` is unique within its outcome or command namespace;
the coordinator assigns stable IDs before sending a mutating request. 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 ## 2. Agent methods
### Agent.Initialize ### Agent.Initialize
@ -110,39 +96,9 @@ interface AgentInitializeResult {
warmupTicks: U64; committedStep: U64; // committedStep == "0" warmupTicks: U64; committedStep: U64; // committedStep == "0"
decisionContextDigest: Digest; decisionContextDigest: Digest;
telemetry: AgentTelemetry; 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 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 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 brain with learning disabled, calibrate the fixed readout and establish Ready(0). Do not
@ -297,25 +253,6 @@ The environment only needs backend-relevant portions of task setup, not reward r
neural policies. `taskConfig` resolves a declared setup configuration; the complete task neural policies. `taskConfig` resolves a declared setup configuration; the complete task
implementation and ledger stay in the coordinator. 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 ### Environment.Advance
```ts ```ts
@ -382,18 +319,6 @@ but not a port assignment. The coordinator supplies the port. Per-agent executor
private; a running macro may emit controls according to its declared policy, but only after 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. 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; 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 progressView supplies task history/objectives. It updates its selected action every step
(movement, path replanning, interaction, completion), not merely replaying a blind button (movement, path replanning, interaction, completion), not merely replaying a blind button
@ -417,15 +342,6 @@ not arbitrary raw inspector memory or incoming chat.
It requests a coordinator-owned policy transition after final reward commit; it cannot reset 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. 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 ## 5. Admission and audience boundary
The first synthetic implementation has no audience input. Later integration maps permitted The first synthetic implementation has no audience input. Later integration maps permitted
@ -440,19 +356,6 @@ v2 contract must specify accepted/applied/rolled-back/aborted states and reconci
paid interactions are enabled. Do not inherit a claim of durable exactly-once stimulation paid interactions are enabled. Do not inherit a claim of durable exactly-once stimulation
from these in-memory worker request caches. 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 ## 6. Health, shutdown and extensions
All workers implement the common Hello/Status/Shutdown/Acknowledge methods. Capture/restore All workers implement the common Hello/Status/Shutdown/Acknowledge methods. Capture/restore
@ -462,56 +365,3 @@ advertised. Unsupported methods return `UNSUPPORTED`, mutation none.
New task-specific fields belong in registered TypedValue schemas. New worker capabilities, New task-specific fields belong in registered TypedValue schemas. New worker capabilities,
variable-duration stepping, subscriptions or additional sensor modalities require a contract 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. 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

@ -640,241 +640,3 @@ then purge; then the stale-doc pass.
the party list and SWITCH the bag since v0.4.0; fixed, THROW BALL now 15/0 in the forest run. 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 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. 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

View file

@ -1,275 +0,0 @@
//! Canonical JSON (RFC 8785) and the digest rules of ipc-v1 section 5.
//!
//! One serialization, two languages: keys sorted by UTF-16 code unit, numbers printed by the
//! ECMAScript `Number::toString` algorithm (so a JavaScript `JSON.stringify` over the same
//! sorted tree produces the same bytes), strings escaped the way `JSON.stringify` escapes
//! them, no insignificant whitespace. A digest is the SHA-256 of those bytes, lowercase hex.
//!
//! A JSON number is canonicalizable when it is finite and, if it is integral, no larger in
//! magnitude than 2^53-1. Integers past that range are refused rather than rounded: every
//! counter and clock in these contracts is a `U64` decimal string, so a large JSON number is
//! a schema error. The rule is stated on the value, not on how it was written, because
//! `JSON.parse` cannot tell `1e21` from `1000000000000000000000`, and two implementations
//! that disagree about one number do not agree about any digest.
use serde_json::{Number, Value};
use sha2::{Digest as _, Sha256};
use crate::scalar::{Result, Scope, err, wire_err};
/// The largest integer a double represents exactly.
pub const MAX_EXACT_INTEGER: i64 = 9_007_199_254_740_991;
/// The bus envelope ceiling every domain message must also fit (bus-v1 section 4).
pub const MAX_ENVELOPE_BYTES: usize = flybus::wire::MAX_ENVELOPE_BYTES;
/// The `f64` a JSON number denotes, or `None` if it is not canonicalizable: not finite, or an
/// integral value outside the exactly representable integer range.
pub fn finite_double(n: &Number) -> Option<f64> {
let value = n.as_f64().filter(|v| v.is_finite())?;
if value.fract() == 0.0 && value.abs() > MAX_EXACT_INTEGER as f64 {
return None;
}
Some(value)
}
/// `String(number)` for a finite double, the ECMAScript algorithm RFC 8785 requires.
fn number_to_string(value: f64) -> String {
if value == 0.0 {
// Covers -0.0, which `JSON.stringify` prints as "0".
return "0".to_owned();
}
let mut buffer = ryu_js::Buffer::new();
buffer.format(value).to_owned()
}
/// Escapes one string the way `JSON.stringify` does.
fn write_string(out: &mut String, s: &str) {
out.push('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\u{08}' => out.push_str("\\b"),
'\u{09}' => out.push_str("\\t"),
'\u{0a}' => out.push_str("\\n"),
'\u{0c}' => out.push_str("\\f"),
'\u{0d}' => out.push_str("\\r"),
c if (c as u32) < 0x20 => out.push_str(&format!("\\u{:04x}", c as u32)),
c => out.push(c),
}
}
out.push('"');
}
/// Sorts object keys by UTF-16 code unit, as RFC 8785 section 3.2.3 specifies.
fn utf16_key(key: &str) -> Vec<u16> {
key.encode_utf16().collect()
}
/// The canonical JSON text of `value`.
pub fn canonicalize(value: &Value) -> Result<String> {
let mut out = String::new();
write_value(&mut out, value)?;
Ok(out)
}
/// The canonical JSON bytes of `value`.
pub fn canonical_bytes(value: &Value) -> Result<Vec<u8>> {
canonicalize(value).map(String::into_bytes)
}
fn write_value(out: &mut String, value: &Value) -> Result<()> {
match value {
Value::Null => out.push_str("null"),
Value::Bool(true) => out.push_str("true"),
Value::Bool(false) => out.push_str("false"),
Value::Number(n) => {
let d = finite_double(n).ok_or_else(|| {
wire_err(format!(
"canonical JSON: {n} is not a finite number in the exact double range"
))
})?;
out.push_str(&number_to_string(d));
}
Value::String(s) => write_string(out, s),
Value::Array(items) => {
out.push('[');
for (i, item) in items.iter().enumerate() {
if i > 0 {
out.push(',');
}
write_value(out, item)?;
}
out.push(']');
}
Value::Object(map) => {
let mut keys: Vec<&String> = map.keys().collect();
keys.sort_by_cached_key(|k| utf16_key(k));
out.push('{');
for (i, key) in keys.iter().enumerate() {
if i > 0 {
out.push(',');
}
write_string(out, key);
out.push(':');
write_value(out, &map[key.as_str()])?;
}
out.push('}');
}
}
Ok(())
}
/// Lowercase hex SHA-256.
pub fn sha256_hex(bytes: &[u8]) -> String {
let digest = Sha256::digest(bytes);
let mut out = String::with_capacity(64);
for byte in digest {
out.push_str(&format!("{byte:02x}"));
}
out
}
/// The canonical digest of a JSON value: SHA-256 over its canonical JSON bytes.
pub fn digest_of(value: &Value) -> Result<String> {
canonical_bytes(value).map(|bytes| sha256_hex(&bytes))
}
/// Parses JSON strictly: duplicate keys at any depth, invalid UTF-8, non-finite numbers and
/// trailing bytes are refused. The bus reader, reused so both layers agree byte for byte.
pub fn parse_strict(bytes: &[u8]) -> Result<Value> {
flybus::wire::parse_json_strict(bytes).map_err(|e| wire_err(e.0))
}
/// Refuses a domain payload that does not fit the bus envelope ceiling.
///
/// The check is on canonical bytes, and the caller passes the overhead the surrounding
/// envelope adds, so a payload that only fits without its envelope still fails.
pub fn require_envelope_fit(value: &Value, envelope_overhead: usize) -> Result<usize> {
let len = canonicalize(value)?.len();
let total = len + envelope_overhead;
if total > MAX_ENVELOPE_BYTES {
return err(format!(
"envelope: {total} bytes exceeds the {MAX_ENVELOPE_BYTES}-byte maximum"
));
}
Ok(total)
}
// ---------------------------------------------------------------------------------------------
// Operation keys and canonical bodies
/// The keys that belong to the bus, never to a domain body (ipc-v1 section 5: the canonical
/// body "excludes changing bus callIds, deliveryIds and owner tokens").
pub const BUS_ONLY_KEYS: &[&str] = &[
"callId",
"deliveryId",
"ownerId",
"ownerIds",
"deliveryIds",
"requestDeliveryId",
"expectedIncarnation",
"serviceIncarnation",
"connectionId",
"topicSequence",
"subscriptionId",
];
/// Fails if any bus-only key appears anywhere in `value`.
pub fn reject_bus_identities(value: &Value) -> Result<()> {
match value {
Value::Object(map) => {
for (key, inner) in map {
if BUS_ONLY_KEYS.contains(&key.as_str()) {
return err(format!(
"canonical body: {key:?} is a bus identity and never part of a domain body"
));
}
reject_bus_identities(inner)?;
}
Ok(())
}
Value::Array(items) => {
for item in items {
reject_bus_identities(item)?;
}
Ok(())
}
_ => Ok(()),
}
}
/// `(sessionId, epoch, step, method, workerId)`: the operation key of a step mutation.
///
/// There is at most one Prepare, Commit or Advance for one key (ipc-v1 section 5). The key
/// deliberately does not contain the requestId: a changed id for an existing key is CONFLICT,
/// which can only be detected if the key is the same.
#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
pub struct OperationKey {
pub scope: Scope,
pub method: String,
pub worker_id: String,
}
impl OperationKey {
pub fn new(scope: Scope, method: &str, worker_id: &str) -> Result<OperationKey> {
let key = OperationKey {
scope,
method: method.to_owned(),
worker_id: worker_id.to_owned(),
};
key.validate()?;
Ok(key)
}
pub fn validate(&self) -> Result<()> {
use crate::scalar::DomainType;
self.scope.validate()?;
if !flybus::wire::is_method(&self.method) {
return err("OperationKey: method must be 1..=128 printable ASCII characters");
}
if !crate::scalar::is_id(&self.worker_id) {
return err("OperationKey: workerId is not a valid id");
}
Ok(())
}
pub fn to_json(&self) -> Value {
use crate::scalar::DomainType;
crate::scalar::obj(vec![
("scope", self.scope.to_json()),
("method", self.method.clone().into()),
("workerId", self.worker_id.clone().into()),
])
}
/// The canonical digest of the key, for a deduplication table that stores digests.
pub fn digest(&self) -> Result<String> {
digest_of(&self.to_json())
}
}
/// The canonical body of a domain operation: method, scope and validated params.
///
/// Two calls of the same operation key whose body digests differ are CONFLICT; two calls with
/// the same digest are the same operation, whatever bus callId carried them.
pub fn canonical_body(method: &str, scope: Option<&Scope>, params: &Value) -> Result<Value> {
if !flybus::wire::is_method(method) {
return err("canonical body: method must be 1..=128 printable ASCII characters");
}
if !params.is_object() {
return err("canonical body: params must be an object");
}
reject_bus_identities(params)?;
Ok(crate::scalar::obj(vec![
("method", method.into()),
("scope", Scope::nullable_to_json(scope)),
("params", params.clone()),
]))
}
/// The canonical body digest of a domain operation.
pub fn body_digest(method: &str, scope: Option<&Scope>, params: &Value) -> Result<String> {
digest_of(&canonical_body(method, scope, params)?)
}

View file

@ -1,375 +0,0 @@
//! `FLYSESS1`: the envelope layout of `docs/design/session-framework/checkpoint-envelope-v1.md`.
//!
//! This is the layout half of the specification, not the store: it lays out a header, a
//! canonical-JSON manifest, a payload table and the payload bytes, and it reads one back.
//! Writing generations, fsyncing and committing a manifest belong to the STATE-01 store slice.
//! `FLYSIM01` is a different format with a different magic and is not touched by any of this.
use serde_json::Value;
use sha2::{Digest as _, Sha256};
use crate::canonical;
use crate::scalar::{Result, err, is_id};
/// Envelope magic. Eight ASCII bytes, distinct from `FLYSIM01`.
pub const MAGIC: &[u8; 8] = b"FLYSESS1";
/// Footer magic, so a truncated file cannot look complete.
pub const FOOTER_MAGIC: &[u8; 8] = b"FLYSESSF";
/// Envelope version, in the header and in the manifest.
pub const VERSION: u32 = 1;
/// Fixed header size in bytes.
pub const HEADER_BYTES: usize = 32;
/// One payload table entry: a 64-byte name field, offset, length and a 32-byte digest.
pub const TABLE_ENTRY_BYTES: usize = 112;
/// Payload name field width.
pub const NAME_BYTES: usize = 64;
/// Footer size in bytes: total length, whole-prefix digest and the footer magic.
pub const FOOTER_BYTES: usize = 48;
/// Payloads start on an eight-byte boundary.
pub const ALIGNMENT: u64 = 8;
/// Payloads per envelope.
pub const MAX_PAYLOADS: usize = 64;
/// One payload's table entry.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct PayloadEntry {
/// An `Id`: the new envelope widens the historical letters-only chunk name deliberately,
/// which is why it is a new version and not an extension of `FLYSIM01`.
pub name: String,
pub offset: u64,
pub byte_length: u64,
/// SHA-256 of exactly `byte_length` bytes at `offset`.
pub digest: [u8; 32],
}
/// A laid-out envelope: where everything is, before any bytes are written.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Layout {
pub manifest_offset: u64,
pub manifest_bytes: u32,
pub table_offset: u64,
pub entries: Vec<PayloadEntry>,
pub footer_offset: u64,
pub total_bytes: u64,
}
fn align_up(value: u64) -> u64 {
value.div_ceil(ALIGNMENT) * ALIGNMENT
}
/// Lays out the envelope for one manifest and a list of `(name, bytes)` payloads.
pub fn layout(manifest: &Value, payloads: &[(String, Vec<u8>)]) -> Result<Layout> {
if payloads.len() > MAX_PAYLOADS {
return err("checkpoint envelope: at most 64 payloads");
}
crate::scalar::require_unique(
payloads.iter().map(|(name, _)| name.as_str()),
"checkpoint envelope: payload names",
)?;
for (name, _) in payloads {
if !is_id(name) {
return err(format!(
"checkpoint envelope: payload name {name:?} is not an Id"
));
}
}
let manifest_text = canonical::canonicalize(manifest)?;
let manifest_bytes = u32::try_from(manifest_text.len())
.map_err(|_| crate::scalar::wire_err("checkpoint envelope: manifest is too large"))?;
let manifest_offset = HEADER_BYTES as u64;
let table_offset = align_up(manifest_offset + u64::from(manifest_bytes));
let mut offset = align_up(table_offset + (payloads.len() * TABLE_ENTRY_BYTES) as u64);
let mut entries = Vec::with_capacity(payloads.len());
for (name, bytes) in payloads {
entries.push(PayloadEntry {
name: name.clone(),
offset,
byte_length: bytes.len() as u64,
digest: Sha256::digest(bytes).into(),
});
offset = align_up(offset + bytes.len() as u64);
}
Ok(Layout {
manifest_offset,
manifest_bytes,
table_offset,
entries,
footer_offset: offset,
total_bytes: offset + FOOTER_BYTES as u64,
})
}
/// Writes one envelope: header, manifest, payload table, payloads, footer.
pub fn encode(manifest: &Value, payloads: &[(String, Vec<u8>)]) -> Result<Vec<u8>> {
let layout = layout(manifest, payloads)?;
let manifest_text = canonical::canonicalize(manifest)?;
let mut out = vec![0u8; layout.footer_offset as usize];
out[0..8].copy_from_slice(MAGIC);
out[8..12].copy_from_slice(&VERSION.to_le_bytes());
out[12..16].copy_from_slice(&(HEADER_BYTES as u32).to_le_bytes());
out[16..20].copy_from_slice(&layout.manifest_bytes.to_le_bytes());
out[20..24].copy_from_slice(&(payloads.len() as u32).to_le_bytes());
out[24..28].copy_from_slice(&(layout.table_offset as u32).to_le_bytes());
out[28..32].copy_from_slice(&0u32.to_le_bytes());
let manifest_start = layout.manifest_offset as usize;
out[manifest_start..manifest_start + manifest_text.len()]
.copy_from_slice(manifest_text.as_bytes());
for (index, entry) in layout.entries.iter().enumerate() {
let base = layout.table_offset as usize + index * TABLE_ENTRY_BYTES;
out[base..base + entry.name.len()].copy_from_slice(entry.name.as_bytes());
let numbers = base + NAME_BYTES;
out[numbers..numbers + 8].copy_from_slice(&entry.offset.to_le_bytes());
out[numbers + 8..numbers + 16].copy_from_slice(&entry.byte_length.to_le_bytes());
out[numbers + 16..numbers + 48].copy_from_slice(&entry.digest);
}
for (entry, (_, bytes)) in layout.entries.iter().zip(payloads) {
let start = entry.offset as usize;
out[start..start + bytes.len()].copy_from_slice(bytes);
}
let digest: [u8; 32] = Sha256::digest(&out).into();
out.extend_from_slice(&layout.total_bytes.to_le_bytes());
out.extend_from_slice(&digest);
out.extend_from_slice(FOOTER_MAGIC);
Ok(out)
}
/// A decoded envelope.
#[derive(Clone, Debug, PartialEq)]
pub struct Envelope {
pub manifest: Value,
pub payloads: Vec<(String, Vec<u8>)>,
pub layout: Layout,
}
impl Envelope {
pub fn payload(&self, name: &str) -> Option<&[u8]> {
self.payloads
.iter()
.find(|(key, _)| key == name)
.map(|(_, bytes)| bytes.as_slice())
}
}
fn u32_at(bytes: &[u8], offset: usize) -> u32 {
u32::from_le_bytes([
bytes[offset],
bytes[offset + 1],
bytes[offset + 2],
bytes[offset + 3],
])
}
fn u64_at(bytes: &[u8], offset: usize) -> u64 {
let mut buf = [0u8; 8];
buf.copy_from_slice(&bytes[offset..offset + 8]);
u64::from_le_bytes(buf)
}
/// Reads and fully validates one envelope: magic, version, footer digest, table ordering,
/// alignment, bounds and every payload digest.
pub fn decode(bytes: &[u8]) -> Result<Envelope> {
if bytes.len() < HEADER_BYTES + FOOTER_BYTES {
return err("checkpoint envelope: shorter than a header plus a footer");
}
if &bytes[0..8] != MAGIC {
return err("checkpoint envelope: wrong magic (FLYSIM01 is a different format)");
}
if u32_at(bytes, 8) != VERSION {
return err("checkpoint envelope: unsupported version");
}
if u32_at(bytes, 12) as usize != HEADER_BYTES {
return err("checkpoint envelope: headerBytes must be 32");
}
if u32_at(bytes, 28) != 0 {
return err("checkpoint envelope: reserved header word must be zero");
}
let manifest_bytes = u32_at(bytes, 16) as usize;
let payload_count = u32_at(bytes, 20) as usize;
let table_offset = u32_at(bytes, 24) as u64;
if payload_count > MAX_PAYLOADS {
return err("checkpoint envelope: at most 64 payloads");
}
let footer_offset = bytes.len() - FOOTER_BYTES;
if &bytes[footer_offset + 40..] != FOOTER_MAGIC {
return err("checkpoint envelope: missing footer magic");
}
if u64_at(bytes, footer_offset) != bytes.len() as u64 {
return err("checkpoint envelope: footer length does not match the file");
}
let recorded = &bytes[footer_offset + 8..footer_offset + 40];
let computed: [u8; 32] = Sha256::digest(&bytes[..footer_offset]).into();
if recorded != computed {
return err("checkpoint envelope: footer digest does not match the contents");
}
let manifest_start = HEADER_BYTES;
let manifest_end = manifest_start + manifest_bytes;
if manifest_end > footer_offset {
return err("checkpoint envelope: manifest runs past the payload area");
}
let manifest = canonical::parse_strict(&bytes[manifest_start..manifest_end])?;
let canonical_manifest = canonical::canonicalize(&manifest)?;
if canonical_manifest.as_bytes() != &bytes[manifest_start..manifest_end] {
return err("checkpoint envelope: the manifest is not canonical JSON");
}
if table_offset != align_up(manifest_end as u64) {
return err("checkpoint envelope: the payload table is not at its laid-out offset");
}
let table_end = table_offset as usize + payload_count * TABLE_ENTRY_BYTES;
if table_end > footer_offset {
return err("checkpoint envelope: the payload table runs past the payload area");
}
let mut entries = Vec::with_capacity(payload_count);
let mut payloads = Vec::with_capacity(payload_count);
let mut previous_end = align_up(table_end as u64);
for index in 0..payload_count {
let base = table_offset as usize + index * TABLE_ENTRY_BYTES;
let name_field = &bytes[base..base + NAME_BYTES];
let length = name_field
.iter()
.position(|b| *b == 0)
.unwrap_or(NAME_BYTES);
if name_field[length..].iter().any(|b| *b != 0) {
return err("checkpoint envelope: a payload name has bytes after its terminator");
}
let name = std::str::from_utf8(&name_field[..length])
.map_err(|_| crate::scalar::wire_err("checkpoint envelope: payload name is not UTF-8"))?
.to_owned();
if !is_id(&name) {
return err(format!(
"checkpoint envelope: payload name {name:?} is not an Id"
));
}
let numbers = base + NAME_BYTES;
let offset = u64_at(bytes, numbers);
let byte_length = u64_at(bytes, numbers + 8);
let mut digest = [0u8; 32];
digest.copy_from_slice(&bytes[numbers + 16..numbers + 48]);
if offset != previous_end {
return err(format!(
"checkpoint envelope: payload {name:?} starts at {offset}, not at its aligned {previous_end}"
));
}
let end = offset
.checked_add(byte_length)
.ok_or_else(|| crate::scalar::wire_err("checkpoint envelope: payload overflows"))?;
if end > footer_offset as u64 {
return err(format!(
"checkpoint envelope: payload {name:?} runs past the payload area"
));
}
let payload = bytes[offset as usize..end as usize].to_vec();
let computed: [u8; 32] = Sha256::digest(&payload).into();
if computed != digest {
return err(format!(
"checkpoint envelope: payload {name:?} fails its digest"
));
}
previous_end = align_up(end);
entries.push(PayloadEntry {
name: name.clone(),
offset,
byte_length,
digest,
});
payloads.push((name, payload));
}
crate::scalar::require_unique(
entries.iter().map(|e| e.name.as_str()),
"checkpoint envelope: payload names",
)?;
if previous_end != footer_offset as u64 {
return err("checkpoint envelope: padding between the last payload and the footer");
}
Ok(Envelope {
manifest,
layout: Layout {
manifest_offset: manifest_start as u64,
manifest_bytes: manifest_bytes as u32,
table_offset,
entries,
footer_offset: footer_offset as u64,
total_bytes: bytes.len() as u64,
},
payloads,
})
}
/// The manifest fields state-media-v1 section 4 requires, checked as a set: a manifest that
/// omits one of them is not a complete checkpoint.
///
/// `helperState` and `environment` join the list under the 2026-09-22 amendment to
/// checkpoint-envelope-v1 section 3: the first has been in that section's table from the
/// start and was missing here, and the second is the holder of the world's own payload, which
/// the table named for every other participant and not for the environment.
pub const REQUIRED_MANIFEST_FIELDS: &[&str] = &[
"envelopeVersion",
"checkpointId",
"sourceScope",
"episodeId",
"worldTime",
"schedulerId",
"compositionDigest",
"portMap",
"compatibility",
"agents",
"coordinator",
"environment",
"helperState",
"payloads",
];
/// Checks the manifest's required field set and that its payload table mirrors the envelope's.
pub fn validate_manifest(envelope: &Envelope) -> Result<()> {
let map = envelope
.manifest
.as_object()
.ok_or_else(|| crate::scalar::wire_err("checkpoint manifest: must be an object"))?;
for field in REQUIRED_MANIFEST_FIELDS {
if !map.contains_key(*field) {
return err(format!("checkpoint manifest: missing {field:?}"));
}
}
if map.get("envelopeVersion").and_then(Value::as_u64) != Some(u64::from(VERSION)) {
return err("checkpoint manifest: envelopeVersion must be 1");
}
let listed = map
.get("payloads")
.and_then(Value::as_array)
.ok_or_else(|| crate::scalar::wire_err("checkpoint manifest: payloads must be an array"))?;
if listed.len() != envelope.layout.entries.len() {
return err("checkpoint manifest: payloads does not match the payload table");
}
for (declared, entry) in listed.iter().zip(&envelope.layout.entries) {
let name = declared.get("name").and_then(Value::as_str);
let length = declared
.get("byteLength")
.and_then(Value::as_str)
.and_then(crate::scalar::parse_u64);
let digest = declared.get("digest").and_then(Value::as_str);
if name != Some(entry.name.as_str()) {
return err("checkpoint manifest: payload name does not match the table");
}
if length != Some(entry.byte_length) {
return err(format!(
"checkpoint manifest: payload {:?} byteLength does not match the table",
entry.name
));
}
if digest != Some(hex(&entry.digest).as_str()) {
return err(format!(
"checkpoint manifest: payload {:?} digest does not match the table",
entry.name
));
}
}
Ok(())
}
/// Lowercase hex of a raw digest, the form the manifest records.
pub fn hex(bytes: &[u8]) -> String {
let mut out = String::with_capacity(bytes.len() * 2);
for byte in bytes {
out.push_str(&format!("{byte:02x}"));
}
out
}

View file

@ -1,414 +0,0 @@
//! The extension methods of the 2026-09-23 amendments (RT-01a): environment slots and the
//! agent side of a declared rollback policy.
//!
//! `workers-v1` section 7 specifies them. They exist because the operator decided (2026-09-23)
//! that the live Game Boy fly runs on this framework rather than beside it, and its ratchet
//! rolls the game back to a saved slot while the brain continues. The shapes are generic: a
//! slot is an environment-held saved state named by an id, and a rollback is an agent
//! installing a new input and context under a new epoch without a tick. Which composition may
//! use them is a capability question, answered by `Worker.Hello`:
//!
//! - an environment that answers `Environment.SaveSlot` / `Environment.RestoreSlot` advertises
//! [`SLOTS_CAPABILITY`];
//! - an agent that answers `Agent.Rollback` advertises [`ROLLBACK_CAPABILITY`].
//!
//! A worker that does not advertise the capability answers `UNSUPPORTED`, mutation none. Nothing
//! Game Boy specific is in these payloads: the console lives in the registered schemas of
//! [`crate::gameboy`], carried inside `TypedValue`s.
use flybus::wire::Fields;
use serde_json::Value;
use crate::scalar::{DomainType, Result, Scope, TypedValue, err, is_digest, is_id, obj, u64_json};
use crate::workers::{AgentTelemetry, SensoryInput, WorldObservation};
/// The environment capability that carries `Environment.SaveSlot` and `Environment.RestoreSlot`.
pub const SLOTS_CAPABILITY: &str = "gameboy-slots-v1";
/// The agent capability that carries `Agent.Rollback`, and the policy id it applies.
pub const ROLLBACK_CAPABILITY: &str = "legacy-ratchet-rollback-v1";
/// The only rollback policy this contract defines.
pub const ROLLBACK_POLICY: &str = "legacy-ratchet-rollback-v1";
pub const METHOD_SAVE_SLOT: &str = "Environment.SaveSlot";
pub const METHOD_RESTORE_SLOT: &str = "Environment.RestoreSlot";
pub const METHOD_AGENT_ROLLBACK: &str = "Agent.Rollback";
/// Slots one environment may hold. Not a stated bound; recorded in the schema set.
pub const MAX_SLOTS: usize = 4;
fn policy_ok(policy: &str, what: &str) -> Result<()> {
if policy != ROLLBACK_POLICY {
return err(format!(
"{what}: policy must be {ROLLBACK_POLICY}, the only rollback policy defined"
));
}
Ok(())
}
// ---------------------------------------------------------------------------------------------
// Environment.SaveSlot
/// `Environment.SaveSlot` params. Scope is the committed boundary the slot records.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SaveSlotParams {
pub slot_id: String,
}
impl DomainType for SaveSlotParams {
const TYPE_NAME: &'static str = "SaveSlotParams";
fn from_json(value: &Value) -> Result<SaveSlotParams> {
let mut f = Fields::new(value, "SaveSlotParams")?;
let slot_id = f.id("slotId")?;
f.finish()?;
let p = SaveSlotParams { slot_id };
p.validate()?;
Ok(p)
}
fn to_json(&self) -> Value {
obj(vec![("slotId", self.slot_id.clone().into())])
}
fn validate(&self) -> Result<()> {
if !is_id(&self.slot_id) {
return err("SaveSlotParams: slotId is not a valid id");
}
Ok(())
}
}
/// `Environment.SaveSlot` result: which boundary the slot now holds, and the digest and length
/// of the saved state bytes, so a later restore can be tied to exactly this save.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct SaveSlotResult {
pub slot_id: String,
pub boundary: u64,
pub state_digest: String,
pub byte_length: u64,
}
impl SaveSlotResult {
/// The slot records the committed boundary the call was scoped to.
pub fn validate_against_scope(&self, scope: &Scope) -> Result<()> {
self.validate()?;
if self.boundary != scope.step {
return err(format!(
"SaveSlotResult: boundary {} must be the scoped committed step {}",
self.boundary, scope.step
));
}
Ok(())
}
}
impl DomainType for SaveSlotResult {
const TYPE_NAME: &'static str = "SaveSlotResult";
fn from_json(value: &Value) -> Result<SaveSlotResult> {
let mut f = Fields::new(value, "SaveSlotResult")?;
let slot_id = f.id("slotId")?;
let boundary = f.u64_string("boundary")?;
let state_digest = f.string("stateDigest")?.to_owned();
let byte_length = f.u64_string("byteLength")?;
f.finish()?;
let r = SaveSlotResult {
slot_id,
boundary,
state_digest,
byte_length,
};
r.validate()?;
Ok(r)
}
fn to_json(&self) -> Value {
obj(vec![
("slotId", self.slot_id.clone().into()),
("boundary", u64_json(self.boundary)),
("stateDigest", self.state_digest.clone().into()),
("byteLength", u64_json(self.byte_length)),
])
}
fn validate(&self) -> Result<()> {
if !is_id(&self.slot_id) {
return err("SaveSlotResult: slotId is not a valid id");
}
if !is_digest(&self.state_digest) {
return err("SaveSlotResult: stateDigest must be 64 lowercase hex digits");
}
if self.byte_length == 0 {
return err("SaveSlotResult: byteLength must be positive");
}
Ok(())
}
}
// ---------------------------------------------------------------------------------------------
// Environment.RestoreSlot
/// `Environment.RestoreSlot` params. Scope is the NEW epoch at the committed boundary the
/// rollback is applied at; `priorEpoch` names the epoch the environment must currently be in.
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct RestoreSlotParams {
pub slot_id: String,
pub prior_epoch: String,
pub policy: String,
}
impl RestoreSlotParams {
/// A rollback moves to a new epoch: the prior epoch cannot be the scoped one.
pub fn validate_against_scope(&self, scope: &Scope) -> Result<()> {
self.validate()?;
if self.prior_epoch == scope.epoch {
return err("RestoreSlotParams: priorEpoch must differ from the scoped (new) epoch");
}
Ok(())
}
}
impl DomainType for RestoreSlotParams {
const TYPE_NAME: &'static str = "RestoreSlotParams";
fn from_json(value: &Value) -> Result<RestoreSlotParams> {
let mut f = Fields::new(value, "RestoreSlotParams")?;
let slot_id = f.id("slotId")?;
let prior_epoch = f.id("priorEpoch")?;
let policy = f.id("policy")?;
f.finish()?;
let p = RestoreSlotParams {
slot_id,
prior_epoch,
policy,
};
p.validate()?;
Ok(p)
}
fn to_json(&self) -> Value {
obj(vec![
("slotId", self.slot_id.clone().into()),
("priorEpoch", self.prior_epoch.clone().into()),
("policy", self.policy.clone().into()),
])
}
fn validate(&self) -> Result<()> {
if !is_id(&self.slot_id) {
return err("RestoreSlotParams: slotId is not a valid id");
}
if !is_id(&self.prior_epoch) {
return err("RestoreSlotParams: priorEpoch is not a valid id");
}
policy_ok(&self.policy, "RestoreSlotParams")
}
}
/// `Environment.RestoreSlot` result: the restored world at the same boundary number under the
/// new epoch. It ran no transition, so it carries no audio chunk.
#[derive(Clone, Debug, PartialEq)]
pub struct RestoreSlotResult {
pub slot_id: String,
pub committed_step: u64,
pub observation: WorldObservation,
}
impl RestoreSlotResult {
pub fn validate_against_scope(&self, scope: &Scope) -> Result<()> {
self.validate()?;
if self.committed_step != scope.step {
return err(format!(
"RestoreSlotResult: committedStep {} must be the scoped step {}",
self.committed_step, scope.step
));
}
Ok(())
}
}
impl DomainType for RestoreSlotResult {
const TYPE_NAME: &'static str = "RestoreSlotResult";
fn from_json(value: &Value) -> Result<RestoreSlotResult> {
let mut f = Fields::new(value, "RestoreSlotResult")?;
let slot_id = f.id("slotId")?;
let committed_step = f.u64_string("committedStep")?;
let observation = WorldObservation::from_json(f.value("observation")?)?;
f.finish()?;
let r = RestoreSlotResult {
slot_id,
committed_step,
observation,
};
r.validate()?;
Ok(r)
}
fn to_json(&self) -> Value {
obj(vec![
("slotId", self.slot_id.clone().into()),
("committedStep", u64_json(self.committed_step)),
("observation", self.observation.to_json()),
])
}
fn validate(&self) -> Result<()> {
if !is_id(&self.slot_id) {
return err("RestoreSlotResult: slotId is not a valid id");
}
self.observation.validate()?;
if self.observation.boundary != self.committed_step {
return err("RestoreSlotResult: the observation boundary must be the committed step");
}
if !self.observation.audio.is_empty() {
return err(
"RestoreSlotResult: a restored slot ran no transition and carries no audio chunk",
);
}
Ok(())
}
}
// ---------------------------------------------------------------------------------------------
// Agent.Rollback
/// `Agent.Rollback` params. Scope is the new epoch at the committed boundary; the agent must be
/// Ready at `priorEpoch` and that same step.
#[derive(Clone, Debug, PartialEq)]
pub struct AgentRollbackParams {
pub agent_id: String,
pub prior_epoch: String,
pub policy: String,
pub input: SensoryInput,
pub decision_context: TypedValue,
}
impl AgentRollbackParams {
/// The installed input is the restored boundary, which is the scoped step.
pub fn validate_against_scope(&self, scope: &Scope) -> Result<()> {
self.validate()?;
if self.prior_epoch == scope.epoch {
return err("AgentRollbackParams: priorEpoch must differ from the scoped (new) epoch");
}
if self.input.boundary != scope.step {
return err(format!(
"AgentRollbackParams: input.boundary {} must be the scoped step {}",
self.input.boundary, scope.step
));
}
Ok(())
}
}
impl DomainType for AgentRollbackParams {
const TYPE_NAME: &'static str = "AgentRollbackParams";
fn from_json(value: &Value) -> Result<AgentRollbackParams> {
let mut f = Fields::new(value, "AgentRollbackParams")?;
let agent_id = f.id("agentId")?;
let prior_epoch = f.id("priorEpoch")?;
let policy = f.id("policy")?;
let input = SensoryInput::from_json(f.value("input")?)?;
let decision_context = TypedValue::from_json(f.value("decisionContext")?)?;
f.finish()?;
let p = AgentRollbackParams {
agent_id,
prior_epoch,
policy,
input,
decision_context,
};
p.validate()?;
Ok(p)
}
fn to_json(&self) -> Value {
obj(vec![
("agentId", self.agent_id.clone().into()),
("priorEpoch", self.prior_epoch.clone().into()),
("policy", self.policy.clone().into()),
("input", self.input.to_json()),
("decisionContext", self.decision_context.to_json()),
])
}
fn validate(&self) -> Result<()> {
if !is_id(&self.agent_id) {
return err("AgentRollbackParams: agentId is not a valid id");
}
if !is_id(&self.prior_epoch) {
return err("AgentRollbackParams: priorEpoch is not a valid id");
}
policy_ok(&self.policy, "AgentRollbackParams")?;
self.input.validate()?;
self.decision_context.validate()
}
}
/// `Agent.Rollback` result: the same acknowledgment shape as a commit, at the same boundary.
#[derive(Clone, Debug, PartialEq)]
pub struct AgentRollbackResult {
pub agent_id: String,
pub committed_step: u64,
pub decision_context_digest: String,
pub telemetry: AgentTelemetry,
}
impl AgentRollbackResult {
pub fn validate_against_scope(&self, scope: &Scope) -> Result<()> {
self.validate()?;
if self.committed_step != scope.step {
return err(format!(
"AgentRollbackResult: committedStep {} must be the scoped step {}; a rollback runs no tick",
self.committed_step, scope.step
));
}
Ok(())
}
}
impl DomainType for AgentRollbackResult {
const TYPE_NAME: &'static str = "AgentRollbackResult";
fn from_json(value: &Value) -> Result<AgentRollbackResult> {
let mut f = Fields::new(value, "AgentRollbackResult")?;
let agent_id = f.id("agentId")?;
let committed_step = f.u64_string("committedStep")?;
let decision_context_digest = f.string("decisionContextDigest")?.to_owned();
let telemetry = AgentTelemetry::from_json(f.value("telemetry")?)?;
f.finish()?;
let r = AgentRollbackResult {
agent_id,
committed_step,
decision_context_digest,
telemetry,
};
r.validate()?;
Ok(r)
}
fn to_json(&self) -> Value {
obj(vec![
("agentId", self.agent_id.clone().into()),
("committedStep", u64_json(self.committed_step)),
(
"decisionContextDigest",
self.decision_context_digest.clone().into(),
),
("telemetry", self.telemetry.to_json()),
])
}
fn validate(&self) -> Result<()> {
if !is_id(&self.agent_id) {
return err("AgentRollbackResult: agentId is not a valid id");
}
if !is_digest(&self.decision_context_digest) {
return err(
"AgentRollbackResult: decisionContextDigest must be 64 lowercase hex digits",
);
}
self.telemetry.validate()
}
}

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