diff --git a/docs/design/session-framework/README.md b/docs/design/session-framework/README.md index 9b71784..af0796c 100644 --- a/docs/design/session-framework/README.md +++ b/docs/design/session-framework/README.md @@ -37,6 +37,15 @@ retroactively to existing [public feed](../../feed-protocol.md), 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. + For context: [modular-session analysis](../malecns-modular-sessions.md) and [Melee audit](../melee-framework-audit.md). Each contract owns its named subject; step ordering wins over an informal diagram, and Flybus owns transport/resource rules. Resolve contradictions diff --git a/docs/design/session-framework/checkpoint-envelope-v1.md b/docs/design/session-framework/checkpoint-envelope-v1.md new file mode 100644 index 0000000..c83bb70 --- /dev/null +++ b/docs/design/session-framework/checkpoint-envelope-v1.md @@ -0,0 +1,181 @@ +# 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 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 | + +`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. diff --git a/docs/design/session-framework/ipc-v1.md b/docs/design/session-framework/ipc-v1.md index 98778f9..083a6ce 100644 --- a/docs/design/session-framework/ipc-v1.md +++ b/docs/design/session-framework/ipc-v1.md @@ -154,6 +154,11 @@ Lifecycle/capture replies are retained until Worker.Acknowledge: not another consumer's bus delivery. Already released/unknown IDs are ignored. Serial watermarks reject reuse after acknowledgment without an unbounded tombstone list. +**Amendment, 2026-09-22 (CONTRACT-01):** those ids are domain request ids in the `req-` +serial form, not arbitrary `Id`s. The serial watermark rule in the sentence above cannot reject +reuse after acknowledgment unless the acknowledged id carries its serial, so a bus callId or a +bare `Id` is refused there. + Bound unacknowledged lifecycle replies at 16, then BUSY before application. Status and Acknowledge use a cache of their last 16 replies; current/previous step records have their separate finite retention. Caches containing big artifacts consume bus owner/byte budgets; diff --git a/docs/design/session-framework/seed-derivation-v1.md b/docs/design/session-framework/seed-derivation-v1.md new file mode 100644 index 0000000..97d0680 --- /dev/null +++ b/docs/design/session-framework/seed-derivation-v1.md @@ -0,0 +1,101 @@ +# 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. diff --git a/docs/design/session-framework/step-v1.md b/docs/design/session-framework/step-v1.md index 3dea51f..ab8554b 100644 --- a/docs/design/session-framework/step-v1.md +++ b/docs/design/session-framework/step-v1.md @@ -30,6 +30,7 @@ Starting → Ready(k) → Preparing(k) → Applying(k) → Observing(k+1) Ready(k) → Paused(k) → Ready(k) Ready(k) / Paused(k) → Capturing(k) → same boundary +pause requested mid-step → Committing(k) → Ready(k+1) → Paused(k+1) any unresolved partial failure → Failed → Restoring(new epoch) → Paused(k) terminal episode → Paused(k) → Resetting(new epoch) → Ready(0) ``` @@ -38,6 +39,12 @@ terminal episode → Paused(k) → Resetting(new epoch) → Ready(0) transition carry `scope.step=k`; result fields identify `nextStep=k+1` where applicable. Do not send Agent.Commit with step `k+1` merely because the observation is newer. +**Amendment, 2026-09-22.** The mid-step pause line above adds no new edge: a pause requested +during a transition is served by the ordinary `Committing(k) → Ready(k+1)` edge followed by +`Ready(k+1) → Paused(k+1)`. It is written into the machine because section 6 requires the +transition to finish first, so the only boundary such a pause can land on is the one the +transition just committed. + ## 3. Transaction sequence ### Phase A: prepare all agents concurrently diff --git a/package-lock.json b/package-lock.json index 342940b..9cc64fc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -14,6 +14,7 @@ "apps/stage": { "name": "@flybrain/stage", "version": "0.1.1", + "license": "Apache-2.0", "dependencies": { "@flybrain/brain": "*", "@flybrain/feed": "*", @@ -892,6 +893,10 @@ "resolved": "packages/feed", "link": true }, + "node_modules/@flybrain/session-types": { + "resolved": "packages/session-types", + "link": true + }, "node_modules/@flybrain/stage": { "resolved": "apps/stage", "link": true @@ -3666,6 +3671,7 @@ "packages/brain": { "name": "@flybrain/brain", "version": "0.1.1", + "license": "Apache-2.0", "devDependencies": { "@types/node": "22.17.0", "@types/three": "0.178.1", @@ -3685,6 +3691,7 @@ "packages/feed": { "name": "@flybrain/feed", "version": "0.1.0", + "license": "Apache-2.0", "dependencies": { "ws": "8.21.3" }, @@ -3696,9 +3703,20 @@ "typescript": "5.9.2" } }, + "packages/session-types": { + "name": "@flybrain/session-types", + "version": "0.1.0", + "license": "Apache-2.0", + "devDependencies": { + "@types/node": "22.17.0", + "tsx": "4.20.3", + "typescript": "5.9.2" + } + }, "services/bridge": { "name": "@flybrain/bridge", "version": "0.1.0", + "license": "Apache-2.0", "dependencies": { "@flybrain/feed": "0.1.0", "@twurple/api": "8.1.4", diff --git a/packages/session-types/README.md b/packages/session-types/README.md new file mode 100644 index 0000000..c49c758 --- /dev/null +++ b/packages/session-types/README.md @@ -0,0 +1,76 @@ +# @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 | +| `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. diff --git a/packages/session-types/package.json b/packages/session-types/package.json new file mode 100644 index 0000000..6a388f4 --- /dev/null +++ b/packages/session-types/package.json @@ -0,0 +1,22 @@ +{ + "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": { + "@types/node": "22.17.0", + "tsx": "4.20.3", + "typescript": "5.9.2" + } +} diff --git a/packages/session-types/src/canonical.ts b/packages/session-types/src/canonical.ts new file mode 100644 index 0000000..1e43c51 --- /dev/null +++ b/packages/session-types/src/canonical.ts @@ -0,0 +1,381 @@ +/** + * 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); + 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)) { + 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); + } +} diff --git a/packages/session-types/src/checkpoint.ts b/packages/session-types/src/checkpoint.ts new file mode 100644 index 0000000..63aac19 --- /dev/null +++ b/packages/session-types/src/checkpoint.ts @@ -0,0 +1,273 @@ +/** + * `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. */ +export const REQUIRED_MANIFEST_FIELDS = [ + 'envelopeVersion', + 'checkpointId', + 'sourceScope', + 'episodeId', + 'worldTime', + 'schedulerId', + 'compositionDigest', + 'portMap', + 'compatibility', + 'agents', + 'coordinator', + '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; + 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; + 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`); + } + }); +} diff --git a/packages/session-types/src/common.ts b/packages/session-types/src/common.ts new file mode 100644 index 0000000..c13398a --- /dev/null +++ b/packages/session-types/src/common.ts @@ -0,0 +1,114 @@ +/** + * `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 { + 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 { + 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; +} diff --git a/packages/session-types/src/fixtures.ts b/packages/session-types/src/fixtures.ts new file mode 100644 index 0000000..87135d5 --- /dev/null +++ b/packages/session-types/src/fixtures.ts @@ -0,0 +1,62 @@ +/** + * 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)[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)[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)[key]; + return typeof found === 'string' ? found : undefined; +} + +export function member(value: Json, key: string): Json { + const found = (value as Record)[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')); +} diff --git a/packages/session-types/src/index.ts b/packages/session-types/src/index.ts new file mode 100644 index 0000000..72e96f9 --- /dev/null +++ b/packages/session-types/src/index.ts @@ -0,0 +1,19 @@ +/** + * `@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 * as seed from './seed'; +export * as checkpoint from './checkpoint'; +export * as fixtures from './fixtures'; diff --git a/packages/session-types/src/media.ts b/packages/session-types/src/media.ts new file mode 100644 index 0000000..adb1f88 --- /dev/null +++ b/packages/session-types/src/media.ts @@ -0,0 +1,264 @@ +/** 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 }; diff --git a/packages/session-types/src/publishing.ts b/packages/session-types/src/publishing.ts new file mode 100644 index 0000000..855460a --- /dev/null +++ b/packages/session-types/src/publishing.ts @@ -0,0 +1,221 @@ +/** 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, + type PortControl, + findPort, + readAgentTelemetry, + readAssetRef, + readEnvironmentDescriptor, + readPortControl, + validatePortControlAgainst, + validateTelemetryRoles, +} from './workers'; + +/** Not stated by a document; this crate's choices, published in the schema set. */ +export const MAX_SUPPORTED_STIMULI = 64; +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) + if (atBoundaryZero && (agent.selectedDecision !== null || agent.appliedControls !== null)) { + fail('CommittedSnapshot: at boundary 0 selectedDecision and appliedControls are null'); + } + if (!atBoundaryZero && (agent.selectedDecision === null || agent.appliedControls === null)) { + fail( + 'CommittedSnapshot: past boundary 0 every agent has a decision and applied controls', + ); + } + } + 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); + } + } +} diff --git a/packages/session-types/src/reader.ts b/packages/session-types/src/reader.ts new file mode 100644 index 0000000..ce99004 --- /dev/null +++ b/packages/session-types/src/reader.ts @@ -0,0 +1,231 @@ +/** + * 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; + private readonly seen = new Set(); + + 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; + } + + 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 { + 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; + } + + 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(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(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(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(); + 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; +} diff --git a/packages/session-types/src/rpc.ts b/packages/session-types/src/rpc.ts new file mode 100644 index 0000000..8ee568a --- /dev/null +++ b/packages/session-types/src/rpc.ts @@ -0,0 +1,140 @@ +/** 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; +} + +export interface SessionRpcSuccess { + type: 'result'; + requestId: DomainRequestId; + workerId: Id; + incarnationId: Id; + scope: Scope | null; + result: Record; +} + +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; +} diff --git a/packages/session-types/src/scalar.ts b/packages/session-types/src/scalar.ts new file mode 100644 index 0000000..8f1866e --- /dev/null +++ b/packages/session-types/src/scalar.ts @@ -0,0 +1,262 @@ +/** + * 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 = string & { readonly [brand]: Name }; + +/** A bus RPC correlation id, `call-`. Not a domain operation id. */ +export type BusCallId = Branded<'BusCallId'>; +/** A domain operation id, `req-`. A safe retry keeps it and gets a new `BusCallId`. */ +export type DomainRequestId = Branded<'DomainRequestId'>; +/** A delivery (`dlv-`) or explicit-hold (`own-`) 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-'); + return value; +} + +export function domainRequestId(value: unknown): DomainRequestId { + if (!isDomainRequestId(value)) fail('a domain requestId must be canonical req-'); + return value; +} + +export function ownerToken(value: unknown): OwnerToken { + if (ownerTokenKind(value) === undefined) { + fail('an owner token must be canonical dlv- or own-'); + } + 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; +} + +/** The canonical-JSON size limit of one `TypedValue`. */ +export const MAX_TYPED_VALUE_BYTES = 32 * 1024; diff --git a/packages/session-types/src/seed.ts b/packages/session-types/src/seed.ts new file mode 100644 index 0000000..b264552 --- /dev/null +++ b/packages/session-types/src/seed.ts @@ -0,0 +1,57 @@ +/** + * `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; +} diff --git a/packages/session-types/src/trace.ts b/packages/session-types/src/trace.ts new file mode 100644 index 0000000..339e31b --- /dev/null +++ b/packages/session-types/src/trace.ts @@ -0,0 +1,268 @@ +/** + * 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'; + +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; +} + +export interface TraceRequest { + agentId: Id; + requestId: DomainRequestId; +} + +export interface TraceOperational { + wallTimeNs: U64; + prepareRequestIds: TraceRequest[]; + advanceRequestId: DomainRequestId; + commitRequestIds: TraceRequest[]; + busCallIds: BusCallId[]; + deliveryIds: OwnerToken[]; +} + +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'); + reader.finish(); + + 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, + }; +} + +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), + }; + 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'); + 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`, + ); + } + } + } + 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'); + 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)) + ); +} diff --git a/packages/session-types/src/workers.ts b/packages/session-types/src/workers.ts new file mode 100644 index 0000000..1b1de22 --- /dev/null +++ b/packages/session-types/src/workers.ts @@ -0,0 +1,996 @@ +/** The closed enums and method payloads of workers-v1, plus the `Worker.*` common methods. */ +import { fail } from './canonical'; +import { readRational, readScope } from './common'; +import { + type AudioDescriptor, + type AudioRef, + MAX_VIEWS, + type ViewDescriptor, + type ViewRef, + readAudioDescriptor, + readAudioList, + readViewDescriptor, + readViewList, + validateAudioAgainst, + validateViewAgainst, +} from './media'; +import { Reader, requireSameOrder, requireUnique, u64 } from './reader'; +import { + type Digest, + type DomainRequestId, + type Id, + type RationalNs, + type SchemaRef, + type Scope, + type TypedValue, + type U64, + addRational, + compareRational, + domainRequestId, + isRationalZero, + requirePositiveRational, +} from './scalar'; +import { readSchemaRef, readTypedValue, readNullableTypedValue } from './common'; + +export const MAX_AGENTS = 4; +export const MAX_PORTS = 4; +export const MAX_RATE_ROLES = 64; +export const MAX_STIMULI = 64; +export const MAX_REWARDS = 64; +export const MAX_BUTTONS = 32; +export const MAX_AXES = 16; +export const MAX_ACKNOWLEDGE = 16; +export const MAX_ENGINE_FRAME_LEN = 64; +/** Not stated by a document; this crate's choice, published in the schema set. */ +export const MAX_CAPABILITIES = 32; +export const MAX_SUPPORTED_MAJORS = 8; +export const MAX_MESSAGE_CODE_POINTS = 512; + +export const ROLES = ['agent', 'environment', 'coordinator'] as const; +export type Role = (typeof ROLES)[number]; + +export const WORKER_STATES = [ + 'uninitialized', + 'ready', + 'preparing', + 'prepared', + 'advancing', + 'committing', + 'capturing', + 'staged-restore', + 'restoring', + 'failed', + 'stopping', +] as const; +export type WorkerState = (typeof WORKER_STATES)[number]; + +export const RECOVERY = ['exact-checkpoint', 'episode-restart'] as const; +export type Recovery = (typeof RECOVERY)[number]; + +export const DETERMINISM = ['fixed-build', 'unverified'] as const; +export type Determinism = (typeof DETERMINISM)[number]; + +export const AXIS_RANGES = ['bipolar', 'unit'] as const; +export type AxisRange = (typeof AXIS_RANGES)[number]; + +export function axisBounds(range: AxisRange): [number, number] { + return range === 'bipolar' ? [-1, 1] : [0, 1]; +} + +export interface AssetRef { + id: Id; + digest: Digest; + byteLength: U64; + format: Id; +} + +export interface SensoryInput { + boundary: U64; + views: ViewRef[]; + structured: TypedValue | null; +} + +export interface Stimulus { + id: Id; + kindId: Id; + durationMs: number; +} + +export interface Reward { + eventId: Id; + ruleId: Id; + value: number; +} + +export interface AgentTelemetry { + brainTicks: U64; + populationRateHz: number; + rates: { roleId: Id; hz: number }[]; + learning: { enabled: boolean; updates: U64; changed: U64; signal: number }; +} + +export function readAssetRef(value: unknown): AssetRef { + const reader = new Reader(value, 'AssetRef'); + const asset: AssetRef = { + id: reader.id('id'), + digest: reader.digest('digest'), + byteLength: reader.u64('byteLength'), + format: reader.id('format'), + }; + reader.finish(); + if (u64(asset.byteLength) === 0n) fail('AssetRef: byteLength must be positive'); + return asset; +} + +export function readSensoryInput(value: unknown): SensoryInput { + const reader = new Reader(value, 'SensoryInput'); + const input: SensoryInput = { + boundary: reader.u64('boundary'), + views: readViewList(reader, 'views'), + structured: readNullableTypedValue(reader.value('structured')), + }; + reader.finish(); + for (const view of input.views) { + if (u64(view.producedStep) > u64(input.boundary)) { + fail(`SensoryInput: view "${view.viewId}" was produced after the observed boundary`); + } + } + return input; +} + +/** Required sensory views against the descriptors that declared them. */ +export function validateSensoryInputAgainst( + input: SensoryInput, + descriptors: readonly ViewDescriptor[], +): void { + for (const view of input.views) { + const descriptor = descriptors.find((candidate) => candidate.viewId === view.viewId); + if (!descriptor) { + fail(`SensoryInput: view "${view.viewId}" is not declared by the environment`); + } + validateViewAgainst(view, descriptor, u64(input.boundary)); + } +} + +/** A pixel-only profile rejects non-null structured input (workers-v1 section 1). */ +export function validateSensoryInputForProfile( + input: SensoryInput, + structuredSensing: boolean, +): void { + if (input.structured !== null && !structuredSensing) { + fail('SensoryInput: a pixel-only profile rejects non-null structured input'); + } +} + +export function readStimulus(value: unknown): Stimulus { + const reader = new Reader(value, 'Stimulus'); + const stimulus: Stimulus = { + id: reader.id('id'), + kindId: reader.id('kindId'), + durationMs: reader.finite('durationMs'), + }; + reader.finish(); + if (stimulus.durationMs <= 0) fail('Stimulus: durationMs must be finite and > 0'); + return stimulus; +} + +export function readReward(value: unknown): Reward { + const reader = new Reader(value, 'Reward'); + const reward: Reward = { + eventId: reader.id('eventId'), + ruleId: reader.id('ruleId'), + value: reader.finite('value'), + }; + reader.finish(); + return reward; +} + +export function readStimulusList(reader: Reader, key: string): Stimulus[] { + const items = reader.list(key, 0, MAX_STIMULI, readStimulus); + requireUnique( + items.map((item) => item.id), + key, + ); + return items; +} + +export function readRewardList(reader: Reader, key: string): Reward[] { + const items = reader.list(key, 0, MAX_REWARDS, readReward); + requireUnique( + items.map((item) => item.eventId), + key, + ); + return items; +} + +export function readAgentTelemetry(value: unknown): AgentTelemetry { + const reader = new Reader(value, 'AgentTelemetry'); + const brainTicks = reader.u64('brainTicks'); + const populationRateHz = reader.finiteIn('populationRateHz', 0, Number.MAX_VALUE); + const rates = reader.list(('rates'), 0, MAX_RATE_ROLES, (item) => { + const rate = new Reader(item, 'AgentTelemetry.rates'); + const entry = { roleId: rate.id('roleId'), hz: rate.finiteIn('hz', 0, Number.MAX_VALUE) }; + rate.finish(); + return entry; + }); + const learningReader = new Reader(reader.value('learning'), 'AgentTelemetry.learning'); + const learning = { + enabled: learningReader.boolean('enabled'), + updates: learningReader.u64('updates'), + changed: learningReader.u64('changed'), + signal: learningReader.finite('signal'), + }; + learningReader.finish(); + reader.finish(); + requireUnique( + rates.map((rate) => rate.roleId), + 'AgentTelemetry.rates', + ); + if (u64(learning.changed) > u64(learning.updates)) { + fail('AgentTelemetry: learning.changed cannot exceed learning.updates'); + } + return { brainTicks, populationRateHz, rates, learning }; +} + +/** Rates are in profile-defined order (workers-v1 section 1). */ +export function validateTelemetryRoles( + telemetry: AgentTelemetry, + roleOrder: readonly string[], +): void { + requireSameOrder( + telemetry.rates.map((rate) => rate.roleId), + roleOrder, + 'AgentTelemetry.rates', + ); +} + +// ------------------------------------------------------------------------------ agent methods + +export interface AgentInitializeParams { + agentId: Id; + profile: AssetRef; + seed: number; + initialInput: SensoryInput; + initialDecisionContext: TypedValue; + workerThreads: number; +} + +export interface AgentInitializeResult { + agentId: Id; + profileDigest: Digest; + tickDuration: RationalNs; + warmupTicks: U64; + committedStep: U64; + decisionContextDigest: Digest; + telemetry: AgentTelemetry; +} + +export interface PrepareParams { + agentId: Id; + profileDigest: Digest; + interval: RationalNs; + decisionContextDigest: Digest; + preStepStimulations: Stimulus[]; +} + +export interface PreparedDecision { + agentId: Id; + ticksAdvanced: U64; + brainTicks: U64; + remainder: RationalNs; + decision: TypedValue; +} + +export interface CommitParams { + agentId: Id; + preparedRequestId: DomainRequestId; + nextInput: SensoryInput; + nextDecisionContext: TypedValue; + rewards: Reward[]; + taskStimulations: Stimulus[]; +} + +export interface AgentCommitResult { + agentId: Id; + committedStep: U64; + decisionContextDigest: Digest; + telemetry: AgentTelemetry; +} + +export function readAgentInitializeParams(value: unknown): AgentInitializeParams { + const reader = new Reader(value, 'AgentInitializeParams'); + const params: AgentInitializeParams = { + agentId: reader.id('agentId'), + profile: readAssetRef(reader.value('profile')), + seed: reader.int('seed', -2_147_483_648, 2_147_483_647), + initialInput: readSensoryInput(reader.value('initialInput')), + initialDecisionContext: readTypedValue(reader.value('initialDecisionContext')), + workerThreads: reader.int('workerThreads', 1, 4_096), + }; + reader.finish(); + return params; +} + +export function readAgentInitializeResult(value: unknown): AgentInitializeResult { + const reader = new Reader(value, 'AgentInitializeResult'); + const result: AgentInitializeResult = { + agentId: reader.id('agentId'), + profileDigest: reader.digest('profileDigest'), + tickDuration: readRational(reader.value('tickDuration')), + warmupTicks: reader.u64('warmupTicks'), + committedStep: reader.u64('committedStep'), + decisionContextDigest: reader.digest('decisionContextDigest'), + telemetry: readAgentTelemetry(reader.value('telemetry')), + }; + reader.finish(); + requirePositiveRational(result.tickDuration, 'AgentInitializeResult.tickDuration'); + if (u64(result.committedStep) !== 0n) { + fail('AgentInitializeResult: committedStep must be "0"'); + } + return result; +} + +export function readPrepareParams(value: unknown): PrepareParams { + const reader = new Reader(value, 'PrepareParams'); + const params: PrepareParams = { + agentId: reader.id('agentId'), + profileDigest: reader.digest('profileDigest'), + interval: readRational(reader.value('interval')), + decisionContextDigest: reader.digest('decisionContextDigest'), + preStepStimulations: readStimulusList(reader, 'preStepStimulations'), + }; + reader.finish(); + requirePositiveRational(params.interval, 'PrepareParams.interval'); + return params; +} + +export function readPreparedDecision(value: unknown): PreparedDecision { + const reader = new Reader(value, 'PreparedDecision'); + const decision: PreparedDecision = { + agentId: reader.id('agentId'), + ticksAdvanced: reader.u64('ticksAdvanced'), + brainTicks: reader.u64('brainTicks'), + remainder: readRational(reader.value('remainder')), + decision: readTypedValue(reader.value('decision')), + }; + reader.finish(); + if (u64(decision.ticksAdvanced) > u64(decision.brainTicks)) { + fail('PreparedDecision: ticksAdvanced cannot exceed the total brainTicks'); + } + return decision; +} + +/** The remainder is always `>= 0` and `< one model tick` (step-v1 section 5). */ +export function validateRemainder(decision: PreparedDecision, tickDuration: RationalNs): void { + requirePositiveRational(tickDuration, 'tickDuration'); + if (compareRational(decision.remainder, tickDuration) >= 0) { + fail('PreparedDecision: remainder must be less than one model tick'); + } +} + +export function readCommitParams(value: unknown): CommitParams { + const reader = new Reader(value, 'CommitParams'); + const params: CommitParams = { + agentId: reader.id('agentId'), + preparedRequestId: domainRequestId(reader.string('preparedRequestId')), + nextInput: readSensoryInput(reader.value('nextInput')), + nextDecisionContext: readTypedValue(reader.value('nextDecisionContext')), + rewards: readRewardList(reader, 'rewards'), + taskStimulations: readStimulusList(reader, 'taskStimulations'), + }; + reader.finish(); + return params; +} + +/** The commit of transition `k -> k+1` carries `scope.step = k` and the input for `k+1`. */ +export function validateCommitAgainstScope(params: CommitParams, scope: Scope): void { + const expected = u64(scope.step) + 1n; + if (u64(params.nextInput.boundary) !== expected) { + fail(`CommitParams: nextInput.boundary must be ${expected} for scope.step ${scope.step}`); + } +} + +export function readAgentCommitResult(value: unknown): AgentCommitResult { + const reader = new Reader(value, 'AgentCommitResult'); + const result: AgentCommitResult = { + agentId: reader.id('agentId'), + committedStep: reader.u64('committedStep'), + decisionContextDigest: reader.digest('decisionContextDigest'), + telemetry: readAgentTelemetry(reader.value('telemetry')), + }; + reader.finish(); + return result; +} + +// ------------------------------------------------------------------- environment methods + +export interface AxisSchema { + id: Id; + range: AxisRange; + neutral: number; +} + +export interface ControllerSchema { + schema: SchemaRef; + buttons: Id[]; + axes: AxisSchema[]; +} + +export interface PortControl { + portId: Id; + buttons: { id: Id; down: boolean }[]; + axes: { id: Id; value: number }[]; +} + +export interface PortDescriptor { + portId: Id; + controls: ControllerSchema; +} + +export interface EnvironmentDescriptor { + backendDigest: Digest; + contentDigest: Digest; + configurationDigest: Digest; + stepDuration: RationalNs; + ports: PortDescriptor[]; + inspectionSchema: SchemaRef; + views: ViewDescriptor[]; + audio: AudioDescriptor[]; + recovery: Recovery; + determinism: Determinism; +} + +export interface WorldObservation { + boundary: U64; + worldTime: RationalNs; + engineFrame: string | null; + sensoryViews: ViewRef[]; + inspection: TypedValue; + broadcastViews: ViewRef[]; + audio: AudioRef[]; +} + +export interface EnvironmentInitializeParams { + backendConfig: AssetRef; + taskConfig: AssetRef; + episodeId: Id; + portBindings: { portId: Id; agentId: Id }[]; +} + +export interface EnvironmentInitializeResult { + descriptor: EnvironmentDescriptor; + observation: WorldObservation; +} + +export interface AdvanceParams { + batchId: Id; + controls: PortControl[]; +} + +export interface StepResult { + batchId: Id; + appliedFromStep: U64; + nextStep: U64; + appliedControlsDigest: Digest; + observation: WorldObservation; +} + +export function readControllerSchema(value: unknown): ControllerSchema { + const reader = new Reader(value, 'ControllerSchema'); + const controls: ControllerSchema = { + schema: readSchemaRef(reader.value('schema')), + buttons: reader.idList('buttons', 0, MAX_BUTTONS), + axes: reader.list('axes', 0, MAX_AXES, (item) => { + const axis = new Reader(item, 'ControllerSchema.axes'); + const id = axis.id('id'); + const range = axis.enumeration('range', AXIS_RANGES); + const [low, high] = axisBounds(range); + const neutral = axis.finiteIn('neutral', low, high); + axis.finish(); + return { id, range, neutral }; + }), + }; + reader.finish(); + requireUnique(controls.buttons, 'ControllerSchema.buttons'); + requireUnique( + controls.axes.map((axis) => axis.id), + 'ControllerSchema.axes', + ); + return controls; +} + +export function readPortControl(value: unknown): PortControl { + const reader = new Reader(value, 'PortControl'); + const control: PortControl = { + portId: reader.id('portId'), + buttons: reader.list('buttons', 0, MAX_BUTTONS, (item) => { + const button = new Reader(item, 'PortControl.buttons'); + const entry = { id: button.id('id'), down: button.boolean('down') }; + button.finish(); + return entry; + }), + axes: reader.list('axes', 0, MAX_AXES, (item) => { + const axis = new Reader(item, 'PortControl.axes'); + const entry = { id: axis.id('id'), value: axis.finite('value') }; + axis.finish(); + return entry; + }), + }; + reader.finish(); + requireUnique( + control.buttons.map((button) => button.id), + 'PortControl.buttons', + ); + requireUnique( + control.axes.map((axis) => axis.id), + 'PortControl.axes', + ); + return control; +} + +/** + * Every declared button and axis, in descriptor order, in range. An out-of-range value is + * refused, never clamped (workers-v1 section 3). + */ +export function validatePortControlAgainst( + control: PortControl, + controls: ControllerSchema, +): void { + requireSameOrder( + control.buttons.map((button) => button.id), + controls.buttons, + 'PortControl.buttons', + ); + requireSameOrder( + control.axes.map((axis) => axis.id), + controls.axes.map((axis) => axis.id), + 'PortControl.axes', + ); + control.axes.forEach((axis, index) => { + const schema = controls.axes[index] as AxisSchema; + const [low, high] = axisBounds(schema.range); + if (axis.value < low || axis.value > high) { + fail( + `PortControl: axis "${axis.id}" value ${axis.value} is outside its ${schema.range} range and is refused, not clamped`, + ); + } + }); +} + +export function readEnvironmentDescriptor(value: unknown): EnvironmentDescriptor { + const reader = new Reader(value, 'EnvironmentDescriptor'); + const descriptor: EnvironmentDescriptor = { + backendDigest: reader.digest('backendDigest'), + contentDigest: reader.digest('contentDigest'), + configurationDigest: reader.digest('configurationDigest'), + stepDuration: readRational(reader.value('stepDuration')), + ports: reader.list('ports', 1, MAX_PORTS, (item) => { + const port = new Reader(item, 'EnvironmentDescriptor.ports'); + const entry = { + portId: port.id('portId'), + controls: readControllerSchema(port.value('controls')), + }; + port.finish(); + return entry; + }), + inspectionSchema: readSchemaRef(reader.value('inspectionSchema')), + views: reader.list('views', 0, MAX_VIEWS, readViewDescriptor), + audio: reader.list('audio', 0, 8, readAudioDescriptor), + recovery: reader.enumeration('recovery', RECOVERY), + determinism: reader.enumeration('determinism', DETERMINISM), + }; + reader.finish(); + requirePositiveRational(descriptor.stepDuration, 'EnvironmentDescriptor.stepDuration'); + requireUnique( + descriptor.ports.map((port) => port.portId), + 'EnvironmentDescriptor.ports', + ); + requireUnique( + descriptor.views.map((view) => view.viewId), + 'EnvironmentDescriptor.views', + ); + requireUnique( + descriptor.audio.map((stream) => stream.streamId), + 'EnvironmentDescriptor.audio', + ); + return descriptor; +} + +export function findPort( + descriptor: EnvironmentDescriptor, + portId: string, +): PortDescriptor | undefined { + return descriptor.ports.find((port) => port.portId === portId); +} + +/** One complete batch: every configured port once, in descriptor order. */ +export function validateBatch( + descriptor: EnvironmentDescriptor, + controls: readonly PortControl[], +): void { + requireSameOrder( + controls.map((control) => control.portId), + descriptor.ports.map((port) => port.portId), + 'Environment.Advance controls', + ); + controls.forEach((control, index) => { + validatePortControlAgainst(control, (descriptor.ports[index] as PortDescriptor).controls); + }); +} + +export function readWorldObservation(value: unknown): WorldObservation { + const reader = new Reader(value, 'WorldObservation'); + const observation: WorldObservation = { + boundary: reader.u64('boundary'), + worldTime: readRational(reader.value('worldTime')), + engineFrame: reader.nullableBoundedString('engineFrame', MAX_ENGINE_FRAME_LEN), + sensoryViews: readViewList(reader, 'sensoryViews'), + inspection: readTypedValue(reader.value('inspection')), + broadcastViews: readViewList(reader, 'broadcastViews'), + audio: readAudioList(reader, 'audio'), + }; + reader.finish(); + for (const view of [...observation.sensoryViews, ...observation.broadcastViews]) { + if (u64(view.producedStep) > u64(observation.boundary)) { + fail('WorldObservation: a view cannot be produced after the boundary'); + } + } + return observation; +} + +export function validateObservationAgainst( + observation: WorldObservation, + descriptor: EnvironmentDescriptor, +): void { + const expected = descriptor.inspectionSchema; + const found = observation.inspection.schema; + if ( + found.id !== expected.id || + found.version !== expected.version || + found.digest !== expected.digest + ) { + fail("WorldObservation: inspection must use the descriptor's inspectionSchema"); + } + for (const view of [...observation.sensoryViews, ...observation.broadcastViews]) { + const declared = descriptor.views.find((candidate) => candidate.viewId === view.viewId); + if (!declared) { + fail(`WorldObservation: view "${view.viewId}" is not declared by the descriptor`); + } + validateViewAgainst(view, declared, u64(observation.boundary)); + } + for (const chunk of observation.audio) { + const declared = descriptor.audio.find((candidate) => candidate.streamId === chunk.streamId); + if (!declared) { + fail(`WorldObservation: audio stream "${chunk.streamId}" is not declared by the descriptor`); + } + validateAudioAgainst(chunk, declared); + } +} + +export function readEnvironmentInitializeParams(value: unknown): EnvironmentInitializeParams { + const reader = new Reader(value, 'EnvironmentInitializeParams'); + const params: EnvironmentInitializeParams = { + backendConfig: readAssetRef(reader.value('backendConfig')), + taskConfig: readAssetRef(reader.value('taskConfig')), + episodeId: reader.id('episodeId'), + portBindings: reader.list('portBindings', 1, MAX_PORTS, (item) => { + const binding = new Reader(item, 'EnvironmentInitializeParams.portBindings'); + const entry = { portId: binding.id('portId'), agentId: binding.id('agentId') }; + binding.finish(); + return entry; + }), + }; + reader.finish(); + requireUnique( + params.portBindings.map((binding) => binding.portId), + 'EnvironmentInitializeParams.portBindings portId', + ); + requireUnique( + params.portBindings.map((binding) => binding.agentId), + 'EnvironmentInitializeParams.portBindings agentId', + ); + return params; +} + +export function readEnvironmentInitializeResult(value: unknown): EnvironmentInitializeResult { + const reader = new Reader(value, 'EnvironmentInitializeResult'); + const result: EnvironmentInitializeResult = { + descriptor: readEnvironmentDescriptor(reader.value('descriptor')), + observation: readWorldObservation(reader.value('observation')), + }; + reader.finish(); + if (u64(result.observation.boundary) !== 0n) { + fail('EnvironmentInitializeResult: the initial observation is boundary 0'); + } + if (!isRationalZero(result.observation.worldTime)) { + fail('EnvironmentInitializeResult: initial worldTime is zero (0/1)'); + } + validateObservationAgainst(result.observation, result.descriptor); + return result; +} + +export function readAdvanceParams(value: unknown): AdvanceParams { + const reader = new Reader(value, 'AdvanceParams'); + const params: AdvanceParams = { + batchId: reader.id('batchId'), + controls: reader.list('controls', 1, MAX_PORTS, readPortControl), + }; + reader.finish(); + requireUnique( + params.controls.map((control) => control.portId), + 'AdvanceParams.controls', + ); + return params; +} + +export function readStepResult(value: unknown): StepResult { + const reader = new Reader(value, 'StepResult'); + const result: StepResult = { + batchId: reader.id('batchId'), + appliedFromStep: reader.u64('appliedFromStep'), + nextStep: reader.u64('nextStep'), + appliedControlsDigest: reader.digest('appliedControlsDigest'), + observation: readWorldObservation(reader.value('observation')), + }; + reader.finish(); + if (u64(result.nextStep) !== u64(result.appliedFromStep) + 1n) { + fail('StepResult: nextStep must be appliedFromStep + 1; one result is one step'); + } + if (u64(result.observation.boundary) !== u64(result.nextStep)) { + fail('StepResult: the observation boundary must be nextStep'); + } + return result; +} + +/** Exactly boundary `k+1`, with world time advanced by exactly one `stepDuration`. */ +export function validateStepResultAgainst( + result: StepResult, + descriptor: EnvironmentDescriptor, + previous: WorldObservation, +): void { + validateObservationAgainst(result.observation, descriptor); + const expected = addRational(previous.worldTime, descriptor.stepDuration); + if (compareRational(result.observation.worldTime, expected) !== 0) { + fail('StepResult: worldTime must advance by exactly one stepDuration'); + } + if (u64(result.observation.boundary) !== u64(previous.boundary) + 1n) { + fail('StepResult: the observation must be exactly the next boundary'); + } +} + +// --------------------------------------------------------------------- common worker methods + +export interface HelloParams { + sessionId: Id; + expectedWorkerId: Id; + role: Role; + supportedMajors: number[]; +} + +export interface HelloResult { + selectedMajor: 1; + selectedMinor: 0; + workerId: Id; + incarnationId: Id; + role: Role; + buildDigest: Digest; + contractDigest: Digest; + capabilities: Id[]; + limits: { maxAgents: number; maxPorts: number }; +} + +export interface StatusResult { + state: WorkerState; + currentScope: Scope | null; + activeRequestId: DomainRequestId | null; + lastCompletedRequestId: DomainRequestId | null; + lastBatchId: Id | null; + progressCounter: U64; +} + +export interface AcknowledgeParams { + requestIds: DomainRequestId[]; +} + +export interface AcknowledgeResult { + acknowledged: DomainRequestId[]; +} + +export interface ShutdownParams { + reason: Id; +} + +export interface ShutdownResult { + stopping: true; +} + +export interface TaskEvent { + id: Id; + kindId: Id; + sourceStep: U64; + agentId: Id | null; + payload: TypedValue; +} + +export interface EpisodeRequest { + kind: 'terminal'; + reason: Id; + outcome: TypedValue; +} + +/** Required capabilities are agent-step-v1 and world-step-v1 for their roles. */ +export function requiredCapability(role: Role): string | null { + if (role === 'agent') return 'agent-step-v1'; + if (role === 'environment') return 'world-step-v1'; + return null; +} + +export function readHelloParams(value: unknown): HelloParams { + const reader = new Reader(value, 'HelloParams'); + const params: HelloParams = { + sessionId: reader.id('sessionId'), + expectedWorkerId: reader.id('expectedWorkerId'), + role: reader.enumeration('role', ROLES), + supportedMajors: reader.list('supportedMajors', 1, MAX_SUPPORTED_MAJORS, (item) => { + if (typeof item !== 'number' || !Number.isInteger(item) || item < 1 || item > 65_535) { + fail('every supported major must be an integer 1..=65535'); + } + return item; + }), + }; + reader.finish(); + requireUnique( + params.supportedMajors.map(String), + 'HelloParams.supportedMajors', + ); + return params; +} + +export function readHelloResult(value: unknown): HelloResult { + const reader = new Reader(value, 'HelloResult'); + const selectedMajor = reader.int('selectedMajor', 1, 1) as 1; + const selectedMinor = reader.int('selectedMinor', 0, 0) as 0; + const workerId = reader.id('workerId'); + const incarnationId = reader.id('incarnationId'); + const role = reader.enumeration('role', ROLES); + const buildDigest = reader.digest('buildDigest'); + const contractDigest = reader.digest('contractDigest'); + const capabilities = reader.idList('capabilities', 0, MAX_CAPABILITIES); + const limitsReader = new Reader(reader.value('limits'), 'HelloResult.limits'); + const limits = { + maxAgents: limitsReader.int('maxAgents', 1, MAX_AGENTS), + maxPorts: limitsReader.int('maxPorts', 1, MAX_PORTS), + }; + limitsReader.finish(); + reader.finish(); + requireUnique(capabilities, 'HelloResult.capabilities'); + const required = requiredCapability(role); + if (required !== null && !capabilities.includes(required)) { + fail(`HelloResult: a ${role} worker must advertise ${required}`); + } + return { + selectedMajor, + selectedMinor, + workerId, + incarnationId, + role, + buildDigest, + contractDigest, + capabilities, + limits, + }; +} + +export function readStatusResult(value: unknown): StatusResult { + const reader = new Reader(value, 'StatusResult'); + const state = reader.enumeration('state', WORKER_STATES); + const currentScopeValue = reader.value('currentScope'); + const currentScope = currentScopeValue === null ? null : readScope(currentScopeValue); + const active = reader.value('activeRequestId'); + const completed = reader.value('lastCompletedRequestId'); + const result: StatusResult = { + state, + currentScope, + activeRequestId: active === null ? null : domainRequestId(active), + lastCompletedRequestId: completed === null ? null : domainRequestId(completed), + lastBatchId: reader.nullableId('lastBatchId'), + progressCounter: reader.u64('progressCounter'), + }; + reader.finish(); + if (result.state === 'uninitialized' && result.currentScope !== null) { + fail('StatusResult: an uninitialized worker has a null currentScope'); + } + return result; +} + +export function readAcknowledgeParams(value: unknown): AcknowledgeParams { + const reader = new Reader(value, 'AcknowledgeParams'); + const params: AcknowledgeParams = { + requestIds: reader.list('requestIds', 1, MAX_ACKNOWLEDGE, domainRequestId), + }; + reader.finish(); + requireUnique(params.requestIds, 'AcknowledgeParams.requestIds'); + return params; +} + +export function readAcknowledgeResult(value: unknown): AcknowledgeResult { + const reader = new Reader(value, 'AcknowledgeResult'); + const result: AcknowledgeResult = { + acknowledged: reader.list('acknowledged', 0, MAX_ACKNOWLEDGE, domainRequestId), + }; + reader.finish(); + requireUnique(result.acknowledged, 'AcknowledgeResult.acknowledged'); + return result; +} + +export function readShutdownParams(value: unknown): ShutdownParams { + const reader = new Reader(value, 'ShutdownParams'); + const params = { reason: reader.id('reason') }; + reader.finish(); + return params; +} + +export function readShutdownResult(value: unknown): ShutdownResult { + const reader = new Reader(value, 'ShutdownResult'); + const result: ShutdownResult = { stopping: reader.constantTrue('stopping') }; + reader.finish(); + return result; +} + +export function readTaskEvent(value: unknown): TaskEvent { + const reader = new Reader(value, 'TaskEvent'); + const event: TaskEvent = { + id: reader.id('id'), + kindId: reader.id('kindId'), + sourceStep: reader.u64('sourceStep'), + agentId: reader.nullableId('agentId'), + payload: readTypedValue(reader.value('payload')), + }; + reader.finish(); + return event; +} + +export function readEpisodeRequest(value: unknown): EpisodeRequest { + const reader = new Reader(value, 'EpisodeRequest'); + const request: EpisodeRequest = { + kind: reader.constant('kind', 'terminal'), + reason: reader.id('reason'), + outcome: readTypedValue(reader.value('outcome')), + }; + reader.finish(); + return request; +} + +export interface ActivateRestoreResult { + committedStep: U64; + checkpointId: Id; + observation: WorldObservation | null; +} + +export function readActivateRestoreResult(value: unknown): ActivateRestoreResult { + const reader = new Reader(value, 'ActivateRestoreResult'); + const observationValue = reader.value('observation'); + const result: ActivateRestoreResult = { + committedStep: reader.u64('committedStep'), + checkpointId: reader.id('checkpointId'), + observation: observationValue === null ? null : readWorldObservation(observationValue), + }; + reader.finish(); + if ( + result.observation !== null && + u64(result.observation.boundary) !== u64(result.committedStep) + ) { + fail('ActivateRestoreResult: the observation boundary must be the committed step'); + } + return result; +} + +/** An environment returns its restored observation; an agent returns null. */ +export function validateActivateRestoreForRole(result: ActivateRestoreResult, role: Role): void { + if (role === 'environment' && result.observation === null) { + fail('ActivateRestoreResult: an environment must return its restored observation'); + } + if (role === 'agent' && result.observation !== null) { + fail('ActivateRestoreResult: an agent returns a null observation'); + } +} diff --git a/packages/session-types/tests/canonical.test.ts b/packages/session-types/tests/canonical.test.ts new file mode 100644 index 0000000..41435bf --- /dev/null +++ b/packages/session-types/tests/canonical.test.ts @@ -0,0 +1,150 @@ +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 = { 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; + 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; + 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`, + ); + } +}); diff --git a/packages/session-types/tests/checkpoint.test.ts b/packages/session-types/tests/checkpoint.test.ts new file mode 100644 index 0000000..57d38cb --- /dev/null +++ b/packages/session-types/tests/checkpoint.test.ts @@ -0,0 +1,126 @@ +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[]; + 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).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; + 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}`); + } +}); diff --git a/packages/session-types/tests/descriptor-checks.test.ts b/packages/session-types/tests/descriptor-checks.test.ts new file mode 100644 index 0000000..6d5d93b --- /dev/null +++ b/packages/session-types/tests/descriptor-checks.test.ts @@ -0,0 +1,85 @@ +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); +}); diff --git a/packages/session-types/tests/encodings.test.ts b/packages/session-types/tests/encodings.test.ts new file mode 100644 index 0000000..7ad9d77 --- /dev/null +++ b/packages/session-types/tests/encodings.test.ts @@ -0,0 +1,70 @@ +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; + 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; + 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', + ); +}); diff --git a/packages/session-types/tests/payloads.test.ts b/packages/session-types/tests/payloads.test.ts new file mode 100644 index 0000000..f3f9158 --- /dev/null +++ b/packages/session-types/tests/payloads.test.ts @@ -0,0 +1,136 @@ +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; + 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) } }), + ); +}); diff --git a/packages/session-types/tests/rational.test.ts b/packages/session-types/tests/rational.test.ts new file mode 100644 index 0000000..b1cf461 --- /dev/null +++ b/packages/session-types/tests/rational.test.ts @@ -0,0 +1,98 @@ +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; + 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; + 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; + 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); +}); diff --git a/packages/session-types/tests/readers.ts b/packages/session-types/tests/readers.ts new file mode 100644 index 0000000..e3dba75 --- /dev/null +++ b/packages/session-types/tests/readers.ts @@ -0,0 +1,122 @@ +/** 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 { 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 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, +}; + +/** 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); +} diff --git a/packages/session-types/tests/schema.test.ts b/packages/session-types/tests/schema.test.ts new file mode 100644 index 0000000..4579056 --- /dev/null +++ b/packages/session-types/tests/schema.test.ts @@ -0,0 +1,100 @@ +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; + 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; + const recorded = fixtures.load('contract-digest.json') as Record; + 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[]; + const renamed = structuredClone(set); + ((renamed.types as Record[])[0]!.fields as Record[])[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[]) { + 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; + const limits = new Map( + (set.limits as Record[]).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); +}); + +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; + const enums = new Map( + (set.enums as Record[]).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]); +}); diff --git a/packages/session-types/tests/seeds.test.ts b/packages/session-types/tests/seeds.test.ts new file mode 100644 index 0000000..9ed59f9 --- /dev/null +++ b/packages/session-types/tests/seeds.test.ts @@ -0,0 +1,61 @@ +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).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).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)); + } + } +}); diff --git a/packages/session-types/tests/traces.test.ts b/packages/session-types/tests/traces.test.ts new file mode 100644 index 0000000..9ca16a4 --- /dev/null +++ b/packages/session-types/tests/traces.test.ts @@ -0,0 +1,80 @@ +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).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)); +}); diff --git a/packages/session-types/tsconfig.json b/packages/session-types/tsconfig.json new file mode 100644 index 0000000..8b46885 --- /dev/null +++ b/packages/session-types/tsconfig.json @@ -0,0 +1,19 @@ +{ + "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"] +} diff --git a/services/flysim/Cargo.lock b/services/flysim/Cargo.lock index b2a05e3..bb2bbe5 100644 --- a/services/flysim/Cargo.lock +++ b/services/flysim/Cargo.lock @@ -416,6 +416,27 @@ dependencies = [ "serde", ] +[[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]] name = "flybrain-core" version = "0.1.1" diff --git a/services/flysim/Cargo.toml b/services/flysim/Cargo.toml index 79b775e..dec6a38 100644 --- a/services/flysim/Cargo.toml +++ b/services/flysim/Cargo.toml @@ -1,6 +1,13 @@ [workspace] resolver = "3" -members = ["crates/flybrain-core", "crates/flybrain-gb", "crates/flybus", "crates/flysim"] +members = [ + "crates/fly-session", + "crates/fly-session-types", + "crates/flybrain-core", + "crates/flybrain-gb", + "crates/flybus", + "crates/flysim", +] [workspace.package] version = "0.1.1" diff --git a/services/flysim/crates/fly-session-types/Cargo.toml b/services/flysim/crates/fly-session-types/Cargo.toml new file mode 100644 index 0000000..bcb29c8 --- /dev/null +++ b/services/flysim/crates/fly-session-types/Cargo.toml @@ -0,0 +1,16 @@ +[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 } diff --git a/services/flysim/crates/fly-session-types/README.md b/services/flysim/crates/fly-session-types/README.md new file mode 100644 index 0000000..72ff894 --- /dev/null +++ b/services/flysim/crates/fly-session-types/README.md @@ -0,0 +1,103 @@ +# 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 | +| `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 | + +The derived files (`schema-set.json`, `contract-digest.json`, the `canonical`/`digest` fields +of `valid.json`, the digests in `operations.json`, `seed-vectors.json` and +`checkpoint-envelope.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. Six are marked `crate` because no document +states them: `maxAudioStreams` (8), `maxCapabilities` (32), `maxSupportedMajors` (8), +`maxSupportedStimuli` (64), `maxAssets` (64) and `maxSnapshotEvents` (64). 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. diff --git a/services/flysim/crates/fly-session-types/examples/update_fixtures.rs b/services/flysim/crates/fly-session-types/examples/update_fixtures.rs new file mode 100644 index 0000000..238287e --- /dev/null +++ b/services/flysim/crates/fly-session-types/examples/update_fixtures.rs @@ -0,0 +1,290 @@ +//! 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::scalar::{DomainType, Scope}; +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()), + ] +} + +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 { + 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(), ¶ms).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 = seed::composition_seeds( + 42, + &agents.iter().map(|a| (*a).to_owned()).collect::>(), + ) + .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": "\\n\\n\\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": {"lastEventId": "evt-1", "lastOrdinal": "7"}, + }, + "helperState": [], + "payloads": payload_table(), + }); + let bytes = checkpoint::encode(&manifest, &payloads()).expect("encode"); + let envelope = checkpoint::decode(&bytes).expect("decode"); + let entries: Vec = 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::>(), + "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)> { + 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) +} diff --git a/services/flysim/crates/fly-session-types/fixtures/boundaries.json b/services/flysim/crates/fly-session-types/fixtures/boundaries.json new file mode 100644 index 0000000..b79d012 --- /dev/null +++ b/services/flysim/crates/fly-session-types/fixtures/boundaries.json @@ -0,0 +1,177 @@ +{ + "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" + } + ] +} \ No newline at end of file diff --git a/services/flysim/crates/fly-session-types/fixtures/checkpoint-envelope.json b/services/flysim/crates/fly-session-types/fixtures/checkpoint-envelope.json new file mode 100644 index 0000000..6636bae --- /dev/null +++ b/services/flysim/crates/fly-session-types/fixtures/checkpoint-envelope.json @@ -0,0 +1,195 @@ +{ + "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": { + "lastEventId": "evt-1", + "lastOrdinal": "7" + } + }, + "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": "RkxZU0VTUzEBAAAAIAAAAAAIAAAFAAAAIAgAAAAAAAB7ImFnZW50cyI6W3siYWdlbnRJZCI6ImZseS1hIiwiYnJhaW5UaWNrcyI6IjI1MzQiLCJkYXRhc2V0RGlnZXN0IjoiNmMwYWYxZjA3ODRlZjYzYTM5M2VlNzdkNjE0ZTgyNDZjNjI1MDUxMzYwZjNmMWE0ODgzODM3NGM1ZDM1NWI1MiIsIm1vZGVsVmVyc2lvbiI6ImxpZi0xbXMtZjY0LXYyIiwicGF5bG9hZCI6ImFnZW50LWZseS1hIiwicGxhc3RpY2l0eVZlcnNpb24iOiJmbHkta2MtbWJvbi1yc3RkcC12MiIsInByb2ZpbGVEaWdlc3QiOiIxOTAwZWFiNmMwMjg0ODNkNzEyNjU5OWVlNmY1MGRlMGQyNzkwN2I1YzY1ZmE5MDUyNDU4MGI0YjBmOTg1MmIwIiwicmVtYWluZGVyIjp7ImRlbm9taW5hdG9yIjoiMyIsIm51bWVyYXRvciI6IjEwMDAwMDAifSwic2VlZCI6LTE4NDk0NjA2M31dLCJjaGVja3BvaW50SWQiOiJja3B0LTEiLCJjb21wYXRpYmlsaXR5Ijp7ImJhY2tlbmREaWdlc3QiOiIxMGUwOGE0MTllODUwZWJhMWViYmExOGZkZDI4ZWI3ZWMxYjdlOGJhYTliY2MzYjk3M2UyYjg4OTFlYzcyNmJlIiwiY29udGVudERpZ2VzdCI6ImVkNzAwMmI0MzllOWFjODQ1ZjIyMzU3ZDgyMmJhYzE0NDQ3MzBmYmRiNjAxNmQzZWM5NDMyMjk3YjllYzlmNzMiLCJjb250cm9sbGVyRGlnZXN0IjoiYzE0NzIxMzViMTRjNzdjOGJlZjk4ZTczZjcwMjA4MzI1ZmEwZGNmMWU2YmQ2NjhhZTliMzFhOWNlYTI5NWZlNyIsInBhcnNlckRpZ2VzdCI6ImIxN2Q0NTEyMTE1MDkyOGYyMTQ2YWY0OWUxOTVlZmYxZWVmNWQ2NzMyNWJlMjczYTczM2ZiNzRhY2FkYWEzNDIiLCJwYXRjaERpZ2VzdCI6ImE0ODk1ZWI0NGFmYzMzNmZlY2JiYTZlNTIwY2Q2N2UxNzhkYWNlMDI3NjY1NWQxMDJmY2VmZmE4ZTVmNzA1NzAiLCJzdGF0ZUZvcm1hdElkIjoiZmx5c2Vzcy0xIn0sImNvbXBvc2l0aW9uRGlnZXN0IjoiNzMwZDcyNWM4YTU5ZDNhNzMwM2RlZjJiZWQwNDFhNTc3ZWRiNDI1NWFhYmQ0ODg5Y2UxMjkxODMxMWQ5NTJmMCIsImNvb3JkaW5hdG9yIjp7ImFkbWlzc2lvblN0YXRlIjpudWxsLCJldmVudFdhdGVybWFya3MiOnsibGFzdEV2ZW50SWQiOiJldnQtMSIsImxhc3RPcmRpbmFsIjoiNyJ9LCJleGVjdXRvclN0YXRlIjpbeyJhZ2VudElkIjoiZmx5LWEiLCJwYXlsb2FkIjoiZXhlY3V0b3ItZmx5LWEifV0sInByaW9ySW5zcGVjdGlvbiI6InByaW9yLWluc3BlY3Rpb24iLCJ0YXNrTGVkZ2VyIjoidGFzay1sZWRnZXIifSwiZW52ZWxvcGVWZXJzaW9uIjoxLCJlcGlzb2RlSWQiOiJlcGlzb2RlLTEiLCJoZWxwZXJTdGF0ZSI6W10sInBheWxvYWRzIjpbeyJieXRlTGVuZ3RoIjoiMTciLCJkaWdlc3QiOiIxMzIxZGZmYjBjZGM2ZjkwOTJjYmY3ZmEyYTVmYzY4YmJlZDEyYzk5M2Q1YWQzOTgyNjQwMTI4MTBjZTliZjkzIiwibmFtZSI6ImFnZW50LWZseS1hIn0seyJieXRlTGVuZ3RoIjoiMTQiLCJkaWdlc3QiOiIzYWVlNjBkZjdlMjllZmViYTdmNWY5OWZjNTg2NzY0N2IzNmFlYmZmMWQ1ZDNjODM4ZGJmZjMyMzEyMmU2NDYyIiwibmFtZSI6ImV4ZWN1dG9yLWZseS1hIn0seyJieXRlTGVuZ3RoIjoiMTEiLCJkaWdlc3QiOiI0MGIwMGVkMmJiYmE5MDFkNjgyMDVmZjcxYjA0YTQ0YjllZTUzYzUxY2IzMTA5YWEyY2VhYTQ0ZjFjNDU3MjdlIiwibmFtZSI6InRhc2stbGVkZ2VyIn0seyJieXRlTGVuZ3RoIjoiMTAiLCJkaWdlc3QiOiIyYzEzYjdiNGQ5YTk5MTY4MDFhYjkxOTFjMzE0ZjMxYjA0NWU5YjljNWI2NjlhNmMwNDc0ZjAyMTdlZjc1YmY1IiwibmFtZSI6InByaW9yLWluc3BlY3Rpb24ifSx7ImJ5dGVMZW5ndGgiOiI2NCIsImRpZ2VzdCI6ImY1YTVmZDQyZDE2YTIwMzAyNzk4ZWY2ZWQzMDk5NzliNDMwMDNkMjMyMGQ5ZjBlOGVhOTgzMWE5Mjc1OWZiNGIiLCJuYW1lIjoid29ybGQifV0sInBvcnRNYXAiOlt7ImFnZW50SWQiOiJmbHktYSIsInBvcnRJZCI6InBvcnQtMSJ9XSwic2NoZWR1bGVySWQiOiJsb2Nrc3RlcC12MSIsInNvdXJjZVNjb3BlIjp7ImVwb2NoIjoiZXBvY2gtMSIsInNlc3Npb25JZCI6ImRlbW8iLCJzdGVwIjoiNDIifSwid29ybGRUaW1lIjp7ImRlbm9taW5hdG9yIjoiMSIsIm51bWVyYXRvciI6IjcwMDAwMDAwMCJ9fWFnZW50LWZseS1hAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAABQCgAAAAAAABEAAAAAAAAAEyHf+wzcb5CSy/f6Kl/Gi77RLJk9WtOYJkASgQzpv5NleGVjdXRvci1mbHktYQAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAaAoAAAAAAAAOAAAAAAAAADruYN9+Ke/rp/X5n8WGdkezauv/HV08g42/8yMSLmRidGFzay1sZWRnZXIAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAHgKAAAAAAAACwAAAAAAAABAsA7Su7qQHWggX/cbBKRLnuU8UcsxCaos6qRPHEVyfnByaW9yLWluc3BlY3Rpb24AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAACICgAAAAAAAAoAAAAAAAAALBO3tNmpkWgBq5GRwxTzGwRem5xbZppsBHTwIX73W/V3b3JsZAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAmAoAAAAAAABAAAAAAAAAAPWl/ULRaiAwJ5jvbtMJl5tDAD0jINnw6OqYMaknWftLYWdlbnQgc3RhdGUgYnl0ZXMAAAAAAAAAZXhlY3V0b3Igc3RhdGUAAHsicmFuayI6MTB9AAAAAAB7Im1hcCI6NDB9AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAgLAAAAAAAAq++fEx+FvDZho/eB4imbENN4HZrGNC2OCAsI7/gp9r5GTFlTRVNTRg==", + "byteLength": 2824, + "layout": { + "headerBytes": 32, + "manifestOffset": "32", + "manifestBytes": 2048, + "tableOffset": "2080", + "tableEntryBytes": 112, + "entries": [ + { + "name": "agent-fly-a", + "offset": "2640", + "byteLength": "17", + "digest": "1321dffb0cdc6f9092cbf7fa2a5fc68bbed12c993d5ad398264012810ce9bf93" + }, + { + "name": "executor-fly-a", + "offset": "2664", + "byteLength": "14", + "digest": "3aee60df7e29efeba7f5f99fc5867647b36aebff1d5d3c838dbff323122e6462" + }, + { + "name": "task-ledger", + "offset": "2680", + "byteLength": "11", + "digest": "40b00ed2bbba901d68205ff71b04a44b9ee53c51cb3109aa2ceaa44f1c45727e" + }, + { + "name": "prior-inspection", + "offset": "2696", + "byteLength": "10", + "digest": "2c13b7b4d9a9916801ab9191c314f31b045e9b9c5b669a6c0474f0217ef75bf5" + }, + { + "name": "world", + "offset": "2712", + "byteLength": "64", + "digest": "f5a5fd42d16a20302798ef6ed309979b43003d2320d9f0e8ea9831a92759fb4b" + } + ], + "footerOffset": "2776", + "footerBytes": 48, + "totalBytes": "2824" + } + }, + "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": 2640, + "reason": "every payload carries its own digest" + }, + { + "name": "a flipped footer digest byte", + "offset": 2784, + "reason": "the footer digest must match the contents" + }, + { + "name": "a flipped footer magic byte", + "offset": 2816, + "reason": "a truncated file cannot look complete" + } + ] +} diff --git a/services/flysim/crates/fly-session-types/fixtures/contract-digest.json b/services/flysim/crates/fly-session-types/fixtures/contract-digest.json new file mode 100644 index 0000000..bae6c95 --- /dev/null +++ b/services/flysim/crates/fly-session-types/fixtures/contract-digest.json @@ -0,0 +1,9 @@ +{ + "description": "contractDigest is the SHA-256 of the canonical schema set in schema-set.json.", + "contractDigest": "7932aef30c4d2d16e428081affc4e0ad187987f5b361138d553e54fd7f843b50", + "schemaSetVersion": 1, + "schemaSetBytes": 26685, + "types": 53, + "enums": 11, + "limits": 25 +} diff --git a/services/flysim/crates/fly-session-types/fixtures/descriptor-checks.json b/services/flysim/crates/fly-session-types/fixtures/descriptor-checks.json new file mode 100644 index 0000000..c536619 --- /dev/null +++ b/services/flysim/crates/fly-session-types/fixtures/descriptor-checks.json @@ -0,0 +1,2085 @@ +{ + "description": "Rules that need a descriptor in hand: complete port batches, the observation delay rule, byte shapes and descriptor agreement.", + "descriptor": { + "backendDigest": "10e08a419e850eba1ebba18fdd28eb7ec1b7e8baa9bcc3b973e2b8891ec726be", + "contentDigest": "ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73", + "configurationDigest": "b7d64a9221007dfd5390f7df6cd5b8f3ea4f82faa1237141e35ebf161f5511a1", + "stepDuration": { + "numerator": "50000000", + "denominator": "3" + }, + "ports": [ + { + "portId": "port-1", + "controls": { + "schema": { + "id": "gameboy.controller.v1", + "version": 1, + "digest": "b1df9d20fe1deaeb52f17cf261bf728b28143c777878b96cacfc4826903a48d5" + }, + "buttons": [ + "a", + "b", + "start", + "select", + "up", + "down", + "left", + "right" + ], + "axes": [ + { + "id": "stick-x", + "range": "bipolar", + "neutral": 0.0 + }, + { + "id": "trigger", + "range": "unit", + "neutral": 0.0 + } + ] + } + } + ], + "inspectionSchema": { + "id": "gameboy.inspection.v1", + "version": 1, + "digest": "1883cee6532d0bfbb28854b82db8450d525c2e4fcec01567b4aa6d99917eafb1" + }, + "views": [ + { + "viewId": "screen", + "width": 160, + "height": 144, + "format": "rgba8", + "rowStride": 640, + "pixelAspect": { + "numerator": 10, + "denominator": 11 + }, + "observationDelaySteps": 0 + } + ], + "audio": [ + { + "streamId": "mix", + "sampleRate": 48000, + "channels": 2, + "format": "f32le-interleaved" + } + ], + "recovery": "exact-checkpoint", + "determinism": "fixed-build" + }, + "delayedDescriptor": { + "backendDigest": "10e08a419e850eba1ebba18fdd28eb7ec1b7e8baa9bcc3b973e2b8891ec726be", + "contentDigest": "ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73", + "configurationDigest": "b7d64a9221007dfd5390f7df6cd5b8f3ea4f82faa1237141e35ebf161f5511a1", + "stepDuration": { + "numerator": "50000000", + "denominator": "3" + }, + "ports": [ + { + "portId": "port-1", + "controls": { + "schema": { + "id": "gameboy.controller.v1", + "version": 1, + "digest": "b1df9d20fe1deaeb52f17cf261bf728b28143c777878b96cacfc4826903a48d5" + }, + "buttons": [ + "a", + "b", + "start", + "select", + "up", + "down", + "left", + "right" + ], + "axes": [ + { + "id": "stick-x", + "range": "bipolar", + "neutral": 0.0 + }, + { + "id": "trigger", + "range": "unit", + "neutral": 0.0 + } + ] + } + } + ], + "inspectionSchema": { + "id": "gameboy.inspection.v1", + "version": 1, + "digest": "1883cee6532d0bfbb28854b82db8450d525c2e4fcec01567b4aa6d99917eafb1" + }, + "views": [ + { + "viewId": "screen", + "width": 160, + "height": 144, + "format": "rgba8", + "rowStride": 640, + "pixelAspect": { + "numerator": 10, + "denominator": 11 + }, + "observationDelaySteps": 2 + } + ], + "audio": [ + { + "streamId": "mix", + "sampleRate": 48000, + "channels": 2, + "format": "f32le-interleaved" + } + ], + "recovery": "exact-checkpoint", + "determinism": "fixed-build" + }, + "sessionDescriptor": { + "sessionId": "demo", + "revision": "7", + "compositionDigest": "730d725c8a59d3a7303def2bed041a577edb4255aabd4889ce12918311d952f0", + "schedulerId": "lockstep-v1", + "environment": { + "backendDigest": "10e08a419e850eba1ebba18fdd28eb7ec1b7e8baa9bcc3b973e2b8891ec726be", + "contentDigest": "ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73", + "configurationDigest": "b7d64a9221007dfd5390f7df6cd5b8f3ea4f82faa1237141e35ebf161f5511a1", + "stepDuration": { + "numerator": "50000000", + "denominator": "3" + }, + "ports": [ + { + "portId": "port-1", + "controls": { + "schema": { + "id": "gameboy.controller.v1", + "version": 1, + "digest": "b1df9d20fe1deaeb52f17cf261bf728b28143c777878b96cacfc4826903a48d5" + }, + "buttons": [ + "a", + "b", + "start", + "select", + "up", + "down", + "left", + "right" + ], + "axes": [ + { + "id": "stick-x", + "range": "bipolar", + "neutral": 0.0 + }, + { + "id": "trigger", + "range": "unit", + "neutral": 0.0 + } + ] + } + } + ], + "inspectionSchema": { + "id": "gameboy.inspection.v1", + "version": 1, + "digest": "1883cee6532d0bfbb28854b82db8450d525c2e4fcec01567b4aa6d99917eafb1" + }, + "views": [ + { + "viewId": "screen", + "width": 160, + "height": 144, + "format": "rgba8", + "rowStride": 640, + "pixelAspect": { + "numerator": 10, + "denominator": 11 + }, + "observationDelaySteps": 0 + } + ], + "audio": [ + { + "streamId": "mix", + "sampleRate": 48000, + "channels": 2, + "format": "f32le-interleaved" + } + ], + "recovery": "exact-checkpoint", + "determinism": "fixed-build" + }, + "taskSchema": { + "id": "pokemon.task.v1", + "version": 1, + "digest": "e4ca46e969a0f0e53a4f537561360851269e0b3f684eccaa634fe9672fdaabb4" + }, + "agents": [ + { + "agentId": "fly-a", + "portId": "port-1", + "profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0", + "datasetDigest": "6c0af1f0784ef63a393ee77d614e8246c625051360f3f1a48838374c5d355b52", + "indexDigest": "1bc04b5291c26a46d918139138b992d2de976d6851d0893b0476b85bfbdfc6e6", + "neuronCount": "139255", + "rateRoles": [ + "kenyon", + "mbon" + ], + "supportedStimuli": [ + "sugar" + ] + } + ], + "assets": [] + }, + "cases": [ + { + "name": "a complete port control", + "kind": "portControl", + "value": { + "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 + } + ] + }, + "expect": "accept", + "reason": "" + }, + { + "name": "a port control at both analog limits", + "kind": "portControl", + "value": { + "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": 1.0 + } + ] + }, + "expect": "accept", + "reason": "the limits are inclusive" + }, + { + "name": "a port control one step past a bipolar limit", + "kind": "portControl", + "value": { + "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.0000000000000002 + }, + { + "id": "trigger", + "value": 0.0 + } + ] + }, + "expect": "reject", + "reason": "out of range is refused, never clamped" + }, + { + "name": "a port control past a unit limit", + "kind": "portControl", + "value": { + "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": 1.5 + } + ] + }, + "expect": "reject", + "reason": "out of range is refused, never clamped" + }, + { + "name": "a port control with a missing button", + "kind": "portControl", + "value": { + "portId": "port-1", + "buttons": [ + { + "id": "a", + "down": false + }, + { + "id": "b", + "down": false + }, + { + "id": "start", + "down": false + }, + { + "id": "select", + "down": false + }, + { + "id": "up", + "down": false + }, + { + "id": "down", + "down": false + }, + { + "id": "left", + "down": false + } + ], + "axes": [ + { + "id": "stick-x", + "value": 0.0 + }, + { + "id": "trigger", + "value": 0.0 + } + ] + }, + "expect": "reject", + "reason": "every declared button must be present" + }, + { + "name": "a port control with an extra button", + "kind": "portControl", + "value": { + "portId": "port-1", + "buttons": [ + { + "id": "a", + "down": false + }, + { + "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 + }, + { + "id": "turbo", + "down": false + } + ], + "axes": [ + { + "id": "stick-x", + "value": 0.0 + }, + { + "id": "trigger", + "value": 0.0 + } + ] + }, + "expect": "reject", + "reason": "no extra controls" + }, + { + "name": "a port control with the buttons in another order", + "kind": "portControl", + "value": { + "portId": "port-1", + "buttons": [ + { + "id": "right", + "down": false + }, + { + "id": "left", + "down": false + }, + { + "id": "down", + "down": false + }, + { + "id": "up", + "down": false + }, + { + "id": "select", + "down": false + }, + { + "id": "start", + "down": false + }, + { + "id": "b", + "down": false + }, + { + "id": "a", + "down": false + } + ], + "axes": [ + { + "id": "stick-x", + "value": 0.0 + }, + { + "id": "trigger", + "value": 0.0 + } + ] + }, + "expect": "reject", + "reason": "descriptor order is part of the contract" + }, + { + "name": "a complete batch", + "kind": "advanceControls", + "value": [ + { + "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 + } + ] + } + ], + "expect": "accept", + "reason": "" + }, + { + "name": "a batch that omits a declared port", + "kind": "advanceControls", + "value": [], + "expect": "reject", + "reason": "uncontrolled ports are configured neutral, not omitted" + }, + { + "name": "a batch with the same port twice", + "kind": "advanceControls", + "value": [ + { + "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 + } + ] + }, + { + "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 + } + ] + } + ], + "expect": "reject", + "reason": "reject duplicates and missing ports" + }, + { + "name": "a batch naming an undeclared port", + "kind": "advanceControls", + "value": [ + { + "portId": "port-9", + "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 + } + ] + } + ], + "expect": "reject", + "reason": "all ids must match exactly" + }, + { + "name": "sensory input at its boundary with no declared delay", + "kind": "sensoryInput", + "value": { + "boundary": "42", + "views": [ + { + "viewId": "screen", + "producedStep": "42", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-1", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + } + ], + "structured": null + }, + "expect": "accept", + "reason": "" + }, + { + "name": "sensory input one boundary stale with no declared delay", + "kind": "sensoryInput", + "value": { + "boundary": "42", + "views": [ + { + "viewId": "screen", + "producedStep": "41", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-1", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + } + ], + "structured": null + }, + "expect": "reject", + "reason": "missing or extra delay is a step failure, not an arbitrary latest frame" + }, + { + "name": "sensory input for an undeclared view", + "kind": "sensoryInput", + "value": { + "boundary": "42", + "views": [ + { + "viewId": "hud", + "producedStep": "42", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-1", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + } + ], + "structured": null + }, + "expect": "reject", + "reason": "the environment declares its views" + }, + { + "name": "sensory input with the wrong frame length", + "kind": "sensoryInput", + "value": { + "boundary": "42", + "views": [ + { + "viewId": "screen", + "producedStep": "42", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-short", + "generation": "1", + "byteLength": "92156", + "contentType": "image/x-rgba8", + "digest": null + } + } + ], + "structured": null + }, + "expect": "reject", + "reason": "artifact length equals rowStride x height" + }, + { + "name": "sensory input honouring a two step pipeline delay", + "kind": "sensoryInputDelayed", + "value": { + "boundary": "42", + "views": [ + { + "viewId": "screen", + "producedStep": "40", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-1", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + } + ], + "structured": null + }, + "expect": "accept", + "reason": "producedStep is max(0, boundary - observationDelaySteps)" + }, + { + "name": "sensory input ignoring a two step pipeline delay", + "kind": "sensoryInputDelayed", + "value": { + "boundary": "42", + "views": [ + { + "viewId": "screen", + "producedStep": "42", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-1", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + } + ], + "structured": null + }, + "expect": "reject", + "reason": "a fresher frame than the declared pipeline is also a failure" + }, + { + "name": "bootstrap repeating boundary zero under a delay", + "kind": "sensoryInputDelayed", + "value": { + "boundary": "1", + "views": [ + { + "viewId": "screen", + "producedStep": "0", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-1", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + } + ], + "structured": null + }, + "expect": "accept", + "reason": "max(0, 1 - 2) is 0: bootstrap may repeat O[0]" + }, + { + "name": "a world observation", + "kind": "worldObservation", + "value": { + "boundary": "42", + "worldTime": { + "numerator": "50000000", + "denominator": "3" + }, + "engineFrame": "1", + "sensoryViews": [ + { + "viewId": "screen", + "producedStep": "42", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-1", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + } + ], + "inspection": { + "schema": { + "id": "gameboy.inspection.v1", + "version": 1, + "digest": "1883cee6532d0bfbb28854b82db8450d525c2e4fcec01567b4aa6d99917eafb1" + }, + "value": { + "map": 40, + "x": 5, + "y": 7 + } + }, + "broadcastViews": [ + { + "viewId": "screen", + "producedStep": "42", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-1", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + } + ], + "audio": [ + { + "streamId": "mix", + "firstSample": "33600", + "sampleFrames": 800, + "samples": { + "storeId": "store-1", + "artifactId": "audio-1", + "generation": "1", + "byteLength": "6400", + "contentType": "audio/x-f32le", + "digest": null + }, + "discontinuity": false + } + ] + }, + "expect": "accept", + "reason": "" + }, + { + "name": "a world observation with a mismatched audio length", + "kind": "worldObservation", + "value": { + "boundary": "42", + "worldTime": { + "numerator": "50000000", + "denominator": "3" + }, + "engineFrame": "1", + "sensoryViews": [ + { + "viewId": "screen", + "producedStep": "42", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-1", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + } + ], + "inspection": { + "schema": { + "id": "gameboy.inspection.v1", + "version": 1, + "digest": "1883cee6532d0bfbb28854b82db8450d525c2e4fcec01567b4aa6d99917eafb1" + }, + "value": { + "map": 40, + "x": 5, + "y": 7 + } + }, + "broadcastViews": [ + { + "viewId": "screen", + "producedStep": "42", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-1", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + } + ], + "audio": [ + { + "streamId": "mix", + "firstSample": "0", + "sampleFrames": 800, + "samples": { + "storeId": "store-1", + "artifactId": "audio-short", + "generation": "1", + "byteLength": "6396", + "contentType": "audio/x-f32le", + "digest": null + }, + "discontinuity": false + } + ] + }, + "expect": "reject", + "reason": "artifact length is sampleFrames x channels x 4" + }, + { + "name": "a world observation with another inspection schema", + "kind": "worldObservation", + "value": { + "boundary": "42", + "worldTime": { + "numerator": "50000000", + "denominator": "3" + }, + "engineFrame": "1", + "sensoryViews": [ + { + "viewId": "screen", + "producedStep": "42", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-1", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + } + ], + "inspection": { + "schema": { + "id": "other.inspection.v1", + "version": 1, + "digest": "8d6ebfb7599aaa93eeeb14acb64a6842e700174be22a5c6a5a5994ee9a54229b" + }, + "value": {} + }, + "broadcastViews": [ + { + "viewId": "screen", + "producedStep": "42", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-1", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + } + ], + "audio": [ + { + "streamId": "mix", + "firstSample": "33600", + "sampleFrames": 800, + "samples": { + "storeId": "store-1", + "artifactId": "audio-1", + "generation": "1", + "byteLength": "6400", + "contentType": "audio/x-f32le", + "digest": null + }, + "discontinuity": false + } + ] + }, + "expect": "reject", + "reason": "inspection uses the descriptor's schema" + }, + { + "name": "a step result that advances world time by one step duration", + "kind": "stepResult", + "value": { + "batchId": "batch-41", + "appliedFromStep": "41", + "nextStep": "42", + "appliedControlsDigest": "1e2135d1b50f14d3d3d6cbe24a4294bcb9e4d5d3c28eb1ce4980ff2a17c5d3b6", + "observation": { + "boundary": "42", + "worldTime": { + "numerator": "100000000", + "denominator": "3" + }, + "engineFrame": "1", + "sensoryViews": [ + { + "viewId": "screen", + "producedStep": "42", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-1", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + } + ], + "inspection": { + "schema": { + "id": "gameboy.inspection.v1", + "version": 1, + "digest": "1883cee6532d0bfbb28854b82db8450d525c2e4fcec01567b4aa6d99917eafb1" + }, + "value": { + "map": 40, + "x": 5, + "y": 7 + } + }, + "broadcastViews": [ + { + "viewId": "screen", + "producedStep": "42", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-1", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + } + ], + "audio": [ + { + "streamId": "mix", + "firstSample": "33600", + "sampleFrames": 800, + "samples": { + "storeId": "store-1", + "artifactId": "audio-1", + "generation": "1", + "byteLength": "6400", + "contentType": "audio/x-f32le", + "digest": null + }, + "discontinuity": false + } + ] + } + }, + "expect": "accept", + "reason": "two steps of 50000000/3 ns" + }, + { + "name": "a step result that advances world time by two step durations", + "kind": "stepResult", + "value": { + "batchId": "batch-41", + "appliedFromStep": "41", + "nextStep": "42", + "appliedControlsDigest": "1e2135d1b50f14d3d3d6cbe24a4294bcb9e4d5d3c28eb1ce4980ff2a17c5d3b6", + "observation": { + "boundary": "42", + "worldTime": { + "numerator": "50000000", + "denominator": "1" + }, + "engineFrame": "1", + "sensoryViews": [ + { + "viewId": "screen", + "producedStep": "42", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-1", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + } + ], + "inspection": { + "schema": { + "id": "gameboy.inspection.v1", + "version": 1, + "digest": "1883cee6532d0bfbb28854b82db8450d525c2e4fcec01567b4aa6d99917eafb1" + }, + "value": { + "map": 40, + "x": 5, + "y": 7 + } + }, + "broadcastViews": [ + { + "viewId": "screen", + "producedStep": "42", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-1", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + } + ], + "audio": [ + { + "streamId": "mix", + "firstSample": "33600", + "sampleFrames": 800, + "samples": { + "storeId": "store-1", + "artifactId": "audio-1", + "generation": "1", + "byteLength": "6400", + "contentType": "audio/x-f32le", + "digest": null + }, + "discontinuity": false + } + ] + } + }, + "expect": "reject", + "reason": "an emulator cannot hide two framework steps behind one result" + }, + { + "name": "a snapshot that agrees with its descriptor", + "kind": "snapshot", + "value": { + "descriptorRevision": "7", + "publisherIncarnation": "pub-1", + "scope": { + "sessionId": "demo", + "epoch": "epoch-1", + "step": "42" + }, + "episodeId": "episode-1", + "sequence": "42", + "worldTime": { + "numerator": "700000000", + "denominator": "1" + }, + "agents": [ + { + "agentId": "fly-a", + "telemetry": { + "brainTicks": "2534", + "populationRateHz": 12.5, + "rates": [ + { + "roleId": "kenyon", + "hz": 3.25 + }, + { + "roleId": "mbon", + "hz": 0.0 + } + ], + "learning": { + "enabled": true, + "updates": "4", + "changed": "2", + "signal": 0.5 + } + }, + "selectedDecision": { + "schema": { + "id": "gameboy.intent.v1", + "version": 1, + "digest": "e70451b26fb5462ec63729d8355912dad01c3963a95bec0e6a8452375717ad9d" + }, + "value": { + "press": "a" + } + }, + "appliedControls": { + "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 + } + ] + } + } + ], + "progress": { + "schema": { + "id": "pokemon.progress.v1", + "version": 1, + "digest": "80fa973157f334bb3208dfff6f7a2f298e79f73f07c72c33f5725975c6cff8e1" + }, + "value": { + "rank": 10 + } + }, + "media": { + "views": [ + { + "viewId": "screen", + "producedStep": "42", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-1", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + } + ], + "audio": [] + }, + "eventIds": [] + }, + "expect": "accept", + "reason": "" + }, + { + "name": "a snapshot naming an agent outside the composition", + "kind": "snapshot", + "value": { + "descriptorRevision": "7", + "publisherIncarnation": "pub-1", + "scope": { + "sessionId": "demo", + "epoch": "epoch-1", + "step": "42" + }, + "episodeId": "episode-1", + "sequence": "42", + "worldTime": { + "numerator": "700000000", + "denominator": "1" + }, + "agents": [ + { + "agentId": "fly-z", + "telemetry": { + "brainTicks": "2534", + "populationRateHz": 12.5, + "rates": [ + { + "roleId": "kenyon", + "hz": 3.25 + }, + { + "roleId": "mbon", + "hz": 0.0 + } + ], + "learning": { + "enabled": true, + "updates": "4", + "changed": "2", + "signal": 0.5 + } + }, + "selectedDecision": { + "schema": { + "id": "gameboy.intent.v1", + "version": 1, + "digest": "e70451b26fb5462ec63729d8355912dad01c3963a95bec0e6a8452375717ad9d" + }, + "value": { + "press": "a" + } + }, + "appliedControls": { + "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 + } + ] + } + } + ], + "progress": { + "schema": { + "id": "pokemon.progress.v1", + "version": 1, + "digest": "80fa973157f334bb3208dfff6f7a2f298e79f73f07c72c33f5725975c6cff8e1" + }, + "value": { + "rank": 10 + } + }, + "media": { + "views": [ + { + "viewId": "screen", + "producedStep": "42", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-1", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + } + ], + "audio": [] + }, + "eventIds": [] + }, + "expect": "reject", + "reason": "descriptor and snapshot must agree" + }, + { + "name": "a snapshot driving another agent's port", + "kind": "snapshot", + "value": { + "descriptorRevision": "7", + "publisherIncarnation": "pub-1", + "scope": { + "sessionId": "demo", + "epoch": "epoch-1", + "step": "42" + }, + "episodeId": "episode-1", + "sequence": "42", + "worldTime": { + "numerator": "700000000", + "denominator": "1" + }, + "agents": [ + { + "agentId": "fly-a", + "telemetry": { + "brainTicks": "2534", + "populationRateHz": 12.5, + "rates": [ + { + "roleId": "kenyon", + "hz": 3.25 + }, + { + "roleId": "mbon", + "hz": 0.0 + } + ], + "learning": { + "enabled": true, + "updates": "4", + "changed": "2", + "signal": 0.5 + } + }, + "selectedDecision": { + "schema": { + "id": "gameboy.intent.v1", + "version": 1, + "digest": "e70451b26fb5462ec63729d8355912dad01c3963a95bec0e6a8452375717ad9d" + }, + "value": { + "press": "a" + } + }, + "appliedControls": { + "portId": "port-2", + "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 + } + ] + } + } + ], + "progress": { + "schema": { + "id": "pokemon.progress.v1", + "version": 1, + "digest": "80fa973157f334bb3208dfff6f7a2f298e79f73f07c72c33f5725975c6cff8e1" + }, + "value": { + "rank": 10 + } + }, + "media": { + "views": [ + { + "viewId": "screen", + "producedStep": "42", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-1", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + } + ], + "audio": [] + }, + "eventIds": [] + }, + "expect": "reject", + "reason": "the coordinator assigns the port, and it is the assigned one" + }, + { + "name": "a snapshot with telemetry roles in another order", + "kind": "snapshot", + "value": { + "descriptorRevision": "7", + "publisherIncarnation": "pub-1", + "scope": { + "sessionId": "demo", + "epoch": "epoch-1", + "step": "42" + }, + "episodeId": "episode-1", + "sequence": "42", + "worldTime": { + "numerator": "700000000", + "denominator": "1" + }, + "agents": [ + { + "agentId": "fly-a", + "telemetry": { + "brainTicks": "2534", + "populationRateHz": 12.5, + "rates": [ + { + "roleId": "mbon", + "hz": 0.0 + }, + { + "roleId": "kenyon", + "hz": 3.25 + } + ], + "learning": { + "enabled": true, + "updates": "4", + "changed": "2", + "signal": 0.5 + } + }, + "selectedDecision": { + "schema": { + "id": "gameboy.intent.v1", + "version": 1, + "digest": "e70451b26fb5462ec63729d8355912dad01c3963a95bec0e6a8452375717ad9d" + }, + "value": { + "press": "a" + } + }, + "appliedControls": { + "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 + } + ] + } + } + ], + "progress": { + "schema": { + "id": "pokemon.progress.v1", + "version": 1, + "digest": "80fa973157f334bb3208dfff6f7a2f298e79f73f07c72c33f5725975c6cff8e1" + }, + "value": { + "rank": 10 + } + }, + "media": { + "views": [ + { + "viewId": "screen", + "producedStep": "42", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-1", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + } + ], + "audio": [] + }, + "eventIds": [] + }, + "expect": "reject", + "reason": "rates are in profile-defined order" + }, + { + "name": "a snapshot for another descriptor revision", + "kind": "snapshot", + "value": { + "descriptorRevision": "6", + "publisherIncarnation": "pub-1", + "scope": { + "sessionId": "demo", + "epoch": "epoch-1", + "step": "42" + }, + "episodeId": "episode-1", + "sequence": "42", + "worldTime": { + "numerator": "700000000", + "denominator": "1" + }, + "agents": [ + { + "agentId": "fly-a", + "telemetry": { + "brainTicks": "2534", + "populationRateHz": 12.5, + "rates": [ + { + "roleId": "kenyon", + "hz": 3.25 + }, + { + "roleId": "mbon", + "hz": 0.0 + } + ], + "learning": { + "enabled": true, + "updates": "4", + "changed": "2", + "signal": 0.5 + } + }, + "selectedDecision": { + "schema": { + "id": "gameboy.intent.v1", + "version": 1, + "digest": "e70451b26fb5462ec63729d8355912dad01c3963a95bec0e6a8452375717ad9d" + }, + "value": { + "press": "a" + } + }, + "appliedControls": { + "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 + } + ] + } + } + ], + "progress": { + "schema": { + "id": "pokemon.progress.v1", + "version": 1, + "digest": "80fa973157f334bb3208dfff6f7a2f298e79f73f07c72c33f5725975c6cff8e1" + }, + "value": { + "rank": 10 + } + }, + "media": { + "views": [ + { + "viewId": "screen", + "producedStep": "42", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-1", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + } + ], + "audio": [] + }, + "eventIds": [] + }, + "expect": "reject", + "reason": "descriptor/index mismatch is visible" + } + ], + "stepResultPrevious": { + "boundary": "41", + "worldTime": { + "numerator": "50000000", + "denominator": "3" + }, + "engineFrame": "1", + "sensoryViews": [ + { + "viewId": "screen", + "producedStep": "41", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-1", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + } + ], + "inspection": { + "schema": { + "id": "gameboy.inspection.v1", + "version": 1, + "digest": "1883cee6532d0bfbb28854b82db8450d525c2e4fcec01567b4aa6d99917eafb1" + }, + "value": { + "map": 40, + "x": 5, + "y": 7 + } + }, + "broadcastViews": [ + { + "viewId": "screen", + "producedStep": "41", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-1", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + } + ], + "audio": [ + { + "streamId": "mix", + "firstSample": "32800", + "sampleFrames": 800, + "samples": { + "storeId": "store-1", + "artifactId": "audio-1", + "generation": "1", + "byteLength": "6400", + "contentType": "audio/x-f32le", + "digest": null + }, + "discontinuity": false + } + ] + } +} \ No newline at end of file diff --git a/services/flysim/crates/fly-session-types/fixtures/generated.json b/services/flysim/crates/fly-session-types/fixtures/generated.json new file mode 100644 index 0000000..9192e73 --- /dev/null +++ b/services/flysim/crates/fly-session-types/fixtures/generated.json @@ -0,0 +1,74 @@ +{ + "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\": 'a' characters}; validate it", + "padded-request": "a SessionRpcRequest req-1 with scope null and params {\"pad\": '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 'x' characters", + "error-message-astral": "the same failure with a message of repetitions of U+10400, one code point and two UTF-16 units each" + } +} \ No newline at end of file diff --git a/services/flysim/crates/fly-session-types/fixtures/identities.json b/services/flysim/crates/fly-session-types/fixtures/identities.json new file mode 100644 index 0000000..be7b38f --- /dev/null +++ b/services/flysim/crates/fly-session-types/fixtures/identities.json @@ -0,0 +1,111 @@ +{ + "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" + } +} \ No newline at end of file diff --git a/services/flysim/crates/fly-session-types/fixtures/invalid.json b/services/flysim/crates/fly-session-types/fixtures/invalid.json new file mode 100644 index 0000000..cb5fa10 --- /dev/null +++ b/services/flysim/crates/fly-session-types/fixtures/invalid.json @@ -0,0 +1,3900 @@ +{ + "description": "Payloads every implementation must refuse in its validate step.", + "cases": [ + { + "name": "scope with a non-canonical step", + "type": "Scope", + "value": { + "sessionId": "demo", + "epoch": "epoch-1", + "step": "041" + }, + "reason": "U64 has no leading zeros" + }, + { + "name": "scope one past the U64 maximum", + "type": "Scope", + "value": { + "sessionId": "demo", + "epoch": "epoch-1", + "step": "18446744073709551616" + }, + "reason": "U64 overflow" + }, + { + "name": "scope with an uppercase session id", + "type": "Scope", + "value": { + "sessionId": "Demo", + "epoch": "epoch-1", + "step": "0" + }, + "reason": "Id is lowercase" + }, + { + "name": "scope with a numeric step", + "type": "Scope", + "value": { + "sessionId": "demo", + "epoch": "epoch-1", + "step": 41 + }, + "reason": "counters are decimal strings" + }, + { + "name": "scope missing its epoch", + "type": "Scope", + "value": { + "sessionId": "demo", + "step": "0" + }, + "reason": "every field is required" + }, + { + "name": "scope with an unknown field", + "type": "Scope", + "value": { + "sessionId": "demo", + "epoch": "epoch-1", + "step": "41", + "boundary": "41" + }, + "reason": "unknown fields are refused" + }, + { + "name": "scope with an empty session id", + "type": "Scope", + "value": { + "sessionId": "", + "epoch": "epoch-1", + "step": "0" + }, + "reason": "Id is 1..=64 characters" + }, + { + "name": "scope with a 65 character session id", + "type": "Scope", + "value": { + "sessionId": "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", + "epoch": "epoch-1", + "step": "0" + }, + "reason": "Id is 1..=64 characters" + }, + { + "name": "rational with a zero denominator", + "type": "RationalNs", + "value": { + "numerator": "1", + "denominator": "0" + }, + "reason": "denominators are positive" + }, + { + "name": "rational zero in another form", + "type": "RationalNs", + "value": { + "numerator": "0", + "denominator": "2" + }, + "reason": "zero is encoded 0/1" + }, + { + "name": "rational that is not reduced", + "type": "RationalNs", + "value": { + "numerator": "2", + "denominator": "4" + }, + "reason": "fractions are reduced" + }, + { + "name": "rational with a numeric numerator", + "type": "RationalNs", + "value": { + "numerator": 1, + "denominator": "2" + }, + "reason": "U64 is a decimal string" + }, + { + "name": "schema reference at version zero", + "type": "SchemaRef", + "value": { + "id": "a.v1", + "version": 0, + "digest": "ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb" + }, + "reason": "version is 1..=65535" + }, + { + "name": "schema reference past the version ceiling", + "type": "SchemaRef", + "value": { + "id": "a.v1", + "version": 65536, + "digest": "ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48bb" + }, + "reason": "version is 1..=65535" + }, + { + "name": "schema reference with an uppercase digest", + "type": "SchemaRef", + "value": { + "id": "a.v1", + "version": 1, + "digest": "CA978112CA1BBDCAFAC231B39A23DC4DA786EFF8147C4E72B9807785AFEE48BB" + }, + "reason": "Digest is lowercase hex" + }, + { + "name": "schema reference with a short digest", + "type": "SchemaRef", + "value": { + "id": "a.v1", + "version": 1, + "digest": "ca978112ca1bbdcafac231b39a23dc4da786eff8147c4e72b9807785afee48b" + }, + "reason": "Digest is 64 hex digits" + }, + { + "name": "typed value whose value is not an object", + "type": "TypedValue", + "value": { + "schema": { + "id": "a.v1", + "version": 1, + "digest": "bba7951c04664cd49bb6eed1da6dc1a4e17e989e787bf2e72f5933a5c6171372" + }, + "value": [ + 1, + 2 + ] + }, + "reason": "typed state is an object" + }, + { + "name": "typed value with a missing schema", + "type": "TypedValue", + "value": { + "value": {} + }, + "reason": "every field is required" + }, + { + "name": "stimulus with a zero duration", + "type": "Stimulus", + "value": { + "id": "stim-1", + "kindId": "sugar", + "durationMs": 0.0 + }, + "reason": "durationMs is > 0" + }, + { + "name": "stimulus with a negative duration", + "type": "Stimulus", + "value": { + "id": "stim-1", + "kindId": "sugar", + "durationMs": -1.0 + }, + "reason": "durationMs is > 0" + }, + { + "name": "stimulus with a string duration", + "type": "Stimulus", + "value": { + "id": "stim-1", + "kindId": "sugar", + "durationMs": "50" + }, + "reason": "durationMs is a JSON number" + }, + { + "name": "reward with a string value", + "type": "Reward", + "value": { + "eventId": "evt-1", + "ruleId": "badge", + "value": "1.5" + }, + "reason": "rewards are finite JSON numbers" + }, + { + "name": "telemetry with a negative rate", + "type": "AgentTelemetry", + "value": { + "brainTicks": "17", + "populationRateHz": 12.5, + "rates": [ + { + "roleId": "kenyon", + "hz": -0.5 + } + ], + "learning": { + "enabled": true, + "updates": "4", + "changed": "2", + "signal": 0.5 + } + }, + "reason": "rates are nonnegative" + }, + { + "name": "telemetry with a duplicate role", + "type": "AgentTelemetry", + "value": { + "brainTicks": "17", + "populationRateHz": 12.5, + "rates": [ + { + "roleId": "kenyon", + "hz": 1.0 + }, + { + "roleId": "kenyon", + "hz": 2.0 + } + ], + "learning": { + "enabled": true, + "updates": "4", + "changed": "2", + "signal": 0.5 + } + }, + "reason": "rates are unique by role id" + }, + { + "name": "telemetry with 65 roles", + "type": "AgentTelemetry", + "value": { + "brainTicks": "17", + "populationRateHz": 12.5, + "rates": [ + { + "roleId": "role-0", + "hz": 1.0 + }, + { + "roleId": "role-1", + "hz": 1.0 + }, + { + "roleId": "role-2", + "hz": 1.0 + }, + { + "roleId": "role-3", + "hz": 1.0 + }, + { + "roleId": "role-4", + "hz": 1.0 + }, + { + "roleId": "role-5", + "hz": 1.0 + }, + { + "roleId": "role-6", + "hz": 1.0 + }, + { + "roleId": "role-7", + "hz": 1.0 + }, + { + "roleId": "role-8", + "hz": 1.0 + }, + { + "roleId": "role-9", + "hz": 1.0 + }, + { + "roleId": "role-10", + "hz": 1.0 + }, + { + "roleId": "role-11", + "hz": 1.0 + }, + { + "roleId": "role-12", + "hz": 1.0 + }, + { + "roleId": "role-13", + "hz": 1.0 + }, + { + "roleId": "role-14", + "hz": 1.0 + }, + { + "roleId": "role-15", + "hz": 1.0 + }, + { + "roleId": "role-16", + "hz": 1.0 + }, + { + "roleId": "role-17", + "hz": 1.0 + }, + { + "roleId": "role-18", + "hz": 1.0 + }, + { + "roleId": "role-19", + "hz": 1.0 + }, + { + "roleId": "role-20", + "hz": 1.0 + }, + { + "roleId": "role-21", + "hz": 1.0 + }, + { + "roleId": "role-22", + "hz": 1.0 + }, + { + "roleId": "role-23", + "hz": 1.0 + }, + { + "roleId": "role-24", + "hz": 1.0 + }, + { + "roleId": "role-25", + "hz": 1.0 + }, + { + "roleId": "role-26", + "hz": 1.0 + }, + { + "roleId": "role-27", + "hz": 1.0 + }, + { + "roleId": "role-28", + "hz": 1.0 + }, + { + "roleId": "role-29", + "hz": 1.0 + }, + { + "roleId": "role-30", + "hz": 1.0 + }, + { + "roleId": "role-31", + "hz": 1.0 + }, + { + "roleId": "role-32", + "hz": 1.0 + }, + { + "roleId": "role-33", + "hz": 1.0 + }, + { + "roleId": "role-34", + "hz": 1.0 + }, + { + "roleId": "role-35", + "hz": 1.0 + }, + { + "roleId": "role-36", + "hz": 1.0 + }, + { + "roleId": "role-37", + "hz": 1.0 + }, + { + "roleId": "role-38", + "hz": 1.0 + }, + { + "roleId": "role-39", + "hz": 1.0 + }, + { + "roleId": "role-40", + "hz": 1.0 + }, + { + "roleId": "role-41", + "hz": 1.0 + }, + { + "roleId": "role-42", + "hz": 1.0 + }, + { + "roleId": "role-43", + "hz": 1.0 + }, + { + "roleId": "role-44", + "hz": 1.0 + }, + { + "roleId": "role-45", + "hz": 1.0 + }, + { + "roleId": "role-46", + "hz": 1.0 + }, + { + "roleId": "role-47", + "hz": 1.0 + }, + { + "roleId": "role-48", + "hz": 1.0 + }, + { + "roleId": "role-49", + "hz": 1.0 + }, + { + "roleId": "role-50", + "hz": 1.0 + }, + { + "roleId": "role-51", + "hz": 1.0 + }, + { + "roleId": "role-52", + "hz": 1.0 + }, + { + "roleId": "role-53", + "hz": 1.0 + }, + { + "roleId": "role-54", + "hz": 1.0 + }, + { + "roleId": "role-55", + "hz": 1.0 + }, + { + "roleId": "role-56", + "hz": 1.0 + }, + { + "roleId": "role-57", + "hz": 1.0 + }, + { + "roleId": "role-58", + "hz": 1.0 + }, + { + "roleId": "role-59", + "hz": 1.0 + }, + { + "roleId": "role-60", + "hz": 1.0 + }, + { + "roleId": "role-61", + "hz": 1.0 + }, + { + "roleId": "role-62", + "hz": 1.0 + }, + { + "roleId": "role-63", + "hz": 1.0 + }, + { + "roleId": "role-64", + "hz": 1.0 + } + ], + "learning": { + "enabled": true, + "updates": "4", + "changed": "2", + "signal": 0.5 + } + }, + "reason": "at most 64 rate roles" + }, + { + "name": "telemetry claiming more changes than updates", + "type": "AgentTelemetry", + "value": { + "brainTicks": "17", + "populationRateHz": 12.5, + "rates": [ + { + "roleId": "kenyon", + "hz": 3.25 + }, + { + "roleId": "mbon", + "hz": 0.0 + } + ], + "learning": { + "enabled": true, + "updates": "1", + "changed": "2", + "signal": 0.0 + } + }, + "reason": "changed <= updates" + }, + { + "name": "sensory input with nine views", + "type": "SensoryInput", + "value": { + "boundary": "1", + "views": [ + { + "viewId": "view-0", + "producedStep": "1", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-0", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + }, + { + "viewId": "view-1", + "producedStep": "1", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-1", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + }, + { + "viewId": "view-2", + "producedStep": "1", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-2", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + }, + { + "viewId": "view-3", + "producedStep": "1", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-3", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + }, + { + "viewId": "view-4", + "producedStep": "1", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-4", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + }, + { + "viewId": "view-5", + "producedStep": "1", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-5", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + }, + { + "viewId": "view-6", + "producedStep": "1", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-6", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + }, + { + "viewId": "view-7", + "producedStep": "1", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-7", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + }, + { + "viewId": "view-8", + "producedStep": "1", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-8", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + } + ], + "structured": null + }, + "reason": "at most 8 views per sensory input" + }, + { + "name": "sensory input with a duplicate view", + "type": "SensoryInput", + "value": { + "boundary": "1", + "views": [ + { + "viewId": "screen", + "producedStep": "1", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-1", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + }, + { + "viewId": "screen", + "producedStep": "1", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-1", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + } + ], + "structured": null + }, + "reason": "views are unique by view id" + }, + { + "name": "sensory input observing the future", + "type": "SensoryInput", + "value": { + "boundary": "1", + "views": [ + { + "viewId": "screen", + "producedStep": "2", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-1", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + } + ], + "structured": null + }, + "reason": "a view cannot be produced after its boundary" + }, + { + "name": "view descriptor with a zero width", + "type": "ViewDescriptor", + "value": { + "viewId": "screen", + "width": 0, + "height": 144, + "format": "rgba8", + "rowStride": 0, + "pixelAspect": { + "numerator": 10, + "denominator": 11 + }, + "observationDelaySteps": 0 + }, + "reason": "dimensions are 1..=4096" + }, + { + "name": "view descriptor past the dimension ceiling", + "type": "ViewDescriptor", + "value": { + "viewId": "screen", + "width": 4097, + "height": 144, + "format": "rgba8", + "rowStride": 16388, + "pixelAspect": { + "numerator": 10, + "denominator": 11 + }, + "observationDelaySteps": 0 + }, + "reason": "dimensions are 1..=4096" + }, + { + "name": "view descriptor with a padded row", + "type": "ViewDescriptor", + "value": { + "viewId": "screen", + "width": 160, + "height": 144, + "format": "rgba8", + "rowStride": 644, + "pixelAspect": { + "numerator": 10, + "denominator": 11 + }, + "observationDelaySteps": 0 + }, + "reason": "rowStride is exactly 4 x width" + }, + { + "name": "view descriptor with a zero pixel aspect", + "type": "ViewDescriptor", + "value": { + "viewId": "screen", + "width": 160, + "height": 144, + "format": "rgba8", + "rowStride": 640, + "pixelAspect": { + "numerator": 0, + "denominator": 11 + }, + "observationDelaySteps": 0 + }, + "reason": "pixel aspect parts are positive" + }, + { + "name": "view descriptor with a nine step delay", + "type": "ViewDescriptor", + "value": { + "viewId": "screen", + "width": 160, + "height": 144, + "format": "rgba8", + "rowStride": 640, + "pixelAspect": { + "numerator": 10, + "denominator": 11 + }, + "observationDelaySteps": 9 + }, + "reason": "observationDelaySteps is 0..=8" + }, + { + "name": "view descriptor in another format", + "type": "ViewDescriptor", + "value": { + "viewId": "screen", + "width": 160, + "height": 144, + "format": "rgb8", + "rowStride": 640, + "pixelAspect": { + "numerator": 10, + "denominator": 11 + }, + "observationDelaySteps": 0 + }, + "reason": "v1 has one pixel format" + }, + { + "name": "audio descriptor below the sample rate floor", + "type": "AudioDescriptor", + "value": { + "streamId": "mix", + "sampleRate": 7999, + "channels": 2, + "format": "f32le-interleaved" + }, + "reason": "sampleRate is 8000..=192000" + }, + { + "name": "audio descriptor with nine channels", + "type": "AudioDescriptor", + "value": { + "streamId": "mix", + "sampleRate": 48000, + "channels": 9, + "format": "f32le-interleaved" + }, + "reason": "channels are 1..=8" + }, + { + "name": "audio chunk past the frame ceiling", + "type": "AudioRef", + "value": { + "streamId": "mix", + "firstSample": "0", + "sampleFrames": 192001, + "samples": { + "storeId": "store-1", + "artifactId": "audio-1", + "generation": "1", + "byteLength": "1536008", + "contentType": "audio/x-f32le", + "digest": null + }, + "discontinuity": false + }, + "reason": "sampleFrames is 0..=192000" + }, + { + "name": "controller schema with a duplicate button", + "type": "ControllerSchema", + "value": { + "schema": { + "id": "gameboy.controller.v1", + "version": 1, + "digest": "b1df9d20fe1deaeb52f17cf261bf728b28143c777878b96cacfc4826903a48d5" + }, + "buttons": [ + "a", + "a" + ], + "axes": [ + { + "id": "stick-x", + "range": "bipolar", + "neutral": 0.0 + }, + { + "id": "trigger", + "range": "unit", + "neutral": 0.0 + } + ] + }, + "reason": "buttons are unique" + }, + { + "name": "controller schema with 33 buttons", + "type": "ControllerSchema", + "value": { + "schema": { + "id": "gameboy.controller.v1", + "version": 1, + "digest": "b1df9d20fe1deaeb52f17cf261bf728b28143c777878b96cacfc4826903a48d5" + }, + "buttons": [ + "b0", + "b1", + "b2", + "b3", + "b4", + "b5", + "b6", + "b7", + "b8", + "b9", + "b10", + "b11", + "b12", + "b13", + "b14", + "b15", + "b16", + "b17", + "b18", + "b19", + "b20", + "b21", + "b22", + "b23", + "b24", + "b25", + "b26", + "b27", + "b28", + "b29", + "b30", + "b31", + "b32" + ], + "axes": [ + { + "id": "stick-x", + "range": "bipolar", + "neutral": 0.0 + }, + { + "id": "trigger", + "range": "unit", + "neutral": 0.0 + } + ] + }, + "reason": "at most 32 buttons" + }, + { + "name": "controller schema with 17 axes", + "type": "ControllerSchema", + "value": { + "schema": { + "id": "gameboy.controller.v1", + "version": 1, + "digest": "b1df9d20fe1deaeb52f17cf261bf728b28143c777878b96cacfc4826903a48d5" + }, + "buttons": [ + "a", + "b", + "start", + "select", + "up", + "down", + "left", + "right" + ], + "axes": [ + { + "id": "ax0", + "range": "unit", + "neutral": 0.0 + }, + { + "id": "ax1", + "range": "unit", + "neutral": 0.0 + }, + { + "id": "ax2", + "range": "unit", + "neutral": 0.0 + }, + { + "id": "ax3", + "range": "unit", + "neutral": 0.0 + }, + { + "id": "ax4", + "range": "unit", + "neutral": 0.0 + }, + { + "id": "ax5", + "range": "unit", + "neutral": 0.0 + }, + { + "id": "ax6", + "range": "unit", + "neutral": 0.0 + }, + { + "id": "ax7", + "range": "unit", + "neutral": 0.0 + }, + { + "id": "ax8", + "range": "unit", + "neutral": 0.0 + }, + { + "id": "ax9", + "range": "unit", + "neutral": 0.0 + }, + { + "id": "ax10", + "range": "unit", + "neutral": 0.0 + }, + { + "id": "ax11", + "range": "unit", + "neutral": 0.0 + }, + { + "id": "ax12", + "range": "unit", + "neutral": 0.0 + }, + { + "id": "ax13", + "range": "unit", + "neutral": 0.0 + }, + { + "id": "ax14", + "range": "unit", + "neutral": 0.0 + }, + { + "id": "ax15", + "range": "unit", + "neutral": 0.0 + }, + { + "id": "ax16", + "range": "unit", + "neutral": 0.0 + } + ] + }, + "reason": "at most 16 axes" + }, + { + "name": "controller schema with an out of range neutral", + "type": "ControllerSchema", + "value": { + "schema": { + "id": "gameboy.controller.v1", + "version": 1, + "digest": "b1df9d20fe1deaeb52f17cf261bf728b28143c777878b96cacfc4826903a48d5" + }, + "buttons": [ + "a", + "b", + "start", + "select", + "up", + "down", + "left", + "right" + ], + "axes": [ + { + "id": "trigger", + "range": "unit", + "neutral": -0.5 + } + ] + }, + "reason": "neutral lies in range" + }, + { + "name": "port control with a non-finite axis", + "type": "PortControl", + "value": { + "portId": "port-1", + "buttons": [], + "axes": [ + { + "id": "stick-x", + "value": "NaN" + } + ] + }, + "reason": "axis values are finite numbers" + }, + { + "name": "port control with a duplicate button", + "type": "PortControl", + "value": { + "portId": "port-1", + "buttons": [ + { + "id": "a", + "down": true + }, + { + "id": "a", + "down": false + } + ], + "axes": [] + }, + "reason": "buttons are unique" + }, + { + "name": "environment descriptor with five ports", + "type": "EnvironmentDescriptor", + "value": { + "backendDigest": "10e08a419e850eba1ebba18fdd28eb7ec1b7e8baa9bcc3b973e2b8891ec726be", + "contentDigest": "ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73", + "configurationDigest": "b7d64a9221007dfd5390f7df6cd5b8f3ea4f82faa1237141e35ebf161f5511a1", + "stepDuration": { + "numerator": "50000000", + "denominator": "3" + }, + "ports": [ + { + "portId": "port-1", + "controls": { + "schema": { + "id": "gameboy.controller.v1", + "version": 1, + "digest": "b1df9d20fe1deaeb52f17cf261bf728b28143c777878b96cacfc4826903a48d5" + }, + "buttons": [ + "a", + "b", + "start", + "select", + "up", + "down", + "left", + "right" + ], + "axes": [ + { + "id": "stick-x", + "range": "bipolar", + "neutral": 0.0 + }, + { + "id": "trigger", + "range": "unit", + "neutral": 0.0 + } + ] + } + }, + { + "portId": "port-2", + "controls": { + "schema": { + "id": "gameboy.controller.v1", + "version": 1, + "digest": "b1df9d20fe1deaeb52f17cf261bf728b28143c777878b96cacfc4826903a48d5" + }, + "buttons": [ + "a", + "b", + "start", + "select", + "up", + "down", + "left", + "right" + ], + "axes": [ + { + "id": "stick-x", + "range": "bipolar", + "neutral": 0.0 + }, + { + "id": "trigger", + "range": "unit", + "neutral": 0.0 + } + ] + } + }, + { + "portId": "port-3", + "controls": { + "schema": { + "id": "gameboy.controller.v1", + "version": 1, + "digest": "b1df9d20fe1deaeb52f17cf261bf728b28143c777878b96cacfc4826903a48d5" + }, + "buttons": [ + "a", + "b", + "start", + "select", + "up", + "down", + "left", + "right" + ], + "axes": [ + { + "id": "stick-x", + "range": "bipolar", + "neutral": 0.0 + }, + { + "id": "trigger", + "range": "unit", + "neutral": 0.0 + } + ] + } + }, + { + "portId": "port-4", + "controls": { + "schema": { + "id": "gameboy.controller.v1", + "version": 1, + "digest": "b1df9d20fe1deaeb52f17cf261bf728b28143c777878b96cacfc4826903a48d5" + }, + "buttons": [ + "a", + "b", + "start", + "select", + "up", + "down", + "left", + "right" + ], + "axes": [ + { + "id": "stick-x", + "range": "bipolar", + "neutral": 0.0 + }, + { + "id": "trigger", + "range": "unit", + "neutral": 0.0 + } + ] + } + }, + { + "portId": "port-5", + "controls": { + "schema": { + "id": "gameboy.controller.v1", + "version": 1, + "digest": "b1df9d20fe1deaeb52f17cf261bf728b28143c777878b96cacfc4826903a48d5" + }, + "buttons": [ + "a", + "b", + "start", + "select", + "up", + "down", + "left", + "right" + ], + "axes": [ + { + "id": "stick-x", + "range": "bipolar", + "neutral": 0.0 + }, + { + "id": "trigger", + "range": "unit", + "neutral": 0.0 + } + ] + } + } + ], + "inspectionSchema": { + "id": "gameboy.inspection.v1", + "version": 1, + "digest": "1883cee6532d0bfbb28854b82db8450d525c2e4fcec01567b4aa6d99917eafb1" + }, + "views": [ + { + "viewId": "screen", + "width": 160, + "height": 144, + "format": "rgba8", + "rowStride": 640, + "pixelAspect": { + "numerator": 10, + "denominator": 11 + }, + "observationDelaySteps": 0 + } + ], + "audio": [ + { + "streamId": "mix", + "sampleRate": 48000, + "channels": 2, + "format": "f32le-interleaved" + } + ], + "recovery": "exact-checkpoint", + "determinism": "fixed-build" + }, + "reason": "the first composition allows four ports" + }, + { + "name": "environment descriptor with a duplicate port", + "type": "EnvironmentDescriptor", + "value": { + "backendDigest": "10e08a419e850eba1ebba18fdd28eb7ec1b7e8baa9bcc3b973e2b8891ec726be", + "contentDigest": "ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73", + "configurationDigest": "b7d64a9221007dfd5390f7df6cd5b8f3ea4f82faa1237141e35ebf161f5511a1", + "stepDuration": { + "numerator": "50000000", + "denominator": "3" + }, + "ports": [ + { + "portId": "port-1", + "controls": { + "schema": { + "id": "gameboy.controller.v1", + "version": 1, + "digest": "b1df9d20fe1deaeb52f17cf261bf728b28143c777878b96cacfc4826903a48d5" + }, + "buttons": [ + "a", + "b", + "start", + "select", + "up", + "down", + "left", + "right" + ], + "axes": [ + { + "id": "stick-x", + "range": "bipolar", + "neutral": 0.0 + }, + { + "id": "trigger", + "range": "unit", + "neutral": 0.0 + } + ] + } + }, + { + "portId": "port-1", + "controls": { + "schema": { + "id": "gameboy.controller.v1", + "version": 1, + "digest": "b1df9d20fe1deaeb52f17cf261bf728b28143c777878b96cacfc4826903a48d5" + }, + "buttons": [ + "a", + "b", + "start", + "select", + "up", + "down", + "left", + "right" + ], + "axes": [ + { + "id": "stick-x", + "range": "bipolar", + "neutral": 0.0 + }, + { + "id": "trigger", + "range": "unit", + "neutral": 0.0 + } + ] + } + } + ], + "inspectionSchema": { + "id": "gameboy.inspection.v1", + "version": 1, + "digest": "1883cee6532d0bfbb28854b82db8450d525c2e4fcec01567b4aa6d99917eafb1" + }, + "views": [ + { + "viewId": "screen", + "width": 160, + "height": 144, + "format": "rgba8", + "rowStride": 640, + "pixelAspect": { + "numerator": 10, + "denominator": 11 + }, + "observationDelaySteps": 0 + } + ], + "audio": [ + { + "streamId": "mix", + "sampleRate": 48000, + "channels": 2, + "format": "f32le-interleaved" + } + ], + "recovery": "exact-checkpoint", + "determinism": "fixed-build" + }, + "reason": "ports are unique" + }, + { + "name": "environment descriptor with a zero step duration", + "type": "EnvironmentDescriptor", + "value": { + "backendDigest": "10e08a419e850eba1ebba18fdd28eb7ec1b7e8baa9bcc3b973e2b8891ec726be", + "contentDigest": "ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73", + "configurationDigest": "b7d64a9221007dfd5390f7df6cd5b8f3ea4f82faa1237141e35ebf161f5511a1", + "stepDuration": { + "numerator": "0", + "denominator": "1" + }, + "ports": [ + { + "portId": "port-1", + "controls": { + "schema": { + "id": "gameboy.controller.v1", + "version": 1, + "digest": "b1df9d20fe1deaeb52f17cf261bf728b28143c777878b96cacfc4826903a48d5" + }, + "buttons": [ + "a", + "b", + "start", + "select", + "up", + "down", + "left", + "right" + ], + "axes": [ + { + "id": "stick-x", + "range": "bipolar", + "neutral": 0.0 + }, + { + "id": "trigger", + "range": "unit", + "neutral": 0.0 + } + ] + } + } + ], + "inspectionSchema": { + "id": "gameboy.inspection.v1", + "version": 1, + "digest": "1883cee6532d0bfbb28854b82db8450d525c2e4fcec01567b4aa6d99917eafb1" + }, + "views": [ + { + "viewId": "screen", + "width": 160, + "height": 144, + "format": "rgba8", + "rowStride": 640, + "pixelAspect": { + "numerator": 10, + "denominator": 11 + }, + "observationDelaySteps": 0 + } + ], + "audio": [ + { + "streamId": "mix", + "sampleRate": 48000, + "channels": 2, + "format": "f32le-interleaved" + } + ], + "recovery": "exact-checkpoint", + "determinism": "fixed-build" + }, + "reason": "durations are positive" + }, + { + "name": "environment descriptor with an unreduced step duration", + "type": "EnvironmentDescriptor", + "value": { + "backendDigest": "10e08a419e850eba1ebba18fdd28eb7ec1b7e8baa9bcc3b973e2b8891ec726be", + "contentDigest": "ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73", + "configurationDigest": "b7d64a9221007dfd5390f7df6cd5b8f3ea4f82faa1237141e35ebf161f5511a1", + "stepDuration": { + "numerator": "100000000", + "denominator": "6" + }, + "ports": [ + { + "portId": "port-1", + "controls": { + "schema": { + "id": "gameboy.controller.v1", + "version": 1, + "digest": "b1df9d20fe1deaeb52f17cf261bf728b28143c777878b96cacfc4826903a48d5" + }, + "buttons": [ + "a", + "b", + "start", + "select", + "up", + "down", + "left", + "right" + ], + "axes": [ + { + "id": "stick-x", + "range": "bipolar", + "neutral": 0.0 + }, + { + "id": "trigger", + "range": "unit", + "neutral": 0.0 + } + ] + } + } + ], + "inspectionSchema": { + "id": "gameboy.inspection.v1", + "version": 1, + "digest": "1883cee6532d0bfbb28854b82db8450d525c2e4fcec01567b4aa6d99917eafb1" + }, + "views": [ + { + "viewId": "screen", + "width": 160, + "height": 144, + "format": "rgba8", + "rowStride": 640, + "pixelAspect": { + "numerator": 10, + "denominator": 11 + }, + "observationDelaySteps": 0 + } + ], + "audio": [ + { + "streamId": "mix", + "sampleRate": 48000, + "channels": 2, + "format": "f32le-interleaved" + } + ], + "recovery": "exact-checkpoint", + "determinism": "fixed-build" + }, + "reason": "fractions are reduced" + }, + { + "name": "environment descriptor with nine views", + "type": "EnvironmentDescriptor", + "value": { + "backendDigest": "10e08a419e850eba1ebba18fdd28eb7ec1b7e8baa9bcc3b973e2b8891ec726be", + "contentDigest": "ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73", + "configurationDigest": "b7d64a9221007dfd5390f7df6cd5b8f3ea4f82faa1237141e35ebf161f5511a1", + "stepDuration": { + "numerator": "50000000", + "denominator": "3" + }, + "ports": [ + { + "portId": "port-1", + "controls": { + "schema": { + "id": "gameboy.controller.v1", + "version": 1, + "digest": "b1df9d20fe1deaeb52f17cf261bf728b28143c777878b96cacfc4826903a48d5" + }, + "buttons": [ + "a", + "b", + "start", + "select", + "up", + "down", + "left", + "right" + ], + "axes": [ + { + "id": "stick-x", + "range": "bipolar", + "neutral": 0.0 + }, + { + "id": "trigger", + "range": "unit", + "neutral": 0.0 + } + ] + } + } + ], + "inspectionSchema": { + "id": "gameboy.inspection.v1", + "version": 1, + "digest": "1883cee6532d0bfbb28854b82db8450d525c2e4fcec01567b4aa6d99917eafb1" + }, + "views": [ + { + "viewId": "view-0", + "width": 160, + "height": 144, + "format": "rgba8", + "rowStride": 640, + "pixelAspect": { + "numerator": 10, + "denominator": 11 + }, + "observationDelaySteps": 0 + }, + { + "viewId": "view-1", + "width": 160, + "height": 144, + "format": "rgba8", + "rowStride": 640, + "pixelAspect": { + "numerator": 10, + "denominator": 11 + }, + "observationDelaySteps": 0 + }, + { + "viewId": "view-2", + "width": 160, + "height": 144, + "format": "rgba8", + "rowStride": 640, + "pixelAspect": { + "numerator": 10, + "denominator": 11 + }, + "observationDelaySteps": 0 + }, + { + "viewId": "view-3", + "width": 160, + "height": 144, + "format": "rgba8", + "rowStride": 640, + "pixelAspect": { + "numerator": 10, + "denominator": 11 + }, + "observationDelaySteps": 0 + }, + { + "viewId": "view-4", + "width": 160, + "height": 144, + "format": "rgba8", + "rowStride": 640, + "pixelAspect": { + "numerator": 10, + "denominator": 11 + }, + "observationDelaySteps": 0 + }, + { + "viewId": "view-5", + "width": 160, + "height": 144, + "format": "rgba8", + "rowStride": 640, + "pixelAspect": { + "numerator": 10, + "denominator": 11 + }, + "observationDelaySteps": 0 + }, + { + "viewId": "view-6", + "width": 160, + "height": 144, + "format": "rgba8", + "rowStride": 640, + "pixelAspect": { + "numerator": 10, + "denominator": 11 + }, + "observationDelaySteps": 0 + }, + { + "viewId": "view-7", + "width": 160, + "height": 144, + "format": "rgba8", + "rowStride": 640, + "pixelAspect": { + "numerator": 10, + "denominator": 11 + }, + "observationDelaySteps": 0 + }, + { + "viewId": "view-8", + "width": 160, + "height": 144, + "format": "rgba8", + "rowStride": 640, + "pixelAspect": { + "numerator": 10, + "denominator": 11 + }, + "observationDelaySteps": 0 + } + ], + "audio": [ + { + "streamId": "mix", + "sampleRate": 48000, + "channels": 2, + "format": "f32le-interleaved" + } + ], + "recovery": "exact-checkpoint", + "determinism": "fixed-build" + }, + "reason": "at most 8 views" + }, + { + "name": "environment descriptor with an unverified recovery mode", + "type": "EnvironmentDescriptor", + "value": { + "backendDigest": "10e08a419e850eba1ebba18fdd28eb7ec1b7e8baa9bcc3b973e2b8891ec726be", + "contentDigest": "ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73", + "configurationDigest": "b7d64a9221007dfd5390f7df6cd5b8f3ea4f82faa1237141e35ebf161f5511a1", + "stepDuration": { + "numerator": "50000000", + "denominator": "3" + }, + "ports": [ + { + "portId": "port-1", + "controls": { + "schema": { + "id": "gameboy.controller.v1", + "version": 1, + "digest": "b1df9d20fe1deaeb52f17cf261bf728b28143c777878b96cacfc4826903a48d5" + }, + "buttons": [ + "a", + "b", + "start", + "select", + "up", + "down", + "left", + "right" + ], + "axes": [ + { + "id": "stick-x", + "range": "bipolar", + "neutral": 0.0 + }, + { + "id": "trigger", + "range": "unit", + "neutral": 0.0 + } + ] + } + } + ], + "inspectionSchema": { + "id": "gameboy.inspection.v1", + "version": 1, + "digest": "1883cee6532d0bfbb28854b82db8450d525c2e4fcec01567b4aa6d99917eafb1" + }, + "views": [ + { + "viewId": "screen", + "width": 160, + "height": 144, + "format": "rgba8", + "rowStride": 640, + "pixelAspect": { + "numerator": 10, + "denominator": 11 + }, + "observationDelaySteps": 0 + } + ], + "audio": [ + { + "streamId": "mix", + "sampleRate": 48000, + "channels": 2, + "format": "f32le-interleaved" + } + ], + "recovery": "best-effort", + "determinism": "fixed-build" + }, + "reason": "recovery is a closed enum" + }, + { + "name": "environment initialize result at boundary one", + "type": "EnvironmentInitializeResult", + "value": { + "descriptor": { + "backendDigest": "10e08a419e850eba1ebba18fdd28eb7ec1b7e8baa9bcc3b973e2b8891ec726be", + "contentDigest": "ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73", + "configurationDigest": "b7d64a9221007dfd5390f7df6cd5b8f3ea4f82faa1237141e35ebf161f5511a1", + "stepDuration": { + "numerator": "50000000", + "denominator": "3" + }, + "ports": [ + { + "portId": "port-1", + "controls": { + "schema": { + "id": "gameboy.controller.v1", + "version": 1, + "digest": "b1df9d20fe1deaeb52f17cf261bf728b28143c777878b96cacfc4826903a48d5" + }, + "buttons": [ + "a", + "b", + "start", + "select", + "up", + "down", + "left", + "right" + ], + "axes": [ + { + "id": "stick-x", + "range": "bipolar", + "neutral": 0.0 + }, + { + "id": "trigger", + "range": "unit", + "neutral": 0.0 + } + ] + } + } + ], + "inspectionSchema": { + "id": "gameboy.inspection.v1", + "version": 1, + "digest": "1883cee6532d0bfbb28854b82db8450d525c2e4fcec01567b4aa6d99917eafb1" + }, + "views": [ + { + "viewId": "screen", + "width": 160, + "height": 144, + "format": "rgba8", + "rowStride": 640, + "pixelAspect": { + "numerator": 10, + "denominator": 11 + }, + "observationDelaySteps": 0 + } + ], + "audio": [ + { + "streamId": "mix", + "sampleRate": 48000, + "channels": 2, + "format": "f32le-interleaved" + } + ], + "recovery": "exact-checkpoint", + "determinism": "fixed-build" + }, + "observation": { + "boundary": "1", + "worldTime": { + "numerator": "50000000", + "denominator": "3" + }, + "engineFrame": "1", + "sensoryViews": [ + { + "viewId": "screen", + "producedStep": "1", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-1", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + } + ], + "inspection": { + "schema": { + "id": "gameboy.inspection.v1", + "version": 1, + "digest": "1883cee6532d0bfbb28854b82db8450d525c2e4fcec01567b4aa6d99917eafb1" + }, + "value": { + "map": 40, + "x": 5, + "y": 7 + } + }, + "broadcastViews": [ + { + "viewId": "screen", + "producedStep": "1", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-1", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + } + ], + "audio": [ + { + "streamId": "mix", + "firstSample": "800", + "sampleFrames": 800, + "samples": { + "storeId": "store-1", + "artifactId": "audio-1", + "generation": "1", + "byteLength": "6400", + "contentType": "audio/x-f32le", + "digest": null + }, + "discontinuity": false + } + ] + } + }, + "reason": "initialization returns boundary 0" + }, + { + "name": "environment initialize result with a nonzero world time", + "type": "EnvironmentInitializeResult", + "value": { + "descriptor": { + "backendDigest": "10e08a419e850eba1ebba18fdd28eb7ec1b7e8baa9bcc3b973e2b8891ec726be", + "contentDigest": "ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73", + "configurationDigest": "b7d64a9221007dfd5390f7df6cd5b8f3ea4f82faa1237141e35ebf161f5511a1", + "stepDuration": { + "numerator": "50000000", + "denominator": "3" + }, + "ports": [ + { + "portId": "port-1", + "controls": { + "schema": { + "id": "gameboy.controller.v1", + "version": 1, + "digest": "b1df9d20fe1deaeb52f17cf261bf728b28143c777878b96cacfc4826903a48d5" + }, + "buttons": [ + "a", + "b", + "start", + "select", + "up", + "down", + "left", + "right" + ], + "axes": [ + { + "id": "stick-x", + "range": "bipolar", + "neutral": 0.0 + }, + { + "id": "trigger", + "range": "unit", + "neutral": 0.0 + } + ] + } + } + ], + "inspectionSchema": { + "id": "gameboy.inspection.v1", + "version": 1, + "digest": "1883cee6532d0bfbb28854b82db8450d525c2e4fcec01567b4aa6d99917eafb1" + }, + "views": [ + { + "viewId": "screen", + "width": 160, + "height": 144, + "format": "rgba8", + "rowStride": 640, + "pixelAspect": { + "numerator": 10, + "denominator": 11 + }, + "observationDelaySteps": 0 + } + ], + "audio": [ + { + "streamId": "mix", + "sampleRate": 48000, + "channels": 2, + "format": "f32le-interleaved" + } + ], + "recovery": "exact-checkpoint", + "determinism": "fixed-build" + }, + "observation": { + "boundary": "0", + "worldTime": { + "numerator": "50000000", + "denominator": "3" + }, + "engineFrame": "1", + "sensoryViews": [ + { + "viewId": "screen", + "producedStep": "0", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-1", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + } + ], + "inspection": { + "schema": { + "id": "gameboy.inspection.v1", + "version": 1, + "digest": "1883cee6532d0bfbb28854b82db8450d525c2e4fcec01567b4aa6d99917eafb1" + }, + "value": { + "map": 40, + "x": 5, + "y": 7 + } + }, + "broadcastViews": [], + "audio": [] + } + }, + "reason": "the initial world time is zero" + }, + { + "name": "port bindings with a duplicate port", + "type": "EnvironmentInitializeParams", + "value": { + "backendConfig": { + "id": "backend-gb", + "digest": "b86130f294ef46c5343a505946e6e0507f061d7a1bcf46f09c6d42e1aeb9a2a1", + "byteLength": "512", + "format": "flybackend" + }, + "taskConfig": { + "id": "task-escape", + "digest": "b48eb1e3b6f871c6384b86387e59d4e3b6c69245168011968d5982204c656ac6", + "byteLength": "256", + "format": "flytask" + }, + "episodeId": "episode-1", + "portBindings": [ + { + "portId": "port-1", + "agentId": "fly-a" + }, + { + "portId": "port-1", + "agentId": "fly-b" + } + ] + }, + "reason": "port bindings are unique by port" + }, + { + "name": "port bindings with a duplicate agent", + "type": "EnvironmentInitializeParams", + "value": { + "backendConfig": { + "id": "backend-gb", + "digest": "b86130f294ef46c5343a505946e6e0507f061d7a1bcf46f09c6d42e1aeb9a2a1", + "byteLength": "512", + "format": "flybackend" + }, + "taskConfig": { + "id": "task-escape", + "digest": "b48eb1e3b6f871c6384b86387e59d4e3b6c69245168011968d5982204c656ac6", + "byteLength": "256", + "format": "flytask" + }, + "episodeId": "episode-1", + "portBindings": [ + { + "portId": "port-1", + "agentId": "fly-a" + }, + { + "portId": "port-2", + "agentId": "fly-a" + } + ] + }, + "reason": "port bindings are unique by agent" + }, + { + "name": "advance params with a duplicate port", + "type": "AdvanceParams", + "value": { + "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 + } + ] + }, + { + "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 + } + ] + } + ] + }, + "reason": "one control per port" + }, + { + "name": "advance params with five ports", + "type": "AdvanceParams", + "value": { + "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 + } + ] + }, + { + "portId": "port-2", + "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 + } + ] + }, + { + "portId": "port-3", + "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 + } + ] + }, + { + "portId": "port-4", + "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 + } + ] + }, + { + "portId": "port-5", + "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 + } + ] + } + ] + }, + "reason": "at most four ports" + }, + { + "name": "advance params with no controls", + "type": "AdvanceParams", + "value": { + "batchId": "batch-41", + "controls": [] + }, + "reason": "an advance applies a complete batch" + }, + { + "name": "step result skipping a step", + "type": "StepResult", + "value": { + "batchId": "batch-41", + "appliedFromStep": "41", + "nextStep": "43", + "appliedControlsDigest": "1e2135d1b50f14d3d3d6cbe24a4294bcb9e4d5d3c28eb1ce4980ff2a17c5d3b6", + "observation": { + "boundary": "43", + "worldTime": { + "numerator": "50000000", + "denominator": "3" + }, + "engineFrame": "1", + "sensoryViews": [ + { + "viewId": "screen", + "producedStep": "43", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-1", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + } + ], + "inspection": { + "schema": { + "id": "gameboy.inspection.v1", + "version": 1, + "digest": "1883cee6532d0bfbb28854b82db8450d525c2e4fcec01567b4aa6d99917eafb1" + }, + "value": { + "map": 40, + "x": 5, + "y": 7 + } + }, + "broadcastViews": [ + { + "viewId": "screen", + "producedStep": "43", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-1", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + } + ], + "audio": [ + { + "streamId": "mix", + "firstSample": "34400", + "sampleFrames": 800, + "samples": { + "storeId": "store-1", + "artifactId": "audio-1", + "generation": "1", + "byteLength": "6400", + "contentType": "audio/x-f32le", + "digest": null + }, + "discontinuity": false + } + ] + } + }, + "reason": "one result is exactly one step" + }, + { + "name": "step result whose observation is the old boundary", + "type": "StepResult", + "value": { + "batchId": "batch-41", + "appliedFromStep": "41", + "nextStep": "42", + "appliedControlsDigest": "1e2135d1b50f14d3d3d6cbe24a4294bcb9e4d5d3c28eb1ce4980ff2a17c5d3b6", + "observation": { + "boundary": "41", + "worldTime": { + "numerator": "50000000", + "denominator": "3" + }, + "engineFrame": "1", + "sensoryViews": [ + { + "viewId": "screen", + "producedStep": "41", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-1", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + } + ], + "inspection": { + "schema": { + "id": "gameboy.inspection.v1", + "version": 1, + "digest": "1883cee6532d0bfbb28854b82db8450d525c2e4fcec01567b4aa6d99917eafb1" + }, + "value": { + "map": 40, + "x": 5, + "y": 7 + } + }, + "broadcastViews": [ + { + "viewId": "screen", + "producedStep": "41", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-1", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + } + ], + "audio": [ + { + "streamId": "mix", + "firstSample": "32800", + "sampleFrames": 800, + "samples": { + "storeId": "store-1", + "artifactId": "audio-1", + "generation": "1", + "byteLength": "6400", + "contentType": "audio/x-f32le", + "digest": null + }, + "discontinuity": false + } + ] + } + }, + "reason": "the observation boundary is nextStep" + }, + { + "name": "prepared decision advancing more ticks than the brain has", + "type": "PreparedDecision", + "value": { + "agentId": "fly-a", + "ticksAdvanced": "18", + "brainTicks": "17", + "remainder": { + "numerator": "0", + "denominator": "1" + }, + "decision": { + "schema": { + "id": "gameboy.intent.v1", + "version": 1, + "digest": "e70451b26fb5462ec63729d8355912dad01c3963a95bec0e6a8452375717ad9d" + }, + "value": {} + } + }, + "reason": "ticksAdvanced <= brainTicks" + }, + { + "name": "agent initialize result at a nonzero committed step", + "type": "AgentInitializeResult", + "value": { + "agentId": "fly-a", + "profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0", + "tickDuration": { + "numerator": "1000000", + "denominator": "1" + }, + "warmupTicks": "2500", + "committedStep": "1", + "decisionContextDigest": "ea7792a26f405e2ae9c6f49ca93bbe6076ceac0a1fc53d83426c7d7f2d9377e4", + "telemetry": { + "brainTicks": "2500", + "populationRateHz": 12.5, + "rates": [ + { + "roleId": "kenyon", + "hz": 3.25 + }, + { + "roleId": "mbon", + "hz": 0.0 + } + ], + "learning": { + "enabled": true, + "updates": "4", + "changed": "2", + "signal": 0.5 + } + } + }, + "reason": "initialization establishes Ready(0)" + }, + { + "name": "agent initialize result with a zero tick duration", + "type": "AgentInitializeResult", + "value": { + "agentId": "fly-a", + "profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0", + "tickDuration": { + "numerator": "0", + "denominator": "1" + }, + "warmupTicks": "2500", + "committedStep": "0", + "decisionContextDigest": "ea7792a26f405e2ae9c6f49ca93bbe6076ceac0a1fc53d83426c7d7f2d9377e4", + "telemetry": { + "brainTicks": "2500", + "populationRateHz": 12.5, + "rates": [ + { + "roleId": "kenyon", + "hz": 3.25 + }, + { + "roleId": "mbon", + "hz": 0.0 + } + ], + "learning": { + "enabled": true, + "updates": "4", + "changed": "2", + "signal": 0.5 + } + } + }, + "reason": "durations are positive" + }, + { + "name": "hello result for an agent without agent-step-v1", + "type": "HelloResult", + "value": { + "selectedMajor": 1, + "selectedMinor": 0, + "workerId": "fly-a", + "incarnationId": "inc-1", + "role": "agent", + "buildDigest": "44575cf5b28512d75644bf54a517dcef304ff809fd511747621b4d64f19aac66", + "contractDigest": "cc8321d6375c494d043fdd0260f21bc0ec51dacc9f6abb7f909cdcd3041b78bf", + "capabilities": [ + "checkpoint-v1" + ], + "limits": { + "maxAgents": 4, + "maxPorts": 4 + } + }, + "reason": "an agent must advertise agent-step-v1" + }, + { + "name": "hello result promising five agents", + "type": "HelloResult", + "value": { + "selectedMajor": 1, + "selectedMinor": 0, + "workerId": "fly-a", + "incarnationId": "inc-1", + "role": "agent", + "buildDigest": "44575cf5b28512d75644bf54a517dcef304ff809fd511747621b4d64f19aac66", + "contractDigest": "cc8321d6375c494d043fdd0260f21bc0ec51dacc9f6abb7f909cdcd3041b78bf", + "capabilities": [ + "agent-step-v1" + ], + "limits": { + "maxAgents": 5, + "maxPorts": 4 + } + }, + "reason": "the first composition allows four agents" + }, + { + "name": "hello result selecting major 2", + "type": "HelloResult", + "value": { + "selectedMajor": 2, + "selectedMinor": 0, + "workerId": "fly-a", + "incarnationId": "inc-1", + "role": "agent", + "buildDigest": "44575cf5b28512d75644bf54a517dcef304ff809fd511747621b4d64f19aac66", + "contractDigest": "cc8321d6375c494d043fdd0260f21bc0ec51dacc9f6abb7f909cdcd3041b78bf", + "capabilities": [ + "agent-step-v1" + ], + "limits": { + "maxAgents": 4, + "maxPorts": 4 + } + }, + "reason": "v1 selects major 1" + }, + { + "name": "hello params with no supported majors", + "type": "HelloParams", + "value": { + "sessionId": "demo", + "expectedWorkerId": "fly-a", + "role": "agent", + "supportedMajors": [] + }, + "reason": "at least one major" + }, + { + "name": "hello params with an unknown role", + "type": "HelloParams", + "value": { + "sessionId": "demo", + "expectedWorkerId": "fly-a", + "role": "presenter", + "supportedMajors": [ + 1 + ] + }, + "reason": "role is a closed enum" + }, + { + "name": "status result that is uninitialized but scoped", + "type": "StatusResult", + "value": { + "state": "uninitialized", + "currentScope": { + "sessionId": "demo", + "epoch": "epoch-1", + "step": "41" + }, + "activeRequestId": null, + "lastCompletedRequestId": null, + "lastBatchId": null, + "progressCounter": "0" + }, + "reason": "an uninitialized worker has no scope" + }, + { + "name": "status result in an unknown phase", + "type": "StatusResult", + "value": { + "state": "thinking", + "currentScope": null, + "activeRequestId": null, + "lastCompletedRequestId": null, + "lastBatchId": null, + "progressCounter": "0" + }, + "reason": "state is a closed enum" + }, + { + "name": "acknowledge params with no ids", + "type": "AcknowledgeParams", + "value": { + "requestIds": [] + }, + "reason": "1..=16 ids" + }, + { + "name": "acknowledge params with 17 ids", + "type": "AcknowledgeParams", + "value": { + "requestIds": [ + "req-1", + "req-2", + "req-3", + "req-4", + "req-5", + "req-6", + "req-7", + "req-8", + "req-9", + "req-10", + "req-11", + "req-12", + "req-13", + "req-14", + "req-15", + "req-16", + "req-17" + ] + }, + "reason": "1..=16 ids" + }, + { + "name": "acknowledge params with a duplicate id", + "type": "AcknowledgeParams", + "value": { + "requestIds": [ + "req-1", + "req-1" + ] + }, + "reason": "ids are unique" + }, + { + "name": "acknowledge params with a bus call id", + "type": "AcknowledgeParams", + "value": { + "requestIds": [ + "call-1" + ] + }, + "reason": "a bus callId is not a domain requestId" + }, + { + "name": "request whose id is a bus call id", + "type": "SessionRpcRequest", + "value": { + "requestId": "call-41", + "scope": { + "sessionId": "demo", + "epoch": "epoch-1", + "step": "41" + }, + "params": {} + }, + "reason": "a bus callId is not a domain requestId" + }, + { + "name": "request whose id has a leading zero", + "type": "SessionRpcRequest", + "value": { + "requestId": "req-041", + "scope": { + "sessionId": "demo", + "epoch": "epoch-1", + "step": "41" + }, + "params": {} + }, + "reason": "the serial is a canonical U64" + }, + { + "name": "request carrying a bus call id in its params", + "type": "SessionRpcRequest", + "value": { + "requestId": "req-41", + "scope": { + "sessionId": "demo", + "epoch": "epoch-1", + "step": "41" + }, + "params": { + "callId": "call-2" + } + }, + "reason": "bus identities are never part of a domain body" + }, + { + "name": "request carrying an owner token in its params", + "type": "SessionRpcRequest", + "value": { + "requestId": "req-41", + "scope": { + "sessionId": "demo", + "epoch": "epoch-1", + "step": "41" + }, + "params": { + "frame": { + "ownerId": "own-3" + } + } + }, + "reason": "bus identities are never part of a domain body" + }, + { + "name": "request with array params", + "type": "SessionRpcRequest", + "value": { + "requestId": "req-41", + "scope": { + "sessionId": "demo", + "epoch": "epoch-1", + "step": "41" + }, + "params": [] + }, + "reason": "params is an object" + }, + { + "name": "failure claiming a mutation for a pre-mutation code", + "type": "SessionRpcFailure", + "value": { + "type": "error", + "requestId": "req-41", + "workerId": "fly-a", + "incarnationId": "inc-1", + "scope": { + "sessionId": "demo", + "epoch": "epoch-1", + "step": "41" + }, + "error": { + "code": "INVALID_ARGUMENT", + "message": "bad range", + "mutation": "applied" + } + }, + "reason": "INVALID_ARGUMENT is raised before mutation" + }, + { + "name": "failure with an unknown code", + "type": "SessionRpcFailure", + "value": { + "type": "error", + "requestId": "req-41", + "workerId": "fly-a", + "incarnationId": "inc-1", + "scope": { + "sessionId": "demo", + "epoch": "epoch-1", + "step": "41" + }, + "error": { + "code": "TIMEOUT", + "message": "late", + "mutation": "unknown" + } + }, + "reason": "the code list is closed" + }, + { + "name": "failure with an unknown mutation certainty", + "type": "SessionRpcFailure", + "value": { + "type": "error", + "requestId": "req-41", + "workerId": "fly-a", + "incarnationId": "inc-1", + "scope": { + "sessionId": "demo", + "epoch": "epoch-1", + "step": "41" + }, + "error": { + "code": "INTERNAL", + "message": "fault", + "mutation": "maybe" + } + }, + "reason": "mutation is none, applied or unknown" + }, + { + "name": "success labelled as an error", + "type": "SessionRpcSuccess", + "value": { + "type": "error", + "requestId": "req-41", + "workerId": "fly-a", + "incarnationId": "inc-1", + "scope": { + "sessionId": "demo", + "epoch": "epoch-1", + "step": "41" + }, + "result": {} + }, + "reason": "the type discriminant is exact" + }, + { + "name": "capture result without a payload digest", + "type": "CaptureResult", + "value": { + "checkpointId": "ckpt-1", + "boundary": "42", + "compatibilityDigest": "2b33a9570d42f8af1c84dbe8b8c9bb50664f9b19ca8f7ee100548b9945a52f5d", + "payload": { + "storeId": "store-1", + "artifactId": "payload-1", + "generation": "1", + "byteLength": "1024", + "contentType": "application/octet-stream", + "digest": null + } + }, + "reason": "checkpoint payload digests are mandatory" + }, + { + "name": "stage restore params without a payload digest", + "type": "StageRestoreParams", + "value": { + "checkpointId": "ckpt-1", + "sourceScope": { + "sessionId": "demo", + "epoch": "epoch-1", + "step": "42" + }, + "compatibilityDigest": "2b33a9570d42f8af1c84dbe8b8c9bb50664f9b19ca8f7ee100548b9945a52f5d", + "payload": { + "storeId": "store-1", + "artifactId": "payload-2", + "generation": "1", + "byteLength": "1024", + "contentType": "application/octet-stream", + "digest": null + } + }, + "reason": "checkpoint payload digests are mandatory" + }, + { + "name": "activate restore result whose observation is another boundary", + "type": "ActivateRestoreResult", + "value": { + "committedStep": "42", + "checkpointId": "ckpt-1", + "observation": { + "boundary": "41", + "worldTime": { + "numerator": "50000000", + "denominator": "3" + }, + "engineFrame": "1", + "sensoryViews": [ + { + "viewId": "screen", + "producedStep": "41", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-1", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + } + ], + "inspection": { + "schema": { + "id": "gameboy.inspection.v1", + "version": 1, + "digest": "1883cee6532d0bfbb28854b82db8450d525c2e4fcec01567b4aa6d99917eafb1" + }, + "value": { + "map": 40, + "x": 5, + "y": 7 + } + }, + "broadcastViews": [ + { + "viewId": "screen", + "producedStep": "41", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-1", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + } + ], + "audio": [ + { + "streamId": "mix", + "firstSample": "32800", + "sampleFrames": 800, + "samples": { + "storeId": "store-1", + "artifactId": "audio-1", + "generation": "1", + "byteLength": "6400", + "contentType": "audio/x-f32le", + "digest": null + }, + "discontinuity": false + } + ] + } + }, + "reason": "the restored observation is the committed boundary" + }, + { + "name": "session descriptor with another scheduler", + "type": "SessionDescriptor", + "value": { + "sessionId": "demo", + "revision": "7", + "compositionDigest": "730d725c8a59d3a7303def2bed041a577edb4255aabd4889ce12918311d952f0", + "schedulerId": "legacy-gameboy-v1", + "environment": { + "backendDigest": "10e08a419e850eba1ebba18fdd28eb7ec1b7e8baa9bcc3b973e2b8891ec726be", + "contentDigest": "ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73", + "configurationDigest": "b7d64a9221007dfd5390f7df6cd5b8f3ea4f82faa1237141e35ebf161f5511a1", + "stepDuration": { + "numerator": "50000000", + "denominator": "3" + }, + "ports": [ + { + "portId": "port-1", + "controls": { + "schema": { + "id": "gameboy.controller.v1", + "version": 1, + "digest": "b1df9d20fe1deaeb52f17cf261bf728b28143c777878b96cacfc4826903a48d5" + }, + "buttons": [ + "a", + "b", + "start", + "select", + "up", + "down", + "left", + "right" + ], + "axes": [ + { + "id": "stick-x", + "range": "bipolar", + "neutral": 0.0 + }, + { + "id": "trigger", + "range": "unit", + "neutral": 0.0 + } + ] + } + } + ], + "inspectionSchema": { + "id": "gameboy.inspection.v1", + "version": 1, + "digest": "1883cee6532d0bfbb28854b82db8450d525c2e4fcec01567b4aa6d99917eafb1" + }, + "views": [ + { + "viewId": "screen", + "width": 160, + "height": 144, + "format": "rgba8", + "rowStride": 640, + "pixelAspect": { + "numerator": 10, + "denominator": 11 + }, + "observationDelaySteps": 0 + } + ], + "audio": [ + { + "streamId": "mix", + "sampleRate": 48000, + "channels": 2, + "format": "f32le-interleaved" + } + ], + "recovery": "exact-checkpoint", + "determinism": "fixed-build" + }, + "taskSchema": { + "id": "pokemon.task.v1", + "version": 1, + "digest": "e4ca46e969a0f0e53a4f537561360851269e0b3f684eccaa634fe9672fdaabb4" + }, + "agents": [ + { + "agentId": "fly-a", + "portId": "port-1", + "profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0", + "datasetDigest": "6c0af1f0784ef63a393ee77d614e8246c625051360f3f1a48838374c5d355b52", + "indexDigest": "1bc04b5291c26a46d918139138b992d2de976d6851d0893b0476b85bfbdfc6e6", + "neuronCount": "139255", + "rateRoles": [ + "kenyon", + "mbon" + ], + "supportedStimuli": [ + "sugar" + ] + } + ], + "assets": [] + }, + "reason": "lockstep-v1 is the scheduler of this contract" + }, + { + "name": "session descriptor binding an undeclared port", + "type": "SessionDescriptor", + "value": { + "sessionId": "demo", + "revision": "7", + "compositionDigest": "730d725c8a59d3a7303def2bed041a577edb4255aabd4889ce12918311d952f0", + "schedulerId": "lockstep-v1", + "environment": { + "backendDigest": "10e08a419e850eba1ebba18fdd28eb7ec1b7e8baa9bcc3b973e2b8891ec726be", + "contentDigest": "ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73", + "configurationDigest": "b7d64a9221007dfd5390f7df6cd5b8f3ea4f82faa1237141e35ebf161f5511a1", + "stepDuration": { + "numerator": "50000000", + "denominator": "3" + }, + "ports": [ + { + "portId": "port-1", + "controls": { + "schema": { + "id": "gameboy.controller.v1", + "version": 1, + "digest": "b1df9d20fe1deaeb52f17cf261bf728b28143c777878b96cacfc4826903a48d5" + }, + "buttons": [ + "a", + "b", + "start", + "select", + "up", + "down", + "left", + "right" + ], + "axes": [ + { + "id": "stick-x", + "range": "bipolar", + "neutral": 0.0 + }, + { + "id": "trigger", + "range": "unit", + "neutral": 0.0 + } + ] + } + } + ], + "inspectionSchema": { + "id": "gameboy.inspection.v1", + "version": 1, + "digest": "1883cee6532d0bfbb28854b82db8450d525c2e4fcec01567b4aa6d99917eafb1" + }, + "views": [ + { + "viewId": "screen", + "width": 160, + "height": 144, + "format": "rgba8", + "rowStride": 640, + "pixelAspect": { + "numerator": 10, + "denominator": 11 + }, + "observationDelaySteps": 0 + } + ], + "audio": [ + { + "streamId": "mix", + "sampleRate": 48000, + "channels": 2, + "format": "f32le-interleaved" + } + ], + "recovery": "exact-checkpoint", + "determinism": "fixed-build" + }, + "taskSchema": { + "id": "pokemon.task.v1", + "version": 1, + "digest": "e4ca46e969a0f0e53a4f537561360851269e0b3f684eccaa634fe9672fdaabb4" + }, + "agents": [ + { + "agentId": "fly-a", + "portId": "port-9", + "profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0", + "datasetDigest": "6c0af1f0784ef63a393ee77d614e8246c625051360f3f1a48838374c5d355b52", + "indexDigest": "1bc04b5291c26a46d918139138b992d2de976d6851d0893b0476b85bfbdfc6e6", + "neuronCount": "139255", + "rateRoles": [ + "kenyon", + "mbon" + ], + "supportedStimuli": [ + "sugar" + ] + } + ], + "assets": [] + }, + "reason": "an agent's port must be declared by the environment" + }, + { + "name": "session descriptor with five agents", + "type": "SessionDescriptor", + "value": { + "sessionId": "demo", + "revision": "7", + "compositionDigest": "730d725c8a59d3a7303def2bed041a577edb4255aabd4889ce12918311d952f0", + "schedulerId": "lockstep-v1", + "environment": { + "backendDigest": "10e08a419e850eba1ebba18fdd28eb7ec1b7e8baa9bcc3b973e2b8891ec726be", + "contentDigest": "ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73", + "configurationDigest": "b7d64a9221007dfd5390f7df6cd5b8f3ea4f82faa1237141e35ebf161f5511a1", + "stepDuration": { + "numerator": "50000000", + "denominator": "3" + }, + "ports": [ + { + "portId": "port-1", + "controls": { + "schema": { + "id": "gameboy.controller.v1", + "version": 1, + "digest": "b1df9d20fe1deaeb52f17cf261bf728b28143c777878b96cacfc4826903a48d5" + }, + "buttons": [ + "a", + "b", + "start", + "select", + "up", + "down", + "left", + "right" + ], + "axes": [ + { + "id": "stick-x", + "range": "bipolar", + "neutral": 0.0 + }, + { + "id": "trigger", + "range": "unit", + "neutral": 0.0 + } + ] + } + }, + { + "portId": "port-2", + "controls": { + "schema": { + "id": "gameboy.controller.v1", + "version": 1, + "digest": "b1df9d20fe1deaeb52f17cf261bf728b28143c777878b96cacfc4826903a48d5" + }, + "buttons": [ + "a", + "b", + "start", + "select", + "up", + "down", + "left", + "right" + ], + "axes": [ + { + "id": "stick-x", + "range": "bipolar", + "neutral": 0.0 + }, + { + "id": "trigger", + "range": "unit", + "neutral": 0.0 + } + ] + } + }, + { + "portId": "port-3", + "controls": { + "schema": { + "id": "gameboy.controller.v1", + "version": 1, + "digest": "b1df9d20fe1deaeb52f17cf261bf728b28143c777878b96cacfc4826903a48d5" + }, + "buttons": [ + "a", + "b", + "start", + "select", + "up", + "down", + "left", + "right" + ], + "axes": [ + { + "id": "stick-x", + "range": "bipolar", + "neutral": 0.0 + }, + { + "id": "trigger", + "range": "unit", + "neutral": 0.0 + } + ] + } + }, + { + "portId": "port-4", + "controls": { + "schema": { + "id": "gameboy.controller.v1", + "version": 1, + "digest": "b1df9d20fe1deaeb52f17cf261bf728b28143c777878b96cacfc4826903a48d5" + }, + "buttons": [ + "a", + "b", + "start", + "select", + "up", + "down", + "left", + "right" + ], + "axes": [ + { + "id": "stick-x", + "range": "bipolar", + "neutral": 0.0 + }, + { + "id": "trigger", + "range": "unit", + "neutral": 0.0 + } + ] + } + } + ], + "inspectionSchema": { + "id": "gameboy.inspection.v1", + "version": 1, + "digest": "1883cee6532d0bfbb28854b82db8450d525c2e4fcec01567b4aa6d99917eafb1" + }, + "views": [ + { + "viewId": "screen", + "width": 160, + "height": 144, + "format": "rgba8", + "rowStride": 640, + "pixelAspect": { + "numerator": 10, + "denominator": 11 + }, + "observationDelaySteps": 0 + } + ], + "audio": [ + { + "streamId": "mix", + "sampleRate": 48000, + "channels": 2, + "format": "f32le-interleaved" + } + ], + "recovery": "exact-checkpoint", + "determinism": "fixed-build" + }, + "taskSchema": { + "id": "pokemon.task.v1", + "version": 1, + "digest": "e4ca46e969a0f0e53a4f537561360851269e0b3f684eccaa634fe9672fdaabb4" + }, + "agents": [ + { + "agentId": "fly-1", + "portId": "port-1", + "profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0", + "datasetDigest": "6c0af1f0784ef63a393ee77d614e8246c625051360f3f1a48838374c5d355b52", + "indexDigest": "1bc04b5291c26a46d918139138b992d2de976d6851d0893b0476b85bfbdfc6e6", + "neuronCount": "139255", + "rateRoles": [ + "kenyon", + "mbon" + ], + "supportedStimuli": [ + "sugar" + ] + }, + { + "agentId": "fly-2", + "portId": "port-2", + "profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0", + "datasetDigest": "6c0af1f0784ef63a393ee77d614e8246c625051360f3f1a48838374c5d355b52", + "indexDigest": "1bc04b5291c26a46d918139138b992d2de976d6851d0893b0476b85bfbdfc6e6", + "neuronCount": "139255", + "rateRoles": [ + "kenyon", + "mbon" + ], + "supportedStimuli": [ + "sugar" + ] + }, + { + "agentId": "fly-3", + "portId": "port-3", + "profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0", + "datasetDigest": "6c0af1f0784ef63a393ee77d614e8246c625051360f3f1a48838374c5d355b52", + "indexDigest": "1bc04b5291c26a46d918139138b992d2de976d6851d0893b0476b85bfbdfc6e6", + "neuronCount": "139255", + "rateRoles": [ + "kenyon", + "mbon" + ], + "supportedStimuli": [ + "sugar" + ] + }, + { + "agentId": "fly-4", + "portId": "port-4", + "profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0", + "datasetDigest": "6c0af1f0784ef63a393ee77d614e8246c625051360f3f1a48838374c5d355b52", + "indexDigest": "1bc04b5291c26a46d918139138b992d2de976d6851d0893b0476b85bfbdfc6e6", + "neuronCount": "139255", + "rateRoles": [ + "kenyon", + "mbon" + ], + "supportedStimuli": [ + "sugar" + ] + }, + { + "agentId": "fly-5", + "portId": "port-5", + "profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0", + "datasetDigest": "6c0af1f0784ef63a393ee77d614e8246c625051360f3f1a48838374c5d355b52", + "indexDigest": "1bc04b5291c26a46d918139138b992d2de976d6851d0893b0476b85bfbdfc6e6", + "neuronCount": "139255", + "rateRoles": [ + "kenyon", + "mbon" + ], + "supportedStimuli": [ + "sugar" + ] + } + ], + "assets": [] + }, + "reason": "the first composition allows four agents" + }, + { + "name": "snapshot with a decision at boundary zero", + "type": "CommittedSnapshot", + "value": { + "descriptorRevision": "7", + "publisherIncarnation": "pub-1", + "scope": { + "sessionId": "demo", + "epoch": "epoch-1", + "step": "0" + }, + "episodeId": "episode-1", + "sequence": "0", + "worldTime": { + "numerator": "0", + "denominator": "1" + }, + "agents": [ + { + "agentId": "fly-a", + "telemetry": { + "brainTicks": "2500", + "populationRateHz": 12.5, + "rates": [ + { + "roleId": "kenyon", + "hz": 3.25 + }, + { + "roleId": "mbon", + "hz": 0.0 + } + ], + "learning": { + "enabled": true, + "updates": "4", + "changed": "2", + "signal": 0.5 + } + }, + "selectedDecision": { + "schema": { + "id": "gameboy.intent.v1", + "version": 1, + "digest": "e70451b26fb5462ec63729d8355912dad01c3963a95bec0e6a8452375717ad9d" + }, + "value": { + "press": "a" + } + }, + "appliedControls": null + } + ], + "progress": { + "schema": { + "id": "pokemon.progress.v1", + "version": 1, + "digest": "80fa973157f334bb3208dfff6f7a2f298e79f73f07c72c33f5725975c6cff8e1" + }, + "value": { + "rank": 0 + } + }, + "media": { + "views": [], + "audio": [] + }, + "eventIds": [] + }, + "reason": "boundary 0 has no preceding transition" + }, + { + "name": "snapshot without controls after a transition", + "type": "CommittedSnapshot", + "value": { + "descriptorRevision": "7", + "publisherIncarnation": "pub-1", + "scope": { + "sessionId": "demo", + "epoch": "epoch-1", + "step": "42" + }, + "episodeId": "episode-1", + "sequence": "42", + "worldTime": { + "numerator": "50000000", + "denominator": "3" + }, + "agents": [ + { + "agentId": "fly-a", + "telemetry": { + "brainTicks": "2534", + "populationRateHz": 12.5, + "rates": [ + { + "roleId": "kenyon", + "hz": 3.25 + }, + { + "roleId": "mbon", + "hz": 0.0 + } + ], + "learning": { + "enabled": true, + "updates": "4", + "changed": "2", + "signal": 0.5 + } + }, + "selectedDecision": { + "schema": { + "id": "gameboy.intent.v1", + "version": 1, + "digest": "e70451b26fb5462ec63729d8355912dad01c3963a95bec0e6a8452375717ad9d" + }, + "value": { + "press": "a" + } + }, + "appliedControls": null + } + ], + "progress": { + "schema": { + "id": "pokemon.progress.v1", + "version": 1, + "digest": "80fa973157f334bb3208dfff6f7a2f298e79f73f07c72c33f5725975c6cff8e1" + }, + "value": { + "rank": 10 + } + }, + "media": { + "views": [], + "audio": [] + }, + "eventIds": [] + }, + "reason": "a committed transition has applied controls" + }, + { + "name": "trace whose commit acknowledgment is the old boundary", + "type": "TransitionTrace", + "value": { + "behaviour": { + "scope": { + "sessionId": "demo", + "epoch": "epoch-1", + "step": "41" + }, + "agents": [ + { + "agentId": "fly-a", + "profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0", + "ticksAdvanced": "17", + "brainTicks": "2517", + "remainder": { + "numerator": "0", + "denominator": "1" + }, + "decisionDigest": "18ac3e7343f016890c510e93f935261169d9e3f565436429830faf0934f4f8e4", + "committedStep": "41" + } + ], + "batchId": "batch-41", + "controlDigest": "1e2135d1b50f14d3d3d6cbe24a4294bcb9e4d5d3c28eb1ce4980ff2a17c5d3b6", + "acknowledgedBoundary": "42", + "observationBoundaries": [], + "outcomeIds": [], + "eventIds": [], + "publishedBoundary": "42" + }, + "operational": { + "wallTimeNs": "1", + "prepareRequestIds": [ + { + "agentId": "fly-a", + "requestId": "req-1" + } + ], + "advanceRequestId": "req-2", + "commitRequestIds": [ + { + "agentId": "fly-a", + "requestId": "req-3" + } + ], + "busCallIds": [], + "deliveryIds": [] + } + }, + "reason": "a commit acknowledges the transition's next boundary" + } + ] +} \ No newline at end of file diff --git a/services/flysim/crates/fly-session-types/fixtures/operations.json b/services/flysim/crates/fly-session-types/fixtures/operations.json new file mode 100644 index 0000000..3c35a6a --- /dev/null +++ b/services/flysim/crates/fly-session-types/fixtures/operations.json @@ -0,0 +1,667 @@ +{ + "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" + } + ] +} diff --git a/services/flysim/crates/fly-session-types/fixtures/rational.json b/services/flysim/crates/fly-session-types/fixtures/rational.json new file mode 100644 index 0000000..17b3634 --- /dev/null +++ b/services/flysim/crates/fly-session-types/fixtures/rational.json @@ -0,0 +1,273 @@ +{ + "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" + } + ] +} \ No newline at end of file diff --git a/services/flysim/crates/fly-session-types/fixtures/raw.json b/services/flysim/crates/fly-session-types/fixtures/raw.json new file mode 100644 index 0000000..9fed80b --- /dev/null +++ b/services/flysim/crates/fly-session-types/fixtures/raw.json @@ -0,0 +1,77 @@ +{ + "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" + } + ] +} \ No newline at end of file diff --git a/services/flysim/crates/fly-session-types/fixtures/schema-set.json b/services/flysim/crates/fly-session-types/fixtures/schema-set.json new file mode 100644 index 0000000..2973a86 --- /dev/null +++ b/services/flysim/crates/fly-session-types/fixtures/schema-set.json @@ -0,0 +1 @@ +{"contract":"fly-session-types","enums":[{"members":["f32le-interleaved"],"name":"AudioFormat","source":"state-media-v1 2"},{"members":["bipolar","unit"],"name":"AxisRange","source":"workers-v1 3"},{"members":["fixed-build","unverified"],"name":"Determinism","source":"workers-v1 3"},{"members":["terminal"],"name":"EpisodeRequestKind","source":"workers-v1 4"},{"members":["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"],"name":"ErrorCode","source":"ipc-v1 7"},{"members":["none","applied","unknown"],"name":"MutationCertainty","source":"ipc-v1 3"},{"members":["exact-checkpoint","episode-restart"],"name":"Recovery","source":"workers-v1 3"},{"members":["agent","environment","coordinator"],"name":"Role","source":"ipc-v1 4"},{"members":["lockstep-v1"],"name":"SchedulerId","source":"publishing-v1 3"},{"members":["rgba8"],"name":"ViewFormat","source":"state-media-v1 2"},{"members":["uninitialized","ready","preparing","prepared","advancing","committing","capturing","staged-restore","restoring","failed","stopping"],"name":"WorkerState","source":"ipc-v1 4"}],"limits":[{"name":"maxAcknowledge","source":"ipc-v1 5","value":16},{"name":"maxAgents","source":"ipc-v1 2","value":4},{"name":"maxAssets","source":"crate","value":64},{"name":"maxAttachments","source":"bus-v1 4","value":32},{"name":"maxAudioStreams","source":"crate","value":8},{"name":"maxAxes","source":"workers-v1 3","value":16},{"name":"maxButtons","source":"workers-v1 3","value":32},{"name":"maxCapabilities","source":"crate","value":32},{"name":"maxEngineFrameLength","source":"workers-v1 3","value":64},{"name":"maxEnvelopeBytes","source":"bus-v1 4","value":65536},{"name":"maxMessageCodePoints","source":"ipc-v1 7","value":512},{"name":"maxObservationDelaySteps","source":"state-media-v1 2","value":8},{"name":"maxPixelAspectPart","source":"state-media-v1 2","value":65535},{"name":"maxPorts","source":"ipc-v1 2","value":4},{"name":"maxRateRoles","source":"ipc-v1 2","value":64},{"name":"maxRewardsPerOperation","source":"workers-v1 1","value":64},{"name":"maxSampleFrames","source":"state-media-v1 2","value":192000},{"name":"maxSchemaVersion","source":"ipc-v1 2","value":65535},{"name":"maxSnapshotEvents","source":"crate","value":64},{"name":"maxStimuliPerOperation","source":"workers-v1 1","value":64},{"name":"maxSupportedMajors","source":"crate","value":8},{"name":"maxSupportedStimuli","source":"crate","value":64},{"name":"maxTypedValueBytes","source":"ipc-v1 2","value":32768},{"name":"maxViewDimension","source":"state-media-v1 2","value":4096},{"name":"maxViews","source":"workers-v1 1","value":8}],"scalars":{"ArtifactIdentity":"storeId, artifactId and generation of a bus ArtifactRef","BusCallId":"call-","Digest":"64 lowercase hexadecimal digits (SHA-256)","DomainRequestId":"req-","Id":"^[a-z0-9][a-z0-9._-]{0,63}$","OwnerToken":"dlv- or own-","U64":"\"0\" or [1-9][0-9]*, <= 18446744073709551615"},"types":[{"fields":[{"constraint":"1..=16, unique","kind":"array","name":"requestIds","required":true}],"name":"AcknowledgeParams","source":"ipc-v1 5"},{"fields":[{"constraint":"<= 16, unique, a subset of the request","kind":"array","name":"acknowledged","required":true}],"name":"AcknowledgeResult","source":"ipc-v1 5"},{"fields":[{"constraint":"","kind":"Id","name":"restoreToken","required":true}],"name":"ActivateRestoreParams","source":"state-media-v1 5"},{"fields":[{"constraint":"","kind":"U64","name":"committedStep","required":true},{"constraint":"","kind":"Id","name":"checkpointId","required":true},{"constraint":"required from an environment, null from an agent","kind":"WorldObservation|null","name":"observation","required":false}],"name":"ActivateRestoreResult","source":"state-media-v1 5"},{"fields":[{"constraint":"unique within an epoch","kind":"Id","name":"batchId","required":true},{"constraint":"one complete batch: every declared port once, descriptor order","kind":"array","name":"controls","required":true}],"name":"AdvanceParams","source":"workers-v1 3"},{"fields":[{"constraint":"","kind":"Id","name":"agentId","required":true},{"constraint":"k+1","kind":"U64","name":"committedStep","required":true},{"constraint":"","kind":"Digest","name":"decisionContextDigest","required":true},{"constraint":"","kind":"AgentTelemetry","name":"telemetry","required":true}],"name":"AgentCommitResult","source":"workers-v1 2"},{"fields":[{"constraint":"","kind":"Id","name":"agentId","required":true},{"constraint":"","kind":"Id","name":"portId","required":true},{"constraint":"","kind":"Digest","name":"profileDigest","required":true},{"constraint":"","kind":"Digest","name":"datasetDigest","required":true},{"constraint":"geometry mapping needs this, not neuronCount","kind":"Digest","name":"indexDigest","required":true},{"constraint":"","kind":"U64","name":"neuronCount","required":true},{"constraint":"<= 64, unique","kind":"array","name":"rateRoles","required":true},{"constraint":"unique","kind":"array","name":"supportedStimuli","required":true}],"name":"AgentDescriptor","source":"publishing-v1 3"},{"fields":[{"constraint":"","kind":"Id","name":"agentId","required":true},{"constraint":"","kind":"AssetRef","name":"profile","required":true},{"constraint":"signed 32-bit","kind":"int","name":"seed","required":true},{"constraint":"","kind":"SensoryInput","name":"initialInput","required":true},{"constraint":"","kind":"TypedValue","name":"initialDecisionContext","required":true},{"constraint":">= 1","kind":"int","name":"workerThreads","required":true}],"name":"AgentInitializeParams","source":"workers-v1 2"},{"fields":[{"constraint":"","kind":"Id","name":"agentId","required":true},{"constraint":"","kind":"Digest","name":"profileDigest","required":true},{"constraint":"positive","kind":"RationalNs","name":"tickDuration","required":true},{"constraint":"","kind":"U64","name":"warmupTicks","required":true},{"constraint":"\"0\"","kind":"U64","name":"committedStep","required":true},{"constraint":"","kind":"Digest","name":"decisionContextDigest","required":true},{"constraint":"","kind":"AgentTelemetry","name":"telemetry","required":true}],"name":"AgentInitializeResult","source":"workers-v1 2"},{"fields":[{"constraint":"","kind":"U64","name":"brainTicks","required":true},{"constraint":"finite and nonnegative","kind":"number","name":"populationRateHz","required":true},{"constraint":"<= 64, unique roleId, profile order, finite nonnegative hz","kind":"array<{roleId:Id,hz:number}>","name":"rates","required":true},{"constraint":"changed <= updates; signal finite","kind":"{enabled:bool,updates:U64,changed:U64,signal:number}","name":"learning","required":true}],"name":"AgentTelemetry","source":"workers-v1 1"},{"fields":[{"constraint":"","kind":"Id","name":"id","required":true},{"constraint":"","kind":"Digest","name":"digest","required":true},{"constraint":"positive","kind":"U64","name":"byteLength","required":true},{"constraint":"","kind":"Id","name":"format","required":true}],"name":"AssetRef","source":"workers-v1 1"},{"fields":[{"constraint":"","kind":"Id","name":"streamId","required":true},{"constraint":"8000..=192000","kind":"int","name":"sampleRate","required":true},{"constraint":"1..=8","kind":"int","name":"channels","required":true},{"constraint":"","kind":"AudioFormat","name":"format","required":true}],"name":"AudioDescriptor","source":"state-media-v1 2"},{"fields":[{"constraint":"","kind":"Id","name":"streamId","required":true},{"constraint":"no overlap or rewind within an epoch","kind":"U64","name":"firstSample","required":true},{"constraint":"0..=192000","kind":"int","name":"sampleFrames","required":true},{"constraint":"byteLength == sampleFrames x channels x 4, finite f32","kind":"ArtifactRef","name":"samples","required":true},{"constraint":"true on the first chunk after restore","kind":"bool","name":"discontinuity","required":true}],"name":"AudioRef","source":"state-media-v1 2"},{"fields":[{"constraint":"","kind":"Id","name":"checkpointId","required":true}],"name":"CaptureParams","source":"state-media-v1 5"},{"fields":[{"constraint":"","kind":"Id","name":"checkpointId","required":true},{"constraint":"the committed boundary","kind":"U64","name":"boundary","required":true},{"constraint":"","kind":"Digest","name":"compatibilityDigest","required":true},{"constraint":"listed attachment; digest required","kind":"ArtifactRef","name":"payload","required":true}],"name":"CaptureResult","source":"state-media-v1 5"},{"fields":[{"constraint":"","kind":"Id","name":"agentId","required":true},{"constraint":"","kind":"DomainRequestId","name":"preparedRequestId","required":true},{"constraint":"boundary == scope.step + 1","kind":"SensoryInput","name":"nextInput","required":true},{"constraint":"","kind":"TypedValue","name":"nextDecisionContext","required":true},{"constraint":"<= 64, unique eventId, order retained","kind":"array","name":"rewards","required":true},{"constraint":"<= 64, unique id, order retained","kind":"array","name":"taskStimulations","required":true}],"name":"CommitParams","source":"workers-v1 2"},{"fields":[{"constraint":"","kind":"U64","name":"descriptorRevision","required":true},{"constraint":"","kind":"Id","name":"publisherIncarnation","required":true},{"constraint":"the committed boundary","kind":"Scope","name":"scope","required":true},{"constraint":"","kind":"Id","name":"episodeId","required":true},{"constraint":"monotonic within publisherIncarnation","kind":"U64","name":"sequence","required":true},{"constraint":"","kind":"RationalNs","name":"worldTime","required":true},{"constraint":"1..=4, unique agentId, telemetry in profile role order","kind":"array","name":"agents","required":true},{"constraint":"","kind":"TypedValue","name":"progress","required":true},{"constraint":"declared attachments held through publication admission","kind":"{views:array,audio:array}","name":"media","required":true},{"constraint":"unique, task order","kind":"array","name":"eventIds","required":true}],"name":"CommittedSnapshot","source":"publishing-v1 3"},{"fields":[{"constraint":"","kind":"SchemaRef","name":"schema","required":true},{"constraint":"<= 32, unique, fixed order","kind":"array","name":"buttons","required":true},{"constraint":"<= 16, unique id, neutral inside its range","kind":"array<{id:Id,range:AxisRange,neutral:number}>","name":"axes","required":true}],"name":"ControllerSchema","source":"workers-v1 3"},{"fields":[{"constraint":"","kind":"Digest","name":"backendDigest","required":true},{"constraint":"","kind":"Digest","name":"contentDigest","required":true},{"constraint":"","kind":"Digest","name":"configurationDigest","required":true},{"constraint":"fixed, reduced, positive","kind":"RationalNs","name":"stepDuration","required":true},{"constraint":"1..=4, unique portId, fixed order","kind":"array<{portId:Id,controls:ControllerSchema}>","name":"ports","required":true},{"constraint":"","kind":"SchemaRef","name":"inspectionSchema","required":true},{"constraint":"<= 8, unique viewId","kind":"array","name":"views","required":true},{"constraint":"unique streamId","kind":"array","name":"audio","required":true},{"constraint":"","kind":"Recovery","name":"recovery","required":true},{"constraint":"","kind":"Determinism","name":"determinism","required":true}],"name":"EnvironmentDescriptor","source":"workers-v1 3"},{"fields":[{"constraint":"","kind":"AssetRef","name":"backendConfig","required":true},{"constraint":"","kind":"AssetRef","name":"taskConfig","required":true},{"constraint":"","kind":"Id","name":"episodeId","required":true},{"constraint":"1..=4, unique portId and unique agentId","kind":"array<{portId:Id,agentId:Id}>","name":"portBindings","required":true}],"name":"EnvironmentInitializeParams","source":"workers-v1 3"},{"fields":[{"constraint":"","kind":"EnvironmentDescriptor","name":"descriptor","required":true},{"constraint":"boundary 0 and worldTime 0/1","kind":"WorldObservation","name":"observation","required":true}],"name":"EnvironmentInitializeResult","source":"workers-v1 3"},{"fields":[{"constraint":"","kind":"EpisodeRequestKind","name":"kind","required":true},{"constraint":"","kind":"Id","name":"reason","required":true},{"constraint":"","kind":"TypedValue","name":"outcome","required":true}],"name":"EpisodeRequest","source":"workers-v1 4"},{"fields":[{"constraint":"","kind":"Id","name":"sessionId","required":true},{"constraint":"","kind":"Id","name":"expectedWorkerId","required":true},{"constraint":"","kind":"Role","name":"role","required":true},{"constraint":"nonempty, unique, 1..=65535","kind":"array","name":"supportedMajors","required":true}],"name":"HelloParams","source":"ipc-v1 4"},{"fields":[{"constraint":"1","kind":"const","name":"selectedMajor","required":true},{"constraint":"0","kind":"const","name":"selectedMinor","required":true},{"constraint":"","kind":"Id","name":"workerId","required":true},{"constraint":"","kind":"Id","name":"incarnationId","required":true},{"constraint":"","kind":"Role","name":"role","required":true},{"constraint":"","kind":"Digest","name":"buildDigest","required":true},{"constraint":"","kind":"Digest","name":"contractDigest","required":true},{"constraint":"unique; agent-step-v1 or world-step-v1 required for that role","kind":"array","name":"capabilities","required":true},{"constraint":"1..=4 agents and 1..=4 ports","kind":"{maxAgents:int,maxPorts:int}","name":"limits","required":true}],"name":"HelloResult","source":"ipc-v1 4"},{"fields":[{"constraint":"","kind":"Id","name":"portId","required":true},{"constraint":"every declared button, descriptor order, no extras","kind":"array<{id:Id,down:bool}>","name":"buttons","required":true},{"constraint":"every declared axis in order; bipolar [-1,1], unit [0,1], refused not clamped","kind":"array<{id:Id,value:number}>","name":"axes","required":true}],"name":"PortControl","source":"workers-v1 3"},{"fields":[{"constraint":"","kind":"Id","name":"agentId","required":true},{"constraint":"","kind":"Digest","name":"profileDigest","required":true},{"constraint":"positive","kind":"RationalNs","name":"interval","required":true},{"constraint":"","kind":"Digest","name":"decisionContextDigest","required":true},{"constraint":"<= 64, unique id, supplied order retained","kind":"array","name":"preStepStimulations","required":true}],"name":"PrepareParams","source":"workers-v1 2"},{"fields":[{"constraint":"","kind":"Id","name":"agentId","required":true},{"constraint":"<= brainTicks","kind":"U64","name":"ticksAdvanced","required":true},{"constraint":"","kind":"U64","name":"brainTicks","required":true},{"constraint":">= 0 and < one model tick","kind":"RationalNs","name":"remainder","required":true},{"constraint":"the profile's registered intent schema","kind":"TypedValue","name":"decision","required":true}],"name":"PreparedDecision","source":"workers-v1 2"},{"fields":[{"constraint":"reduced against denominator","kind":"U64","name":"numerator","required":true},{"constraint":"positive; zero is encoded 0/1; arithmetic is checked","kind":"U64","name":"denominator","required":true}],"name":"RationalNs","source":"ipc-v1 2"},{"fields":[{"constraint":"unique within its outcome namespace","kind":"Id","name":"eventId","required":true},{"constraint":"","kind":"Id","name":"ruleId","required":true},{"constraint":"finite; positive-only profiles reject negatives","kind":"number","name":"value","required":true}],"name":"Reward","source":"workers-v1 1"},{"fields":[{"constraint":"","kind":"Id","name":"id","required":true},{"constraint":"1..=65535","kind":"int","name":"version","required":true},{"constraint":"64 lowercase hex digits","kind":"Digest","name":"digest","required":true}],"name":"SchemaRef","source":"ipc-v1 2"},{"fields":[{"constraint":"^[a-z0-9][a-z0-9._-]{0,63}$","kind":"Id","name":"sessionId","required":true},{"constraint":"^[a-z0-9][a-z0-9._-]{0,63}$","kind":"Id","name":"epoch","required":true},{"constraint":"decimal string, <= 18446744073709551615","kind":"U64","name":"step","required":true}],"name":"Scope","source":"ipc-v1 2"},{"fields":[{"constraint":"the observed environment boundary","kind":"U64","name":"boundary","required":true},{"constraint":"<= 8, unique viewId, producedStep <= boundary","kind":"array","name":"views","required":true},{"constraint":"a pixel-only profile rejects non-null","kind":"TypedValue|null","name":"structured","required":false}],"name":"SensoryInput","source":"workers-v1 1"},{"fields":[{"constraint":"","kind":"Id","name":"sessionId","required":true},{"constraint":"","kind":"U64","name":"revision","required":true},{"constraint":"","kind":"Digest","name":"compositionDigest","required":true},{"constraint":"","kind":"SchedulerId","name":"schedulerId","required":true},{"constraint":"","kind":"EnvironmentDescriptor","name":"environment","required":true},{"constraint":"","kind":"SchemaRef","name":"taskSchema","required":true},{"constraint":"1..=4, unique agentId and portId, each portId declared by the environment","kind":"array","name":"agents","required":true},{"constraint":"unique id","kind":"array","name":"assets","required":true}],"name":"SessionDescriptor","source":"publishing-v1 3"},{"fields":[{"constraint":"\"error\"","kind":"const","name":"type","required":true},{"constraint":"echoes the request","kind":"DomainRequestId","name":"requestId","required":true},{"constraint":"","kind":"Id","name":"workerId","required":true},{"constraint":"","kind":"Id","name":"incarnationId","required":true},{"constraint":"echoes the request scope","kind":"Scope|null","name":"scope","required":false},{"constraint":"","kind":"ErrorCode","name":"error.code","required":true},{"constraint":"<= 512 code points","kind":"string","name":"error.message","required":true},{"constraint":"none for every code raised before mutation","kind":"MutationCertainty","name":"error.mutation","required":true}],"name":"SessionRpcFailure","source":"ipc-v1 3"},{"fields":[{"constraint":"req-","kind":"DomainRequestId","name":"requestId","required":true},{"constraint":"null for lifecycle calls","kind":"Scope|null","name":"scope","required":false},{"constraint":"no bus callId/deliveryId/ownerId keys","kind":"object","name":"params","required":true}],"name":"SessionRpcRequest","source":"ipc-v1 2"},{"fields":[{"constraint":"\"result\"","kind":"const","name":"type","required":true},{"constraint":"echoes the request","kind":"DomainRequestId","name":"requestId","required":true},{"constraint":"","kind":"Id","name":"workerId","required":true},{"constraint":"","kind":"Id","name":"incarnationId","required":true},{"constraint":"echoes the request scope","kind":"Scope|null","name":"scope","required":false},{"constraint":"","kind":"object","name":"result","required":true}],"name":"SessionRpcSuccess","source":"ipc-v1 3"},{"fields":[{"constraint":"","kind":"Id","name":"reason","required":true}],"name":"ShutdownParams","source":"ipc-v1 7"},{"fields":[{"constraint":"true","kind":"const","name":"stopping","required":true}],"name":"ShutdownResult","source":"ipc-v1 7"},{"fields":[{"constraint":"","kind":"Id","name":"agentId","required":true},{"constraint":"","kind":"AgentTelemetry","name":"telemetry","required":true},{"constraint":"null exactly at boundary 0","kind":"TypedValue|null","name":"selectedDecision","required":false},{"constraint":"null exactly at boundary 0; the agent's assigned port","kind":"PortControl|null","name":"appliedControls","required":false}],"name":"SnapshotAgent","source":"publishing-v1 3"},{"fields":[{"constraint":"","kind":"Id","name":"checkpointId","required":true},{"constraint":"provenance, not the new handles","kind":"Scope","name":"sourceScope","required":true},{"constraint":"","kind":"Digest","name":"compatibilityDigest","required":true},{"constraint":"newly imported; digest required","kind":"ArtifactRef","name":"payload","required":true}],"name":"StageRestoreParams","source":"state-media-v1 5"},{"fields":[{"constraint":"","kind":"Id","name":"checkpointId","required":true},{"constraint":"activates once, bound to scope and payload","kind":"Id","name":"restoreToken","required":true}],"name":"StageRestoreResult","source":"state-media-v1 5"},{"fields":[{"constraint":"","kind":"WorkerState","name":"state","required":true},{"constraint":"null before initialization","kind":"Scope|null","name":"currentScope","required":false},{"constraint":"","kind":"DomainRequestId|null","name":"activeRequestId","required":false},{"constraint":"","kind":"DomainRequestId|null","name":"lastCompletedRequestId","required":false},{"constraint":"","kind":"Id|null","name":"lastBatchId","required":false},{"constraint":"advances on progress, not on status queries","kind":"U64","name":"progressCounter","required":true}],"name":"StatusResult","source":"ipc-v1 4"},{"fields":[{"constraint":"echoes the request","kind":"Id","name":"batchId","required":true},{"constraint":"k","kind":"U64","name":"appliedFromStep","required":true},{"constraint":"appliedFromStep + 1","kind":"U64","name":"nextStep","required":true},{"constraint":"over validated canonical requested controls","kind":"Digest","name":"appliedControlsDigest","required":true},{"constraint":"boundary == nextStep","kind":"WorldObservation","name":"observation","required":true}],"name":"StepResult","source":"workers-v1 3"},{"fields":[{"constraint":"unique within its command namespace","kind":"Id","name":"id","required":true},{"constraint":"resolved through a profile capability","kind":"Id","name":"kindId","required":true},{"constraint":"finite and > 0","kind":"number","name":"durationMs","required":true}],"name":"Stimulus","source":"workers-v1 1"},{"fields":[{"constraint":"derived from epoch, source step, rule and ordinal","kind":"Id","name":"id","required":true},{"constraint":"","kind":"Id","name":"kindId","required":true},{"constraint":"the newly reached boundary, 0 for bootstrap","kind":"U64","name":"sourceStep","required":true},{"constraint":"","kind":"Id|null","name":"agentId","required":false},{"constraint":"","kind":"TypedValue","name":"payload","required":true}],"name":"TaskEvent","source":"workers-v1 4"},{"fields":[{"constraint":"","kind":"Id","name":"agentId","required":true},{"constraint":"","kind":"Digest","name":"profileDigest","required":true},{"constraint":"","kind":"U64","name":"ticksAdvanced","required":true},{"constraint":"","kind":"U64","name":"brainTicks","required":true},{"constraint":"","kind":"RationalNs","name":"remainder","required":true},{"constraint":"","kind":"Digest","name":"decisionDigest","required":true},{"constraint":"the commit acknowledgment","kind":"U64","name":"committedStep","required":true}],"name":"TraceAgent","source":"step-v1 8"},{"fields":[{"constraint":"","kind":"Scope","name":"scope","required":true},{"constraint":"sorted by agentId, independent of dispatch order","kind":"array","name":"agents","required":true},{"constraint":"","kind":"Id","name":"batchId","required":true},{"constraint":"","kind":"Digest","name":"controlDigest","required":true},{"constraint":"the world boundary the environment acknowledged","kind":"U64","name":"acknowledgedBoundary","required":true},{"constraint":"sorted by viewId","kind":"array<{viewId:Id,producedStep:U64}>","name":"observationBoundaries","required":true},{"constraint":"task outcome ids in task order","kind":"array","name":"outcomeIds","required":true},{"constraint":"task event ids in task order","kind":"array","name":"eventIds","required":true},{"constraint":"","kind":"U64","name":"publishedBoundary","required":true}],"name":"TraceBehaviour","source":"step-v1 8"},{"fields":[{"constraint":"","kind":"U64","name":"wallTimeNs","required":true},{"constraint":"","kind":"array<{agentId:Id,requestId:DomainRequestId}>","name":"prepareRequestIds","required":true},{"constraint":"","kind":"DomainRequestId","name":"advanceRequestId","required":true},{"constraint":"","kind":"array<{agentId:Id,requestId:DomainRequestId}>","name":"commitRequestIds","required":true},{"constraint":"call-","kind":"array","name":"busCallIds","required":true},{"constraint":"dlv- or own-","kind":"array","name":"deliveryIds","required":true}],"name":"TraceOperational","source":"step-v1 8"},{"fields":[{"constraint":"compared between runs","kind":"TraceBehaviour","name":"behaviour","required":true},{"constraint":"recorded, never compared: wall time and transport identities","kind":"TraceOperational","name":"operational","required":true}],"name":"TransitionTrace","source":"step-v1 8"},{"fields":[{"constraint":"","kind":"SchemaRef","name":"schema","required":true},{"constraint":"canonical JSON of the whole TypedValue <= 32768 bytes","kind":"object","name":"value","required":true}],"name":"TypedValue","source":"ipc-v1 2"},{"fields":[{"constraint":"","kind":"Id","name":"viewId","required":true},{"constraint":"1..=4096","kind":"int","name":"width","required":true},{"constraint":"1..=4096","kind":"int","name":"height","required":true},{"constraint":"","kind":"ViewFormat","name":"format","required":true},{"constraint":"exactly 4 x width","kind":"int","name":"rowStride","required":true},{"constraint":"positive integers <= 65535","kind":"{numerator:int,denominator:int}","name":"pixelAspect","required":true},{"constraint":"0..=8","kind":"int","name":"observationDelaySteps","required":true}],"name":"ViewDescriptor","source":"state-media-v1 2"},{"fields":[{"constraint":"","kind":"Id","name":"viewId","required":true},{"constraint":"max(0, boundary - observationDelaySteps) for required sensory views","kind":"U64","name":"producedStep","required":true},{"constraint":"listed attachment; byteLength == rowStride x height","kind":"ArtifactRef","name":"pixels","required":true}],"name":"ViewRef","source":"state-media-v1 2"},{"fields":[{"constraint":"","kind":"U64","name":"boundary","required":true},{"constraint":"logical time since episode start","kind":"RationalNs","name":"worldTime","required":true},{"constraint":"<= 64 characters","kind":"string|null","name":"engineFrame","required":false},{"constraint":"<= 8, unique viewId","kind":"array","name":"sensoryViews","required":true},{"constraint":"the descriptor's inspectionSchema","kind":"TypedValue","name":"inspection","required":true},{"constraint":"<= 8, unique viewId","kind":"array","name":"broadcastViews","required":true},{"constraint":"unique streamId","kind":"array","name":"audio","required":true}],"name":"WorldObservation","source":"workers-v1 3"}],"version":1} diff --git a/services/flysim/crates/fly-session-types/fixtures/seed-vectors.json b/services/flysim/crates/fly-session-types/fixtures/seed-vectors.json new file mode 100644 index 0000000..ecc3c5d --- /dev/null +++ b/services/flysim/crates/fly-session-types/fixtures/seed-vectors.json @@ -0,0 +1,185 @@ +{ + "description": "seed-derivation-v1 test vectors. Both languages must reproduce every seed.", + "algorithm": "seed-derivation-v1", + "prefix": "flybrain/seed-derivation-v1", + "materialTemplate": "\\n\\n\\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" + } + ] +} diff --git a/services/flysim/crates/fly-session-types/fixtures/traces.json b/services/flysim/crates/fly-session-types/fixtures/traces.json new file mode 100644 index 0000000..71d8189 --- /dev/null +++ b/services/flysim/crates/fly-session-types/fixtures/traces.json @@ -0,0 +1,1141 @@ +{ + "description": "step-v1 section 8: sequential, concurrent and reversed runs must agree on behaviour, and only on behaviour.", + "baseline": { + "behaviour": { + "scope": { + "sessionId": "demo", + "epoch": "epoch-1", + "step": "41" + }, + "agents": [ + { + "agentId": "fly-a", + "profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0", + "ticksAdvanced": "17", + "brainTicks": "2517", + "remainder": { + "numerator": "1000000", + "denominator": "3" + }, + "decisionDigest": "a62db30eb7427e2ea9ec58661e69a1eff38e67f21ec97df7d91b24015890d89b", + "committedStep": "42" + }, + { + "agentId": "fly-b", + "profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0", + "ticksAdvanced": "17", + "brainTicks": "2550", + "remainder": { + "numerator": "0", + "denominator": "1" + }, + "decisionDigest": "ddc2e7b8b300cda681c3d91368050fcb95dd66d20fd295834c31307d3df2623e", + "committedStep": "42" + } + ], + "batchId": "batch-41", + "controlDigest": "1e2135d1b50f14d3d3d6cbe24a4294bcb9e4d5d3c28eb1ce4980ff2a17c5d3b6", + "acknowledgedBoundary": "42", + "observationBoundaries": [ + { + "viewId": "screen", + "producedStep": "42" + }, + { + "viewId": "spectator", + "producedStep": "41" + } + ], + "outcomeIds": [ + "outcome-1", + "outcome-2" + ], + "eventIds": [ + "evt-1", + "evt-2" + ], + "publishedBoundary": "42" + }, + "operational": { + "wallTimeNs": "1234567890", + "prepareRequestIds": [ + { + "agentId": "fly-a", + "requestId": "req-41" + }, + { + "agentId": "fly-b", + "requestId": "req-42" + } + ], + "advanceRequestId": "req-43", + "commitRequestIds": [ + { + "agentId": "fly-a", + "requestId": "req-44" + }, + { + "agentId": "fly-b", + "requestId": "req-45" + } + ], + "busCallIds": [ + "call-100", + "call-101", + "call-102" + ], + "deliveryIds": [ + "dlv-7", + "own-9" + ] + } + }, + "variants": [ + { + "name": "reversed dispatch and completion order", + "trace": { + "behaviour": { + "scope": { + "sessionId": "demo", + "epoch": "epoch-1", + "step": "41" + }, + "agents": [ + { + "agentId": "fly-b", + "profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0", + "ticksAdvanced": "17", + "brainTicks": "2550", + "remainder": { + "numerator": "0", + "denominator": "1" + }, + "decisionDigest": "ddc2e7b8b300cda681c3d91368050fcb95dd66d20fd295834c31307d3df2623e", + "committedStep": "42" + }, + { + "agentId": "fly-a", + "profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0", + "ticksAdvanced": "17", + "brainTicks": "2517", + "remainder": { + "numerator": "1000000", + "denominator": "3" + }, + "decisionDigest": "a62db30eb7427e2ea9ec58661e69a1eff38e67f21ec97df7d91b24015890d89b", + "committedStep": "42" + } + ], + "batchId": "batch-41", + "controlDigest": "1e2135d1b50f14d3d3d6cbe24a4294bcb9e4d5d3c28eb1ce4980ff2a17c5d3b6", + "acknowledgedBoundary": "42", + "observationBoundaries": [ + { + "viewId": "spectator", + "producedStep": "41" + }, + { + "viewId": "screen", + "producedStep": "42" + } + ], + "outcomeIds": [ + "outcome-1", + "outcome-2" + ], + "eventIds": [ + "evt-1", + "evt-2" + ], + "publishedBoundary": "42" + }, + "operational": { + "wallTimeNs": "1234567890", + "prepareRequestIds": [ + { + "agentId": "fly-b", + "requestId": "req-42" + }, + { + "agentId": "fly-a", + "requestId": "req-41" + } + ], + "advanceRequestId": "req-43", + "commitRequestIds": [ + { + "agentId": "fly-b", + "requestId": "req-45" + }, + { + "agentId": "fly-a", + "requestId": "req-44" + } + ], + "busCallIds": [ + "call-100", + "call-101", + "call-102" + ], + "deliveryIds": [ + "dlv-7", + "own-9" + ] + } + }, + "behaviourEquals": true, + "reason": "completion order never affects the committed result" + }, + { + "name": "a safe retry with fresh bus callIds, delivery ids and wall time", + "trace": { + "behaviour": { + "scope": { + "sessionId": "demo", + "epoch": "epoch-1", + "step": "41" + }, + "agents": [ + { + "agentId": "fly-a", + "profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0", + "ticksAdvanced": "17", + "brainTicks": "2517", + "remainder": { + "numerator": "1000000", + "denominator": "3" + }, + "decisionDigest": "a62db30eb7427e2ea9ec58661e69a1eff38e67f21ec97df7d91b24015890d89b", + "committedStep": "42" + }, + { + "agentId": "fly-b", + "profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0", + "ticksAdvanced": "17", + "brainTicks": "2550", + "remainder": { + "numerator": "0", + "denominator": "1" + }, + "decisionDigest": "ddc2e7b8b300cda681c3d91368050fcb95dd66d20fd295834c31307d3df2623e", + "committedStep": "42" + } + ], + "batchId": "batch-41", + "controlDigest": "1e2135d1b50f14d3d3d6cbe24a4294bcb9e4d5d3c28eb1ce4980ff2a17c5d3b6", + "acknowledgedBoundary": "42", + "observationBoundaries": [ + { + "viewId": "screen", + "producedStep": "42" + }, + { + "viewId": "spectator", + "producedStep": "41" + } + ], + "outcomeIds": [ + "outcome-1", + "outcome-2" + ], + "eventIds": [ + "evt-1", + "evt-2" + ], + "publishedBoundary": "42" + }, + "operational": { + "wallTimeNs": "9999999999", + "prepareRequestIds": [ + { + "agentId": "fly-a", + "requestId": "req-41" + }, + { + "agentId": "fly-b", + "requestId": "req-42" + } + ], + "advanceRequestId": "req-43", + "commitRequestIds": [ + { + "agentId": "fly-a", + "requestId": "req-44" + }, + { + "agentId": "fly-b", + "requestId": "req-45" + } + ], + "busCallIds": [ + "call-500", + "call-501", + "call-502", + "call-503" + ], + "deliveryIds": [ + "dlv-91", + "own-92" + ] + } + }, + "behaviourEquals": true, + "reason": "a retry has a new transport correlation and the same behaviour" + }, + { + "name": "another run's domain request ids", + "trace": { + "behaviour": { + "scope": { + "sessionId": "demo", + "epoch": "epoch-1", + "step": "41" + }, + "agents": [ + { + "agentId": "fly-a", + "profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0", + "ticksAdvanced": "17", + "brainTicks": "2517", + "remainder": { + "numerator": "1000000", + "denominator": "3" + }, + "decisionDigest": "a62db30eb7427e2ea9ec58661e69a1eff38e67f21ec97df7d91b24015890d89b", + "committedStep": "42" + }, + { + "agentId": "fly-b", + "profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0", + "ticksAdvanced": "17", + "brainTicks": "2550", + "remainder": { + "numerator": "0", + "denominator": "1" + }, + "decisionDigest": "ddc2e7b8b300cda681c3d91368050fcb95dd66d20fd295834c31307d3df2623e", + "committedStep": "42" + } + ], + "batchId": "batch-41", + "controlDigest": "1e2135d1b50f14d3d3d6cbe24a4294bcb9e4d5d3c28eb1ce4980ff2a17c5d3b6", + "acknowledgedBoundary": "42", + "observationBoundaries": [ + { + "viewId": "screen", + "producedStep": "42" + }, + { + "viewId": "spectator", + "producedStep": "41" + } + ], + "outcomeIds": [ + "outcome-1", + "outcome-2" + ], + "eventIds": [ + "evt-1", + "evt-2" + ], + "publishedBoundary": "42" + }, + "operational": { + "wallTimeNs": "1234567890", + "prepareRequestIds": [ + { + "agentId": "fly-a", + "requestId": "req-1041" + }, + { + "agentId": "fly-b", + "requestId": "req-1042" + } + ], + "advanceRequestId": "req-1043", + "commitRequestIds": [ + { + "agentId": "fly-a", + "requestId": "req-1044" + }, + { + "agentId": "fly-b", + "requestId": "req-1045" + } + ], + "busCallIds": [ + "call-100", + "call-101", + "call-102" + ], + "deliveryIds": [ + "dlv-7", + "own-9" + ] + } + }, + "behaviourEquals": true, + "reason": "step-v1 section 8 excludes request ids from the comparison" + }, + { + "name": "one extra neural tick", + "trace": { + "behaviour": { + "scope": { + "sessionId": "demo", + "epoch": "epoch-1", + "step": "41" + }, + "agents": [ + { + "agentId": "fly-a", + "profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0", + "ticksAdvanced": "18", + "brainTicks": "2517", + "remainder": { + "numerator": "1000000", + "denominator": "3" + }, + "decisionDigest": "a62db30eb7427e2ea9ec58661e69a1eff38e67f21ec97df7d91b24015890d89b", + "committedStep": "42" + }, + { + "agentId": "fly-b", + "profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0", + "ticksAdvanced": "17", + "brainTicks": "2550", + "remainder": { + "numerator": "0", + "denominator": "1" + }, + "decisionDigest": "ddc2e7b8b300cda681c3d91368050fcb95dd66d20fd295834c31307d3df2623e", + "committedStep": "42" + } + ], + "batchId": "batch-41", + "controlDigest": "1e2135d1b50f14d3d3d6cbe24a4294bcb9e4d5d3c28eb1ce4980ff2a17c5d3b6", + "acknowledgedBoundary": "42", + "observationBoundaries": [ + { + "viewId": "screen", + "producedStep": "42" + }, + { + "viewId": "spectator", + "producedStep": "41" + } + ], + "outcomeIds": [ + "outcome-1", + "outcome-2" + ], + "eventIds": [ + "evt-1", + "evt-2" + ], + "publishedBoundary": "42" + }, + "operational": { + "wallTimeNs": "1234567890", + "prepareRequestIds": [ + { + "agentId": "fly-a", + "requestId": "req-41" + }, + { + "agentId": "fly-b", + "requestId": "req-42" + } + ], + "advanceRequestId": "req-43", + "commitRequestIds": [ + { + "agentId": "fly-a", + "requestId": "req-44" + }, + { + "agentId": "fly-b", + "requestId": "req-45" + } + ], + "busCallIds": [ + "call-100", + "call-101", + "call-102" + ], + "deliveryIds": [ + "dlv-7", + "own-9" + ] + } + }, + "behaviourEquals": false, + "diffContains": "agent fly-a" + }, + { + "name": "a different tick remainder", + "trace": { + "behaviour": { + "scope": { + "sessionId": "demo", + "epoch": "epoch-1", + "step": "41" + }, + "agents": [ + { + "agentId": "fly-a", + "profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0", + "ticksAdvanced": "17", + "brainTicks": "2517", + "remainder": { + "numerator": "0", + "denominator": "1" + }, + "decisionDigest": "a62db30eb7427e2ea9ec58661e69a1eff38e67f21ec97df7d91b24015890d89b", + "committedStep": "42" + }, + { + "agentId": "fly-b", + "profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0", + "ticksAdvanced": "17", + "brainTicks": "2550", + "remainder": { + "numerator": "0", + "denominator": "1" + }, + "decisionDigest": "ddc2e7b8b300cda681c3d91368050fcb95dd66d20fd295834c31307d3df2623e", + "committedStep": "42" + } + ], + "batchId": "batch-41", + "controlDigest": "1e2135d1b50f14d3d3d6cbe24a4294bcb9e4d5d3c28eb1ce4980ff2a17c5d3b6", + "acknowledgedBoundary": "42", + "observationBoundaries": [ + { + "viewId": "screen", + "producedStep": "42" + }, + { + "viewId": "spectator", + "producedStep": "41" + } + ], + "outcomeIds": [ + "outcome-1", + "outcome-2" + ], + "eventIds": [ + "evt-1", + "evt-2" + ], + "publishedBoundary": "42" + }, + "operational": { + "wallTimeNs": "1234567890", + "prepareRequestIds": [ + { + "agentId": "fly-a", + "requestId": "req-41" + }, + { + "agentId": "fly-b", + "requestId": "req-42" + } + ], + "advanceRequestId": "req-43", + "commitRequestIds": [ + { + "agentId": "fly-a", + "requestId": "req-44" + }, + { + "agentId": "fly-b", + "requestId": "req-45" + } + ], + "busCallIds": [ + "call-100", + "call-101", + "call-102" + ], + "deliveryIds": [ + "dlv-7", + "own-9" + ] + } + }, + "behaviourEquals": false, + "diffContains": "agent fly-a" + }, + { + "name": "a different decision digest", + "trace": { + "behaviour": { + "scope": { + "sessionId": "demo", + "epoch": "epoch-1", + "step": "41" + }, + "agents": [ + { + "agentId": "fly-a", + "profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0", + "ticksAdvanced": "17", + "brainTicks": "2517", + "remainder": { + "numerator": "1000000", + "denominator": "3" + }, + "decisionDigest": "3c7a9d8634cce0a0624d30476695f94c9c1f31d47d471396497db4f562a2e068", + "committedStep": "42" + }, + { + "agentId": "fly-b", + "profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0", + "ticksAdvanced": "17", + "brainTicks": "2550", + "remainder": { + "numerator": "0", + "denominator": "1" + }, + "decisionDigest": "ddc2e7b8b300cda681c3d91368050fcb95dd66d20fd295834c31307d3df2623e", + "committedStep": "42" + } + ], + "batchId": "batch-41", + "controlDigest": "1e2135d1b50f14d3d3d6cbe24a4294bcb9e4d5d3c28eb1ce4980ff2a17c5d3b6", + "acknowledgedBoundary": "42", + "observationBoundaries": [ + { + "viewId": "screen", + "producedStep": "42" + }, + { + "viewId": "spectator", + "producedStep": "41" + } + ], + "outcomeIds": [ + "outcome-1", + "outcome-2" + ], + "eventIds": [ + "evt-1", + "evt-2" + ], + "publishedBoundary": "42" + }, + "operational": { + "wallTimeNs": "1234567890", + "prepareRequestIds": [ + { + "agentId": "fly-a", + "requestId": "req-41" + }, + { + "agentId": "fly-b", + "requestId": "req-42" + } + ], + "advanceRequestId": "req-43", + "commitRequestIds": [ + { + "agentId": "fly-a", + "requestId": "req-44" + }, + { + "agentId": "fly-b", + "requestId": "req-45" + } + ], + "busCallIds": [ + "call-100", + "call-101", + "call-102" + ], + "deliveryIds": [ + "dlv-7", + "own-9" + ] + } + }, + "behaviourEquals": false, + "diffContains": "agent fly-a" + }, + { + "name": "a different control digest", + "trace": { + "behaviour": { + "scope": { + "sessionId": "demo", + "epoch": "epoch-1", + "step": "41" + }, + "agents": [ + { + "agentId": "fly-a", + "profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0", + "ticksAdvanced": "17", + "brainTicks": "2517", + "remainder": { + "numerator": "1000000", + "denominator": "3" + }, + "decisionDigest": "a62db30eb7427e2ea9ec58661e69a1eff38e67f21ec97df7d91b24015890d89b", + "committedStep": "42" + }, + { + "agentId": "fly-b", + "profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0", + "ticksAdvanced": "17", + "brainTicks": "2550", + "remainder": { + "numerator": "0", + "denominator": "1" + }, + "decisionDigest": "ddc2e7b8b300cda681c3d91368050fcb95dd66d20fd295834c31307d3df2623e", + "committedStep": "42" + } + ], + "batchId": "batch-41", + "controlDigest": "b80f397807afe6543058911e501c7f2945266a04badaad4815997e17c908d38f", + "acknowledgedBoundary": "42", + "observationBoundaries": [ + { + "viewId": "screen", + "producedStep": "42" + }, + { + "viewId": "spectator", + "producedStep": "41" + } + ], + "outcomeIds": [ + "outcome-1", + "outcome-2" + ], + "eventIds": [ + "evt-1", + "evt-2" + ], + "publishedBoundary": "42" + }, + "operational": { + "wallTimeNs": "1234567890", + "prepareRequestIds": [ + { + "agentId": "fly-a", + "requestId": "req-41" + }, + { + "agentId": "fly-b", + "requestId": "req-42" + } + ], + "advanceRequestId": "req-43", + "commitRequestIds": [ + { + "agentId": "fly-a", + "requestId": "req-44" + }, + { + "agentId": "fly-b", + "requestId": "req-45" + } + ], + "busCallIds": [ + "call-100", + "call-101", + "call-102" + ], + "deliveryIds": [ + "dlv-7", + "own-9" + ] + } + }, + "behaviourEquals": false, + "diffContains": "controlDigest" + }, + { + "name": "a different batch id", + "trace": { + "behaviour": { + "scope": { + "sessionId": "demo", + "epoch": "epoch-1", + "step": "41" + }, + "agents": [ + { + "agentId": "fly-a", + "profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0", + "ticksAdvanced": "17", + "brainTicks": "2517", + "remainder": { + "numerator": "1000000", + "denominator": "3" + }, + "decisionDigest": "a62db30eb7427e2ea9ec58661e69a1eff38e67f21ec97df7d91b24015890d89b", + "committedStep": "42" + }, + { + "agentId": "fly-b", + "profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0", + "ticksAdvanced": "17", + "brainTicks": "2550", + "remainder": { + "numerator": "0", + "denominator": "1" + }, + "decisionDigest": "ddc2e7b8b300cda681c3d91368050fcb95dd66d20fd295834c31307d3df2623e", + "committedStep": "42" + } + ], + "batchId": "batch-999", + "controlDigest": "1e2135d1b50f14d3d3d6cbe24a4294bcb9e4d5d3c28eb1ce4980ff2a17c5d3b6", + "acknowledgedBoundary": "42", + "observationBoundaries": [ + { + "viewId": "screen", + "producedStep": "42" + }, + { + "viewId": "spectator", + "producedStep": "41" + } + ], + "outcomeIds": [ + "outcome-1", + "outcome-2" + ], + "eventIds": [ + "evt-1", + "evt-2" + ], + "publishedBoundary": "42" + }, + "operational": { + "wallTimeNs": "1234567890", + "prepareRequestIds": [ + { + "agentId": "fly-a", + "requestId": "req-41" + }, + { + "agentId": "fly-b", + "requestId": "req-42" + } + ], + "advanceRequestId": "req-43", + "commitRequestIds": [ + { + "agentId": "fly-a", + "requestId": "req-44" + }, + { + "agentId": "fly-b", + "requestId": "req-45" + } + ], + "busCallIds": [ + "call-100", + "call-101", + "call-102" + ], + "deliveryIds": [ + "dlv-7", + "own-9" + ] + } + }, + "behaviourEquals": false, + "diffContains": "batchId" + }, + { + "name": "an observation produced at another boundary", + "trace": { + "behaviour": { + "scope": { + "sessionId": "demo", + "epoch": "epoch-1", + "step": "41" + }, + "agents": [ + { + "agentId": "fly-a", + "profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0", + "ticksAdvanced": "17", + "brainTicks": "2517", + "remainder": { + "numerator": "1000000", + "denominator": "3" + }, + "decisionDigest": "a62db30eb7427e2ea9ec58661e69a1eff38e67f21ec97df7d91b24015890d89b", + "committedStep": "42" + }, + { + "agentId": "fly-b", + "profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0", + "ticksAdvanced": "17", + "brainTicks": "2550", + "remainder": { + "numerator": "0", + "denominator": "1" + }, + "decisionDigest": "ddc2e7b8b300cda681c3d91368050fcb95dd66d20fd295834c31307d3df2623e", + "committedStep": "42" + } + ], + "batchId": "batch-41", + "controlDigest": "1e2135d1b50f14d3d3d6cbe24a4294bcb9e4d5d3c28eb1ce4980ff2a17c5d3b6", + "acknowledgedBoundary": "42", + "observationBoundaries": [ + { + "viewId": "screen", + "producedStep": "41" + }, + { + "viewId": "spectator", + "producedStep": "41" + } + ], + "outcomeIds": [ + "outcome-1", + "outcome-2" + ], + "eventIds": [ + "evt-1", + "evt-2" + ], + "publishedBoundary": "42" + }, + "operational": { + "wallTimeNs": "1234567890", + "prepareRequestIds": [ + { + "agentId": "fly-a", + "requestId": "req-41" + }, + { + "agentId": "fly-b", + "requestId": "req-42" + } + ], + "advanceRequestId": "req-43", + "commitRequestIds": [ + { + "agentId": "fly-a", + "requestId": "req-44" + }, + { + "agentId": "fly-b", + "requestId": "req-45" + } + ], + "busCallIds": [ + "call-100", + "call-101", + "call-102" + ], + "deliveryIds": [ + "dlv-7", + "own-9" + ] + } + }, + "behaviourEquals": false, + "diffContains": "observationBoundaries" + }, + { + "name": "task events in another order", + "trace": { + "behaviour": { + "scope": { + "sessionId": "demo", + "epoch": "epoch-1", + "step": "41" + }, + "agents": [ + { + "agentId": "fly-a", + "profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0", + "ticksAdvanced": "17", + "brainTicks": "2517", + "remainder": { + "numerator": "1000000", + "denominator": "3" + }, + "decisionDigest": "a62db30eb7427e2ea9ec58661e69a1eff38e67f21ec97df7d91b24015890d89b", + "committedStep": "42" + }, + { + "agentId": "fly-b", + "profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0", + "ticksAdvanced": "17", + "brainTicks": "2550", + "remainder": { + "numerator": "0", + "denominator": "1" + }, + "decisionDigest": "ddc2e7b8b300cda681c3d91368050fcb95dd66d20fd295834c31307d3df2623e", + "committedStep": "42" + } + ], + "batchId": "batch-41", + "controlDigest": "1e2135d1b50f14d3d3d6cbe24a4294bcb9e4d5d3c28eb1ce4980ff2a17c5d3b6", + "acknowledgedBoundary": "42", + "observationBoundaries": [ + { + "viewId": "screen", + "producedStep": "42" + }, + { + "viewId": "spectator", + "producedStep": "41" + } + ], + "outcomeIds": [ + "outcome-1", + "outcome-2" + ], + "eventIds": [ + "evt-2", + "evt-1" + ], + "publishedBoundary": "42" + }, + "operational": { + "wallTimeNs": "1234567890", + "prepareRequestIds": [ + { + "agentId": "fly-a", + "requestId": "req-41" + }, + { + "agentId": "fly-b", + "requestId": "req-42" + } + ], + "advanceRequestId": "req-43", + "commitRequestIds": [ + { + "agentId": "fly-a", + "requestId": "req-44" + }, + { + "agentId": "fly-b", + "requestId": "req-45" + } + ], + "busCallIds": [ + "call-100", + "call-101", + "call-102" + ], + "deliveryIds": [ + "dlv-7", + "own-9" + ] + } + }, + "behaviourEquals": false, + "diffContains": "eventIds" + }, + { + "name": "a different agent in the transition", + "trace": { + "behaviour": { + "scope": { + "sessionId": "demo", + "epoch": "epoch-1", + "step": "41" + }, + "agents": [ + { + "agentId": "fly-a", + "profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0", + "ticksAdvanced": "17", + "brainTicks": "2517", + "remainder": { + "numerator": "1000000", + "denominator": "3" + }, + "decisionDigest": "a62db30eb7427e2ea9ec58661e69a1eff38e67f21ec97df7d91b24015890d89b", + "committedStep": "42" + }, + { + "agentId": "fly-c", + "profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0", + "ticksAdvanced": "17", + "brainTicks": "2550", + "remainder": { + "numerator": "0", + "denominator": "1" + }, + "decisionDigest": "ddc2e7b8b300cda681c3d91368050fcb95dd66d20fd295834c31307d3df2623e", + "committedStep": "42" + } + ], + "batchId": "batch-41", + "controlDigest": "1e2135d1b50f14d3d3d6cbe24a4294bcb9e4d5d3c28eb1ce4980ff2a17c5d3b6", + "acknowledgedBoundary": "42", + "observationBoundaries": [ + { + "viewId": "screen", + "producedStep": "42" + }, + { + "viewId": "spectator", + "producedStep": "41" + } + ], + "outcomeIds": [ + "outcome-1", + "outcome-2" + ], + "eventIds": [ + "evt-1", + "evt-2" + ], + "publishedBoundary": "42" + }, + "operational": { + "wallTimeNs": "1234567890", + "prepareRequestIds": [ + { + "agentId": "fly-a", + "requestId": "req-41" + }, + { + "agentId": "fly-c", + "requestId": "req-42" + } + ], + "advanceRequestId": "req-43", + "commitRequestIds": [ + { + "agentId": "fly-a", + "requestId": "req-44" + }, + { + "agentId": "fly-c", + "requestId": "req-45" + } + ], + "busCallIds": [ + "call-100", + "call-101", + "call-102" + ], + "deliveryIds": [ + "dlv-7", + "own-9" + ] + } + }, + "behaviourEquals": false, + "diffContains": "agents" + } + ] +} \ No newline at end of file diff --git a/services/flysim/crates/fly-session-types/fixtures/valid.json b/services/flysim/crates/fly-session-types/fixtures/valid.json new file mode 100644 index 0000000..c316f95 --- /dev/null +++ b/services/flysim/crates/fly-session-types/fixtures/valid.json @@ -0,0 +1,2787 @@ +{ + "description": "Payloads every implementation must accept, round trip and canonicalize identically.", + "cases": [ + { + "name": "scope at step 41", + "type": "Scope", + "value": { + "sessionId": "demo", + "epoch": "epoch-1", + "step": "41" + }, + "note": "", + "canonical": "{\"epoch\":\"epoch-1\",\"sessionId\":\"demo\",\"step\":\"41\"}", + "digest": "1953a38efa113f682ee69e2db93e9cf01e17638f3a82af6d4b1666dde8c25d7b" + }, + { + "name": "scope at boundary zero", + "type": "Scope", + "value": { + "sessionId": "demo", + "epoch": "epoch-1", + "step": "0" + }, + "note": "", + "canonical": "{\"epoch\":\"epoch-1\",\"sessionId\":\"demo\",\"step\":\"0\"}", + "digest": "de1105b866a42a79ba7162e9b2c914dd1da8d96b11b65f0458baddd17d6e356c" + }, + { + "name": "scope at the U64 maximum", + "type": "Scope", + "value": { + "sessionId": "demo", + "epoch": "epoch-1", + "step": "18446744073709551615" + }, + "note": "", + "canonical": "{\"epoch\":\"epoch-1\",\"sessionId\":\"demo\",\"step\":\"18446744073709551615\"}", + "digest": "d941cef9e565492376d10812c27787d661bbee13995e9310ddaabb7aa55cc2bd" + }, + { + "name": "rational zero in its only legal form", + "type": "RationalNs", + "value": { + "numerator": "0", + "denominator": "1" + }, + "note": "zero is encoded 0/1", + "canonical": "{\"denominator\":\"1\",\"numerator\":\"0\"}", + "digest": "a93ea196102f24ae67e9e177f75ac40c9597c06d361c37fbc5a742f517c55425" + }, + { + "name": "reduced 60 Hz step duration", + "type": "RationalNs", + "value": { + "numerator": "50000000", + "denominator": "3" + }, + "note": "", + "canonical": "{\"denominator\":\"3\",\"numerator\":\"50000000\"}", + "digest": "952c3304ef49b0e8422de5a46b0c95c3e2a54db8608a605dbfe195b84dc686d2" + }, + { + "name": "one millisecond tick duration", + "type": "RationalNs", + "value": { + "numerator": "1000000", + "denominator": "1" + }, + "note": "", + "canonical": "{\"denominator\":\"1\",\"numerator\":\"1000000\"}", + "digest": "663997e4702a3fb1a303fcd3500e11fcda0d662e20c995b1fa070e32c65e7ad3" + }, + { + "name": "schema reference at version 1", + "type": "SchemaRef", + "value": { + "id": "pokemon.progress.v1", + "version": 1, + "digest": "80fa973157f334bb3208dfff6f7a2f298e79f73f07c72c33f5725975c6cff8e1" + }, + "note": "", + "canonical": "{\"digest\":\"80fa973157f334bb3208dfff6f7a2f298e79f73f07c72c33f5725975c6cff8e1\",\"id\":\"pokemon.progress.v1\",\"version\":1}", + "digest": "929dcd71459199d62b8e215c0866e8ade178ff4ed5f24f8aa11b9e33c8380122" + }, + { + "name": "schema reference at the maximum version", + "type": "SchemaRef", + "value": { + "id": "pokemon.progress.v1", + "version": 65535, + "digest": "80fa973157f334bb3208dfff6f7a2f298e79f73f07c72c33f5725975c6cff8e1" + }, + "note": "", + "canonical": "{\"digest\":\"80fa973157f334bb3208dfff6f7a2f298e79f73f07c72c33f5725975c6cff8e1\",\"id\":\"pokemon.progress.v1\",\"version\":65535}", + "digest": "7d2ce2fe9d99be170ca980d0c47ffa49bff5800bd21a371a92588e015b4ee55d" + }, + { + "name": "typed value", + "type": "TypedValue", + "value": { + "schema": { + "id": "pokemon.progress.v1", + "version": 1, + "digest": "80fa973157f334bb3208dfff6f7a2f298e79f73f07c72c33f5725975c6cff8e1" + }, + "value": { + "rank": 10, + "badges": 2 + } + }, + "note": "", + "canonical": "{\"schema\":{\"digest\":\"80fa973157f334bb3208dfff6f7a2f298e79f73f07c72c33f5725975c6cff8e1\",\"id\":\"pokemon.progress.v1\",\"version\":1},\"value\":{\"badges\":2,\"rank\":10}}", + "digest": "d0e2d096f61633567f6959e3bbfb6e8f1f2bb507a3c54219118b9eee96c20e32" + }, + { + "name": "typed value with an empty object", + "type": "TypedValue", + "value": { + "schema": { + "id": "empty.v1", + "version": 1, + "digest": "b86e38af20e53d5426f2ae6cd496f258bf7592bd047aea2b0f6e41dfed7ef79e" + }, + "value": {} + }, + "note": "", + "canonical": "{\"schema\":{\"digest\":\"b86e38af20e53d5426f2ae6cd496f258bf7592bd047aea2b0f6e41dfed7ef79e\",\"id\":\"empty.v1\",\"version\":1},\"value\":{}}", + "digest": "a39dd4af1f8581cdc2afaa6d86fb7b5b4c384dff5ea6d98212dc7b983d0a5d0b" + }, + { + "name": "asset reference", + "type": "AssetRef", + "value": { + "id": "profile-fly-a", + "digest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0", + "byteLength": "4096", + "format": "flyprofile" + }, + "note": "", + "canonical": "{\"byteLength\":\"4096\",\"digest\":\"1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0\",\"format\":\"flyprofile\",\"id\":\"profile-fly-a\"}", + "digest": "9fef94f7c3f2601a13d235077441c5d28f604b68a3156d1ad01c5ad26e953c49" + }, + { + "name": "sensory input with one view", + "type": "SensoryInput", + "value": { + "boundary": "1", + "views": [ + { + "viewId": "screen", + "producedStep": "1", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-1", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + } + ], + "structured": null + }, + "note": "", + "canonical": "{\"boundary\":\"1\",\"structured\":null,\"views\":[{\"pixels\":{\"artifactId\":\"frame-1\",\"byteLength\":\"92160\",\"contentType\":\"image/x-rgba8\",\"digest\":null,\"generation\":\"1\",\"storeId\":\"store-1\"},\"producedStep\":\"1\",\"viewId\":\"screen\"}]}", + "digest": "41754900becead6be965919ca73661069ef2e25ac4ba2c44717e1419de91970a" + }, + { + "name": "sensory input with no views at bootstrap", + "type": "SensoryInput", + "value": { + "boundary": "0", + "views": [], + "structured": null + }, + "note": "", + "canonical": "{\"boundary\":\"0\",\"structured\":null,\"views\":[]}", + "digest": "7f92dff3688a5042f2aeec4ca6e902f53db84635651f780b03a26c2e3356d42d" + }, + { + "name": "sensory input with structured sensing", + "type": "SensoryInput", + "value": { + "boundary": "3", + "views": [ + { + "viewId": "screen", + "producedStep": "3", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-1", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + } + ], + "structured": { + "schema": { + "id": "arena.features.v1", + "version": 1, + "digest": "81414e2c9750102a91d75ba71b8d3dd5161f3e2d69a0c470c6961033def0710d" + }, + "value": { + "near": 1 + } + } + }, + "note": "", + "canonical": "{\"boundary\":\"3\",\"structured\":{\"schema\":{\"digest\":\"81414e2c9750102a91d75ba71b8d3dd5161f3e2d69a0c470c6961033def0710d\",\"id\":\"arena.features.v1\",\"version\":1},\"value\":{\"near\":1}},\"views\":[{\"pixels\":{\"artifactId\":\"frame-1\",\"byteLength\":\"92160\",\"contentType\":\"image/x-rgba8\",\"digest\":null,\"generation\":\"1\",\"storeId\":\"store-1\"},\"producedStep\":\"3\",\"viewId\":\"screen\"}]}", + "digest": "e491f273bd045b3f954bc1b5051b551868346fc28115a2e13781b696c1256757" + }, + { + "name": "stimulus", + "type": "Stimulus", + "value": { + "id": "stim-1", + "kindId": "sugar", + "durationMs": 50.0 + }, + "note": "", + "canonical": "{\"durationMs\":50,\"id\":\"stim-1\",\"kindId\":\"sugar\"}", + "digest": "67590e9e9852976a074d7ae91632c4f10cde7264459f300a0cc1903077ed58c5" + }, + { + "name": "reward", + "type": "Reward", + "value": { + "eventId": "evt-1", + "ruleId": "badge", + "value": 1.5 + }, + "note": "", + "canonical": "{\"eventId\":\"evt-1\",\"ruleId\":\"badge\",\"value\":1.5}", + "digest": "95f4c5f144f496f85642fffa483cd94dc674e332a0d7e95e88e664796d438801" + }, + { + "name": "reward of zero", + "type": "Reward", + "value": { + "eventId": "evt-2", + "ruleId": "idle", + "value": 0.0 + }, + "note": "", + "canonical": "{\"eventId\":\"evt-2\",\"ruleId\":\"idle\",\"value\":0}", + "digest": "7bb6160ed3e10345e4084118546ff27725eea5666407b483e23bd9b95a87a51e" + }, + { + "name": "agent telemetry", + "type": "AgentTelemetry", + "value": { + "brainTicks": "17", + "populationRateHz": 12.5, + "rates": [ + { + "roleId": "kenyon", + "hz": 3.25 + }, + { + "roleId": "mbon", + "hz": 0.0 + } + ], + "learning": { + "enabled": true, + "updates": "4", + "changed": "2", + "signal": 0.5 + } + }, + "note": "", + "canonical": "{\"brainTicks\":\"17\",\"learning\":{\"changed\":\"2\",\"enabled\":true,\"signal\":0.5,\"updates\":\"4\"},\"populationRateHz\":12.5,\"rates\":[{\"hz\":3.25,\"roleId\":\"kenyon\"},{\"hz\":0,\"roleId\":\"mbon\"}]}", + "digest": "de4c4e0a2cc3cac459929480a057495029a74e67e9444ea4e197b403659155ed" + }, + { + "name": "agent telemetry with no tracked roles", + "type": "AgentTelemetry", + "value": { + "brainTicks": "0", + "populationRateHz": 0.0, + "rates": [], + "learning": { + "enabled": false, + "updates": "0", + "changed": "0", + "signal": 0.0 + } + }, + "note": "", + "canonical": "{\"brainTicks\":\"0\",\"learning\":{\"changed\":\"0\",\"enabled\":false,\"signal\":0,\"updates\":\"0\"},\"populationRateHz\":0,\"rates\":[]}", + "digest": "51c684931ea3d7c54f67a78a90d676944d9527b0bc4465d5a6d4e02b87ff7485" + }, + { + "name": "session rpc request with a scope", + "type": "SessionRpcRequest", + "value": { + "requestId": "req-41", + "scope": { + "sessionId": "demo", + "epoch": "epoch-1", + "step": "41" + }, + "params": { + "agentId": "fly-a" + } + }, + "note": "", + "canonical": "{\"params\":{\"agentId\":\"fly-a\"},\"requestId\":\"req-41\",\"scope\":{\"epoch\":\"epoch-1\",\"sessionId\":\"demo\",\"step\":\"41\"}}", + "digest": "236215ca3453d8d07cbaa4cfe3b5c9d7c92ed145bd8e6cd3b85a9adcd1de77af" + }, + { + "name": "session rpc request without a scope", + "type": "SessionRpcRequest", + "value": { + "requestId": "req-1", + "scope": null, + "params": {} + }, + "note": "", + "canonical": "{\"params\":{},\"requestId\":\"req-1\",\"scope\":null}", + "digest": "2442374424ed68182d5c77ee66f026caeb286bccd836d9713e8def9e50b55976" + }, + { + "name": "session rpc success", + "type": "SessionRpcSuccess", + "value": { + "type": "result", + "requestId": "req-41", + "workerId": "fly-a", + "incarnationId": "inc-1", + "scope": { + "sessionId": "demo", + "epoch": "epoch-1", + "step": "41" + }, + "result": { + "agentId": "fly-a" + } + }, + "note": "", + "canonical": "{\"incarnationId\":\"inc-1\",\"requestId\":\"req-41\",\"result\":{\"agentId\":\"fly-a\"},\"scope\":{\"epoch\":\"epoch-1\",\"sessionId\":\"demo\",\"step\":\"41\"},\"type\":\"result\",\"workerId\":\"fly-a\"}", + "digest": "d4bf3e4a30901d8936a3b1034447cd2ca4d6821de832bfb63018d138f504d5d9" + }, + { + "name": "session rpc failure before mutation", + "type": "SessionRpcFailure", + "value": { + "type": "error", + "requestId": "req-41", + "workerId": "fly-a", + "incarnationId": "inc-1", + "scope": { + "sessionId": "demo", + "epoch": "epoch-1", + "step": "41" + }, + "error": { + "code": "INVALID_ARGUMENT", + "message": "interval is not reduced", + "mutation": "none" + } + }, + "note": "", + "canonical": "{\"error\":{\"code\":\"INVALID_ARGUMENT\",\"message\":\"interval is not reduced\",\"mutation\":\"none\"},\"incarnationId\":\"inc-1\",\"requestId\":\"req-41\",\"scope\":{\"epoch\":\"epoch-1\",\"sessionId\":\"demo\",\"step\":\"41\"},\"type\":\"error\",\"workerId\":\"fly-a\"}", + "digest": "c7cd2c928eb476a53edb1ed6ff297b4c1097fd26d3934da1c8823bb63f8670e2" + }, + { + "name": "session rpc failure with uncertain mutation", + "type": "SessionRpcFailure", + "value": { + "type": "error", + "requestId": "req-42", + "workerId": "world", + "incarnationId": "inc-2", + "scope": { + "sessionId": "demo", + "epoch": "epoch-1", + "step": "42" + }, + "error": { + "code": "BACKEND_FAILURE", + "message": "backend stopped responding", + "mutation": "unknown" + } + }, + "note": "", + "canonical": "{\"error\":{\"code\":\"BACKEND_FAILURE\",\"message\":\"backend stopped responding\",\"mutation\":\"unknown\"},\"incarnationId\":\"inc-2\",\"requestId\":\"req-42\",\"scope\":{\"epoch\":\"epoch-1\",\"sessionId\":\"demo\",\"step\":\"42\"},\"type\":\"error\",\"workerId\":\"world\"}", + "digest": "9f59e69015ec7b9dea324242cd1e63d9d79d8229b389ea67f6b2e51f927eeadf" + }, + { + "name": "agent initialize params", + "type": "AgentInitializeParams", + "value": { + "agentId": "fly-a", + "profile": { + "id": "profile-fly-a", + "digest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0", + "byteLength": "4096", + "format": "flyprofile" + }, + "seed": -2147483648, + "initialInput": { + "boundary": "0", + "views": [ + { + "viewId": "screen", + "producedStep": "0", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-1", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + } + ], + "structured": null + }, + "initialDecisionContext": { + "schema": { + "id": "gameboy.context.v1", + "version": 1, + "digest": "e37846f922a5c35b218684fb650d9f2566db7d93dc8eb0e2deb86e22860784b0" + }, + "value": { + "available": [ + "a", + "start" + ] + } + }, + "workerThreads": 3 + }, + "note": "", + "canonical": "{\"agentId\":\"fly-a\",\"initialDecisionContext\":{\"schema\":{\"digest\":\"e37846f922a5c35b218684fb650d9f2566db7d93dc8eb0e2deb86e22860784b0\",\"id\":\"gameboy.context.v1\",\"version\":1},\"value\":{\"available\":[\"a\",\"start\"]}},\"initialInput\":{\"boundary\":\"0\",\"structured\":null,\"views\":[{\"pixels\":{\"artifactId\":\"frame-1\",\"byteLength\":\"92160\",\"contentType\":\"image/x-rgba8\",\"digest\":null,\"generation\":\"1\",\"storeId\":\"store-1\"},\"producedStep\":\"0\",\"viewId\":\"screen\"}]},\"profile\":{\"byteLength\":\"4096\",\"digest\":\"1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0\",\"format\":\"flyprofile\",\"id\":\"profile-fly-a\"},\"seed\":-2147483648,\"workerThreads\":3}", + "digest": "d5d768bc0d130d87dc0020cf95648b77a68c15d63e1acac99c89df0c7de3eb98" + }, + { + "name": "agent initialize result", + "type": "AgentInitializeResult", + "value": { + "agentId": "fly-a", + "profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0", + "tickDuration": { + "numerator": "1000000", + "denominator": "1" + }, + "warmupTicks": "2500", + "committedStep": "0", + "decisionContextDigest": "ea7792a26f405e2ae9c6f49ca93bbe6076ceac0a1fc53d83426c7d7f2d9377e4", + "telemetry": { + "brainTicks": "2500", + "populationRateHz": 12.5, + "rates": [ + { + "roleId": "kenyon", + "hz": 3.25 + }, + { + "roleId": "mbon", + "hz": 0.0 + } + ], + "learning": { + "enabled": true, + "updates": "4", + "changed": "2", + "signal": 0.5 + } + } + }, + "note": "", + "canonical": "{\"agentId\":\"fly-a\",\"committedStep\":\"0\",\"decisionContextDigest\":\"ea7792a26f405e2ae9c6f49ca93bbe6076ceac0a1fc53d83426c7d7f2d9377e4\",\"profileDigest\":\"1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0\",\"telemetry\":{\"brainTicks\":\"2500\",\"learning\":{\"changed\":\"2\",\"enabled\":true,\"signal\":0.5,\"updates\":\"4\"},\"populationRateHz\":12.5,\"rates\":[{\"hz\":3.25,\"roleId\":\"kenyon\"},{\"hz\":0,\"roleId\":\"mbon\"}]},\"tickDuration\":{\"denominator\":\"1\",\"numerator\":\"1000000\"},\"warmupTicks\":\"2500\"}", + "digest": "a220160c75d758720fe43145089939201ee35c86bb100fae0c4efdd3c4eafaa6" + }, + { + "name": "prepare params", + "type": "PrepareParams", + "value": { + "agentId": "fly-a", + "profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0", + "interval": { + "numerator": "50000000", + "denominator": "3" + }, + "decisionContextDigest": "ea7792a26f405e2ae9c6f49ca93bbe6076ceac0a1fc53d83426c7d7f2d9377e4", + "preStepStimulations": [ + { + "id": "stim-1", + "kindId": "sugar", + "durationMs": 50.0 + } + ] + }, + "note": "", + "canonical": "{\"agentId\":\"fly-a\",\"decisionContextDigest\":\"ea7792a26f405e2ae9c6f49ca93bbe6076ceac0a1fc53d83426c7d7f2d9377e4\",\"interval\":{\"denominator\":\"3\",\"numerator\":\"50000000\"},\"preStepStimulations\":[{\"durationMs\":50,\"id\":\"stim-1\",\"kindId\":\"sugar\"}],\"profileDigest\":\"1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0\"}", + "digest": "cdad8a625a5d33b5029a587b91959c234b265d2e23aa2f8e86169abc2cca8730" + }, + { + "name": "prepare params with no stimulation", + "type": "PrepareParams", + "value": { + "agentId": "fly-b", + "profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0", + "interval": { + "numerator": "50000000", + "denominator": "3" + }, + "decisionContextDigest": "ea7792a26f405e2ae9c6f49ca93bbe6076ceac0a1fc53d83426c7d7f2d9377e4", + "preStepStimulations": [] + }, + "note": "", + "canonical": "{\"agentId\":\"fly-b\",\"decisionContextDigest\":\"ea7792a26f405e2ae9c6f49ca93bbe6076ceac0a1fc53d83426c7d7f2d9377e4\",\"interval\":{\"denominator\":\"3\",\"numerator\":\"50000000\"},\"preStepStimulations\":[],\"profileDigest\":\"1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0\"}", + "digest": "549b9f9d93ac1d826f3bba2a57f1293b1371404304ae8ab2c173f05f76b78065" + }, + { + "name": "prepared decision", + "type": "PreparedDecision", + "value": { + "agentId": "fly-a", + "ticksAdvanced": "17", + "brainTicks": "2517", + "remainder": { + "numerator": "1000000", + "denominator": "3" + }, + "decision": { + "schema": { + "id": "gameboy.intent.v1", + "version": 1, + "digest": "e70451b26fb5462ec63729d8355912dad01c3963a95bec0e6a8452375717ad9d" + }, + "value": { + "press": "a" + } + } + }, + "note": "", + "canonical": "{\"agentId\":\"fly-a\",\"brainTicks\":\"2517\",\"decision\":{\"schema\":{\"digest\":\"e70451b26fb5462ec63729d8355912dad01c3963a95bec0e6a8452375717ad9d\",\"id\":\"gameboy.intent.v1\",\"version\":1},\"value\":{\"press\":\"a\"}},\"remainder\":{\"denominator\":\"3\",\"numerator\":\"1000000\"},\"ticksAdvanced\":\"17\"}", + "digest": "55f6ba23215c294f85515712613f7c1eb16a7e4e395556da273b65e0ad871698" + }, + { + "name": "prepared decision with a zero remainder", + "type": "PreparedDecision", + "value": { + "agentId": "fly-a", + "ticksAdvanced": "17", + "brainTicks": "2550", + "remainder": { + "numerator": "0", + "denominator": "1" + }, + "decision": { + "schema": { + "id": "gameboy.intent.v1", + "version": 1, + "digest": "e70451b26fb5462ec63729d8355912dad01c3963a95bec0e6a8452375717ad9d" + }, + "value": { + "press": "none" + } + } + }, + "note": "", + "canonical": "{\"agentId\":\"fly-a\",\"brainTicks\":\"2550\",\"decision\":{\"schema\":{\"digest\":\"e70451b26fb5462ec63729d8355912dad01c3963a95bec0e6a8452375717ad9d\",\"id\":\"gameboy.intent.v1\",\"version\":1},\"value\":{\"press\":\"none\"}},\"remainder\":{\"denominator\":\"1\",\"numerator\":\"0\"},\"ticksAdvanced\":\"17\"}", + "digest": "36f799eb6897274d8bc7afe96d5dd49fd68a15d47b4d6ea15bcfc6057018228a" + }, + { + "name": "commit params", + "type": "CommitParams", + "value": { + "agentId": "fly-a", + "preparedRequestId": "req-41", + "nextInput": { + "boundary": "42", + "views": [ + { + "viewId": "screen", + "producedStep": "42", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-1", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + } + ], + "structured": null + }, + "nextDecisionContext": { + "schema": { + "id": "gameboy.context.v1", + "version": 1, + "digest": "e37846f922a5c35b218684fb650d9f2566db7d93dc8eb0e2deb86e22860784b0" + }, + "value": { + "available": [ + "b" + ] + } + }, + "rewards": [ + { + "eventId": "evt-1", + "ruleId": "badge", + "value": 1.5 + } + ], + "taskStimulations": [ + { + "id": "stim-2", + "kindId": "sugar", + "durationMs": 25.0 + } + ] + }, + "note": "", + "canonical": "{\"agentId\":\"fly-a\",\"nextDecisionContext\":{\"schema\":{\"digest\":\"e37846f922a5c35b218684fb650d9f2566db7d93dc8eb0e2deb86e22860784b0\",\"id\":\"gameboy.context.v1\",\"version\":1},\"value\":{\"available\":[\"b\"]}},\"nextInput\":{\"boundary\":\"42\",\"structured\":null,\"views\":[{\"pixels\":{\"artifactId\":\"frame-1\",\"byteLength\":\"92160\",\"contentType\":\"image/x-rgba8\",\"digest\":null,\"generation\":\"1\",\"storeId\":\"store-1\"},\"producedStep\":\"42\",\"viewId\":\"screen\"}]},\"preparedRequestId\":\"req-41\",\"rewards\":[{\"eventId\":\"evt-1\",\"ruleId\":\"badge\",\"value\":1.5}],\"taskStimulations\":[{\"durationMs\":25,\"id\":\"stim-2\",\"kindId\":\"sugar\"}]}", + "digest": "cb55fa3e34b0a895f69b59359035120fcd9205dfc8bb4b0b3cd5097b58852dd7" + }, + { + "name": "commit params with empty outcome arrays", + "type": "CommitParams", + "value": { + "agentId": "fly-b", + "preparedRequestId": "req-41", + "nextInput": { + "boundary": "42", + "views": [ + { + "viewId": "screen", + "producedStep": "42", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-1", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + } + ], + "structured": null + }, + "nextDecisionContext": { + "schema": { + "id": "gameboy.context.v1", + "version": 1, + "digest": "e37846f922a5c35b218684fb650d9f2566db7d93dc8eb0e2deb86e22860784b0" + }, + "value": { + "available": [] + } + }, + "rewards": [], + "taskStimulations": [] + }, + "note": "", + "canonical": "{\"agentId\":\"fly-b\",\"nextDecisionContext\":{\"schema\":{\"digest\":\"e37846f922a5c35b218684fb650d9f2566db7d93dc8eb0e2deb86e22860784b0\",\"id\":\"gameboy.context.v1\",\"version\":1},\"value\":{\"available\":[]}},\"nextInput\":{\"boundary\":\"42\",\"structured\":null,\"views\":[{\"pixels\":{\"artifactId\":\"frame-1\",\"byteLength\":\"92160\",\"contentType\":\"image/x-rgba8\",\"digest\":null,\"generation\":\"1\",\"storeId\":\"store-1\"},\"producedStep\":\"42\",\"viewId\":\"screen\"}]},\"preparedRequestId\":\"req-41\",\"rewards\":[],\"taskStimulations\":[]}", + "digest": "698d48a40e9438e7973af49afa13e92f1e3d4681cea71bc75a38a8f41310f86b" + }, + { + "name": "agent commit result", + "type": "AgentCommitResult", + "value": { + "agentId": "fly-a", + "committedStep": "42", + "decisionContextDigest": "ea7792a26f405e2ae9c6f49ca93bbe6076ceac0a1fc53d83426c7d7f2d9377e4", + "telemetry": { + "brainTicks": "2534", + "populationRateHz": 12.5, + "rates": [ + { + "roleId": "kenyon", + "hz": 3.25 + }, + { + "roleId": "mbon", + "hz": 0.0 + } + ], + "learning": { + "enabled": true, + "updates": "4", + "changed": "2", + "signal": 0.5 + } + } + }, + "note": "", + "canonical": "{\"agentId\":\"fly-a\",\"committedStep\":\"42\",\"decisionContextDigest\":\"ea7792a26f405e2ae9c6f49ca93bbe6076ceac0a1fc53d83426c7d7f2d9377e4\",\"telemetry\":{\"brainTicks\":\"2534\",\"learning\":{\"changed\":\"2\",\"enabled\":true,\"signal\":0.5,\"updates\":\"4\"},\"populationRateHz\":12.5,\"rates\":[{\"hz\":3.25,\"roleId\":\"kenyon\"},{\"hz\":0,\"roleId\":\"mbon\"}]}}", + "digest": "9f0a8812101f8f41a147846f6a94b67ae7898ca143fa3cf44ae3a3d5924cd17c" + }, + { + "name": "controller schema", + "type": "ControllerSchema", + "value": { + "schema": { + "id": "gameboy.controller.v1", + "version": 1, + "digest": "b1df9d20fe1deaeb52f17cf261bf728b28143c777878b96cacfc4826903a48d5" + }, + "buttons": [ + "a", + "b", + "start", + "select", + "up", + "down", + "left", + "right" + ], + "axes": [ + { + "id": "stick-x", + "range": "bipolar", + "neutral": 0.0 + }, + { + "id": "trigger", + "range": "unit", + "neutral": 0.0 + } + ] + }, + "note": "", + "canonical": "{\"axes\":[{\"id\":\"stick-x\",\"neutral\":0,\"range\":\"bipolar\"},{\"id\":\"trigger\",\"neutral\":0,\"range\":\"unit\"}],\"buttons\":[\"a\",\"b\",\"start\",\"select\",\"up\",\"down\",\"left\",\"right\"],\"schema\":{\"digest\":\"b1df9d20fe1deaeb52f17cf261bf728b28143c777878b96cacfc4826903a48d5\",\"id\":\"gameboy.controller.v1\",\"version\":1}}", + "digest": "4a551ed563aa1fae4a4598a5547eb58367ed9a81c3db706b89e7aca1fb50ff6d" + }, + { + "name": "port control at neutral", + "type": "PortControl", + "value": { + "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 + } + ] + }, + "note": "", + "canonical": "{\"axes\":[{\"id\":\"stick-x\",\"value\":0},{\"id\":\"trigger\",\"value\":0}],\"buttons\":[{\"down\":true,\"id\":\"a\"},{\"down\":false,\"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\"}],\"portId\":\"port-1\"}", + "digest": "305732269e99981d7cac457acd0f711ff3687ba9acab2af2fddb9587401456b5" + }, + { + "name": "port control at the analog limits", + "type": "PortControl", + "value": { + "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": 1.0 + } + ] + }, + "note": "", + "canonical": "{\"axes\":[{\"id\":\"stick-x\",\"value\":-1},{\"id\":\"trigger\",\"value\":1}],\"buttons\":[{\"down\":true,\"id\":\"a\"},{\"down\":false,\"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\"}],\"portId\":\"port-1\"}", + "digest": "11cef7568389c537fd826845523021f3408762a5a8b8d7827e6a62e5a9116e02" + }, + { + "name": "environment descriptor with one port", + "type": "EnvironmentDescriptor", + "value": { + "backendDigest": "10e08a419e850eba1ebba18fdd28eb7ec1b7e8baa9bcc3b973e2b8891ec726be", + "contentDigest": "ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73", + "configurationDigest": "b7d64a9221007dfd5390f7df6cd5b8f3ea4f82faa1237141e35ebf161f5511a1", + "stepDuration": { + "numerator": "50000000", + "denominator": "3" + }, + "ports": [ + { + "portId": "port-1", + "controls": { + "schema": { + "id": "gameboy.controller.v1", + "version": 1, + "digest": "b1df9d20fe1deaeb52f17cf261bf728b28143c777878b96cacfc4826903a48d5" + }, + "buttons": [ + "a", + "b", + "start", + "select", + "up", + "down", + "left", + "right" + ], + "axes": [ + { + "id": "stick-x", + "range": "bipolar", + "neutral": 0.0 + }, + { + "id": "trigger", + "range": "unit", + "neutral": 0.0 + } + ] + } + } + ], + "inspectionSchema": { + "id": "gameboy.inspection.v1", + "version": 1, + "digest": "1883cee6532d0bfbb28854b82db8450d525c2e4fcec01567b4aa6d99917eafb1" + }, + "views": [ + { + "viewId": "screen", + "width": 160, + "height": 144, + "format": "rgba8", + "rowStride": 640, + "pixelAspect": { + "numerator": 10, + "denominator": 11 + }, + "observationDelaySteps": 0 + } + ], + "audio": [ + { + "streamId": "mix", + "sampleRate": 48000, + "channels": 2, + "format": "f32le-interleaved" + } + ], + "recovery": "exact-checkpoint", + "determinism": "fixed-build" + }, + "note": "", + "canonical": "{\"audio\":[{\"channels\":2,\"format\":\"f32le-interleaved\",\"sampleRate\":48000,\"streamId\":\"mix\"}],\"backendDigest\":\"10e08a419e850eba1ebba18fdd28eb7ec1b7e8baa9bcc3b973e2b8891ec726be\",\"configurationDigest\":\"b7d64a9221007dfd5390f7df6cd5b8f3ea4f82faa1237141e35ebf161f5511a1\",\"contentDigest\":\"ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73\",\"determinism\":\"fixed-build\",\"inspectionSchema\":{\"digest\":\"1883cee6532d0bfbb28854b82db8450d525c2e4fcec01567b4aa6d99917eafb1\",\"id\":\"gameboy.inspection.v1\",\"version\":1},\"ports\":[{\"controls\":{\"axes\":[{\"id\":\"stick-x\",\"neutral\":0,\"range\":\"bipolar\"},{\"id\":\"trigger\",\"neutral\":0,\"range\":\"unit\"}],\"buttons\":[\"a\",\"b\",\"start\",\"select\",\"up\",\"down\",\"left\",\"right\"],\"schema\":{\"digest\":\"b1df9d20fe1deaeb52f17cf261bf728b28143c777878b96cacfc4826903a48d5\",\"id\":\"gameboy.controller.v1\",\"version\":1}},\"portId\":\"port-1\"}],\"recovery\":\"exact-checkpoint\",\"stepDuration\":{\"denominator\":\"3\",\"numerator\":\"50000000\"},\"views\":[{\"format\":\"rgba8\",\"height\":144,\"observationDelaySteps\":0,\"pixelAspect\":{\"denominator\":11,\"numerator\":10},\"rowStride\":640,\"viewId\":\"screen\",\"width\":160}]}", + "digest": "885834e2ba9a5dee35b12a7844768746aa2842fec2f07622ead180905fa11d8f" + }, + { + "name": "environment descriptor with four ports", + "type": "EnvironmentDescriptor", + "value": { + "backendDigest": "10e08a419e850eba1ebba18fdd28eb7ec1b7e8baa9bcc3b973e2b8891ec726be", + "contentDigest": "ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73", + "configurationDigest": "b7d64a9221007dfd5390f7df6cd5b8f3ea4f82faa1237141e35ebf161f5511a1", + "stepDuration": { + "numerator": "50000000", + "denominator": "3" + }, + "ports": [ + { + "portId": "port-1", + "controls": { + "schema": { + "id": "gameboy.controller.v1", + "version": 1, + "digest": "b1df9d20fe1deaeb52f17cf261bf728b28143c777878b96cacfc4826903a48d5" + }, + "buttons": [ + "a", + "b", + "start", + "select", + "up", + "down", + "left", + "right" + ], + "axes": [ + { + "id": "stick-x", + "range": "bipolar", + "neutral": 0.0 + }, + { + "id": "trigger", + "range": "unit", + "neutral": 0.0 + } + ] + } + }, + { + "portId": "port-2", + "controls": { + "schema": { + "id": "gameboy.controller.v1", + "version": 1, + "digest": "b1df9d20fe1deaeb52f17cf261bf728b28143c777878b96cacfc4826903a48d5" + }, + "buttons": [ + "a", + "b", + "start", + "select", + "up", + "down", + "left", + "right" + ], + "axes": [ + { + "id": "stick-x", + "range": "bipolar", + "neutral": 0.0 + }, + { + "id": "trigger", + "range": "unit", + "neutral": 0.0 + } + ] + } + }, + { + "portId": "port-3", + "controls": { + "schema": { + "id": "gameboy.controller.v1", + "version": 1, + "digest": "b1df9d20fe1deaeb52f17cf261bf728b28143c777878b96cacfc4826903a48d5" + }, + "buttons": [ + "a", + "b", + "start", + "select", + "up", + "down", + "left", + "right" + ], + "axes": [ + { + "id": "stick-x", + "range": "bipolar", + "neutral": 0.0 + }, + { + "id": "trigger", + "range": "unit", + "neutral": 0.0 + } + ] + } + }, + { + "portId": "port-4", + "controls": { + "schema": { + "id": "gameboy.controller.v1", + "version": 1, + "digest": "b1df9d20fe1deaeb52f17cf261bf728b28143c777878b96cacfc4826903a48d5" + }, + "buttons": [ + "a", + "b", + "start", + "select", + "up", + "down", + "left", + "right" + ], + "axes": [ + { + "id": "stick-x", + "range": "bipolar", + "neutral": 0.0 + }, + { + "id": "trigger", + "range": "unit", + "neutral": 0.0 + } + ] + } + } + ], + "inspectionSchema": { + "id": "gameboy.inspection.v1", + "version": 1, + "digest": "1883cee6532d0bfbb28854b82db8450d525c2e4fcec01567b4aa6d99917eafb1" + }, + "views": [ + { + "viewId": "screen", + "width": 160, + "height": 144, + "format": "rgba8", + "rowStride": 640, + "pixelAspect": { + "numerator": 10, + "denominator": 11 + }, + "observationDelaySteps": 0 + } + ], + "audio": [ + { + "streamId": "mix", + "sampleRate": 48000, + "channels": 2, + "format": "f32le-interleaved" + } + ], + "recovery": "exact-checkpoint", + "determinism": "fixed-build" + }, + "note": "", + "canonical": "{\"audio\":[{\"channels\":2,\"format\":\"f32le-interleaved\",\"sampleRate\":48000,\"streamId\":\"mix\"}],\"backendDigest\":\"10e08a419e850eba1ebba18fdd28eb7ec1b7e8baa9bcc3b973e2b8891ec726be\",\"configurationDigest\":\"b7d64a9221007dfd5390f7df6cd5b8f3ea4f82faa1237141e35ebf161f5511a1\",\"contentDigest\":\"ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73\",\"determinism\":\"fixed-build\",\"inspectionSchema\":{\"digest\":\"1883cee6532d0bfbb28854b82db8450d525c2e4fcec01567b4aa6d99917eafb1\",\"id\":\"gameboy.inspection.v1\",\"version\":1},\"ports\":[{\"controls\":{\"axes\":[{\"id\":\"stick-x\",\"neutral\":0,\"range\":\"bipolar\"},{\"id\":\"trigger\",\"neutral\":0,\"range\":\"unit\"}],\"buttons\":[\"a\",\"b\",\"start\",\"select\",\"up\",\"down\",\"left\",\"right\"],\"schema\":{\"digest\":\"b1df9d20fe1deaeb52f17cf261bf728b28143c777878b96cacfc4826903a48d5\",\"id\":\"gameboy.controller.v1\",\"version\":1}},\"portId\":\"port-1\"},{\"controls\":{\"axes\":[{\"id\":\"stick-x\",\"neutral\":0,\"range\":\"bipolar\"},{\"id\":\"trigger\",\"neutral\":0,\"range\":\"unit\"}],\"buttons\":[\"a\",\"b\",\"start\",\"select\",\"up\",\"down\",\"left\",\"right\"],\"schema\":{\"digest\":\"b1df9d20fe1deaeb52f17cf261bf728b28143c777878b96cacfc4826903a48d5\",\"id\":\"gameboy.controller.v1\",\"version\":1}},\"portId\":\"port-2\"},{\"controls\":{\"axes\":[{\"id\":\"stick-x\",\"neutral\":0,\"range\":\"bipolar\"},{\"id\":\"trigger\",\"neutral\":0,\"range\":\"unit\"}],\"buttons\":[\"a\",\"b\",\"start\",\"select\",\"up\",\"down\",\"left\",\"right\"],\"schema\":{\"digest\":\"b1df9d20fe1deaeb52f17cf261bf728b28143c777878b96cacfc4826903a48d5\",\"id\":\"gameboy.controller.v1\",\"version\":1}},\"portId\":\"port-3\"},{\"controls\":{\"axes\":[{\"id\":\"stick-x\",\"neutral\":0,\"range\":\"bipolar\"},{\"id\":\"trigger\",\"neutral\":0,\"range\":\"unit\"}],\"buttons\":[\"a\",\"b\",\"start\",\"select\",\"up\",\"down\",\"left\",\"right\"],\"schema\":{\"digest\":\"b1df9d20fe1deaeb52f17cf261bf728b28143c777878b96cacfc4826903a48d5\",\"id\":\"gameboy.controller.v1\",\"version\":1}},\"portId\":\"port-4\"}],\"recovery\":\"exact-checkpoint\",\"stepDuration\":{\"denominator\":\"3\",\"numerator\":\"50000000\"},\"views\":[{\"format\":\"rgba8\",\"height\":144,\"observationDelaySteps\":0,\"pixelAspect\":{\"denominator\":11,\"numerator\":10},\"rowStride\":640,\"viewId\":\"screen\",\"width\":160}]}", + "digest": "a8e29d9cdb27e83478f86450e8a24b287de2def947860ba0b85147ef9baf23ec" + }, + { + "name": "environment descriptor that only restarts episodes", + "type": "EnvironmentDescriptor", + "value": { + "backendDigest": "10e08a419e850eba1ebba18fdd28eb7ec1b7e8baa9bcc3b973e2b8891ec726be", + "contentDigest": "ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73", + "configurationDigest": "b7d64a9221007dfd5390f7df6cd5b8f3ea4f82faa1237141e35ebf161f5511a1", + "stepDuration": { + "numerator": "50000000", + "denominator": "3" + }, + "ports": [ + { + "portId": "port-1", + "controls": { + "schema": { + "id": "gameboy.controller.v1", + "version": 1, + "digest": "b1df9d20fe1deaeb52f17cf261bf728b28143c777878b96cacfc4826903a48d5" + }, + "buttons": [ + "a", + "b", + "start", + "select", + "up", + "down", + "left", + "right" + ], + "axes": [ + { + "id": "stick-x", + "range": "bipolar", + "neutral": 0.0 + }, + { + "id": "trigger", + "range": "unit", + "neutral": 0.0 + } + ] + } + } + ], + "inspectionSchema": { + "id": "gameboy.inspection.v1", + "version": 1, + "digest": "1883cee6532d0bfbb28854b82db8450d525c2e4fcec01567b4aa6d99917eafb1" + }, + "views": [ + { + "viewId": "screen", + "width": 160, + "height": 144, + "format": "rgba8", + "rowStride": 640, + "pixelAspect": { + "numerator": 10, + "denominator": 11 + }, + "observationDelaySteps": 0 + } + ], + "audio": [ + { + "streamId": "mix", + "sampleRate": 48000, + "channels": 2, + "format": "f32le-interleaved" + } + ], + "recovery": "episode-restart", + "determinism": "fixed-build" + }, + "note": "", + "canonical": "{\"audio\":[{\"channels\":2,\"format\":\"f32le-interleaved\",\"sampleRate\":48000,\"streamId\":\"mix\"}],\"backendDigest\":\"10e08a419e850eba1ebba18fdd28eb7ec1b7e8baa9bcc3b973e2b8891ec726be\",\"configurationDigest\":\"b7d64a9221007dfd5390f7df6cd5b8f3ea4f82faa1237141e35ebf161f5511a1\",\"contentDigest\":\"ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73\",\"determinism\":\"fixed-build\",\"inspectionSchema\":{\"digest\":\"1883cee6532d0bfbb28854b82db8450d525c2e4fcec01567b4aa6d99917eafb1\",\"id\":\"gameboy.inspection.v1\",\"version\":1},\"ports\":[{\"controls\":{\"axes\":[{\"id\":\"stick-x\",\"neutral\":0,\"range\":\"bipolar\"},{\"id\":\"trigger\",\"neutral\":0,\"range\":\"unit\"}],\"buttons\":[\"a\",\"b\",\"start\",\"select\",\"up\",\"down\",\"left\",\"right\"],\"schema\":{\"digest\":\"b1df9d20fe1deaeb52f17cf261bf728b28143c777878b96cacfc4826903a48d5\",\"id\":\"gameboy.controller.v1\",\"version\":1}},\"portId\":\"port-1\"}],\"recovery\":\"episode-restart\",\"stepDuration\":{\"denominator\":\"3\",\"numerator\":\"50000000\"},\"views\":[{\"format\":\"rgba8\",\"height\":144,\"observationDelaySteps\":0,\"pixelAspect\":{\"denominator\":11,\"numerator\":10},\"rowStride\":640,\"viewId\":\"screen\",\"width\":160}]}", + "digest": "e46887f6c10989a8b1bcd8e5bfa40e66b33b3208389ee6ebad7907ca9277e6e3" + }, + { + "name": "environment initialize params", + "type": "EnvironmentInitializeParams", + "value": { + "backendConfig": { + "id": "backend-gb", + "digest": "b86130f294ef46c5343a505946e6e0507f061d7a1bcf46f09c6d42e1aeb9a2a1", + "byteLength": "512", + "format": "flybackend" + }, + "taskConfig": { + "id": "task-escape", + "digest": "b48eb1e3b6f871c6384b86387e59d4e3b6c69245168011968d5982204c656ac6", + "byteLength": "256", + "format": "flytask" + }, + "episodeId": "episode-1", + "portBindings": [ + { + "portId": "port-1", + "agentId": "fly-a" + } + ] + }, + "note": "", + "canonical": "{\"backendConfig\":{\"byteLength\":\"512\",\"digest\":\"b86130f294ef46c5343a505946e6e0507f061d7a1bcf46f09c6d42e1aeb9a2a1\",\"format\":\"flybackend\",\"id\":\"backend-gb\"},\"episodeId\":\"episode-1\",\"portBindings\":[{\"agentId\":\"fly-a\",\"portId\":\"port-1\"}],\"taskConfig\":{\"byteLength\":\"256\",\"digest\":\"b48eb1e3b6f871c6384b86387e59d4e3b6c69245168011968d5982204c656ac6\",\"format\":\"flytask\",\"id\":\"task-escape\"}}", + "digest": "be5e458e0e900e16b3a4bb3befe032047463c5d2576f424e3c0f32d1ed194012" + }, + { + "name": "environment initialize result at boundary zero", + "type": "EnvironmentInitializeResult", + "value": { + "descriptor": { + "backendDigest": "10e08a419e850eba1ebba18fdd28eb7ec1b7e8baa9bcc3b973e2b8891ec726be", + "contentDigest": "ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73", + "configurationDigest": "b7d64a9221007dfd5390f7df6cd5b8f3ea4f82faa1237141e35ebf161f5511a1", + "stepDuration": { + "numerator": "50000000", + "denominator": "3" + }, + "ports": [ + { + "portId": "port-1", + "controls": { + "schema": { + "id": "gameboy.controller.v1", + "version": 1, + "digest": "b1df9d20fe1deaeb52f17cf261bf728b28143c777878b96cacfc4826903a48d5" + }, + "buttons": [ + "a", + "b", + "start", + "select", + "up", + "down", + "left", + "right" + ], + "axes": [ + { + "id": "stick-x", + "range": "bipolar", + "neutral": 0.0 + }, + { + "id": "trigger", + "range": "unit", + "neutral": 0.0 + } + ] + } + } + ], + "inspectionSchema": { + "id": "gameboy.inspection.v1", + "version": 1, + "digest": "1883cee6532d0bfbb28854b82db8450d525c2e4fcec01567b4aa6d99917eafb1" + }, + "views": [ + { + "viewId": "screen", + "width": 160, + "height": 144, + "format": "rgba8", + "rowStride": 640, + "pixelAspect": { + "numerator": 10, + "denominator": 11 + }, + "observationDelaySteps": 0 + } + ], + "audio": [ + { + "streamId": "mix", + "sampleRate": 48000, + "channels": 2, + "format": "f32le-interleaved" + } + ], + "recovery": "exact-checkpoint", + "determinism": "fixed-build" + }, + "observation": { + "boundary": "0", + "worldTime": { + "numerator": "0", + "denominator": "1" + }, + "engineFrame": null, + "sensoryViews": [ + { + "viewId": "screen", + "producedStep": "0", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-1", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + } + ], + "inspection": { + "schema": { + "id": "gameboy.inspection.v1", + "version": 1, + "digest": "1883cee6532d0bfbb28854b82db8450d525c2e4fcec01567b4aa6d99917eafb1" + }, + "value": { + "map": 0, + "x": 0, + "y": 0 + } + }, + "broadcastViews": [], + "audio": [] + } + }, + "note": "", + "canonical": "{\"descriptor\":{\"audio\":[{\"channels\":2,\"format\":\"f32le-interleaved\",\"sampleRate\":48000,\"streamId\":\"mix\"}],\"backendDigest\":\"10e08a419e850eba1ebba18fdd28eb7ec1b7e8baa9bcc3b973e2b8891ec726be\",\"configurationDigest\":\"b7d64a9221007dfd5390f7df6cd5b8f3ea4f82faa1237141e35ebf161f5511a1\",\"contentDigest\":\"ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73\",\"determinism\":\"fixed-build\",\"inspectionSchema\":{\"digest\":\"1883cee6532d0bfbb28854b82db8450d525c2e4fcec01567b4aa6d99917eafb1\",\"id\":\"gameboy.inspection.v1\",\"version\":1},\"ports\":[{\"controls\":{\"axes\":[{\"id\":\"stick-x\",\"neutral\":0,\"range\":\"bipolar\"},{\"id\":\"trigger\",\"neutral\":0,\"range\":\"unit\"}],\"buttons\":[\"a\",\"b\",\"start\",\"select\",\"up\",\"down\",\"left\",\"right\"],\"schema\":{\"digest\":\"b1df9d20fe1deaeb52f17cf261bf728b28143c777878b96cacfc4826903a48d5\",\"id\":\"gameboy.controller.v1\",\"version\":1}},\"portId\":\"port-1\"}],\"recovery\":\"exact-checkpoint\",\"stepDuration\":{\"denominator\":\"3\",\"numerator\":\"50000000\"},\"views\":[{\"format\":\"rgba8\",\"height\":144,\"observationDelaySteps\":0,\"pixelAspect\":{\"denominator\":11,\"numerator\":10},\"rowStride\":640,\"viewId\":\"screen\",\"width\":160}]},\"observation\":{\"audio\":[],\"boundary\":\"0\",\"broadcastViews\":[],\"engineFrame\":null,\"inspection\":{\"schema\":{\"digest\":\"1883cee6532d0bfbb28854b82db8450d525c2e4fcec01567b4aa6d99917eafb1\",\"id\":\"gameboy.inspection.v1\",\"version\":1},\"value\":{\"map\":0,\"x\":0,\"y\":0}},\"sensoryViews\":[{\"pixels\":{\"artifactId\":\"frame-1\",\"byteLength\":\"92160\",\"contentType\":\"image/x-rgba8\",\"digest\":null,\"generation\":\"1\",\"storeId\":\"store-1\"},\"producedStep\":\"0\",\"viewId\":\"screen\"}],\"worldTime\":{\"denominator\":\"1\",\"numerator\":\"0\"}}}", + "digest": "f7a51022984e5584f8cb366e09673a814755a4154520ba802867947f44824769" + }, + { + "name": "world observation", + "type": "WorldObservation", + "value": { + "boundary": "1", + "worldTime": { + "numerator": "50000000", + "denominator": "3" + }, + "engineFrame": "1", + "sensoryViews": [ + { + "viewId": "screen", + "producedStep": "1", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-1", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + } + ], + "inspection": { + "schema": { + "id": "gameboy.inspection.v1", + "version": 1, + "digest": "1883cee6532d0bfbb28854b82db8450d525c2e4fcec01567b4aa6d99917eafb1" + }, + "value": { + "map": 40, + "x": 5, + "y": 7 + } + }, + "broadcastViews": [ + { + "viewId": "screen", + "producedStep": "1", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-1", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + } + ], + "audio": [ + { + "streamId": "mix", + "firstSample": "800", + "sampleFrames": 800, + "samples": { + "storeId": "store-1", + "artifactId": "audio-1", + "generation": "1", + "byteLength": "6400", + "contentType": "audio/x-f32le", + "digest": null + }, + "discontinuity": false + } + ] + }, + "note": "", + "canonical": "{\"audio\":[{\"discontinuity\":false,\"firstSample\":\"800\",\"sampleFrames\":800,\"samples\":{\"artifactId\":\"audio-1\",\"byteLength\":\"6400\",\"contentType\":\"audio/x-f32le\",\"digest\":null,\"generation\":\"1\",\"storeId\":\"store-1\"},\"streamId\":\"mix\"}],\"boundary\":\"1\",\"broadcastViews\":[{\"pixels\":{\"artifactId\":\"frame-1\",\"byteLength\":\"92160\",\"contentType\":\"image/x-rgba8\",\"digest\":null,\"generation\":\"1\",\"storeId\":\"store-1\"},\"producedStep\":\"1\",\"viewId\":\"screen\"}],\"engineFrame\":\"1\",\"inspection\":{\"schema\":{\"digest\":\"1883cee6532d0bfbb28854b82db8450d525c2e4fcec01567b4aa6d99917eafb1\",\"id\":\"gameboy.inspection.v1\",\"version\":1},\"value\":{\"map\":40,\"x\":5,\"y\":7}},\"sensoryViews\":[{\"pixels\":{\"artifactId\":\"frame-1\",\"byteLength\":\"92160\",\"contentType\":\"image/x-rgba8\",\"digest\":null,\"generation\":\"1\",\"storeId\":\"store-1\"},\"producedStep\":\"1\",\"viewId\":\"screen\"}],\"worldTime\":{\"denominator\":\"3\",\"numerator\":\"50000000\"}}", + "digest": "f60d51dab4cf43c98b55724ee33fae362092527c3e76ebc480eca3a3be4b47cd" + }, + { + "name": "advance params", + "type": "AdvanceParams", + "value": { + "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 + } + ] + } + ] + }, + "note": "", + "canonical": "{\"batchId\":\"batch-41\",\"controls\":[{\"axes\":[{\"id\":\"stick-x\",\"value\":0},{\"id\":\"trigger\",\"value\":0}],\"buttons\":[{\"down\":true,\"id\":\"a\"},{\"down\":false,\"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\"}],\"portId\":\"port-1\"}]}", + "digest": "1b6088e49d17d1d58d415348018f36be0df2b758f27267839d1d31cc434abe32" + }, + { + "name": "advance params for four ports", + "type": "AdvanceParams", + "value": { + "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 + } + ] + }, + { + "portId": "port-2", + "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 + } + ] + }, + { + "portId": "port-3", + "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 + } + ] + }, + { + "portId": "port-4", + "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 + } + ] + } + ] + }, + "note": "", + "canonical": "{\"batchId\":\"batch-41\",\"controls\":[{\"axes\":[{\"id\":\"stick-x\",\"value\":0},{\"id\":\"trigger\",\"value\":0}],\"buttons\":[{\"down\":true,\"id\":\"a\"},{\"down\":false,\"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\"}],\"portId\":\"port-1\"},{\"axes\":[{\"id\":\"stick-x\",\"value\":0},{\"id\":\"trigger\",\"value\":0}],\"buttons\":[{\"down\":true,\"id\":\"a\"},{\"down\":false,\"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\"}],\"portId\":\"port-2\"},{\"axes\":[{\"id\":\"stick-x\",\"value\":0},{\"id\":\"trigger\",\"value\":0}],\"buttons\":[{\"down\":true,\"id\":\"a\"},{\"down\":false,\"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\"}],\"portId\":\"port-3\"},{\"axes\":[{\"id\":\"stick-x\",\"value\":0},{\"id\":\"trigger\",\"value\":0}],\"buttons\":[{\"down\":true,\"id\":\"a\"},{\"down\":false,\"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\"}],\"portId\":\"port-4\"}]}", + "digest": "a174e6849f0f02a4c160af2e487517f18bee3e58f3f0a7fa33a66afe5904df77" + }, + { + "name": "step result", + "type": "StepResult", + "value": { + "batchId": "batch-41", + "appliedFromStep": "41", + "nextStep": "42", + "appliedControlsDigest": "1e2135d1b50f14d3d3d6cbe24a4294bcb9e4d5d3c28eb1ce4980ff2a17c5d3b6", + "observation": { + "boundary": "42", + "worldTime": { + "numerator": "50000000", + "denominator": "3" + }, + "engineFrame": "1", + "sensoryViews": [ + { + "viewId": "screen", + "producedStep": "42", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-1", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + } + ], + "inspection": { + "schema": { + "id": "gameboy.inspection.v1", + "version": 1, + "digest": "1883cee6532d0bfbb28854b82db8450d525c2e4fcec01567b4aa6d99917eafb1" + }, + "value": { + "map": 40, + "x": 5, + "y": 7 + } + }, + "broadcastViews": [ + { + "viewId": "screen", + "producedStep": "42", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-1", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + } + ], + "audio": [ + { + "streamId": "mix", + "firstSample": "33600", + "sampleFrames": 800, + "samples": { + "storeId": "store-1", + "artifactId": "audio-1", + "generation": "1", + "byteLength": "6400", + "contentType": "audio/x-f32le", + "digest": null + }, + "discontinuity": false + } + ] + } + }, + "note": "", + "canonical": "{\"appliedControlsDigest\":\"1e2135d1b50f14d3d3d6cbe24a4294bcb9e4d5d3c28eb1ce4980ff2a17c5d3b6\",\"appliedFromStep\":\"41\",\"batchId\":\"batch-41\",\"nextStep\":\"42\",\"observation\":{\"audio\":[{\"discontinuity\":false,\"firstSample\":\"33600\",\"sampleFrames\":800,\"samples\":{\"artifactId\":\"audio-1\",\"byteLength\":\"6400\",\"contentType\":\"audio/x-f32le\",\"digest\":null,\"generation\":\"1\",\"storeId\":\"store-1\"},\"streamId\":\"mix\"}],\"boundary\":\"42\",\"broadcastViews\":[{\"pixels\":{\"artifactId\":\"frame-1\",\"byteLength\":\"92160\",\"contentType\":\"image/x-rgba8\",\"digest\":null,\"generation\":\"1\",\"storeId\":\"store-1\"},\"producedStep\":\"42\",\"viewId\":\"screen\"}],\"engineFrame\":\"1\",\"inspection\":{\"schema\":{\"digest\":\"1883cee6532d0bfbb28854b82db8450d525c2e4fcec01567b4aa6d99917eafb1\",\"id\":\"gameboy.inspection.v1\",\"version\":1},\"value\":{\"map\":40,\"x\":5,\"y\":7}},\"sensoryViews\":[{\"pixels\":{\"artifactId\":\"frame-1\",\"byteLength\":\"92160\",\"contentType\":\"image/x-rgba8\",\"digest\":null,\"generation\":\"1\",\"storeId\":\"store-1\"},\"producedStep\":\"42\",\"viewId\":\"screen\"}],\"worldTime\":{\"denominator\":\"3\",\"numerator\":\"50000000\"}}}", + "digest": "afabd9118523470ac7ccc4caa8bb907b0bd6b79202b22c3d1cec0eaf33855e8f" + }, + { + "name": "hello params", + "type": "HelloParams", + "value": { + "sessionId": "demo", + "expectedWorkerId": "fly-a", + "role": "agent", + "supportedMajors": [ + 1 + ] + }, + "note": "", + "canonical": "{\"expectedWorkerId\":\"fly-a\",\"role\":\"agent\",\"sessionId\":\"demo\",\"supportedMajors\":[1]}", + "digest": "d1d183576de14e9a4f3a247e86b467520f5ee48b861ff1f78c2e21aa6c65acb4" + }, + { + "name": "hello result for an agent", + "type": "HelloResult", + "value": { + "selectedMajor": 1, + "selectedMinor": 0, + "workerId": "fly-a", + "incarnationId": "inc-1", + "role": "agent", + "buildDigest": "44575cf5b28512d75644bf54a517dcef304ff809fd511747621b4d64f19aac66", + "contractDigest": "cc8321d6375c494d043fdd0260f21bc0ec51dacc9f6abb7f909cdcd3041b78bf", + "capabilities": [ + "agent-step-v1", + "checkpoint-v1", + "pixel-observation-v1" + ], + "limits": { + "maxAgents": 4, + "maxPorts": 4 + } + }, + "note": "", + "canonical": "{\"buildDigest\":\"44575cf5b28512d75644bf54a517dcef304ff809fd511747621b4d64f19aac66\",\"capabilities\":[\"agent-step-v1\",\"checkpoint-v1\",\"pixel-observation-v1\"],\"contractDigest\":\"cc8321d6375c494d043fdd0260f21bc0ec51dacc9f6abb7f909cdcd3041b78bf\",\"incarnationId\":\"inc-1\",\"limits\":{\"maxAgents\":4,\"maxPorts\":4},\"role\":\"agent\",\"selectedMajor\":1,\"selectedMinor\":0,\"workerId\":\"fly-a\"}", + "digest": "9f8cbc9dfe7a7532c2e41b53c4ce001973ca95703a81aad30ee2b1c8b640bc13" + }, + { + "name": "hello result for an environment", + "type": "HelloResult", + "value": { + "selectedMajor": 1, + "selectedMinor": 0, + "workerId": "world", + "incarnationId": "inc-2", + "role": "environment", + "buildDigest": "44575cf5b28512d75644bf54a517dcef304ff809fd511747621b4d64f19aac66", + "contractDigest": "cc8321d6375c494d043fdd0260f21bc0ec51dacc9f6abb7f909cdcd3041b78bf", + "capabilities": [ + "world-step-v1", + "checkpoint-v1" + ], + "limits": { + "maxAgents": 1, + "maxPorts": 1 + } + }, + "note": "", + "canonical": "{\"buildDigest\":\"44575cf5b28512d75644bf54a517dcef304ff809fd511747621b4d64f19aac66\",\"capabilities\":[\"world-step-v1\",\"checkpoint-v1\"],\"contractDigest\":\"cc8321d6375c494d043fdd0260f21bc0ec51dacc9f6abb7f909cdcd3041b78bf\",\"incarnationId\":\"inc-2\",\"limits\":{\"maxAgents\":1,\"maxPorts\":1},\"role\":\"environment\",\"selectedMajor\":1,\"selectedMinor\":0,\"workerId\":\"world\"}", + "digest": "0dc33a6cbf6bf66c4d28dc41b17b2eb7918b55c65469f4161e009f662f11bca2" + }, + { + "name": "status result before initialization", + "type": "StatusResult", + "value": { + "state": "uninitialized", + "currentScope": null, + "activeRequestId": null, + "lastCompletedRequestId": null, + "lastBatchId": null, + "progressCounter": "0" + }, + "note": "", + "canonical": "{\"activeRequestId\":null,\"currentScope\":null,\"lastBatchId\":null,\"lastCompletedRequestId\":null,\"progressCounter\":\"0\",\"state\":\"uninitialized\"}", + "digest": "3c3fccc7e3f27a83bcadfd5cc081861039edd0845001c9dc5484d4b6042a1597" + }, + { + "name": "status result while advancing", + "type": "StatusResult", + "value": { + "state": "advancing", + "currentScope": { + "sessionId": "demo", + "epoch": "epoch-1", + "step": "41" + }, + "activeRequestId": "req-41", + "lastCompletedRequestId": "req-40", + "lastBatchId": "batch-41", + "progressCounter": "9001" + }, + "note": "", + "canonical": "{\"activeRequestId\":\"req-41\",\"currentScope\":{\"epoch\":\"epoch-1\",\"sessionId\":\"demo\",\"step\":\"41\"},\"lastBatchId\":\"batch-41\",\"lastCompletedRequestId\":\"req-40\",\"progressCounter\":\"9001\",\"state\":\"advancing\"}", + "digest": "7685d8f26c9ac05e98b31e49623482473ce6562e7312fe7a07ad2d0140885442" + }, + { + "name": "acknowledge params", + "type": "AcknowledgeParams", + "value": { + "requestIds": [ + "req-1", + "req-2" + ] + }, + "note": "", + "canonical": "{\"requestIds\":[\"req-1\",\"req-2\"]}", + "digest": "ecdd5329b6c03e60e4baa506410e49631cdc6c6bc69b91c4a587e990c1bc2d0b" + }, + { + "name": "acknowledge params at the cap", + "type": "AcknowledgeParams", + "value": { + "requestIds": [ + "req-1", + "req-2", + "req-3", + "req-4", + "req-5", + "req-6", + "req-7", + "req-8", + "req-9", + "req-10", + "req-11", + "req-12", + "req-13", + "req-14", + "req-15", + "req-16" + ] + }, + "note": "", + "canonical": "{\"requestIds\":[\"req-1\",\"req-2\",\"req-3\",\"req-4\",\"req-5\",\"req-6\",\"req-7\",\"req-8\",\"req-9\",\"req-10\",\"req-11\",\"req-12\",\"req-13\",\"req-14\",\"req-15\",\"req-16\"]}", + "digest": "4f1bceb404d3fc6dce13ad4cd9fbb013b3d86a9c4ee9d6eaf0c333ee5d2f9a95" + }, + { + "name": "acknowledge result dropping unknown ids", + "type": "AcknowledgeResult", + "value": { + "acknowledged": [ + "req-1" + ] + }, + "note": "", + "canonical": "{\"acknowledged\":[\"req-1\"]}", + "digest": "ef54663300ad31be91aac4d9fe3a30a8a043287865fe775ada4789dd4ce2c294" + }, + { + "name": "shutdown params", + "type": "ShutdownParams", + "value": { + "reason": "operator-request" + }, + "note": "", + "canonical": "{\"reason\":\"operator-request\"}", + "digest": "a69c521cf80c05893c9972bf7929c6ec1733314ece599bc6e650247adf74ec2b" + }, + { + "name": "shutdown result", + "type": "ShutdownResult", + "value": { + "stopping": true + }, + "note": "", + "canonical": "{\"stopping\":true}", + "digest": "345f7d009561b6b78b4a89f1a3e5aa15d7b3249c9037cfbaa61088deaae4ed3f" + }, + { + "name": "task event for a transition", + "type": "TaskEvent", + "value": { + "id": "evt-1", + "kindId": "badge", + "sourceStep": "42", + "agentId": "fly-a", + "payload": { + "schema": { + "id": "pokemon.badge.v1", + "version": 1, + "digest": "8d3c215ae3088736279f7cd130ff173ed57101a729629983c553246dc9bd44db" + }, + "value": { + "badge": 2 + } + } + }, + "note": "", + "canonical": "{\"agentId\":\"fly-a\",\"id\":\"evt-1\",\"kindId\":\"badge\",\"payload\":{\"schema\":{\"digest\":\"8d3c215ae3088736279f7cd130ff173ed57101a729629983c553246dc9bd44db\",\"id\":\"pokemon.badge.v1\",\"version\":1},\"value\":{\"badge\":2}},\"sourceStep\":\"42\"}", + "digest": "e4e879b453bce7b5560e6e31412b8e30b4ae5ad5baef4aaacd86fe64c72c61ed" + }, + { + "name": "task event with no recipient", + "type": "TaskEvent", + "value": { + "id": "evt-2", + "kindId": "bootstrap", + "sourceStep": "0", + "agentId": null, + "payload": { + "schema": { + "id": "pokemon.bootstrap.v1", + "version": 1, + "digest": "88537024c85058bd9d01546dada047a39d341811d7e74b47fdcfceae728cb20d" + }, + "value": {} + } + }, + "note": "", + "canonical": "{\"agentId\":null,\"id\":\"evt-2\",\"kindId\":\"bootstrap\",\"payload\":{\"schema\":{\"digest\":\"88537024c85058bd9d01546dada047a39d341811d7e74b47fdcfceae728cb20d\",\"id\":\"pokemon.bootstrap.v1\",\"version\":1},\"value\":{}},\"sourceStep\":\"0\"}", + "digest": "f7b2de586eb822ea82a59380188478066717d51319cdb65d913e5d12f4fd5402" + }, + { + "name": "terminal episode request", + "type": "EpisodeRequest", + "value": { + "kind": "terminal", + "reason": "game-over", + "outcome": { + "schema": { + "id": "pokemon.outcome.v1", + "version": 1, + "digest": "ca50728d52392393703acff8c15d1d40e0bf3c65d5eb2686037923a8c503a425" + }, + "value": { + "rank": 10 + } + } + }, + "note": "", + "canonical": "{\"kind\":\"terminal\",\"outcome\":{\"schema\":{\"digest\":\"ca50728d52392393703acff8c15d1d40e0bf3c65d5eb2686037923a8c503a425\",\"id\":\"pokemon.outcome.v1\",\"version\":1},\"value\":{\"rank\":10}},\"reason\":\"game-over\"}", + "digest": "7bf1b49d1144d4cc9c8090e4f74fc7067ab7ac89a428f2cb4d39e97cb8e7df30" + }, + { + "name": "view descriptor", + "type": "ViewDescriptor", + "value": { + "viewId": "screen", + "width": 160, + "height": 144, + "format": "rgba8", + "rowStride": 640, + "pixelAspect": { + "numerator": 10, + "denominator": 11 + }, + "observationDelaySteps": 0 + }, + "note": "", + "canonical": "{\"format\":\"rgba8\",\"height\":144,\"observationDelaySteps\":0,\"pixelAspect\":{\"denominator\":11,\"numerator\":10},\"rowStride\":640,\"viewId\":\"screen\",\"width\":160}", + "digest": "53979af7b85fd859eefb4724fc01f40e6d89bfa3f7c602532a9148ccf6e1df73" + }, + { + "name": "view descriptor with a two step pipeline delay", + "type": "ViewDescriptor", + "value": { + "viewId": "screen", + "width": 160, + "height": 144, + "format": "rgba8", + "rowStride": 640, + "pixelAspect": { + "numerator": 10, + "denominator": 11 + }, + "observationDelaySteps": 2 + }, + "note": "", + "canonical": "{\"format\":\"rgba8\",\"height\":144,\"observationDelaySteps\":2,\"pixelAspect\":{\"denominator\":11,\"numerator\":10},\"rowStride\":640,\"viewId\":\"screen\",\"width\":160}", + "digest": "72454a1263266ec7660b78bb3df4f94cd13ec90813a282a57044182c80151498" + }, + { + "name": "view reference", + "type": "ViewRef", + "value": { + "viewId": "screen", + "producedStep": "41", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-1", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + }, + "note": "", + "canonical": "{\"pixels\":{\"artifactId\":\"frame-1\",\"byteLength\":\"92160\",\"contentType\":\"image/x-rgba8\",\"digest\":null,\"generation\":\"1\",\"storeId\":\"store-1\"},\"producedStep\":\"41\",\"viewId\":\"screen\"}", + "digest": "0e480b869f260e96dba893b9cb836cb4c98de67f1fbd7cd47d78c3be93a81efa" + }, + { + "name": "audio descriptor", + "type": "AudioDescriptor", + "value": { + "streamId": "mix", + "sampleRate": 48000, + "channels": 2, + "format": "f32le-interleaved" + }, + "note": "", + "canonical": "{\"channels\":2,\"format\":\"f32le-interleaved\",\"sampleRate\":48000,\"streamId\":\"mix\"}", + "digest": "0c04ad89aae871a1210e603abd4af011fe05e0b94284f41927eaa240e03b9a3c" + }, + { + "name": "audio reference", + "type": "AudioRef", + "value": { + "streamId": "mix", + "firstSample": "0", + "sampleFrames": 800, + "samples": { + "storeId": "store-1", + "artifactId": "audio-1", + "generation": "1", + "byteLength": "6400", + "contentType": "audio/x-f32le", + "digest": null + }, + "discontinuity": false + }, + "note": "", + "canonical": "{\"discontinuity\":false,\"firstSample\":\"0\",\"sampleFrames\":800,\"samples\":{\"artifactId\":\"audio-1\",\"byteLength\":\"6400\",\"contentType\":\"audio/x-f32le\",\"digest\":null,\"generation\":\"1\",\"storeId\":\"store-1\"},\"streamId\":\"mix\"}", + "digest": "0b3ae931f6c9b8854203a5aa17370cc36f9dff27bcfdb49e33edcb670b546cba" + }, + { + "name": "audio reference marking a discontinuity", + "type": "AudioRef", + "value": { + "streamId": "mix", + "firstSample": "48000", + "sampleFrames": 800, + "samples": { + "storeId": "store-1", + "artifactId": "audio-1", + "generation": "1", + "byteLength": "6400", + "contentType": "audio/x-f32le", + "digest": null + }, + "discontinuity": true + }, + "note": "", + "canonical": "{\"discontinuity\":true,\"firstSample\":\"48000\",\"sampleFrames\":800,\"samples\":{\"artifactId\":\"audio-1\",\"byteLength\":\"6400\",\"contentType\":\"audio/x-f32le\",\"digest\":null,\"generation\":\"1\",\"storeId\":\"store-1\"},\"streamId\":\"mix\"}", + "digest": "e1ef310a3f4e93f392e36354d376701cf9a407f9fc56c0d5f52bfd15f3b3ea1f" + }, + { + "name": "empty audio chunk", + "type": "AudioRef", + "value": { + "streamId": "mix", + "firstSample": "0", + "sampleFrames": 0, + "samples": { + "storeId": "store-1", + "artifactId": "audio-empty", + "generation": "1", + "byteLength": "0", + "contentType": "audio/x-f32le", + "digest": null + }, + "discontinuity": true + }, + "note": "", + "canonical": "{\"discontinuity\":true,\"firstSample\":\"0\",\"sampleFrames\":0,\"samples\":{\"artifactId\":\"audio-empty\",\"byteLength\":\"0\",\"contentType\":\"audio/x-f32le\",\"digest\":null,\"generation\":\"1\",\"storeId\":\"store-1\"},\"streamId\":\"mix\"}", + "digest": "0ff6dcec5a76aa3e6347026f05704c34207c39dbe8f582c7ad329ac1cd3aba57" + }, + { + "name": "capture params", + "type": "CaptureParams", + "value": { + "checkpointId": "ckpt-1" + }, + "note": "", + "canonical": "{\"checkpointId\":\"ckpt-1\"}", + "digest": "0840127e4277d0eac4d69dd88468c7737d0a8b64aad12ef554a25e4a7c653baa" + }, + { + "name": "capture result", + "type": "CaptureResult", + "value": { + "checkpointId": "ckpt-1", + "boundary": "42", + "compatibilityDigest": "2b33a9570d42f8af1c84dbe8b8c9bb50664f9b19ca8f7ee100548b9945a52f5d", + "payload": { + "storeId": "store-1", + "artifactId": "payload-1", + "generation": "1", + "byteLength": "1024", + "contentType": "application/octet-stream", + "digest": "239f59ed55e737c77147cf55ad0c1b030b6d7ee748a7426952f9b852d5a935e5" + } + }, + "note": "", + "canonical": "{\"boundary\":\"42\",\"checkpointId\":\"ckpt-1\",\"compatibilityDigest\":\"2b33a9570d42f8af1c84dbe8b8c9bb50664f9b19ca8f7ee100548b9945a52f5d\",\"payload\":{\"artifactId\":\"payload-1\",\"byteLength\":\"1024\",\"contentType\":\"application/octet-stream\",\"digest\":\"239f59ed55e737c77147cf55ad0c1b030b6d7ee748a7426952f9b852d5a935e5\",\"generation\":\"1\",\"storeId\":\"store-1\"}}", + "digest": "73ef46de54c10ca8a28bb9c71aecb25199c995027af7271ca4bad9d2540a61c3" + }, + { + "name": "stage restore params", + "type": "StageRestoreParams", + "value": { + "checkpointId": "ckpt-1", + "sourceScope": { + "sessionId": "demo", + "epoch": "epoch-1", + "step": "42" + }, + "compatibilityDigest": "2b33a9570d42f8af1c84dbe8b8c9bb50664f9b19ca8f7ee100548b9945a52f5d", + "payload": { + "storeId": "store-1", + "artifactId": "payload-2", + "generation": "1", + "byteLength": "1024", + "contentType": "application/octet-stream", + "digest": "239f59ed55e737c77147cf55ad0c1b030b6d7ee748a7426952f9b852d5a935e5" + } + }, + "note": "", + "canonical": "{\"checkpointId\":\"ckpt-1\",\"compatibilityDigest\":\"2b33a9570d42f8af1c84dbe8b8c9bb50664f9b19ca8f7ee100548b9945a52f5d\",\"payload\":{\"artifactId\":\"payload-2\",\"byteLength\":\"1024\",\"contentType\":\"application/octet-stream\",\"digest\":\"239f59ed55e737c77147cf55ad0c1b030b6d7ee748a7426952f9b852d5a935e5\",\"generation\":\"1\",\"storeId\":\"store-1\"},\"sourceScope\":{\"epoch\":\"epoch-1\",\"sessionId\":\"demo\",\"step\":\"42\"}}", + "digest": "da479328a30068075fd8702cd53e06e2aaecd8ddc9b015998ee7901e8a2dcab0" + }, + { + "name": "stage restore result", + "type": "StageRestoreResult", + "value": { + "checkpointId": "ckpt-1", + "restoreToken": "restore-1" + }, + "note": "", + "canonical": "{\"checkpointId\":\"ckpt-1\",\"restoreToken\":\"restore-1\"}", + "digest": "006d551d31d2cb4a6d850a8bb4f19ba16b4173835fda5eda8f6806cb03ec163b" + }, + { + "name": "activate restore params", + "type": "ActivateRestoreParams", + "value": { + "restoreToken": "restore-1" + }, + "note": "", + "canonical": "{\"restoreToken\":\"restore-1\"}", + "digest": "fadaacb907911c4402937640c711d3368bfe348842916856d34750795bad6ee3" + }, + { + "name": "activate restore result for an agent", + "type": "ActivateRestoreResult", + "value": { + "committedStep": "42", + "checkpointId": "ckpt-1", + "observation": null + }, + "note": "", + "canonical": "{\"checkpointId\":\"ckpt-1\",\"committedStep\":\"42\",\"observation\":null}", + "digest": "eec98a58c81ad50c3dcd4406c743c7139e60108869dedbfd6d55a2f037bcd41a" + }, + { + "name": "activate restore result for an environment", + "type": "ActivateRestoreResult", + "value": { + "committedStep": "42", + "checkpointId": "ckpt-1", + "observation": { + "boundary": "42", + "worldTime": { + "numerator": "50000000", + "denominator": "3" + }, + "engineFrame": "1", + "sensoryViews": [ + { + "viewId": "screen", + "producedStep": "42", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-1", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + } + ], + "inspection": { + "schema": { + "id": "gameboy.inspection.v1", + "version": 1, + "digest": "1883cee6532d0bfbb28854b82db8450d525c2e4fcec01567b4aa6d99917eafb1" + }, + "value": { + "map": 40, + "x": 5, + "y": 7 + } + }, + "broadcastViews": [ + { + "viewId": "screen", + "producedStep": "42", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-1", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + } + ], + "audio": [ + { + "streamId": "mix", + "firstSample": "33600", + "sampleFrames": 800, + "samples": { + "storeId": "store-1", + "artifactId": "audio-1", + "generation": "1", + "byteLength": "6400", + "contentType": "audio/x-f32le", + "digest": null + }, + "discontinuity": false + } + ] + } + }, + "note": "", + "canonical": "{\"checkpointId\":\"ckpt-1\",\"committedStep\":\"42\",\"observation\":{\"audio\":[{\"discontinuity\":false,\"firstSample\":\"33600\",\"sampleFrames\":800,\"samples\":{\"artifactId\":\"audio-1\",\"byteLength\":\"6400\",\"contentType\":\"audio/x-f32le\",\"digest\":null,\"generation\":\"1\",\"storeId\":\"store-1\"},\"streamId\":\"mix\"}],\"boundary\":\"42\",\"broadcastViews\":[{\"pixels\":{\"artifactId\":\"frame-1\",\"byteLength\":\"92160\",\"contentType\":\"image/x-rgba8\",\"digest\":null,\"generation\":\"1\",\"storeId\":\"store-1\"},\"producedStep\":\"42\",\"viewId\":\"screen\"}],\"engineFrame\":\"1\",\"inspection\":{\"schema\":{\"digest\":\"1883cee6532d0bfbb28854b82db8450d525c2e4fcec01567b4aa6d99917eafb1\",\"id\":\"gameboy.inspection.v1\",\"version\":1},\"value\":{\"map\":40,\"x\":5,\"y\":7}},\"sensoryViews\":[{\"pixels\":{\"artifactId\":\"frame-1\",\"byteLength\":\"92160\",\"contentType\":\"image/x-rgba8\",\"digest\":null,\"generation\":\"1\",\"storeId\":\"store-1\"},\"producedStep\":\"42\",\"viewId\":\"screen\"}],\"worldTime\":{\"denominator\":\"3\",\"numerator\":\"50000000\"}}}", + "digest": "90950aaf244f6f779268687db63b69c3cb1ee316de2541fcf0b1fa393a34b819" + }, + { + "name": "session descriptor", + "type": "SessionDescriptor", + "value": { + "sessionId": "demo", + "revision": "7", + "compositionDigest": "730d725c8a59d3a7303def2bed041a577edb4255aabd4889ce12918311d952f0", + "schedulerId": "lockstep-v1", + "environment": { + "backendDigest": "10e08a419e850eba1ebba18fdd28eb7ec1b7e8baa9bcc3b973e2b8891ec726be", + "contentDigest": "ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73", + "configurationDigest": "b7d64a9221007dfd5390f7df6cd5b8f3ea4f82faa1237141e35ebf161f5511a1", + "stepDuration": { + "numerator": "50000000", + "denominator": "3" + }, + "ports": [ + { + "portId": "port-1", + "controls": { + "schema": { + "id": "gameboy.controller.v1", + "version": 1, + "digest": "b1df9d20fe1deaeb52f17cf261bf728b28143c777878b96cacfc4826903a48d5" + }, + "buttons": [ + "a", + "b", + "start", + "select", + "up", + "down", + "left", + "right" + ], + "axes": [ + { + "id": "stick-x", + "range": "bipolar", + "neutral": 0.0 + }, + { + "id": "trigger", + "range": "unit", + "neutral": 0.0 + } + ] + } + } + ], + "inspectionSchema": { + "id": "gameboy.inspection.v1", + "version": 1, + "digest": "1883cee6532d0bfbb28854b82db8450d525c2e4fcec01567b4aa6d99917eafb1" + }, + "views": [ + { + "viewId": "screen", + "width": 160, + "height": 144, + "format": "rgba8", + "rowStride": 640, + "pixelAspect": { + "numerator": 10, + "denominator": 11 + }, + "observationDelaySteps": 0 + } + ], + "audio": [ + { + "streamId": "mix", + "sampleRate": 48000, + "channels": 2, + "format": "f32le-interleaved" + } + ], + "recovery": "exact-checkpoint", + "determinism": "fixed-build" + }, + "taskSchema": { + "id": "pokemon.task.v1", + "version": 1, + "digest": "e4ca46e969a0f0e53a4f537561360851269e0b3f684eccaa634fe9672fdaabb4" + }, + "agents": [ + { + "agentId": "fly-a", + "portId": "port-1", + "profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0", + "datasetDigest": "6c0af1f0784ef63a393ee77d614e8246c625051360f3f1a48838374c5d355b52", + "indexDigest": "1bc04b5291c26a46d918139138b992d2de976d6851d0893b0476b85bfbdfc6e6", + "neuronCount": "139255", + "rateRoles": [ + "kenyon", + "mbon" + ], + "supportedStimuli": [ + "sugar" + ] + } + ], + "assets": [ + { + "id": "profile-fly-a", + "digest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0", + "byteLength": "4096", + "format": "flyprofile" + } + ] + }, + "note": "", + "canonical": "{\"agents\":[{\"agentId\":\"fly-a\",\"datasetDigest\":\"6c0af1f0784ef63a393ee77d614e8246c625051360f3f1a48838374c5d355b52\",\"indexDigest\":\"1bc04b5291c26a46d918139138b992d2de976d6851d0893b0476b85bfbdfc6e6\",\"neuronCount\":\"139255\",\"portId\":\"port-1\",\"profileDigest\":\"1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0\",\"rateRoles\":[\"kenyon\",\"mbon\"],\"supportedStimuli\":[\"sugar\"]}],\"assets\":[{\"byteLength\":\"4096\",\"digest\":\"1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0\",\"format\":\"flyprofile\",\"id\":\"profile-fly-a\"}],\"compositionDigest\":\"730d725c8a59d3a7303def2bed041a577edb4255aabd4889ce12918311d952f0\",\"environment\":{\"audio\":[{\"channels\":2,\"format\":\"f32le-interleaved\",\"sampleRate\":48000,\"streamId\":\"mix\"}],\"backendDigest\":\"10e08a419e850eba1ebba18fdd28eb7ec1b7e8baa9bcc3b973e2b8891ec726be\",\"configurationDigest\":\"b7d64a9221007dfd5390f7df6cd5b8f3ea4f82faa1237141e35ebf161f5511a1\",\"contentDigest\":\"ed7002b439e9ac845f22357d822bac1444730fbdb6016d3ec9432297b9ec9f73\",\"determinism\":\"fixed-build\",\"inspectionSchema\":{\"digest\":\"1883cee6532d0bfbb28854b82db8450d525c2e4fcec01567b4aa6d99917eafb1\",\"id\":\"gameboy.inspection.v1\",\"version\":1},\"ports\":[{\"controls\":{\"axes\":[{\"id\":\"stick-x\",\"neutral\":0,\"range\":\"bipolar\"},{\"id\":\"trigger\",\"neutral\":0,\"range\":\"unit\"}],\"buttons\":[\"a\",\"b\",\"start\",\"select\",\"up\",\"down\",\"left\",\"right\"],\"schema\":{\"digest\":\"b1df9d20fe1deaeb52f17cf261bf728b28143c777878b96cacfc4826903a48d5\",\"id\":\"gameboy.controller.v1\",\"version\":1}},\"portId\":\"port-1\"}],\"recovery\":\"exact-checkpoint\",\"stepDuration\":{\"denominator\":\"3\",\"numerator\":\"50000000\"},\"views\":[{\"format\":\"rgba8\",\"height\":144,\"observationDelaySteps\":0,\"pixelAspect\":{\"denominator\":11,\"numerator\":10},\"rowStride\":640,\"viewId\":\"screen\",\"width\":160}]},\"revision\":\"7\",\"schedulerId\":\"lockstep-v1\",\"sessionId\":\"demo\",\"taskSchema\":{\"digest\":\"e4ca46e969a0f0e53a4f537561360851269e0b3f684eccaa634fe9672fdaabb4\",\"id\":\"pokemon.task.v1\",\"version\":1}}", + "digest": "58857f4d64c19fdda320e8b3f857c6964d12748d01b7ba5a4fd40fc4393529e8" + }, + { + "name": "committed snapshot at boundary zero", + "type": "CommittedSnapshot", + "value": { + "descriptorRevision": "7", + "publisherIncarnation": "pub-1", + "scope": { + "sessionId": "demo", + "epoch": "epoch-1", + "step": "0" + }, + "episodeId": "episode-1", + "sequence": "0", + "worldTime": { + "numerator": "0", + "denominator": "1" + }, + "agents": [ + { + "agentId": "fly-a", + "telemetry": { + "brainTicks": "2500", + "populationRateHz": 12.5, + "rates": [ + { + "roleId": "kenyon", + "hz": 3.25 + }, + { + "roleId": "mbon", + "hz": 0.0 + } + ], + "learning": { + "enabled": true, + "updates": "4", + "changed": "2", + "signal": 0.5 + } + }, + "selectedDecision": null, + "appliedControls": null + } + ], + "progress": { + "schema": { + "id": "pokemon.progress.v1", + "version": 1, + "digest": "80fa973157f334bb3208dfff6f7a2f298e79f73f07c72c33f5725975c6cff8e1" + }, + "value": { + "rank": 0 + } + }, + "media": { + "views": [ + { + "viewId": "screen", + "producedStep": "0", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-1", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + } + ], + "audio": [] + }, + "eventIds": [] + }, + "note": "", + "canonical": "{\"agents\":[{\"agentId\":\"fly-a\",\"appliedControls\":null,\"selectedDecision\":null,\"telemetry\":{\"brainTicks\":\"2500\",\"learning\":{\"changed\":\"2\",\"enabled\":true,\"signal\":0.5,\"updates\":\"4\"},\"populationRateHz\":12.5,\"rates\":[{\"hz\":3.25,\"roleId\":\"kenyon\"},{\"hz\":0,\"roleId\":\"mbon\"}]}}],\"descriptorRevision\":\"7\",\"episodeId\":\"episode-1\",\"eventIds\":[],\"media\":{\"audio\":[],\"views\":[{\"pixels\":{\"artifactId\":\"frame-1\",\"byteLength\":\"92160\",\"contentType\":\"image/x-rgba8\",\"digest\":null,\"generation\":\"1\",\"storeId\":\"store-1\"},\"producedStep\":\"0\",\"viewId\":\"screen\"}]},\"progress\":{\"schema\":{\"digest\":\"80fa973157f334bb3208dfff6f7a2f298e79f73f07c72c33f5725975c6cff8e1\",\"id\":\"pokemon.progress.v1\",\"version\":1},\"value\":{\"rank\":0}},\"publisherIncarnation\":\"pub-1\",\"scope\":{\"epoch\":\"epoch-1\",\"sessionId\":\"demo\",\"step\":\"0\"},\"sequence\":\"0\",\"worldTime\":{\"denominator\":\"1\",\"numerator\":\"0\"}}", + "digest": "e9c432eee00e95c28d8a12428aaa9cb71568bcaf079b557b01fb1fde26723da9" + }, + { + "name": "committed snapshot after a transition", + "type": "CommittedSnapshot", + "value": { + "descriptorRevision": "7", + "publisherIncarnation": "pub-1", + "scope": { + "sessionId": "demo", + "epoch": "epoch-1", + "step": "42" + }, + "episodeId": "episode-1", + "sequence": "42", + "worldTime": { + "numerator": "700000000", + "denominator": "1" + }, + "agents": [ + { + "agentId": "fly-a", + "telemetry": { + "brainTicks": "2534", + "populationRateHz": 12.5, + "rates": [ + { + "roleId": "kenyon", + "hz": 3.25 + }, + { + "roleId": "mbon", + "hz": 0.0 + } + ], + "learning": { + "enabled": true, + "updates": "4", + "changed": "2", + "signal": 0.5 + } + }, + "selectedDecision": { + "schema": { + "id": "gameboy.intent.v1", + "version": 1, + "digest": "e70451b26fb5462ec63729d8355912dad01c3963a95bec0e6a8452375717ad9d" + }, + "value": { + "press": "a" + } + }, + "appliedControls": { + "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 + } + ] + } + } + ], + "progress": { + "schema": { + "id": "pokemon.progress.v1", + "version": 1, + "digest": "80fa973157f334bb3208dfff6f7a2f298e79f73f07c72c33f5725975c6cff8e1" + }, + "value": { + "rank": 10 + } + }, + "media": { + "views": [ + { + "viewId": "screen", + "producedStep": "42", + "pixels": { + "storeId": "store-1", + "artifactId": "frame-1", + "generation": "1", + "byteLength": "92160", + "contentType": "image/x-rgba8", + "digest": null + } + } + ], + "audio": [ + { + "streamId": "mix", + "firstSample": "33600", + "sampleFrames": 800, + "samples": { + "storeId": "store-1", + "artifactId": "audio-1", + "generation": "1", + "byteLength": "6400", + "contentType": "audio/x-f32le", + "digest": null + }, + "discontinuity": false + } + ] + }, + "eventIds": [ + "evt-1" + ] + }, + "note": "", + "canonical": "{\"agents\":[{\"agentId\":\"fly-a\",\"appliedControls\":{\"axes\":[{\"id\":\"stick-x\",\"value\":0},{\"id\":\"trigger\",\"value\":0}],\"buttons\":[{\"down\":true,\"id\":\"a\"},{\"down\":false,\"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\"}],\"portId\":\"port-1\"},\"selectedDecision\":{\"schema\":{\"digest\":\"e70451b26fb5462ec63729d8355912dad01c3963a95bec0e6a8452375717ad9d\",\"id\":\"gameboy.intent.v1\",\"version\":1},\"value\":{\"press\":\"a\"}},\"telemetry\":{\"brainTicks\":\"2534\",\"learning\":{\"changed\":\"2\",\"enabled\":true,\"signal\":0.5,\"updates\":\"4\"},\"populationRateHz\":12.5,\"rates\":[{\"hz\":3.25,\"roleId\":\"kenyon\"},{\"hz\":0,\"roleId\":\"mbon\"}]}}],\"descriptorRevision\":\"7\",\"episodeId\":\"episode-1\",\"eventIds\":[\"evt-1\"],\"media\":{\"audio\":[{\"discontinuity\":false,\"firstSample\":\"33600\",\"sampleFrames\":800,\"samples\":{\"artifactId\":\"audio-1\",\"byteLength\":\"6400\",\"contentType\":\"audio/x-f32le\",\"digest\":null,\"generation\":\"1\",\"storeId\":\"store-1\"},\"streamId\":\"mix\"}],\"views\":[{\"pixels\":{\"artifactId\":\"frame-1\",\"byteLength\":\"92160\",\"contentType\":\"image/x-rgba8\",\"digest\":null,\"generation\":\"1\",\"storeId\":\"store-1\"},\"producedStep\":\"42\",\"viewId\":\"screen\"}]},\"progress\":{\"schema\":{\"digest\":\"80fa973157f334bb3208dfff6f7a2f298e79f73f07c72c33f5725975c6cff8e1\",\"id\":\"pokemon.progress.v1\",\"version\":1},\"value\":{\"rank\":10}},\"publisherIncarnation\":\"pub-1\",\"scope\":{\"epoch\":\"epoch-1\",\"sessionId\":\"demo\",\"step\":\"42\"},\"sequence\":\"42\",\"worldTime\":{\"denominator\":\"1\",\"numerator\":\"700000000\"}}", + "digest": "d5ed83ccb866318b3cf17b53c8a5b1dea2f404b10649f6b640bf5f2b88a31435" + }, + { + "name": "transition trace", + "type": "TransitionTrace", + "value": { + "behaviour": { + "scope": { + "sessionId": "demo", + "epoch": "epoch-1", + "step": "41" + }, + "agents": [ + { + "agentId": "fly-a", + "profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0", + "ticksAdvanced": "17", + "brainTicks": "2517", + "remainder": { + "numerator": "1000000", + "denominator": "3" + }, + "decisionDigest": "a62db30eb7427e2ea9ec58661e69a1eff38e67f21ec97df7d91b24015890d89b", + "committedStep": "42" + }, + { + "agentId": "fly-b", + "profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0", + "ticksAdvanced": "17", + "brainTicks": "2550", + "remainder": { + "numerator": "0", + "denominator": "1" + }, + "decisionDigest": "ddc2e7b8b300cda681c3d91368050fcb95dd66d20fd295834c31307d3df2623e", + "committedStep": "42" + } + ], + "batchId": "batch-41", + "controlDigest": "1e2135d1b50f14d3d3d6cbe24a4294bcb9e4d5d3c28eb1ce4980ff2a17c5d3b6", + "acknowledgedBoundary": "42", + "observationBoundaries": [ + { + "viewId": "screen", + "producedStep": "42" + } + ], + "outcomeIds": [ + "outcome-1" + ], + "eventIds": [ + "evt-1" + ], + "publishedBoundary": "42" + }, + "operational": { + "wallTimeNs": "1234567890", + "prepareRequestIds": [ + { + "agentId": "fly-a", + "requestId": "req-41" + }, + { + "agentId": "fly-b", + "requestId": "req-42" + } + ], + "advanceRequestId": "req-43", + "commitRequestIds": [ + { + "agentId": "fly-a", + "requestId": "req-44" + }, + { + "agentId": "fly-b", + "requestId": "req-45" + } + ], + "busCallIds": [ + "call-100", + "call-101", + "call-102" + ], + "deliveryIds": [ + "dlv-7", + "own-9" + ] + } + }, + "note": "", + "canonical": "{\"behaviour\":{\"acknowledgedBoundary\":\"42\",\"agents\":[{\"agentId\":\"fly-a\",\"brainTicks\":\"2517\",\"committedStep\":\"42\",\"decisionDigest\":\"a62db30eb7427e2ea9ec58661e69a1eff38e67f21ec97df7d91b24015890d89b\",\"profileDigest\":\"1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0\",\"remainder\":{\"denominator\":\"3\",\"numerator\":\"1000000\"},\"ticksAdvanced\":\"17\"},{\"agentId\":\"fly-b\",\"brainTicks\":\"2550\",\"committedStep\":\"42\",\"decisionDigest\":\"ddc2e7b8b300cda681c3d91368050fcb95dd66d20fd295834c31307d3df2623e\",\"profileDigest\":\"1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0\",\"remainder\":{\"denominator\":\"1\",\"numerator\":\"0\"},\"ticksAdvanced\":\"17\"}],\"batchId\":\"batch-41\",\"controlDigest\":\"1e2135d1b50f14d3d3d6cbe24a4294bcb9e4d5d3c28eb1ce4980ff2a17c5d3b6\",\"eventIds\":[\"evt-1\"],\"observationBoundaries\":[{\"producedStep\":\"42\",\"viewId\":\"screen\"}],\"outcomeIds\":[\"outcome-1\"],\"publishedBoundary\":\"42\",\"scope\":{\"epoch\":\"epoch-1\",\"sessionId\":\"demo\",\"step\":\"41\"}},\"operational\":{\"advanceRequestId\":\"req-43\",\"busCallIds\":[\"call-100\",\"call-101\",\"call-102\"],\"commitRequestIds\":[{\"agentId\":\"fly-a\",\"requestId\":\"req-44\"},{\"agentId\":\"fly-b\",\"requestId\":\"req-45\"}],\"deliveryIds\":[\"dlv-7\",\"own-9\"],\"prepareRequestIds\":[{\"agentId\":\"fly-a\",\"requestId\":\"req-41\"},{\"agentId\":\"fly-b\",\"requestId\":\"req-42\"}],\"wallTimeNs\":\"1234567890\"}}", + "digest": "707701d6dcf5529a67cc9b1e300d5e1a273b9a7b02d91154d4a2a1978cd8e549" + }, + { + "name": "trace behaviour on its own", + "type": "TraceBehaviour", + "value": { + "scope": { + "sessionId": "demo", + "epoch": "epoch-1", + "step": "41" + }, + "agents": [ + { + "agentId": "fly-a", + "profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0", + "ticksAdvanced": "17", + "brainTicks": "2517", + "remainder": { + "numerator": "1000000", + "denominator": "3" + }, + "decisionDigest": "a62db30eb7427e2ea9ec58661e69a1eff38e67f21ec97df7d91b24015890d89b", + "committedStep": "42" + }, + { + "agentId": "fly-b", + "profileDigest": "1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0", + "ticksAdvanced": "17", + "brainTicks": "2550", + "remainder": { + "numerator": "0", + "denominator": "1" + }, + "decisionDigest": "ddc2e7b8b300cda681c3d91368050fcb95dd66d20fd295834c31307d3df2623e", + "committedStep": "42" + } + ], + "batchId": "batch-41", + "controlDigest": "1e2135d1b50f14d3d3d6cbe24a4294bcb9e4d5d3c28eb1ce4980ff2a17c5d3b6", + "acknowledgedBoundary": "42", + "observationBoundaries": [ + { + "viewId": "screen", + "producedStep": "42" + } + ], + "outcomeIds": [ + "outcome-1" + ], + "eventIds": [ + "evt-1" + ], + "publishedBoundary": "42" + }, + "note": "the half two runs must agree on", + "canonical": "{\"acknowledgedBoundary\":\"42\",\"agents\":[{\"agentId\":\"fly-a\",\"brainTicks\":\"2517\",\"committedStep\":\"42\",\"decisionDigest\":\"a62db30eb7427e2ea9ec58661e69a1eff38e67f21ec97df7d91b24015890d89b\",\"profileDigest\":\"1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0\",\"remainder\":{\"denominator\":\"3\",\"numerator\":\"1000000\"},\"ticksAdvanced\":\"17\"},{\"agentId\":\"fly-b\",\"brainTicks\":\"2550\",\"committedStep\":\"42\",\"decisionDigest\":\"ddc2e7b8b300cda681c3d91368050fcb95dd66d20fd295834c31307d3df2623e\",\"profileDigest\":\"1900eab6c028483d7126599ee6f50de0d27907b5c65fa90524580b4b0f9852b0\",\"remainder\":{\"denominator\":\"1\",\"numerator\":\"0\"},\"ticksAdvanced\":\"17\"}],\"batchId\":\"batch-41\",\"controlDigest\":\"1e2135d1b50f14d3d3d6cbe24a4294bcb9e4d5d3c28eb1ce4980ff2a17c5d3b6\",\"eventIds\":[\"evt-1\"],\"observationBoundaries\":[{\"producedStep\":\"42\",\"viewId\":\"screen\"}],\"outcomeIds\":[\"outcome-1\"],\"publishedBoundary\":\"42\",\"scope\":{\"epoch\":\"epoch-1\",\"sessionId\":\"demo\",\"step\":\"41\"}}", + "digest": "d938188fe3006537dbc0038fabf2a05df584e64b361b1106d79f3f965dff414a" + }, + { + "name": "trace operational metadata on its own", + "type": "TraceOperational", + "value": { + "wallTimeNs": "1234567890", + "prepareRequestIds": [ + { + "agentId": "fly-a", + "requestId": "req-41" + }, + { + "agentId": "fly-b", + "requestId": "req-42" + } + ], + "advanceRequestId": "req-43", + "commitRequestIds": [ + { + "agentId": "fly-a", + "requestId": "req-44" + }, + { + "agentId": "fly-b", + "requestId": "req-45" + } + ], + "busCallIds": [ + "call-100", + "call-101", + "call-102" + ], + "deliveryIds": [ + "dlv-7", + "own-9" + ] + }, + "note": "recorded by step-v1 section 8, excluded from its comparison", + "canonical": "{\"advanceRequestId\":\"req-43\",\"busCallIds\":[\"call-100\",\"call-101\",\"call-102\"],\"commitRequestIds\":[{\"agentId\":\"fly-a\",\"requestId\":\"req-44\"},{\"agentId\":\"fly-b\",\"requestId\":\"req-45\"}],\"deliveryIds\":[\"dlv-7\",\"own-9\"],\"prepareRequestIds\":[{\"agentId\":\"fly-a\",\"requestId\":\"req-41\"},{\"agentId\":\"fly-b\",\"requestId\":\"req-42\"}],\"wallTimeNs\":\"1234567890\"}", + "digest": "07c3402ab0e48b186d56f667da980a15b2a48b21381235cff37523a24ff0dfff" + } + ] +} diff --git a/services/flysim/crates/fly-session-types/src/canonical.rs b/services/flysim/crates/fly-session-types/src/canonical.rs new file mode 100644 index 0000000..8fae54c --- /dev/null +++ b/services/flysim/crates/fly-session-types/src/canonical.rs @@ -0,0 +1,275 @@ +//! 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 { + 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 { + key.encode_utf16().collect() +} + +/// The canonical JSON text of `value`. +pub fn canonicalize(value: &Value) -> Result { + 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> { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 { + digest_of(&canonical_body(method, scope, params)?) +} diff --git a/services/flysim/crates/fly-session-types/src/checkpoint.rs b/services/flysim/crates/fly-session-types/src/checkpoint.rs new file mode 100644 index 0000000..341f5c5 --- /dev/null +++ b/services/flysim/crates/fly-session-types/src/checkpoint.rs @@ -0,0 +1,368 @@ +//! `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, + 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)]) -> Result { + 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)]) -> Result> { + 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)>, + 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 { + 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. +pub const REQUIRED_MANIFEST_FIELDS: &[&str] = &[ + "envelopeVersion", + "checkpointId", + "sourceScope", + "episodeId", + "worldTime", + "schedulerId", + "compositionDigest", + "portMap", + "compatibility", + "agents", + "coordinator", + "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 +} diff --git a/services/flysim/crates/fly-session-types/src/fixtures.rs b/services/flysim/crates/fly-session-types/src/fixtures.rs new file mode 100644 index 0000000..661859e --- /dev/null +++ b/services/flysim/crates/fly-session-types/src/fixtures.rs @@ -0,0 +1,108 @@ +//! Loading the crate's `fixtures/` directory. +//! +//! The same files are read by the Rust tests and by `packages/session-types`, so a case only +//! has to be written once to hold both languages to it. + +use std::path::{Path, PathBuf}; + +use serde_json::Value; + +use crate::canonical; +use crate::scalar::{Result, err, wire_err}; + +/// The crate's `fixtures/` directory. +pub fn dir() -> PathBuf { + Path::new(env!("CARGO_MANIFEST_DIR")).join("fixtures") +} + +/// Reads one fixture file, parsed strictly. +pub fn load(name: &str) -> Result { + let path = dir().join(name); + let bytes = + std::fs::read(&path).map_err(|e| wire_err(format!("fixture {}: {e}", path.display())))?; + canonical::parse_strict(&bytes) +} + +/// Reads one fixture file as raw bytes, for the cases that are deliberately not valid JSON. +pub fn load_bytes(name: &str) -> Result> { + let path = dir().join(name); + std::fs::read(&path).map_err(|e| wire_err(format!("fixture {}: {e}", path.display()))) +} + +/// The `cases` array of a fixture file. +pub fn cases(file: &Value) -> Result<&Vec> { + match file.get("cases").and_then(Value::as_array) { + Some(cases) if !cases.is_empty() => Ok(cases), + _ => err("fixture: cases must be a nonempty array"), + } +} + +/// A string field of one case. +pub fn field<'a>(case: &'a Value, key: &str) -> Result<&'a str> { + case.get(key) + .and_then(Value::as_str) + .ok_or_else(|| wire_err(format!("fixture case: missing string field {key:?}"))) +} + +/// Decodes the `base64` field of a case that carries raw bytes. +pub fn base64(case: &Value, key: &str) -> Result> { + decode_base64(field(case, key)?) +} + +/// Standard base64 with padding. Small and local: the crate has no base64 dependency and the +/// fixtures only carry a few hundred bytes. +pub fn decode_base64(text: &str) -> Result> { + const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let bytes = text.as_bytes(); + if !bytes.len().is_multiple_of(4) { + return err("base64: length must be a multiple of 4"); + } + let mut out = Vec::with_capacity(bytes.len() / 4 * 3); + for quad in bytes.chunks_exact(4) { + let mut buffer = 0u32; + let mut keep = 3; + for (index, byte) in quad.iter().enumerate() { + let value = if *byte == b'=' { + if index < 2 { + return err("base64: misplaced padding"); + } + keep -= 1; + 0 + } else { + ALPHABET + .iter() + .position(|c| c == byte) + .ok_or_else(|| wire_err("base64: invalid character"))? as u32 + }; + buffer = (buffer << 6) | value; + } + let triple = buffer.to_be_bytes(); + out.extend_from_slice(&triple[1..1 + keep]); + } + Ok(out) +} + +/// Standard base64 with padding, for generating fixtures. +pub fn encode_base64(bytes: &[u8]) -> String { + const ALPHABET: &[u8] = b"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"; + let mut out = String::with_capacity(bytes.len().div_ceil(3) * 4); + for chunk in bytes.chunks(3) { + let mut buffer = [0u8; 3]; + buffer[..chunk.len()].copy_from_slice(chunk); + let value = u32::from_be_bytes([0, buffer[0], buffer[1], buffer[2]]); + let indexes = [ + (value >> 18) & 0x3f, + (value >> 12) & 0x3f, + (value >> 6) & 0x3f, + value & 0x3f, + ]; + for (position, index) in indexes.iter().enumerate() { + if position <= chunk.len() { + out.push(ALPHABET[*index as usize] as char); + } else { + out.push('='); + } + } + } + out +} diff --git a/services/flysim/crates/fly-session-types/src/lib.rs b/services/flysim/crates/fly-session-types/src/lib.rs new file mode 100644 index 0000000..bacac0f --- /dev/null +++ b/services/flysim/crates/fly-session-types/src/lib.rs @@ -0,0 +1,56 @@ +//! `fly-session-types`: the executable schemas of the session framework (CONTRACT-01). +//! +//! What this crate is: +//! +//! - the domain scalars of [ipc-v1] section 2 ([`scalar`]), reusing the bus's `Id`, `U64` and +//! `Digest` encodings rather than restating them; +//! the domain request/reply envelope and error codes of sections 3 and 7 ([`rpc`]); +//! - the closed enums and method payloads of [workers-v1] ([`workers`]), the native media +//! and State.* payloads of [state-media-v1] ([`media`]), and the publication types of +//! [publishing-v1] ([`publishing`]); +//! - canonical JSON (RFC 8785), canonical digests, the operation key and the canonical body +//! rules of ipc-v1 section 5 ([`canonical`]); +//! - the documented canonical schema set and `contractDigest` ([`schema`]); +//! - the trace format of [step-v1] section 8, with behaviour separated from operational +//! metadata and a comparator over behaviour alone ([`trace`]); +//! - `seed-derivation-v1` ([`seed`]) and the `FLYSESS1` checkpoint envelope layout +//! ([`checkpoint`]), the two specifications CONTRACT-01 has to settle before the real-agent +//! and store slices. +//! +//! What it is not: a transport, a worker, a coordinator or a store. It holds no Game Boy FFI, +//! no Melee parser and no console-specific state, and it never reaches the network. +//! +//! Every type implements [`scalar::DomainType`]: `from_json` reads and validates, `to_json` +//! writes the canonical shape, and `validate` re-checks the rules that span fields. Reading +//! refuses unknown fields, so a payload with a misspelled required field fails instead of +//! silently defaulting. +//! +//! [ipc-v1]: ../../../../docs/design/session-framework/ipc-v1.md +//! [workers-v1]: ../../../../docs/design/session-framework/workers-v1.md +//! [state-media-v1]: ../../../../docs/design/session-framework/state-media-v1.md +//! [publishing-v1]: ../../../../docs/design/session-framework/publishing-v1.md +//! [step-v1]: ../../../../docs/design/session-framework/step-v1.md + +pub mod canonical; +pub mod checkpoint; +pub mod fixtures; +pub mod media; +pub mod publishing; +pub mod rpc; +pub mod scalar; +pub mod schema; +pub mod seed; +pub mod trace; +pub mod workers; + +pub use canonical::{OperationKey, body_digest, canonicalize, digest_of}; +pub use scalar::{ + ArtifactIdentity, BusCallId, DomainRequestId, DomainType, OwnerKind, OwnerToken, RationalNs, + SchemaRef, Scope, TypedValue, +}; +pub use schema::contract_digest; +pub use trace::{TraceBehaviour, TraceOperational, TransitionTrace}; + +/// The bus `ArtifactRef` these contracts reference. Re-exported so a consumer does not have +/// to decide whether the domain has its own copy: it does not. +pub use flybus::wire::ArtifactRef; diff --git a/services/flysim/crates/fly-session-types/src/media.rs b/services/flysim/crates/fly-session-types/src/media.rs new file mode 100644 index 0000000..5f2a1d8 --- /dev/null +++ b/services/flysim/crates/fly-session-types/src/media.rs @@ -0,0 +1,716 @@ +//! Native observation media (state-media-v1 section 2) and the State.* payloads (section 5). +//! +//! Descriptors carry the shape; refs carry one produced object. Both are validated against +//! the descriptor, because a ref on its own cannot know its own row stride: use +//! [`ViewRef::validate_against`] and [`AudioRef::validate_against`] wherever the descriptor +//! is in hand. + +use flybus::wire::{ArtifactRef, Fields}; + +use serde_json::Value; + +use crate::scalar::{ + DomainType, RationalNs, Result, Scope, constant, err, finite_in, is_digest, is_id, list, obj, + require_unique, u64_json, +}; + +/// Max views per sensory input (workers-v1 section 1). The same bound applies to a +/// descriptor's view list and to an observation's view lists: a descriptor that declared more +/// views than one sensory input can carry could not be satisfied. +pub const MAX_VIEWS: usize = 8; +/// View dimensions are integers 1..=4096 (state-media-v1 section 2). +pub const MAX_VIEW_DIMENSION: u64 = 4096; +/// Pixel aspect numerator/denominator are positive integers <=65535. +pub const MAX_PIXEL_ASPECT: u64 = 65_535; +/// observationDelaySteps is an integer 0..=8. +pub const MAX_OBSERVATION_DELAY_STEPS: u64 = 8; +/// sampleFrames is 0..=192000 per chunk; sampleRate is 8000..=192000. +pub const MAX_SAMPLE_FRAMES: u64 = 192_000; +/// Audio streams per descriptor. Not a stated bound: chosen so an envelope cannot be filled +/// with descriptors, and recorded in the schema set so it cannot drift silently. +pub const MAX_AUDIO_STREAMS: usize = 8; + +/// `ViewDescriptor`: the fixed shape of one native view. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ViewDescriptor { + pub view_id: String, + pub width: u64, + pub height: u64, + pub row_stride: u64, + pub pixel_aspect_numerator: u64, + pub pixel_aspect_denominator: u64, + pub observation_delay_steps: u64, +} + +impl ViewDescriptor { + /// The exact byte length of one frame of this view. + pub fn frame_bytes(&self) -> u64 { + self.row_stride * self.height + } + + /// The producing boundary a required sensory view must have at `boundary` + /// (state-media-v1 section 2): `max(0, boundary - observationDelaySteps)`. + pub fn required_produced_step(&self, boundary: u64) -> u64 { + boundary.saturating_sub(self.observation_delay_steps) + } +} + +impl DomainType for ViewDescriptor { + const TYPE_NAME: &'static str = "ViewDescriptor"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "ViewDescriptor")?; + let view_id = f.id("viewId")?; + let width = f.int("width", 1, MAX_VIEW_DIMENSION)?; + let height = f.int("height", 1, MAX_VIEW_DIMENSION)?; + constant(&mut f, "format", "rgba8")?; + let row_stride = f.int("rowStride", 1, MAX_VIEW_DIMENSION * 4)?; + let aspect = f.value("pixelAspect")?; + let (pixel_aspect_numerator, pixel_aspect_denominator) = { + let mut a = Fields::new(aspect, "ViewDescriptor.pixelAspect")?; + let n = a.int("numerator", 1, MAX_PIXEL_ASPECT)?; + let d = a.int("denominator", 1, MAX_PIXEL_ASPECT)?; + a.finish()?; + (n, d) + }; + let observation_delay_steps = + f.int("observationDelaySteps", 0, MAX_OBSERVATION_DELAY_STEPS)?; + f.finish()?; + let d = ViewDescriptor { + view_id, + width, + height, + row_stride, + pixel_aspect_numerator, + pixel_aspect_denominator, + observation_delay_steps, + }; + d.validate()?; + Ok(d) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("viewId", self.view_id.clone().into()), + ("width", Value::from(self.width)), + ("height", Value::from(self.height)), + ("format", "rgba8".into()), + ("rowStride", Value::from(self.row_stride)), + ( + "pixelAspect", + obj(vec![ + ("numerator", Value::from(self.pixel_aspect_numerator)), + ("denominator", Value::from(self.pixel_aspect_denominator)), + ]), + ), + ( + "observationDelaySteps", + Value::from(self.observation_delay_steps), + ), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.view_id) { + return err("ViewDescriptor: viewId is not a valid id"); + } + if !(1..=MAX_VIEW_DIMENSION).contains(&self.width) + || !(1..=MAX_VIEW_DIMENSION).contains(&self.height) + { + return err("ViewDescriptor: width and height must be integers 1..=4096"); + } + if self.row_stride != self.width * 4 { + return err( + "ViewDescriptor: rowStride must be exactly 4 x width (no padded rows in v1)", + ); + } + if !(1..=MAX_PIXEL_ASPECT).contains(&self.pixel_aspect_numerator) + || !(1..=MAX_PIXEL_ASPECT).contains(&self.pixel_aspect_denominator) + { + return err("ViewDescriptor: pixelAspect parts must be positive integers <=65535"); + } + if self.observation_delay_steps > MAX_OBSERVATION_DELAY_STEPS { + return err("ViewDescriptor: observationDelaySteps must be 0..=8"); + } + Ok(()) + } +} + +/// `ViewRef`: one produced frame of one view. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ViewRef { + pub view_id: String, + pub produced_step: u64, + pub pixels: ArtifactRef, +} + +impl ViewRef { + /// Byte shape and producing boundary against the descriptor that declared this view. + /// + /// `boundary` is the observation's boundary; a required sensory view must have been + /// produced at exactly `max(0, boundary - observationDelaySteps)`. + pub fn validate_against( + &self, + descriptor: &ViewDescriptor, + boundary: Option, + ) -> Result<()> { + if self.view_id != descriptor.view_id { + return err(format!( + "ViewRef: viewId {:?} does not match descriptor {:?}", + self.view_id, descriptor.view_id + )); + } + if self.pixels.byte_length != descriptor.frame_bytes() { + return err(format!( + "ViewRef {}: artifact is {} bytes, rowStride x height is {}", + self.view_id, + self.pixels.byte_length, + descriptor.frame_bytes() + )); + } + if let Some(boundary) = boundary { + let expected = descriptor.required_produced_step(boundary); + if self.produced_step != expected { + return err(format!( + "ViewRef {}: producedStep {} must be max(0, {boundary} - {}) = {expected}", + self.view_id, self.produced_step, descriptor.observation_delay_steps + )); + } + } + Ok(()) + } +} + +impl DomainType for ViewRef { + const TYPE_NAME: &'static str = "ViewRef"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "ViewRef")?; + let view_id = f.id("viewId")?; + let produced_step = f.u64_string("producedStep")?; + let pixels = ArtifactRef::from_json(f.value("pixels")?)?; + f.finish()?; + let r = ViewRef { + view_id, + produced_step, + pixels, + }; + r.validate()?; + Ok(r) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("viewId", self.view_id.clone().into()), + ("producedStep", u64_json(self.produced_step)), + ("pixels", self.pixels.to_json()), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.view_id) { + return err("ViewRef: viewId is not a valid id"); + } + if self.pixels.byte_length == 0 { + return err("ViewRef: pixels must have a positive byte length"); + } + Ok(()) + } +} + +/// `AudioDescriptor`: one native audio stream's shape. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AudioDescriptor { + pub stream_id: String, + pub sample_rate: u64, + pub channels: u64, +} + +impl AudioDescriptor { + /// The exact byte length of `frames` interleaved f32 frames. + pub fn chunk_bytes(&self, frames: u64) -> u64 { + frames * self.channels * 4 + } +} + +impl DomainType for AudioDescriptor { + const TYPE_NAME: &'static str = "AudioDescriptor"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "AudioDescriptor")?; + let stream_id = f.id("streamId")?; + let sample_rate = f.int("sampleRate", 8_000, 192_000)?; + let channels = f.int("channels", 1, 8)?; + constant(&mut f, "format", "f32le-interleaved")?; + f.finish()?; + let d = AudioDescriptor { + stream_id, + sample_rate, + channels, + }; + d.validate()?; + Ok(d) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("streamId", self.stream_id.clone().into()), + ("sampleRate", Value::from(self.sample_rate)), + ("channels", Value::from(self.channels)), + ("format", "f32le-interleaved".into()), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.stream_id) { + return err("AudioDescriptor: streamId is not a valid id"); + } + if !(8_000..=192_000).contains(&self.sample_rate) { + return err("AudioDescriptor: sampleRate must be an integer 8000..=192000"); + } + if !(1..=8).contains(&self.channels) { + return err("AudioDescriptor: channels must be an integer 1..=8"); + } + Ok(()) + } +} + +/// `AudioRef`: one produced chunk of one audio stream. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AudioRef { + pub stream_id: String, + pub first_sample: u64, + pub sample_frames: u64, + pub samples: ArtifactRef, + pub discontinuity: bool, +} + +impl AudioRef { + /// Byte shape against the descriptor that declared this stream. + pub fn validate_against(&self, descriptor: &AudioDescriptor) -> Result<()> { + if self.stream_id != descriptor.stream_id { + return err(format!( + "AudioRef: streamId {:?} does not match descriptor {:?}", + self.stream_id, descriptor.stream_id + )); + } + let expected = descriptor.chunk_bytes(self.sample_frames); + if self.samples.byte_length != expected { + return err(format!( + "AudioRef {}: artifact is {} bytes, sampleFrames x channels x 4 is {expected}", + self.stream_id, self.samples.byte_length + )); + } + Ok(()) + } + + /// Within an epoch chunks cannot overlap or go backwards (state-media-v1 section 2). + pub fn follows(&self, previous: &AudioRef) -> Result<()> { + if self.stream_id != previous.stream_id { + return err("AudioRef: chunks of different streams are not ordered against each other"); + } + let expected = previous.first_sample + previous.sample_frames; + if self.first_sample < expected { + return err(format!( + "AudioRef {}: firstSample {} overlaps the previous chunk, which ends at {expected}", + self.stream_id, self.first_sample + )); + } + Ok(()) + } +} + +impl DomainType for AudioRef { + const TYPE_NAME: &'static str = "AudioRef"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "AudioRef")?; + let stream_id = f.id("streamId")?; + let first_sample = f.u64_string("firstSample")?; + let sample_frames = f.int("sampleFrames", 0, MAX_SAMPLE_FRAMES)?; + let samples = ArtifactRef::from_json(f.value("samples")?)?; + let discontinuity = f.boolean("discontinuity")?; + f.finish()?; + let r = AudioRef { + stream_id, + first_sample, + sample_frames, + samples, + discontinuity, + }; + r.validate()?; + Ok(r) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("streamId", self.stream_id.clone().into()), + ("firstSample", u64_json(self.first_sample)), + ("sampleFrames", Value::from(self.sample_frames)), + ("samples", self.samples.to_json()), + ("discontinuity", Value::Bool(self.discontinuity)), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.stream_id) { + return err("AudioRef: streamId is not a valid id"); + } + if self.sample_frames > MAX_SAMPLE_FRAMES { + return err("AudioRef: sampleFrames must be an integer 0..=192000"); + } + if self.first_sample.checked_add(self.sample_frames).is_none() { + return err("AudioRef: firstSample + sampleFrames overflows U64"); + } + Ok(()) + } +} + +/// Reads a bounded, unique-by-`viewId` list of view refs. +pub fn view_list(f: &mut Fields<'_>, key: &'static str) -> Result> { + let views = list(f, key, 0, MAX_VIEWS, ViewRef::from_json)?; + require_unique(views.iter().map(|v| v.view_id.as_str()), key)?; + Ok(views) +} + +/// Reads a bounded, unique-by-`streamId` list of audio refs. +pub fn audio_list(f: &mut Fields<'_>, key: &'static str) -> Result> { + let audio = list(f, key, 0, MAX_AUDIO_STREAMS, AudioRef::from_json)?; + require_unique(audio.iter().map(|a| a.stream_id.as_str()), key)?; + Ok(audio) +} + +// --------------------------------------------------------------------------------------------- +// State.* payloads (state-media-v1 section 5) + +/// A checkpoint payload artifact: the digest is mandatory on checkpoint payloads +/// (state-media-v1 section 1). +fn checkpoint_payload(f: &mut Fields<'_>, key: &'static str) -> Result { + let reference = ArtifactRef::from_json(f.value(key)?)?; + match &reference.digest { + Some(d) if is_digest(d) => Ok(reference), + _ => err(format!( + "{key}: a checkpoint payload must carry a content digest" + )), + } +} + +/// `State.Capture` params. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CaptureParams { + pub checkpoint_id: String, +} + +impl DomainType for CaptureParams { + const TYPE_NAME: &'static str = "CaptureParams"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "CaptureParams")?; + let checkpoint_id = f.id("checkpointId")?; + f.finish()?; + let p = CaptureParams { checkpoint_id }; + p.validate()?; + Ok(p) + } + + fn to_json(&self) -> Value { + obj(vec![("checkpointId", self.checkpoint_id.clone().into())]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.checkpoint_id) { + return err("CaptureParams: checkpointId is not a valid id"); + } + Ok(()) + } +} + +/// `State.Capture` result. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct CaptureResult { + pub checkpoint_id: String, + pub boundary: u64, + pub compatibility_digest: String, + pub payload: ArtifactRef, +} + +impl DomainType for CaptureResult { + const TYPE_NAME: &'static str = "CaptureResult"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "CaptureResult")?; + let checkpoint_id = f.id("checkpointId")?; + let boundary = f.u64_string("boundary")?; + let compatibility_digest = f.string("compatibilityDigest")?.to_owned(); + let payload = checkpoint_payload(&mut f, "payload")?; + f.finish()?; + let r = CaptureResult { + checkpoint_id, + boundary, + compatibility_digest, + payload, + }; + r.validate()?; + Ok(r) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("checkpointId", self.checkpoint_id.clone().into()), + ("boundary", u64_json(self.boundary)), + ( + "compatibilityDigest", + self.compatibility_digest.clone().into(), + ), + ("payload", self.payload.to_json()), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.checkpoint_id) { + return err("CaptureResult: checkpointId is not a valid id"); + } + if !is_digest(&self.compatibility_digest) { + return err("CaptureResult: compatibilityDigest must be 64 lowercase hex digits"); + } + match &self.payload.digest { + Some(d) if is_digest(d) => Ok(()), + _ => err("CaptureResult: a checkpoint payload must carry a content digest"), + } + } +} + +/// `State.StageRestore` params. The scope is the source boundary, under a proposed new epoch. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StageRestoreParams { + pub checkpoint_id: String, + pub source_scope: Scope, + pub compatibility_digest: String, + pub payload: ArtifactRef, +} + +impl DomainType for StageRestoreParams { + const TYPE_NAME: &'static str = "StageRestoreParams"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "StageRestoreParams")?; + let checkpoint_id = f.id("checkpointId")?; + let source_scope = Scope::from_json(f.value("sourceScope")?)?; + let compatibility_digest = f.string("compatibilityDigest")?.to_owned(); + let payload = checkpoint_payload(&mut f, "payload")?; + f.finish()?; + let p = StageRestoreParams { + checkpoint_id, + source_scope, + compatibility_digest, + payload, + }; + p.validate()?; + Ok(p) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("checkpointId", self.checkpoint_id.clone().into()), + ("sourceScope", self.source_scope.to_json()), + ( + "compatibilityDigest", + self.compatibility_digest.clone().into(), + ), + ("payload", self.payload.to_json()), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.checkpoint_id) { + return err("StageRestoreParams: checkpointId is not a valid id"); + } + self.source_scope.validate()?; + if !is_digest(&self.compatibility_digest) { + return err("StageRestoreParams: compatibilityDigest must be 64 lowercase hex digits"); + } + match &self.payload.digest { + Some(d) if is_digest(d) => Ok(()), + _ => err("StageRestoreParams: a checkpoint payload must carry a content digest"), + } + } +} + +/// `State.StageRestore` result. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StageRestoreResult { + pub checkpoint_id: String, + pub restore_token: String, +} + +impl DomainType for StageRestoreResult { + const TYPE_NAME: &'static str = "StageRestoreResult"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "StageRestoreResult")?; + let checkpoint_id = f.id("checkpointId")?; + let restore_token = f.id("restoreToken")?; + f.finish()?; + let r = StageRestoreResult { + checkpoint_id, + restore_token, + }; + r.validate()?; + Ok(r) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("checkpointId", self.checkpoint_id.clone().into()), + ("restoreToken", self.restore_token.clone().into()), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.checkpoint_id) || !is_id(&self.restore_token) { + return err("StageRestoreResult: checkpointId and restoreToken must be valid ids"); + } + Ok(()) + } +} + +/// `State.ActivateRestore` params. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ActivateRestoreParams { + pub restore_token: String, +} + +impl DomainType for ActivateRestoreParams { + const TYPE_NAME: &'static str = "ActivateRestoreParams"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "ActivateRestoreParams")?; + let restore_token = f.id("restoreToken")?; + f.finish()?; + let p = ActivateRestoreParams { restore_token }; + p.validate()?; + Ok(p) + } + + fn to_json(&self) -> Value { + obj(vec![("restoreToken", self.restore_token.clone().into())]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.restore_token) { + return err("ActivateRestoreParams: restoreToken is not a valid id"); + } + Ok(()) + } +} + +/// `State.ActivateRestore` result. The observation is required from an environment and null +/// from an agent (state-media-v1 section 5); which one applies is the caller's role, so the +/// role-specific check is [`ActivateRestoreResult::validate_for_role`]. +#[derive(Clone, Debug, PartialEq)] +pub struct ActivateRestoreResult { + pub committed_step: u64, + pub checkpoint_id: String, + pub observation: Option, +} + +impl ActivateRestoreResult { + pub fn validate_for_role(&self, role: crate::workers::Role) -> Result<()> { + self.validate()?; + match (role, &self.observation) { + (crate::workers::Role::Environment, None) => { + err("ActivateRestoreResult: an environment must return its restored observation") + } + (crate::workers::Role::Agent, Some(_)) => { + err("ActivateRestoreResult: an agent returns a null observation") + } + _ => Ok(()), + } + } +} + +impl DomainType for ActivateRestoreResult { + const TYPE_NAME: &'static str = "ActivateRestoreResult"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "ActivateRestoreResult")?; + let committed_step = f.u64_string("committedStep")?; + let checkpoint_id = f.id("checkpointId")?; + let observation = match f.value("observation")? { + Value::Null => None, + v => Some(crate::workers::WorldObservation::from_json(v)?), + }; + f.finish()?; + let r = ActivateRestoreResult { + committed_step, + checkpoint_id, + observation, + }; + r.validate()?; + Ok(r) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("committedStep", u64_json(self.committed_step)), + ("checkpointId", self.checkpoint_id.clone().into()), + ( + "observation", + self.observation + .as_ref() + .map_or(Value::Null, |o| o.to_json()), + ), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.checkpoint_id) { + return err("ActivateRestoreResult: checkpointId is not a valid id"); + } + if let Some(observation) = &self.observation { + observation.validate()?; + if observation.boundary != self.committed_step { + return err( + "ActivateRestoreResult: the observation boundary must be the committed step", + ); + } + } + Ok(()) + } +} + +/// The pixel aspect of a view as a rational, for presentation. +pub fn pixel_aspect(descriptor: &ViewDescriptor) -> Result { + RationalNs::reduced( + u128::from(descriptor.pixel_aspect_numerator), + u128::from(descriptor.pixel_aspect_denominator), + ) +} + +/// Audio presentation timestamp, `firstSample / sampleRate` seconds, as a checked rational. +pub fn audio_pts(reference: &AudioRef, descriptor: &AudioDescriptor) -> Result { + RationalNs::reduced( + u128::from(reference.first_sample), + u128::from(descriptor.sample_rate), + ) +} + +/// Samples must be finite f32 (state-media-v1 section 2). The bytes live in an artifact, so +/// this is the check a reader runs over a mapped chunk. +pub fn require_finite_samples(bytes: &[u8]) -> Result<()> { + if !bytes.len().is_multiple_of(4) { + return err("audio chunk: length must be a multiple of 4"); + } + for (index, chunk) in bytes.chunks_exact(4).enumerate() { + let sample = f32::from_le_bytes([chunk[0], chunk[1], chunk[2], chunk[3]]); + if !sample.is_finite() { + return err(format!("audio chunk: sample {index} is not finite")); + } + } + Ok(()) +} + +/// A unit-range helper for presentation code that needs the neutral-in-range rule. +pub fn require_unit(f: &mut Fields<'_>, key: &'static str) -> Result { + finite_in(f, key, 0.0, 1.0) +} diff --git a/services/flysim/crates/fly-session-types/src/publishing.rs b/services/flysim/crates/fly-session-types/src/publishing.rs new file mode 100644 index 0000000..b90d8e5 --- /dev/null +++ b/services/flysim/crates/fly-session-types/src/publishing.rs @@ -0,0 +1,450 @@ +//! The publication types of publishing-v1 section 3. +//! +//! A descriptor changes rarely and a snapshot changes every boundary; both are published on +//! the same bus, and a snapshot names the descriptor revision it was shaped by. + +use flybus::wire::Fields; +use serde_json::Value; + +use crate::media::{AudioRef, MAX_VIEWS, ViewRef, audio_list, view_list}; +use crate::scalar::{ + DomainType, RationalNs, Result, SchemaRef, Scope, TypedValue, constant, err, id_list, + is_digest, is_id, list, obj, require_unique, u64_json, +}; +use crate::workers::{ + AgentTelemetry, AssetRef, EnvironmentDescriptor, MAX_AGENTS, MAX_RATE_ROLES, PortControl, +}; + +/// Declared stimulus kinds per agent. Not a stated bound; recorded in the schema set. +pub const MAX_SUPPORTED_STIMULI: usize = 64; +/// Installed assets in one descriptor. Not a stated bound; recorded in the schema set. +pub const MAX_ASSETS: usize = 64; +/// Scoped event ids in one snapshot. Not a stated bound; recorded in the schema set. +pub const MAX_SNAPSHOT_EVENTS: usize = 64; + +/// One agent's place in the composition. +#[derive(Clone, Debug, PartialEq)] +pub struct AgentDescriptor { + pub agent_id: String, + pub port_id: String, + pub profile_digest: String, + pub dataset_digest: String, + pub index_digest: String, + pub neuron_count: u64, + pub rate_roles: Vec, + pub supported_stimuli: Vec, +} + +/// `SessionDescriptor`: the framework shape of one running session. +#[derive(Clone, Debug, PartialEq)] +pub struct SessionDescriptor { + pub session_id: String, + pub revision: u64, + pub composition_digest: String, + pub environment: EnvironmentDescriptor, + pub task_schema: SchemaRef, + pub agents: Vec, + pub assets: Vec, +} + +impl DomainType for SessionDescriptor { + const TYPE_NAME: &'static str = "SessionDescriptor"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "SessionDescriptor")?; + let session_id = f.id("sessionId")?; + let revision = f.u64_string("revision")?; + let composition_digest = f.string("compositionDigest")?.to_owned(); + constant(&mut f, "schedulerId", "lockstep-v1")?; + let environment = EnvironmentDescriptor::from_json(f.value("environment")?)?; + let task_schema = SchemaRef::from_json(f.value("taskSchema")?)?; + let agents = list(&mut f, "agents", 1, MAX_AGENTS, |v| { + let mut a = Fields::new(v, "SessionDescriptor.agents")?; + let agent_id = a.id("agentId")?; + let port_id = a.id("portId")?; + let profile_digest = a.string("profileDigest")?.to_owned(); + let dataset_digest = a.string("datasetDigest")?.to_owned(); + let index_digest = a.string("indexDigest")?.to_owned(); + let neuron_count = a.u64_string("neuronCount")?; + let rate_roles = id_list(&mut a, "rateRoles", 0, MAX_RATE_ROLES)?; + let supported_stimuli = id_list(&mut a, "supportedStimuli", 0, MAX_SUPPORTED_STIMULI)?; + a.finish()?; + Ok(AgentDescriptor { + agent_id, + port_id, + profile_digest, + dataset_digest, + index_digest, + neuron_count, + rate_roles, + supported_stimuli, + }) + })?; + let assets = list(&mut f, "assets", 0, MAX_ASSETS, AssetRef::from_json)?; + f.finish()?; + let d = SessionDescriptor { + session_id, + revision, + composition_digest, + environment, + task_schema, + agents, + assets, + }; + d.validate()?; + Ok(d) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("sessionId", self.session_id.clone().into()), + ("revision", u64_json(self.revision)), + ("compositionDigest", self.composition_digest.clone().into()), + ("schedulerId", "lockstep-v1".into()), + ("environment", self.environment.to_json()), + ("taskSchema", self.task_schema.to_json()), + ( + "agents", + Value::Array( + self.agents + .iter() + .map(|a| { + obj(vec![ + ("agentId", a.agent_id.clone().into()), + ("portId", a.port_id.clone().into()), + ("profileDigest", a.profile_digest.clone().into()), + ("datasetDigest", a.dataset_digest.clone().into()), + ("indexDigest", a.index_digest.clone().into()), + ("neuronCount", u64_json(a.neuron_count)), + ( + "rateRoles", + Value::Array( + a.rate_roles.iter().map(|r| r.clone().into()).collect(), + ), + ), + ( + "supportedStimuli", + Value::Array( + a.supported_stimuli + .iter() + .map(|s| s.clone().into()) + .collect(), + ), + ), + ]) + }) + .collect(), + ), + ), + ( + "assets", + Value::Array(self.assets.iter().map(AssetRef::to_json).collect()), + ), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.session_id) { + return err("SessionDescriptor: sessionId is not a valid id"); + } + if !is_digest(&self.composition_digest) { + return err("SessionDescriptor: compositionDigest must be 64 lowercase hex digits"); + } + self.environment.validate()?; + self.task_schema.validate()?; + if self.agents.is_empty() || self.agents.len() > MAX_AGENTS { + return err("SessionDescriptor: 1..=4 agents in the first composition"); + } + require_unique( + self.agents.iter().map(|a| a.agent_id.as_str()), + "SessionDescriptor.agents agentId", + )?; + require_unique( + self.agents.iter().map(|a| a.port_id.as_str()), + "SessionDescriptor.agents portId", + )?; + for agent in &self.agents { + if !is_id(&agent.agent_id) || !is_id(&agent.port_id) { + return err("SessionDescriptor: agentId and portId must be valid ids"); + } + for (what, digest) in [ + ("profileDigest", &agent.profile_digest), + ("datasetDigest", &agent.dataset_digest), + ("indexDigest", &agent.index_digest), + ] { + if !is_digest(digest) { + return err(format!( + "SessionDescriptor: agent {what} must be 64 lowercase hex digits" + )); + } + } + if agent.rate_roles.len() > MAX_RATE_ROLES { + return err("SessionDescriptor: at most 64 rate roles per agent"); + } + require_unique( + agent.rate_roles.iter().map(String::as_str), + "SessionDescriptor.agents rateRoles", + )?; + require_unique( + agent.supported_stimuli.iter().map(String::as_str), + "SessionDescriptor.agents supportedStimuli", + )?; + if self.environment.port(&agent.port_id).is_none() { + return err(format!( + "SessionDescriptor: agent {:?} is bound to port {:?}, which the environment does not declare", + agent.agent_id, agent.port_id + )); + } + } + require_unique( + self.assets.iter().map(|a| a.id.as_str()), + "SessionDescriptor.assets", + )?; + for asset in &self.assets { + asset.validate()?; + } + Ok(()) + } +} + +/// One agent's committed values in a snapshot. +#[derive(Clone, Debug, PartialEq)] +pub struct SnapshotAgent { + pub agent_id: String, + pub telemetry: AgentTelemetry, + pub selected_decision: Option, + pub applied_controls: Option, +} + +/// `CommittedSnapshot`: the values of one committed boundary. +#[derive(Clone, Debug, PartialEq)] +pub struct CommittedSnapshot { + pub descriptor_revision: u64, + pub publisher_incarnation: String, + pub scope: Scope, + pub episode_id: String, + pub sequence: u64, + pub world_time: RationalNs, + pub agents: Vec, + pub progress: TypedValue, + pub views: Vec, + pub audio: Vec, + pub event_ids: Vec, +} + +impl CommittedSnapshot { + /// Descriptor agreement: the revision, the agent set and the port each control names. + pub fn validate_against(&self, descriptor: &SessionDescriptor) -> Result<()> { + self.validate()?; + if self.descriptor_revision != descriptor.revision { + return err("CommittedSnapshot: descriptorRevision does not match the descriptor"); + } + if self.scope.session_id != descriptor.session_id { + return err("CommittedSnapshot: sessionId does not match the descriptor"); + } + for agent in &self.agents { + let declared = descriptor + .agents + .iter() + .find(|a| a.agent_id == agent.agent_id) + .ok_or_else(|| { + crate::scalar::wire_err(format!( + "CommittedSnapshot: agent {:?} is not in the descriptor", + agent.agent_id + )) + })?; + agent + .telemetry + .validate_against_roles(&declared.rate_roles)?; + if let Some(controls) = &agent.applied_controls { + if controls.port_id != declared.port_id { + return err(format!( + "CommittedSnapshot: agent {:?} controls port {:?}, not its assigned {:?}", + agent.agent_id, controls.port_id, declared.port_id + )); + } + let port = descriptor + .environment + .port(&declared.port_id) + .ok_or_else(|| { + crate::scalar::wire_err("CommittedSnapshot: assigned port is not declared") + })?; + controls.validate_against(&port.controls)?; + } + } + Ok(()) + } +} + +impl DomainType for CommittedSnapshot { + const TYPE_NAME: &'static str = "CommittedSnapshot"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "CommittedSnapshot")?; + let descriptor_revision = f.u64_string("descriptorRevision")?; + let publisher_incarnation = f.id("publisherIncarnation")?; + let scope = Scope::from_json(f.value("scope")?)?; + let episode_id = f.id("episodeId")?; + let sequence = f.u64_string("sequence")?; + let world_time = RationalNs::from_json(f.value("worldTime")?)?; + let agents = list(&mut f, "agents", 1, MAX_AGENTS, |v| { + let mut a = Fields::new(v, "CommittedSnapshot.agents")?; + let agent_id = a.id("agentId")?; + let telemetry = AgentTelemetry::from_json(a.value("telemetry")?)?; + let selected_decision = TypedValue::nullable_from_json(a.value("selectedDecision")?)?; + let applied_controls = match a.value("appliedControls")? { + Value::Null => None, + v => Some(PortControl::from_json(v)?), + }; + a.finish()?; + Ok(SnapshotAgent { + agent_id, + telemetry, + selected_decision, + applied_controls, + }) + })?; + let progress = TypedValue::from_json(f.value("progress")?)?; + let (views, audio) = { + let v = f.value("media")?; + let mut m = Fields::new(v, "CommittedSnapshot.media")?; + let views = view_list(&mut m, "views")?; + let audio = audio_list(&mut m, "audio")?; + m.finish()?; + (views, audio) + }; + let event_ids = id_list(&mut f, "eventIds", 0, MAX_SNAPSHOT_EVENTS)?; + f.finish()?; + let s = CommittedSnapshot { + descriptor_revision, + publisher_incarnation, + scope, + episode_id, + sequence, + world_time, + agents, + progress, + views, + audio, + event_ids, + }; + s.validate()?; + Ok(s) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("descriptorRevision", u64_json(self.descriptor_revision)), + ( + "publisherIncarnation", + self.publisher_incarnation.clone().into(), + ), + ("scope", self.scope.to_json()), + ("episodeId", self.episode_id.clone().into()), + ("sequence", u64_json(self.sequence)), + ("worldTime", self.world_time.to_json()), + ( + "agents", + Value::Array( + self.agents + .iter() + .map(|a| { + obj(vec![ + ("agentId", a.agent_id.clone().into()), + ("telemetry", a.telemetry.to_json()), + ( + "selectedDecision", + TypedValue::nullable_to_json(a.selected_decision.as_ref()), + ), + ( + "appliedControls", + a.applied_controls + .as_ref() + .map_or(Value::Null, PortControl::to_json), + ), + ]) + }) + .collect(), + ), + ), + ("progress", self.progress.to_json()), + ( + "media", + obj(vec![ + ( + "views", + Value::Array(self.views.iter().map(ViewRef::to_json).collect()), + ), + ( + "audio", + Value::Array(self.audio.iter().map(AudioRef::to_json).collect()), + ), + ]), + ), + ( + "eventIds", + Value::Array(self.event_ids.iter().map(|e| e.clone().into()).collect()), + ), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.publisher_incarnation) || !is_id(&self.episode_id) { + return err("CommittedSnapshot: publisherIncarnation and episodeId must be valid ids"); + } + self.scope.validate()?; + self.world_time.validate()?; + if self.agents.is_empty() || self.agents.len() > MAX_AGENTS { + return err("CommittedSnapshot: 1..=4 agents"); + } + require_unique( + self.agents.iter().map(|a| a.agent_id.as_str()), + "CommittedSnapshot.agents", + )?; + for agent in &self.agents { + if !is_id(&agent.agent_id) { + return err("CommittedSnapshot: agentId is not a valid id"); + } + agent.telemetry.validate()?; + if let Some(decision) = &agent.selected_decision { + decision.validate()?; + } + if let Some(controls) = &agent.applied_controls { + controls.validate()?; + } + // "Decisions/controls describe the transition ending at that boundary, null at + // initial boundary 0." (publishing-v1 section 3) + if self.scope.step == 0 + && (agent.selected_decision.is_some() || agent.applied_controls.is_some()) + { + return err( + "CommittedSnapshot: at boundary 0 selectedDecision and appliedControls are null", + ); + } + if self.scope.step > 0 + && (agent.selected_decision.is_none() || agent.applied_controls.is_none()) + { + return err( + "CommittedSnapshot: past boundary 0 every agent has a decision and applied controls", + ); + } + } + self.progress.validate()?; + if self.views.len() > MAX_VIEWS { + return err("CommittedSnapshot: at most 8 views"); + } + require_unique( + self.views.iter().map(|v| v.view_id.as_str()), + "CommittedSnapshot.media.views", + )?; + require_unique( + self.audio.iter().map(|a| a.stream_id.as_str()), + "CommittedSnapshot.media.audio", + )?; + require_unique( + self.event_ids.iter().map(String::as_str), + "CommittedSnapshot.eventIds", + )?; + Ok(()) + } +} diff --git a/services/flysim/crates/fly-session-types/src/rpc.rs b/services/flysim/crates/fly-session-types/src/rpc.rs new file mode 100644 index 0000000..e271cf9 --- /dev/null +++ b/services/flysim/crates/fly-session-types/src/rpc.rs @@ -0,0 +1,420 @@ +//! The domain request/reply envelope of ipc-v1 section 3 and the error codes of section 7. +//! +//! A domain reply is the `outcome` object inside a bus `rpc.result`. Bus route or admission +//! failure is not one of these: it never reaches a handler, so it cannot carry a mutation +//! certainty. + +use flybus::wire::Fields; +use serde_json::Value; + +use crate::canonical; +use crate::scalar::{ + DomainRequestId, DomainType, Result, Scope, bounded_string, constant, enumeration, err, is_id, + obj, +}; +use crate::workers::MAX_MESSAGE_CODE_POINTS; + +/// The domain error codes of ipc-v1 section 7. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum ErrorCode { + /// Invalid schema/range, before mutation. + InvalidArgument, + /// Missing method or capability. + Unsupported, + /// Wrong session/profile/port/build/asset identity. + IdentityMismatch, + StaleEpoch, + StaleStep, + FutureStep, + /// Wrong worker phase. + InvalidPhase, + /// Existing logical operation with a changed id or body. + Conflict, + /// The original operation is still executing; this duplicate bus call started no work. + InProgress, + /// Domain capacity unavailable before admission. + Busy, + /// Missing, unowned or mismatched artifact, or an invalid media shape. + BufferInvalid, + /// Safe replay is no longer available; never recompute to replace it. + ResultExpired, + /// Restore validation failed before activation. + IncompatibleState, + BackendFailure, + Internal, +} + +impl ErrorCode { + pub const ALL: &'static [&'static str] = &[ + "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", + ]; + + pub fn as_str(self) -> &'static str { + match self { + ErrorCode::InvalidArgument => "INVALID_ARGUMENT", + ErrorCode::Unsupported => "UNSUPPORTED", + ErrorCode::IdentityMismatch => "IDENTITY_MISMATCH", + ErrorCode::StaleEpoch => "STALE_EPOCH", + ErrorCode::StaleStep => "STALE_STEP", + ErrorCode::FutureStep => "FUTURE_STEP", + ErrorCode::InvalidPhase => "INVALID_PHASE", + ErrorCode::Conflict => "CONFLICT", + ErrorCode::InProgress => "IN_PROGRESS", + ErrorCode::Busy => "BUSY", + ErrorCode::BufferInvalid => "BUFFER_INVALID", + ErrorCode::ResultExpired => "RESULT_EXPIRED", + ErrorCode::IncompatibleState => "INCOMPATIBLE_STATE", + ErrorCode::BackendFailure => "BACKEND_FAILURE", + ErrorCode::Internal => "INTERNAL", + } + } + + pub fn parse(s: &str) -> Result { + Ok(match s { + "INVALID_ARGUMENT" => ErrorCode::InvalidArgument, + "UNSUPPORTED" => ErrorCode::Unsupported, + "IDENTITY_MISMATCH" => ErrorCode::IdentityMismatch, + "STALE_EPOCH" => ErrorCode::StaleEpoch, + "STALE_STEP" => ErrorCode::StaleStep, + "FUTURE_STEP" => ErrorCode::FutureStep, + "INVALID_PHASE" => ErrorCode::InvalidPhase, + "CONFLICT" => ErrorCode::Conflict, + "IN_PROGRESS" => ErrorCode::InProgress, + "BUSY" => ErrorCode::Busy, + "BUFFER_INVALID" => ErrorCode::BufferInvalid, + "RESULT_EXPIRED" => ErrorCode::ResultExpired, + "INCOMPATIBLE_STATE" => ErrorCode::IncompatibleState, + "BACKEND_FAILURE" => ErrorCode::BackendFailure, + "INTERNAL" => ErrorCode::Internal, + _ => return err("code is not one of the fifteen domain error codes"), + }) + } + + /// The codes that are raised strictly before any mutation, so their certainty is `none`. + pub fn is_before_mutation(self) -> bool { + matches!( + self, + ErrorCode::InvalidArgument + | ErrorCode::Unsupported + | ErrorCode::IdentityMismatch + | ErrorCode::StaleEpoch + | ErrorCode::StaleStep + | ErrorCode::FutureStep + | ErrorCode::InvalidPhase + | ErrorCode::Conflict + | ErrorCode::InProgress + | ErrorCode::Busy + | ErrorCode::BufferInvalid + | ErrorCode::IncompatibleState + ) + } +} + +/// How certain the responder is that the operation mutated state. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum MutationCertainty { + /// Nothing was applied. + None, + /// The mutation completed. + Applied, + /// Completion is not established. "Errors after partial mutation use unknown unless + /// completion is established." (ipc-v1 section 7) + Unknown, +} + +impl MutationCertainty { + pub const ALL: &'static [&'static str] = &["none", "applied", "unknown"]; + + pub fn as_str(self) -> &'static str { + match self { + MutationCertainty::None => "none", + MutationCertainty::Applied => "applied", + MutationCertainty::Unknown => "unknown", + } + } + + pub fn parse(s: &str) -> Result { + match s { + "none" => Ok(MutationCertainty::None), + "applied" => Ok(MutationCertainty::Applied), + "unknown" => Ok(MutationCertainty::Unknown), + _ => err("mutation must be none, applied or unknown"), + } + } +} + +/// `SessionRpcRequest`: one domain operation, independent of the bus callId that carries it. +#[derive(Clone, Debug, PartialEq)] +pub struct SessionRpcRequest { + pub request_id: DomainRequestId, + pub scope: Option, + pub params: Value, +} + +impl SessionRpcRequest { + /// The canonical body digest of this request under `method` (ipc-v1 section 5). + pub fn body_digest(&self, method: &str) -> Result { + canonical::body_digest(method, self.scope.as_ref(), &self.params) + } + + /// The operation key of a step mutation issued by `worker_id` under `method`. Lifecycle + /// calls with a null scope have no step operation key. + pub fn operation_key(&self, method: &str, worker_id: &str) -> Result { + let scope = self + .scope + .clone() + .ok_or_else(|| crate::scalar::wire_err("operation key: a step mutation has a scope"))?; + canonical::OperationKey::new(scope, method, worker_id) + } +} + +impl DomainType for SessionRpcRequest { + const TYPE_NAME: &'static str = "SessionRpcRequest"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "SessionRpcRequest")?; + let request_id = DomainRequestId::read(&mut f, "requestId")?; + let scope = Scope::nullable_from_json(f.value("scope")?)?; + let params = f.object("params")?.clone(); + f.finish()?; + let r = SessionRpcRequest { + request_id, + scope, + params: Value::Object(params), + }; + r.validate()?; + Ok(r) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("requestId", self.request_id.to_json()), + ("scope", Scope::nullable_to_json(self.scope.as_ref())), + ("params", self.params.clone()), + ]) + } + + fn validate(&self) -> Result<()> { + if !self.params.is_object() { + return err("SessionRpcRequest: params must be an object"); + } + if let Some(scope) = &self.scope { + scope.validate()?; + } + canonical::reject_bus_identities(&self.params) + } +} + +/// `SessionRpcSuccess`: a terminal domain success, echoing the request scope. +#[derive(Clone, Debug, PartialEq)] +pub struct SessionRpcSuccess { + pub request_id: DomainRequestId, + pub worker_id: String, + pub incarnation_id: String, + pub scope: Option, + pub result: Value, +} + +impl DomainType for SessionRpcSuccess { + const TYPE_NAME: &'static str = "SessionRpcSuccess"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "SessionRpcSuccess")?; + constant(&mut f, "type", "result")?; + let request_id = DomainRequestId::read(&mut f, "requestId")?; + let worker_id = f.id("workerId")?; + let incarnation_id = f.id("incarnationId")?; + let scope = Scope::nullable_from_json(f.value("scope")?)?; + let result = f.object("result")?.clone(); + f.finish()?; + let s = SessionRpcSuccess { + request_id, + worker_id, + incarnation_id, + scope, + result: Value::Object(result), + }; + s.validate()?; + Ok(s) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("type", "result".into()), + ("requestId", self.request_id.to_json()), + ("workerId", self.worker_id.clone().into()), + ("incarnationId", self.incarnation_id.clone().into()), + ("scope", Scope::nullable_to_json(self.scope.as_ref())), + ("result", self.result.clone()), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.worker_id) || !is_id(&self.incarnation_id) { + return err("SessionRpcSuccess: workerId and incarnationId must be valid ids"); + } + if !self.result.is_object() { + return err("SessionRpcSuccess: result must be an object"); + } + if let Some(scope) = &self.scope { + scope.validate()?; + } + Ok(()) + } +} + +/// `SessionRpcFailure`: a terminal domain error with an explicit mutation certainty. +#[derive(Clone, Debug, PartialEq)] +pub struct SessionRpcFailure { + pub request_id: DomainRequestId, + pub worker_id: String, + pub incarnation_id: String, + pub scope: Option, + pub code: ErrorCode, + pub message: String, + pub mutation: MutationCertainty, +} + +impl DomainType for SessionRpcFailure { + const TYPE_NAME: &'static str = "SessionRpcFailure"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "SessionRpcFailure")?; + constant(&mut f, "type", "error")?; + let request_id = DomainRequestId::read(&mut f, "requestId")?; + let worker_id = f.id("workerId")?; + let incarnation_id = f.id("incarnationId")?; + let scope = Scope::nullable_from_json(f.value("scope")?)?; + let (code, message, mutation) = { + let v = f.value("error")?; + let mut e = Fields::new(v, "SessionRpcFailure.error")?; + let code = ErrorCode::parse(&enumeration(&mut e, "code", ErrorCode::ALL)?)?; + let message = bounded_string(&mut e, "message", MAX_MESSAGE_CODE_POINTS)?; + let mutation = MutationCertainty::parse(&enumeration( + &mut e, + "mutation", + MutationCertainty::ALL, + )?)?; + e.finish()?; + (code, message, mutation) + }; + f.finish()?; + let failure = SessionRpcFailure { + request_id, + worker_id, + incarnation_id, + scope, + code, + message, + mutation, + }; + failure.validate()?; + Ok(failure) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("type", "error".into()), + ("requestId", self.request_id.to_json()), + ("workerId", self.worker_id.clone().into()), + ("incarnationId", self.incarnation_id.clone().into()), + ("scope", Scope::nullable_to_json(self.scope.as_ref())), + ( + "error", + obj(vec![ + ("code", self.code.as_str().into()), + ("message", self.message.clone().into()), + ("mutation", self.mutation.as_str().into()), + ]), + ), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.worker_id) || !is_id(&self.incarnation_id) { + return err("SessionRpcFailure: workerId and incarnationId must be valid ids"); + } + if self.message.chars().count() > MAX_MESSAGE_CODE_POINTS { + return err("SessionRpcFailure: message is at most 512 code points"); + } + if self.code.is_before_mutation() && self.mutation != MutationCertainty::None { + return err(format!( + "SessionRpcFailure: {} is raised before mutation, so mutation is \"none\"", + self.code.as_str() + )); + } + if let Some(scope) = &self.scope { + scope.validate()?; + } + Ok(()) + } +} + +/// A terminal domain outcome: success or failure. +#[derive(Clone, Debug, PartialEq)] +pub enum SessionRpcOutcome { + Success(SessionRpcSuccess), + Failure(SessionRpcFailure), +} + +impl SessionRpcOutcome { + pub fn request_id(&self) -> &DomainRequestId { + match self { + SessionRpcOutcome::Success(s) => &s.request_id, + SessionRpcOutcome::Failure(f) => &f.request_id, + } + } + + /// Replies echo the original scope (ipc-v1 section 3). + pub fn echoes(&self, request: &SessionRpcRequest) -> bool { + let scope = match self { + SessionRpcOutcome::Success(s) => &s.scope, + SessionRpcOutcome::Failure(f) => &f.scope, + }; + self.request_id() == &request.request_id && scope == &request.scope + } +} + +impl DomainType for SessionRpcOutcome { + const TYPE_NAME: &'static str = "SessionRpcOutcome"; + + fn from_json(value: &Value) -> Result { + let kind = value + .get("type") + .and_then(Value::as_str) + .ok_or_else(|| crate::scalar::wire_err("SessionRpcOutcome: missing type"))?; + match kind { + "result" => SessionRpcSuccess::from_json(value).map(SessionRpcOutcome::Success), + "error" => SessionRpcFailure::from_json(value).map(SessionRpcOutcome::Failure), + _ => err("SessionRpcOutcome: type must be \"result\" or \"error\""), + } + } + + fn to_json(&self) -> Value { + match self { + SessionRpcOutcome::Success(s) => s.to_json(), + SessionRpcOutcome::Failure(f) => f.to_json(), + } + } + + fn validate(&self) -> Result<()> { + match self { + SessionRpcOutcome::Success(s) => s.validate(), + SessionRpcOutcome::Failure(f) => f.validate(), + } + } +} diff --git a/services/flysim/crates/fly-session-types/src/scalar.rs b/services/flysim/crates/fly-session-types/src/scalar.rs new file mode 100644 index 0000000..0fdc030 --- /dev/null +++ b/services/flysim/crates/fly-session-types/src/scalar.rs @@ -0,0 +1,739 @@ +//! 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: this module calls straight into +//! [`flybus::wire`] instead of restating the regular expressions, and +//! `tests/encodings.rs` pins that the two agree. Everything else here is domain-only: +//! `Scope`, `RationalNs` (reduced, positive denominator, zero as `0/1`, checked arithmetic), +//! `SchemaRef` and `TypedValue` with its 32-KiB canonical-JSON cap. + +use std::cmp::Ordering; + +use flybus::wire::{self, Fields, WireError}; +use serde_json::{Map, Value}; + +use crate::canonical; + +pub type Result = std::result::Result; + +/// Every parsed domain type re-validates itself, so a value built in Rust and a value read +/// from JSON are held to the same rules. +pub trait DomainType: Sized { + /// The name this type has in the canonical schema set. + const TYPE_NAME: &'static str; + + /// Reads and validates one JSON value. Unknown fields are refused. + fn from_json(value: &Value) -> Result; + + /// The canonical JSON shape of this value. + fn to_json(&self) -> Value; + + /// The rules that are not expressible as one field read: ranges that depend on another + /// field, uniqueness, ordering and size caps. + fn validate(&self) -> Result<()>; +} + +pub fn err(message: impl Into) -> Result { + Err(WireError(message.into())) +} + +pub fn wire_err(message: impl Into) -> WireError { + WireError(message.into()) +} + +pub(crate) fn obj(pairs: Vec<(&str, Value)>) -> Value { + let mut map = Map::new(); + for (key, value) in pairs { + map.insert(key.to_owned(), value); + } + Value::Object(map) +} + +/// A `U64` field: the decimal string encoding, never a JSON number. +pub fn u64_json(n: u64) -> Value { + Value::String(n.to_string()) +} + +/// `Id`: `^[a-z0-9][a-z0-9._-]{0,63}$`, exactly the bus encoding. +pub fn is_id(s: &str) -> bool { + wire::is_id(s) +} + +/// `Digest`: 64 lowercase hexadecimal digits, exactly the bus encoding. +pub fn is_digest(s: &str) -> bool { + wire::is_digest(s) +} + +/// `U64`: `"0"` or `[1-9][0-9]*` up to `u64::MAX`, exactly the bus encoding. +pub fn parse_u64(s: &str) -> Option { + wire::parse_u64(s) +} + +// --------------------------------------------------------------------------------------------- +// Field readers the bus reader does not have + +/// A finite JSON number. NaN and infinities never survive strict parsing; this also refuses +/// integers outside the exactly representable double range, which canonical JSON cannot encode. +pub fn finite(f: &mut Fields<'_>, key: &'static str) -> Result { + let value = f.value(key)?; + match value { + Value::Number(n) => canonical::finite_double(n) + .ok_or_else(|| wire_err(format!("{key} must be a finite JSON number"))), + _ => err(format!("{key} must be a finite JSON number")), + } +} + +/// A finite JSON number inside `lo..=hi`, refused rather than clamped. +pub fn finite_in(f: &mut Fields<'_>, key: &'static str, lo: f64, hi: f64) -> Result { + let n = finite(f, key)?; + if n < lo || n > hi { + return err(format!("{key} must be in [{lo}, {hi}]")); + } + Ok(n) +} + +/// A JSON integer in `i32` range, the seed encoding Agent.Initialize uses. +pub fn i32_field(f: &mut Fields<'_>, key: &'static str) -> Result { + let value = f.value(key)?; + match value.as_i64() { + Some(n) if i64::from(i32::MIN) <= n && n <= i64::from(i32::MAX) => Ok(n as i32), + _ => err(format!("{key} must be a signed 32-bit integer")), + } +} + +/// One member of a closed string enum. +pub fn enumeration(f: &mut Fields<'_>, key: &'static str, allowed: &[&str]) -> Result { + let s = f.string(key)?; + if allowed.contains(&s) { + Ok(s.to_owned()) + } else { + err(format!("{key} must be one of {}", allowed.join(", "))) + } +} + +/// A string constant: a field whose only legal value is `expected`. +pub fn constant(f: &mut Fields<'_>, key: &'static str, expected: &str) -> Result<()> { + let s = f.string(key)?; + if s == expected { + Ok(()) + } else { + err(format!("{key} must be {expected:?}")) + } +} + +/// A `true` constant. +pub fn constant_true(f: &mut Fields<'_>, key: &'static str) -> Result<()> { + if f.boolean(key)? { + Ok(()) + } else { + err(format!("{key} must be true")) + } +} + +/// A string of at most `max` Unicode code points. +pub fn bounded_string(f: &mut Fields<'_>, key: &'static str, max: usize) -> Result { + let s = f.string(key)?; + if s.chars().count() > max { + return err(format!("{key} must be at most {max} code points")); + } + Ok(s.to_owned()) +} + +/// `null`, or a string of at most `max` code points. +pub fn nullable_bounded_string( + f: &mut Fields<'_>, + key: &'static str, + max: usize, +) -> Result> { + match f.value(key)? { + Value::Null => Ok(None), + _ => bounded_string(f, key, max).map(Some), + } +} + +/// Reads an array of `lo..=hi` items through `read`, keeping the supplied order. +pub fn list( + f: &mut Fields<'_>, + key: &'static str, + lo: usize, + hi: usize, + read: impl Fn(&Value) -> Result, +) -> Result> { + let items = f.array(key, lo, hi)?; + let mut out = Vec::with_capacity(items.len()); + for item in items { + out.push(read(item).map_err(|e| wire_err(format!("{key}: {e}")))?); + } + Ok(out) +} + +/// An array of `lo..=hi` `Id`s. +pub fn id_list(f: &mut Fields<'_>, key: &'static str, lo: usize, hi: usize) -> Result> { + list(f, key, lo, hi, |v| match v.as_str() { + Some(s) if is_id(s) => Ok(s.to_owned()), + _ => err("every entry must be an id"), + }) +} + +/// Fails on the first repeated key, naming it. +pub fn require_unique<'a>(keys: impl IntoIterator, what: &str) -> Result<()> { + let mut seen: Vec<&str> = Vec::new(); + for key in keys { + if seen.contains(&key) { + return err(format!("{what}: duplicate {key:?}")); + } + seen.push(key); + } + Ok(()) +} + +/// Fails unless `actual` is exactly `expected`, in that order: descriptor order is part of +/// the contract, not a set membership test. +pub fn require_same_order<'a>( + actual: impl IntoIterator, + expected: impl IntoIterator, + what: &str, +) -> Result<()> { + let actual: Vec<&str> = actual.into_iter().collect(); + let expected: Vec<&str> = expected.into_iter().collect(); + if actual != expected { + return err(format!( + "{what}: must list [{}] in that order, found [{}]", + expected.join(", "), + actual.join(", ") + )); + } + Ok(()) +} + +// --------------------------------------------------------------------------------------------- +// Scope + +/// `Scope`: the simulation timeline identity. Never the bus route or store incarnation. +#[derive(Clone, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub struct Scope { + pub session_id: String, + pub epoch: String, + pub step: u64, +} + +impl Scope { + pub fn new(session_id: &str, epoch: &str, step: u64) -> Result { + let scope = Scope { + session_id: session_id.to_owned(), + epoch: epoch.to_owned(), + step, + }; + scope.validate()?; + Ok(scope) + } + + /// `null`, or a scope. + pub fn nullable_from_json(value: &Value) -> Result> { + match value { + Value::Null => Ok(None), + _ => Scope::from_json(value).map(Some), + } + } + + pub fn nullable_to_json(scope: Option<&Scope>) -> Value { + scope.map_or(Value::Null, Scope::to_json) + } +} + +impl DomainType for Scope { + const TYPE_NAME: &'static str = "Scope"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "Scope")?; + let session_id = f.id("sessionId")?; + let epoch = f.id("epoch")?; + let step = f.u64_string("step")?; + f.finish()?; + let scope = Scope { + session_id, + epoch, + step, + }; + scope.validate()?; + Ok(scope) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("sessionId", self.session_id.clone().into()), + ("epoch", self.epoch.clone().into()), + ("step", u64_json(self.step)), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.session_id) { + return err("Scope: sessionId is not a valid id"); + } + if !is_id(&self.epoch) { + return err("Scope: epoch is not a valid id"); + } + Ok(()) + } +} + +// --------------------------------------------------------------------------------------------- +// RationalNs + +/// A nanosecond rational: reduced, positive denominator, zero encoded `0/1`. +/// +/// ipc-v1 section 2: "Fractions are reduced, denominators positive, durations positive; zero +/// is encoded 0/1. Arithmetic is checked." Durations are checked with +/// [`RationalNs::require_positive`] by the fields that are durations; `worldTime` and a tick +/// remainder are legitimately zero. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub struct RationalNs { + pub numerator: u64, + pub denominator: u64, +} + +fn gcd(a: u64, b: u64) -> u64 { + let (mut a, mut b) = (a, b); + while b != 0 { + let t = a % b; + a = b; + b = t; + } + a +} + +/// One checked `u64` x `u64` product. The product itself always fits `u128`; the function +/// exists so every multiplication in the arithmetic below goes through one checked path. +fn mul(a: u64, b: u64) -> Result { + u128::from(a) + .checked_mul(u128::from(b)) + .ok_or_else(|| wire_err("RationalNs: multiplication overflowed")) +} + +fn gcd128(a: u128, b: u128) -> u128 { + let (mut a, mut b) = (a, b); + while b != 0 { + let t = a % b; + a = b; + b = t; + } + a +} + +impl RationalNs { + pub const ZERO: RationalNs = RationalNs { + numerator: 0, + denominator: 1, + }; + + /// Exactly the supplied pair, which must already be in canonical form. + pub fn new(numerator: u64, denominator: u64) -> Result { + let r = RationalNs { + numerator, + denominator, + }; + r.validate()?; + Ok(r) + } + + /// Reduces first, then validates: the constructor for arithmetic results. + pub fn reduced(numerator: u128, denominator: u128) -> Result { + if denominator == 0 { + return err("RationalNs: denominator must be positive"); + } + let (n, d) = if numerator == 0 { + (0u128, 1u128) + } else { + let g = gcd128(numerator, denominator); + (numerator / g, denominator / g) + }; + if n > u128::from(u64::MAX) || d > u128::from(u64::MAX) { + return err("RationalNs: reduced value does not fit U64"); + } + RationalNs::new(n as u64, d as u64) + } + + pub fn is_zero(&self) -> bool { + self.numerator == 0 + } + + /// Durations must be positive (ipc-v1 section 2). + pub fn require_positive(&self, what: &str) -> Result<()> { + if self.is_zero() { + return err(format!("{what}: duration must be positive")); + } + Ok(()) + } + + /// Cross-multiplication of two `U64` pairs fits `u128`, but their *sum* does not: two + /// reduced fractions near the `U64` maximum add to about 2^129. Every step is checked, as + /// ipc-v1 section 2 requires; nothing here may wrap in release and panic in debug. + pub fn checked_add(&self, other: &RationalNs) -> Result { + let left = mul(self.numerator, other.denominator)?; + let right = mul(other.numerator, self.denominator)?; + let n = left + .checked_add(right) + .ok_or_else(|| wire_err("RationalNs: addition overflowed"))?; + let d = mul(self.denominator, other.denominator)?; + RationalNs::reduced(n, d) + } + + pub fn checked_sub(&self, other: &RationalNs) -> Result { + let left = mul(self.numerator, other.denominator)?; + let right = mul(other.numerator, self.denominator)?; + if right > left { + return err("RationalNs: subtraction would be negative"); + } + let d = mul(self.denominator, other.denominator)?; + RationalNs::reduced(left - right, d) + } + + pub fn checked_mul_u64(&self, k: u64) -> Result { + let n = u128::from(self.numerator) + .checked_mul(u128::from(k)) + .ok_or_else(|| wire_err("RationalNs: multiplication overflowed"))?; + RationalNs::reduced(n, u128::from(self.denominator)) + } + + /// The step-v1 section 5 accumulator: `ticks = floor(self / tick)` and the remainder + /// `self - ticks * tick`, which is always `>= 0` and `< tick`. + pub fn divide_floor(&self, tick: &RationalNs) -> Result<(u64, RationalNs)> { + tick.require_positive("RationalNs::divide_floor tick")?; + let n = mul(self.numerator, tick.denominator)?; + let d = mul(self.denominator, tick.numerator)?; + let ticks = n / d; + if ticks > u128::from(u64::MAX) { + return err("RationalNs: tick count does not fit U64"); + } + let ticks = ticks as u64; + let remainder = self.checked_sub(&tick.checked_mul_u64(ticks)?)?; + Ok((ticks, remainder)) + } +} + +impl PartialOrd for RationalNs { + fn partial_cmp(&self, other: &RationalNs) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for RationalNs { + fn cmp(&self, other: &RationalNs) -> Ordering { + let left = u128::from(self.numerator) * u128::from(other.denominator); + let right = u128::from(other.numerator) * u128::from(self.denominator); + left.cmp(&right) + } +} + +impl DomainType for RationalNs { + const TYPE_NAME: &'static str = "RationalNs"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "RationalNs")?; + let numerator = f.u64_string("numerator")?; + let denominator = f.u64_string("denominator")?; + f.finish()?; + RationalNs::new(numerator, denominator) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("numerator", u64_json(self.numerator)), + ("denominator", u64_json(self.denominator)), + ]) + } + + fn validate(&self) -> Result<()> { + if self.denominator == 0 { + return err("RationalNs: denominator must be positive"); + } + if self.numerator == 0 && self.denominator != 1 { + return err("RationalNs: zero is encoded 0/1"); + } + if self.numerator != 0 && gcd(self.numerator, self.denominator) != 1 { + return err("RationalNs: fraction must be reduced"); + } + Ok(()) + } +} + +// --------------------------------------------------------------------------------------------- +// SchemaRef and TypedValue + +/// `SchemaRef`: the identity of a registered typed payload schema. Version is 1..=65535. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct SchemaRef { + pub id: String, + pub version: u16, + pub digest: String, +} + +impl SchemaRef { + pub fn new(id: &str, version: u16, digest: &str) -> Result { + let r = SchemaRef { + id: id.to_owned(), + version, + digest: digest.to_owned(), + }; + r.validate()?; + Ok(r) + } +} + +impl DomainType for SchemaRef { + const TYPE_NAME: &'static str = "SchemaRef"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "SchemaRef")?; + let id = f.id("id")?; + let version = f.int("version", 1, 65_535)? as u16; + let digest = f.string("digest")?.to_owned(); + f.finish()?; + let r = SchemaRef { + id, + version, + digest, + }; + r.validate()?; + Ok(r) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("id", self.id.clone().into()), + ("version", Value::from(u64::from(self.version))), + ("digest", self.digest.clone().into()), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.id) { + return err("SchemaRef: id is not a valid id"); + } + if self.version == 0 { + return err("SchemaRef: version must be 1..=65535"); + } + if !is_digest(&self.digest) { + return err("SchemaRef: digest must be 64 lowercase hex digits"); + } + Ok(()) + } +} + +/// The canonical-JSON size limit of one `TypedValue` (ipc-v1 section 2, workers-v1 section 1). +pub const MAX_TYPED_VALUE_BYTES: usize = 32 * 1024; + +/// `TypedValue`: a schema identity plus an object, capped at 32 KiB of canonical JSON. +#[derive(Clone, Debug, PartialEq)] +pub struct TypedValue { + pub schema: SchemaRef, + pub value: Value, +} + +impl TypedValue { + pub fn new(schema: SchemaRef, value: Value) -> Result { + let t = TypedValue { schema, value }; + t.validate()?; + Ok(t) + } + + pub fn nullable_from_json(value: &Value) -> Result> { + match value { + Value::Null => Ok(None), + _ => TypedValue::from_json(value).map(Some), + } + } + + pub fn nullable_to_json(value: Option<&TypedValue>) -> Value { + value.map_or(Value::Null, TypedValue::to_json) + } + + /// The canonical JSON byte length of the whole typed value. + pub fn canonical_len(&self) -> Result { + canonical::canonicalize(&self.to_json()).map(|s| s.len()) + } +} + +impl DomainType for TypedValue { + const TYPE_NAME: &'static str = "TypedValue"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "TypedValue")?; + let schema = SchemaRef::from_json(f.value("schema")?)?; + let inner = f.value("value")?.clone(); + f.finish()?; + let t = TypedValue { + schema, + value: inner, + }; + t.validate()?; + Ok(t) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("schema", self.schema.to_json()), + ("value", self.value.clone()), + ]) + } + + fn validate(&self) -> Result<()> { + self.schema.validate()?; + if !self.value.is_object() { + return err("TypedValue: value must be an object"); + } + let len = self.canonical_len()?; + if len > MAX_TYPED_VALUE_BYTES { + return err(format!( + "TypedValue: {len} bytes of canonical JSON exceeds the {MAX_TYPED_VALUE_BYTES}-byte limit" + )); + } + Ok(()) + } +} + +// --------------------------------------------------------------------------------------------- +// The four identities + +/// A bus RPC correlation id, `call-` (bus-v1 section 6). It is not a domain operation id: +/// a safe domain retry keeps its [`DomainRequestId`] and gets a new `BusCallId`. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct BusCallId(String); + +/// A domain operation id, `req-` plus a canonical `U64` serial (ipc-v1 section 5). +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct DomainRequestId(String); + +/// The identity of an immutable artifact: store incarnation, artifact id and generation. +/// Not an address, not authority to read, and not an [`crate::workers::AssetRef`]. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct ArtifactIdentity { + pub store_id: String, + pub artifact_id: String, + pub generation: u64, +} + +/// Which kind of ownership root a token names. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum OwnerKind { + /// One recipient's delivery, `dlv-`. + Delivery, + /// An explicit artifact hold, `own-`. + Hold, +} + +/// A delivery or explicit-hold owner token. Connection-private: it never appears in a domain +/// payload or a canonical body digest. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct OwnerToken { + token: String, + kind: OwnerKind, +} + +macro_rules! serial_identity { + ($type:ty, $prefix:literal, $what:literal) => { + impl $type { + /// Parses the canonical `prefix-` form; any other prefix is refused, which is + /// what keeps the four identities from being swapped for one another. + pub fn parse(s: &str) -> Result { + match wire::parse_serial_id($prefix, s) { + Some(_) => Ok(Self(s.to_owned())), + None => err(concat!($what, " must be canonical ", $prefix, "-")), + } + } + + pub fn from_serial(serial: u64) -> Self { + Self(wire::serial_id($prefix, serial)) + } + + pub fn serial(&self) -> u64 { + wire::parse_serial_id($prefix, &self.0).expect("validated on construction") + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + pub fn read(f: &mut Fields<'_>, key: &'static str) -> Result { + let s = f.string(key)?; + Self::parse(s).map_err(|e| wire_err(format!("{key}: {e}"))) + } + + pub fn read_nullable(f: &mut Fields<'_>, key: &'static str) -> Result> { + match f.value(key)? { + Value::Null => Ok(None), + _ => Self::read(f, key).map(Some), + } + } + + pub fn to_json(&self) -> Value { + Value::String(self.0.clone()) + } + } + }; +} + +serial_identity!(BusCallId, "call", "a bus callId"); +serial_identity!(DomainRequestId, "req", "a domain requestId"); + +impl OwnerToken { + pub fn parse(s: &str) -> Result { + if wire::parse_serial_id("dlv", s).is_some() { + return Ok(OwnerToken { + token: s.to_owned(), + kind: OwnerKind::Delivery, + }); + } + if wire::parse_serial_id("own", s).is_some() { + return Ok(OwnerToken { + token: s.to_owned(), + kind: OwnerKind::Hold, + }); + } + err("an owner token must be canonical dlv- or own-") + } + + pub fn delivery(serial: u64) -> OwnerToken { + OwnerToken { + token: wire::serial_id("dlv", serial), + kind: OwnerKind::Delivery, + } + } + + pub fn hold(serial: u64) -> OwnerToken { + OwnerToken { + token: wire::serial_id("own", serial), + kind: OwnerKind::Hold, + } + } + + pub fn kind(&self) -> OwnerKind { + self.kind + } + + pub fn as_str(&self) -> &str { + &self.token + } +} + +impl ArtifactIdentity { + /// The identity half of a bus `ArtifactRef`: the parts that name the bytes, without the + /// byte length, content type or optional digest. + pub fn of(reference: &flybus::wire::ArtifactRef) -> ArtifactIdentity { + ArtifactIdentity { + store_id: reference.store_id.clone(), + artifact_id: reference.artifact_id.clone(), + generation: reference.generation, + } + } + + pub fn validate(&self) -> Result<()> { + if !is_id(&self.store_id) { + return err("ArtifactIdentity: storeId is not a valid id"); + } + if !is_id(&self.artifact_id) { + return err("ArtifactIdentity: artifactId is not a valid id"); + } + Ok(()) + } +} diff --git a/services/flysim/crates/fly-session-types/src/schema.rs b/services/flysim/crates/fly-session-types/src/schema.rs new file mode 100644 index 0000000..9a122c0 --- /dev/null +++ b/services/flysim/crates/fly-session-types/src/schema.rs @@ -0,0 +1,1109 @@ +//! The canonical schema set, and `contractDigest`. +//! +//! The digest is taken over a *declaration*, not over this file's text: every type below is +//! a row of `(name, source document, fields)` and every field a row of +//! `(name, kind, required, constraint)`. Reformatting the source, reordering the rows, +//! renaming a Rust struct field or adding a comment cannot change the digest; changing a +//! type name, a JSON field name, a kind, a bound or a closed enum's members does. The rows +//! are sorted and rendered as canonical JSON (RFC 8785) before hashing, so two +//! implementations that agree on the schema set agree on the digest. +//! +//! `fixtures/schema-set.json` is the rendered set, and `fixtures/contract-digest.json` +//! records the digest; `tests/schema_set.rs` regenerates both, and the TypeScript package +//! hashes the same file with its own canonical JSON. + +use serde_json::Value; + +use crate::canonical; +use crate::scalar::{Result, obj}; + +/// The schema set version. Bumped when the set gains or loses types, so an old digest is +/// never mistaken for a new one. +pub const SCHEMA_SET_VERSION: u64 = 1; + +/// One field of one type. +#[derive(Clone, Copy, Debug)] +pub struct FieldSchema { + pub name: &'static str, + pub kind: &'static str, + pub required: bool, + pub constraint: &'static str, +} + +/// One domain type. +#[derive(Clone, Copy, Debug)] +pub struct TypeSchema { + pub name: &'static str, + /// The contract and section this type is specified in. + pub source: &'static str, + pub fields: &'static [FieldSchema], +} + +/// One closed enum. +#[derive(Clone, Copy, Debug)] +pub struct EnumSchema { + pub name: &'static str, + pub source: &'static str, + pub members: &'static [&'static str], +} + +/// One named bound. +#[derive(Clone, Copy, Debug)] +pub struct LimitSchema { + pub name: &'static str, + pub value: u64, + /// Where the bound comes from: a contract section, or `crate` when this crate chose it + /// because the documents state none. + pub source: &'static str, +} + +const fn req(name: &'static str, kind: &'static str, constraint: &'static str) -> FieldSchema { + FieldSchema { + name, + kind, + required: true, + constraint, + } +} + +const fn opt(name: &'static str, kind: &'static str, constraint: &'static str) -> FieldSchema { + FieldSchema { + name, + kind, + required: false, + constraint, + } +} + +pub const ENUMS: &[EnumSchema] = &[ + EnumSchema { + name: "ErrorCode", + source: "ipc-v1 7", + members: crate::rpc::ErrorCode::ALL, + }, + EnumSchema { + name: "MutationCertainty", + source: "ipc-v1 3", + members: crate::rpc::MutationCertainty::ALL, + }, + EnumSchema { + name: "Role", + source: "ipc-v1 4", + members: crate::workers::Role::ALL, + }, + EnumSchema { + name: "WorkerState", + source: "ipc-v1 4", + members: crate::workers::WorkerState::ALL, + }, + EnumSchema { + name: "Recovery", + source: "workers-v1 3", + members: crate::workers::Recovery::ALL, + }, + EnumSchema { + name: "Determinism", + source: "workers-v1 3", + members: crate::workers::Determinism::ALL, + }, + EnumSchema { + name: "AxisRange", + source: "workers-v1 3", + members: crate::workers::AxisRange::ALL, + }, + EnumSchema { + name: "ViewFormat", + source: "state-media-v1 2", + members: &["rgba8"], + }, + EnumSchema { + name: "AudioFormat", + source: "state-media-v1 2", + members: &["f32le-interleaved"], + }, + EnumSchema { + name: "SchedulerId", + source: "publishing-v1 3", + members: &["lockstep-v1"], + }, + EnumSchema { + name: "EpisodeRequestKind", + source: "workers-v1 4", + members: &["terminal"], + }, +]; + +pub const LIMITS: &[LimitSchema] = &[ + LimitSchema { + name: "maxAgents", + value: crate::workers::MAX_AGENTS as u64, + source: "ipc-v1 2", + }, + LimitSchema { + name: "maxPorts", + value: crate::workers::MAX_PORTS as u64, + source: "ipc-v1 2", + }, + LimitSchema { + name: "maxRateRoles", + value: crate::workers::MAX_RATE_ROLES as u64, + source: "ipc-v1 2", + }, + LimitSchema { + name: "maxStimuliPerOperation", + value: crate::workers::MAX_STIMULI as u64, + source: "workers-v1 1", + }, + LimitSchema { + name: "maxRewardsPerOperation", + value: crate::workers::MAX_REWARDS as u64, + source: "workers-v1 1", + }, + LimitSchema { + name: "maxViews", + value: crate::media::MAX_VIEWS as u64, + source: "workers-v1 1", + }, + LimitSchema { + name: "maxButtons", + value: crate::workers::MAX_BUTTONS as u64, + source: "workers-v1 3", + }, + LimitSchema { + name: "maxAxes", + value: crate::workers::MAX_AXES as u64, + source: "workers-v1 3", + }, + LimitSchema { + name: "maxAcknowledge", + value: crate::workers::MAX_ACKNOWLEDGE as u64, + source: "ipc-v1 5", + }, + LimitSchema { + name: "maxTypedValueBytes", + value: crate::scalar::MAX_TYPED_VALUE_BYTES as u64, + source: "ipc-v1 2", + }, + LimitSchema { + name: "maxEnvelopeBytes", + value: canonical::MAX_ENVELOPE_BYTES as u64, + source: "bus-v1 4", + }, + LimitSchema { + name: "maxAttachments", + value: flybus::wire::MAX_ATTACHMENTS as u64, + source: "bus-v1 4", + }, + LimitSchema { + name: "maxMessageCodePoints", + value: crate::workers::MAX_MESSAGE_CODE_POINTS as u64, + source: "ipc-v1 7", + }, + LimitSchema { + name: "maxViewDimension", + value: crate::media::MAX_VIEW_DIMENSION, + source: "state-media-v1 2", + }, + LimitSchema { + name: "maxPixelAspectPart", + value: crate::media::MAX_PIXEL_ASPECT, + source: "state-media-v1 2", + }, + LimitSchema { + name: "maxObservationDelaySteps", + value: crate::media::MAX_OBSERVATION_DELAY_STEPS, + source: "state-media-v1 2", + }, + LimitSchema { + name: "maxSampleFrames", + value: crate::media::MAX_SAMPLE_FRAMES, + source: "state-media-v1 2", + }, + LimitSchema { + name: "maxEngineFrameLength", + value: crate::workers::MAX_ENGINE_FRAME_LEN as u64, + source: "workers-v1 3", + }, + LimitSchema { + name: "maxSchemaVersion", + value: 65_535, + source: "ipc-v1 2", + }, + LimitSchema { + name: "maxAudioStreams", + value: crate::media::MAX_AUDIO_STREAMS as u64, + source: "crate", + }, + LimitSchema { + name: "maxCapabilities", + value: crate::workers::MAX_CAPABILITIES as u64, + source: "crate", + }, + LimitSchema { + name: "maxSupportedMajors", + value: crate::workers::MAX_SUPPORTED_MAJORS as u64, + source: "crate", + }, + LimitSchema { + name: "maxSupportedStimuli", + value: crate::publishing::MAX_SUPPORTED_STIMULI as u64, + source: "crate", + }, + LimitSchema { + name: "maxAssets", + value: crate::publishing::MAX_ASSETS as u64, + source: "crate", + }, + LimitSchema { + name: "maxSnapshotEvents", + value: crate::publishing::MAX_SNAPSHOT_EVENTS as u64, + source: "crate", + }, +]; + +pub const SCHEMAS: &[TypeSchema] = &[ + TypeSchema { + name: "Scope", + source: "ipc-v1 2", + fields: &[ + req("sessionId", "Id", "^[a-z0-9][a-z0-9._-]{0,63}$"), + req("epoch", "Id", "^[a-z0-9][a-z0-9._-]{0,63}$"), + req("step", "U64", "decimal string, <= 18446744073709551615"), + ], + }, + TypeSchema { + name: "RationalNs", + source: "ipc-v1 2", + fields: &[ + req("numerator", "U64", "reduced against denominator"), + req( + "denominator", + "U64", + "positive; zero is encoded 0/1; arithmetic is checked", + ), + ], + }, + TypeSchema { + name: "SchemaRef", + source: "ipc-v1 2", + fields: &[ + req("id", "Id", ""), + req("version", "int", "1..=65535"), + req("digest", "Digest", "64 lowercase hex digits"), + ], + }, + TypeSchema { + name: "TypedValue", + source: "ipc-v1 2", + fields: &[ + req("schema", "SchemaRef", ""), + req( + "value", + "object", + "canonical JSON of the whole TypedValue <= 32768 bytes", + ), + ], + }, + TypeSchema { + name: "SessionRpcRequest", + source: "ipc-v1 2", + fields: &[ + req("requestId", "DomainRequestId", "req-"), + opt("scope", "Scope|null", "null for lifecycle calls"), + req("params", "object", "no bus callId/deliveryId/ownerId keys"), + ], + }, + TypeSchema { + name: "SessionRpcSuccess", + source: "ipc-v1 3", + fields: &[ + req("type", "const", "\"result\""), + req("requestId", "DomainRequestId", "echoes the request"), + req("workerId", "Id", ""), + req("incarnationId", "Id", ""), + opt("scope", "Scope|null", "echoes the request scope"), + req("result", "object", ""), + ], + }, + TypeSchema { + name: "SessionRpcFailure", + source: "ipc-v1 3", + fields: &[ + req("type", "const", "\"error\""), + req("requestId", "DomainRequestId", "echoes the request"), + req("workerId", "Id", ""), + req("incarnationId", "Id", ""), + opt("scope", "Scope|null", "echoes the request scope"), + req("error.code", "ErrorCode", ""), + req("error.message", "string", "<= 512 code points"), + req( + "error.mutation", + "MutationCertainty", + "none for every code raised before mutation", + ), + ], + }, + TypeSchema { + name: "AssetRef", + source: "workers-v1 1", + fields: &[ + req("id", "Id", ""), + req("digest", "Digest", ""), + req("byteLength", "U64", "positive"), + req("format", "Id", ""), + ], + }, + TypeSchema { + name: "SensoryInput", + source: "workers-v1 1", + fields: &[ + req("boundary", "U64", "the observed environment boundary"), + req( + "views", + "array", + "<= 8, unique viewId, producedStep <= boundary", + ), + opt( + "structured", + "TypedValue|null", + "a pixel-only profile rejects non-null", + ), + ], + }, + TypeSchema { + name: "Stimulus", + source: "workers-v1 1", + fields: &[ + req("id", "Id", "unique within its command namespace"), + req("kindId", "Id", "resolved through a profile capability"), + req("durationMs", "number", "finite and > 0"), + ], + }, + TypeSchema { + name: "Reward", + source: "workers-v1 1", + fields: &[ + req("eventId", "Id", "unique within its outcome namespace"), + req("ruleId", "Id", ""), + req( + "value", + "number", + "finite; positive-only profiles reject negatives", + ), + ], + }, + TypeSchema { + name: "AgentTelemetry", + source: "workers-v1 1", + fields: &[ + req("brainTicks", "U64", ""), + req("populationRateHz", "number", "finite and nonnegative"), + req( + "rates", + "array<{roleId:Id,hz:number}>", + "<= 64, unique roleId, profile order, finite nonnegative hz", + ), + req( + "learning", + "{enabled:bool,updates:U64,changed:U64,signal:number}", + "changed <= updates; signal finite", + ), + ], + }, + TypeSchema { + name: "AgentInitializeParams", + source: "workers-v1 2", + fields: &[ + req("agentId", "Id", ""), + req("profile", "AssetRef", ""), + req("seed", "int", "signed 32-bit"), + req("initialInput", "SensoryInput", ""), + req("initialDecisionContext", "TypedValue", ""), + req("workerThreads", "int", ">= 1"), + ], + }, + TypeSchema { + name: "AgentInitializeResult", + source: "workers-v1 2", + fields: &[ + req("agentId", "Id", ""), + req("profileDigest", "Digest", ""), + req("tickDuration", "RationalNs", "positive"), + req("warmupTicks", "U64", ""), + req("committedStep", "U64", "\"0\""), + req("decisionContextDigest", "Digest", ""), + req("telemetry", "AgentTelemetry", ""), + ], + }, + TypeSchema { + name: "PrepareParams", + source: "workers-v1 2", + fields: &[ + req("agentId", "Id", ""), + req("profileDigest", "Digest", ""), + req("interval", "RationalNs", "positive"), + req("decisionContextDigest", "Digest", ""), + req( + "preStepStimulations", + "array", + "<= 64, unique id, supplied order retained", + ), + ], + }, + TypeSchema { + name: "PreparedDecision", + source: "workers-v1 2", + fields: &[ + req("agentId", "Id", ""), + req("ticksAdvanced", "U64", "<= brainTicks"), + req("brainTicks", "U64", ""), + req("remainder", "RationalNs", ">= 0 and < one model tick"), + req( + "decision", + "TypedValue", + "the profile's registered intent schema", + ), + ], + }, + TypeSchema { + name: "CommitParams", + source: "workers-v1 2", + fields: &[ + req("agentId", "Id", ""), + req("preparedRequestId", "DomainRequestId", ""), + req("nextInput", "SensoryInput", "boundary == scope.step + 1"), + req("nextDecisionContext", "TypedValue", ""), + req( + "rewards", + "array", + "<= 64, unique eventId, order retained", + ), + req( + "taskStimulations", + "array", + "<= 64, unique id, order retained", + ), + ], + }, + TypeSchema { + name: "AgentCommitResult", + source: "workers-v1 2", + fields: &[ + req("agentId", "Id", ""), + req("committedStep", "U64", "k+1"), + req("decisionContextDigest", "Digest", ""), + req("telemetry", "AgentTelemetry", ""), + ], + }, + TypeSchema { + name: "ControllerSchema", + source: "workers-v1 3", + fields: &[ + req("schema", "SchemaRef", ""), + req("buttons", "array", "<= 32, unique, fixed order"), + req( + "axes", + "array<{id:Id,range:AxisRange,neutral:number}>", + "<= 16, unique id, neutral inside its range", + ), + ], + }, + TypeSchema { + name: "PortControl", + source: "workers-v1 3", + fields: &[ + req("portId", "Id", ""), + req( + "buttons", + "array<{id:Id,down:bool}>", + "every declared button, descriptor order, no extras", + ), + req( + "axes", + "array<{id:Id,value:number}>", + "every declared axis in order; bipolar [-1,1], unit [0,1], refused not clamped", + ), + ], + }, + TypeSchema { + name: "EnvironmentDescriptor", + source: "workers-v1 3", + fields: &[ + req("backendDigest", "Digest", ""), + req("contentDigest", "Digest", ""), + req("configurationDigest", "Digest", ""), + req("stepDuration", "RationalNs", "fixed, reduced, positive"), + req( + "ports", + "array<{portId:Id,controls:ControllerSchema}>", + "1..=4, unique portId, fixed order", + ), + req("inspectionSchema", "SchemaRef", ""), + req("views", "array", "<= 8, unique viewId"), + req("audio", "array", "unique streamId"), + req("recovery", "Recovery", ""), + req("determinism", "Determinism", ""), + ], + }, + TypeSchema { + name: "EnvironmentInitializeParams", + source: "workers-v1 3", + fields: &[ + req("backendConfig", "AssetRef", ""), + req("taskConfig", "AssetRef", ""), + req("episodeId", "Id", ""), + req( + "portBindings", + "array<{portId:Id,agentId:Id}>", + "1..=4, unique portId and unique agentId", + ), + ], + }, + TypeSchema { + name: "EnvironmentInitializeResult", + source: "workers-v1 3", + fields: &[ + req("descriptor", "EnvironmentDescriptor", ""), + req( + "observation", + "WorldObservation", + "boundary 0 and worldTime 0/1", + ), + ], + }, + TypeSchema { + name: "WorldObservation", + source: "workers-v1 3", + fields: &[ + req("boundary", "U64", ""), + req( + "worldTime", + "RationalNs", + "logical time since episode start", + ), + opt("engineFrame", "string|null", "<= 64 characters"), + req("sensoryViews", "array", "<= 8, unique viewId"), + req( + "inspection", + "TypedValue", + "the descriptor's inspectionSchema", + ), + req("broadcastViews", "array", "<= 8, unique viewId"), + req("audio", "array", "unique streamId"), + ], + }, + TypeSchema { + name: "AdvanceParams", + source: "workers-v1 3", + fields: &[ + req("batchId", "Id", "unique within an epoch"), + req( + "controls", + "array", + "one complete batch: every declared port once, descriptor order", + ), + ], + }, + TypeSchema { + name: "StepResult", + source: "workers-v1 3", + fields: &[ + req("batchId", "Id", "echoes the request"), + req("appliedFromStep", "U64", "k"), + req("nextStep", "U64", "appliedFromStep + 1"), + req( + "appliedControlsDigest", + "Digest", + "over validated canonical requested controls", + ), + req("observation", "WorldObservation", "boundary == nextStep"), + ], + }, + TypeSchema { + name: "HelloParams", + source: "ipc-v1 4", + fields: &[ + req("sessionId", "Id", ""), + req("expectedWorkerId", "Id", ""), + req("role", "Role", ""), + req( + "supportedMajors", + "array", + "nonempty, unique, 1..=65535", + ), + ], + }, + TypeSchema { + name: "HelloResult", + source: "ipc-v1 4", + fields: &[ + req("selectedMajor", "const", "1"), + req("selectedMinor", "const", "0"), + req("workerId", "Id", ""), + req("incarnationId", "Id", ""), + req("role", "Role", ""), + req("buildDigest", "Digest", ""), + req("contractDigest", "Digest", ""), + req( + "capabilities", + "array", + "unique; agent-step-v1 or world-step-v1 required for that role", + ), + req( + "limits", + "{maxAgents:int,maxPorts:int}", + "1..=4 agents and 1..=4 ports", + ), + ], + }, + TypeSchema { + name: "StatusResult", + source: "ipc-v1 4", + fields: &[ + req("state", "WorkerState", ""), + opt("currentScope", "Scope|null", "null before initialization"), + opt("activeRequestId", "DomainRequestId|null", ""), + opt("lastCompletedRequestId", "DomainRequestId|null", ""), + opt("lastBatchId", "Id|null", ""), + req( + "progressCounter", + "U64", + "advances on progress, not on status queries", + ), + ], + }, + TypeSchema { + name: "AcknowledgeParams", + source: "ipc-v1 5", + fields: &[req( + "requestIds", + "array", + "1..=16, unique", + )], + }, + TypeSchema { + name: "AcknowledgeResult", + source: "ipc-v1 5", + fields: &[req( + "acknowledged", + "array", + "<= 16, unique, a subset of the request", + )], + }, + TypeSchema { + name: "ShutdownParams", + source: "ipc-v1 7", + fields: &[req("reason", "Id", "")], + }, + TypeSchema { + name: "ShutdownResult", + source: "ipc-v1 7", + fields: &[req("stopping", "const", "true")], + }, + TypeSchema { + name: "TaskEvent", + source: "workers-v1 4", + fields: &[ + req( + "id", + "Id", + "derived from epoch, source step, rule and ordinal", + ), + req("kindId", "Id", ""), + req( + "sourceStep", + "U64", + "the newly reached boundary, 0 for bootstrap", + ), + opt("agentId", "Id|null", ""), + req("payload", "TypedValue", ""), + ], + }, + TypeSchema { + name: "EpisodeRequest", + source: "workers-v1 4", + fields: &[ + req("kind", "EpisodeRequestKind", ""), + req("reason", "Id", ""), + req("outcome", "TypedValue", ""), + ], + }, + TypeSchema { + name: "ViewDescriptor", + source: "state-media-v1 2", + fields: &[ + req("viewId", "Id", ""), + req("width", "int", "1..=4096"), + req("height", "int", "1..=4096"), + req("format", "ViewFormat", ""), + req("rowStride", "int", "exactly 4 x width"), + req( + "pixelAspect", + "{numerator:int,denominator:int}", + "positive integers <= 65535", + ), + req("observationDelaySteps", "int", "0..=8"), + ], + }, + TypeSchema { + name: "ViewRef", + source: "state-media-v1 2", + fields: &[ + req("viewId", "Id", ""), + req( + "producedStep", + "U64", + "max(0, boundary - observationDelaySteps) for required sensory views", + ), + req( + "pixels", + "ArtifactRef", + "listed attachment; byteLength == rowStride x height", + ), + ], + }, + TypeSchema { + name: "AudioDescriptor", + source: "state-media-v1 2", + fields: &[ + req("streamId", "Id", ""), + req("sampleRate", "int", "8000..=192000"), + req("channels", "int", "1..=8"), + req("format", "AudioFormat", ""), + ], + }, + TypeSchema { + name: "AudioRef", + source: "state-media-v1 2", + fields: &[ + req("streamId", "Id", ""), + req("firstSample", "U64", "no overlap or rewind within an epoch"), + req("sampleFrames", "int", "0..=192000"), + req( + "samples", + "ArtifactRef", + "byteLength == sampleFrames x channels x 4, finite f32", + ), + req( + "discontinuity", + "bool", + "true on the first chunk after restore", + ), + ], + }, + TypeSchema { + name: "CaptureParams", + source: "state-media-v1 5", + fields: &[req("checkpointId", "Id", "")], + }, + TypeSchema { + name: "CaptureResult", + source: "state-media-v1 5", + fields: &[ + req("checkpointId", "Id", ""), + req("boundary", "U64", "the committed boundary"), + req("compatibilityDigest", "Digest", ""), + req( + "payload", + "ArtifactRef", + "listed attachment; digest required", + ), + ], + }, + TypeSchema { + name: "StageRestoreParams", + source: "state-media-v1 5", + fields: &[ + req("checkpointId", "Id", ""), + req("sourceScope", "Scope", "provenance, not the new handles"), + req("compatibilityDigest", "Digest", ""), + req("payload", "ArtifactRef", "newly imported; digest required"), + ], + }, + TypeSchema { + name: "StageRestoreResult", + source: "state-media-v1 5", + fields: &[ + req("checkpointId", "Id", ""), + req( + "restoreToken", + "Id", + "activates once, bound to scope and payload", + ), + ], + }, + TypeSchema { + name: "ActivateRestoreParams", + source: "state-media-v1 5", + fields: &[req("restoreToken", "Id", "")], + }, + TypeSchema { + name: "ActivateRestoreResult", + source: "state-media-v1 5", + fields: &[ + req("committedStep", "U64", ""), + req("checkpointId", "Id", ""), + opt( + "observation", + "WorldObservation|null", + "required from an environment, null from an agent", + ), + ], + }, + TypeSchema { + name: "SessionDescriptor", + source: "publishing-v1 3", + fields: &[ + req("sessionId", "Id", ""), + req("revision", "U64", ""), + req("compositionDigest", "Digest", ""), + req("schedulerId", "SchedulerId", ""), + req("environment", "EnvironmentDescriptor", ""), + req("taskSchema", "SchemaRef", ""), + req( + "agents", + "array", + "1..=4, unique agentId and portId, each portId declared by the environment", + ), + req("assets", "array", "unique id"), + ], + }, + TypeSchema { + name: "AgentDescriptor", + source: "publishing-v1 3", + fields: &[ + req("agentId", "Id", ""), + req("portId", "Id", ""), + req("profileDigest", "Digest", ""), + req("datasetDigest", "Digest", ""), + req( + "indexDigest", + "Digest", + "geometry mapping needs this, not neuronCount", + ), + req("neuronCount", "U64", ""), + req("rateRoles", "array", "<= 64, unique"), + req("supportedStimuli", "array", "unique"), + ], + }, + TypeSchema { + name: "CommittedSnapshot", + source: "publishing-v1 3", + fields: &[ + req("descriptorRevision", "U64", ""), + req("publisherIncarnation", "Id", ""), + req("scope", "Scope", "the committed boundary"), + req("episodeId", "Id", ""), + req("sequence", "U64", "monotonic within publisherIncarnation"), + req("worldTime", "RationalNs", ""), + req( + "agents", + "array", + "1..=4, unique agentId, telemetry in profile role order", + ), + req("progress", "TypedValue", ""), + req( + "media", + "{views:array,audio:array}", + "declared attachments held through publication admission", + ), + req("eventIds", "array", "unique, task order"), + ], + }, + TypeSchema { + name: "SnapshotAgent", + source: "publishing-v1 3", + fields: &[ + req("agentId", "Id", ""), + req("telemetry", "AgentTelemetry", ""), + opt( + "selectedDecision", + "TypedValue|null", + "null exactly at boundary 0", + ), + opt( + "appliedControls", + "PortControl|null", + "null exactly at boundary 0; the agent's assigned port", + ), + ], + }, + TypeSchema { + name: "TransitionTrace", + source: "step-v1 8", + fields: &[ + req("behaviour", "TraceBehaviour", "compared between runs"), + req( + "operational", + "TraceOperational", + "recorded, never compared: wall time and transport identities", + ), + ], + }, + TypeSchema { + name: "TraceBehaviour", + source: "step-v1 8", + fields: &[ + req("scope", "Scope", ""), + req( + "agents", + "array", + "sorted by agentId, independent of dispatch order", + ), + req("batchId", "Id", ""), + req("controlDigest", "Digest", ""), + req( + "acknowledgedBoundary", + "U64", + "the world boundary the environment acknowledged", + ), + req( + "observationBoundaries", + "array<{viewId:Id,producedStep:U64}>", + "sorted by viewId", + ), + req("outcomeIds", "array", "task outcome ids in task order"), + req("eventIds", "array", "task event ids in task order"), + req("publishedBoundary", "U64", ""), + ], + }, + TypeSchema { + name: "TraceAgent", + source: "step-v1 8", + fields: &[ + req("agentId", "Id", ""), + req("profileDigest", "Digest", ""), + req("ticksAdvanced", "U64", ""), + req("brainTicks", "U64", ""), + req("remainder", "RationalNs", ""), + req("decisionDigest", "Digest", ""), + req("committedStep", "U64", "the commit acknowledgment"), + ], + }, + TypeSchema { + name: "TraceOperational", + source: "step-v1 8", + fields: &[ + req("wallTimeNs", "U64", ""), + req( + "prepareRequestIds", + "array<{agentId:Id,requestId:DomainRequestId}>", + "", + ), + req("advanceRequestId", "DomainRequestId", ""), + req( + "commitRequestIds", + "array<{agentId:Id,requestId:DomainRequestId}>", + "", + ), + req("busCallIds", "array", "call-"), + req("deliveryIds", "array", "dlv- or own-"), + ], + }, +]; + +/// The schema set as canonical-JSON-ready data. +pub fn schema_set() -> Value { + let mut types: Vec<&TypeSchema> = SCHEMAS.iter().collect(); + types.sort_by_key(|t| t.name); + let mut enums: Vec<&EnumSchema> = ENUMS.iter().collect(); + enums.sort_by_key(|e| e.name); + let mut limits: Vec<&LimitSchema> = LIMITS.iter().collect(); + limits.sort_by_key(|l| l.name); + + obj(vec![ + ("contract", "fly-session-types".into()), + ("version", Value::from(SCHEMA_SET_VERSION)), + ( + "scalars", + obj(vec![ + ("Id", "^[a-z0-9][a-z0-9._-]{0,63}$".into()), + ( + "U64", + "\"0\" or [1-9][0-9]*, <= 18446744073709551615".into(), + ), + ("Digest", "64 lowercase hexadecimal digits (SHA-256)".into()), + ("BusCallId", "call-".into()), + ("DomainRequestId", "req-".into()), + ("OwnerToken", "dlv- or own-".into()), + ( + "ArtifactIdentity", + "storeId, artifactId and generation of a bus ArtifactRef".into(), + ), + ]), + ), + ( + "limits", + Value::Array( + limits + .iter() + .map(|l| { + obj(vec![ + ("name", l.name.into()), + ("value", Value::from(l.value)), + ("source", l.source.into()), + ]) + }) + .collect(), + ), + ), + ( + "enums", + Value::Array( + enums + .iter() + .map(|e| { + obj(vec![ + ("name", e.name.into()), + ("source", e.source.into()), + ( + "members", + Value::Array(e.members.iter().map(|m| (*m).into()).collect()), + ), + ]) + }) + .collect(), + ), + ), + ( + "types", + Value::Array( + types + .iter() + .map(|t| { + obj(vec![ + ("name", t.name.into()), + ("source", t.source.into()), + ( + "fields", + Value::Array( + t.fields + .iter() + .map(|f| { + obj(vec![ + ("name", f.name.into()), + ("kind", f.kind.into()), + ("required", Value::Bool(f.required)), + ("constraint", f.constraint.into()), + ]) + }) + .collect(), + ), + ), + ]) + }) + .collect(), + ), + ), + ]) +} + +/// The canonical JSON text of the schema set. +pub fn schema_set_json() -> Result { + canonical::canonicalize(&schema_set()) +} + +/// `contractDigest`: the SHA-256 of the canonical schema set. +pub fn contract_digest() -> String { + canonical::digest_of(&schema_set()).expect("the schema set is canonicalizable") +} diff --git a/services/flysim/crates/fly-session-types/src/seed.rs b/services/flysim/crates/fly-session-types/src/seed.rs new file mode 100644 index 0000000..fbc5002 --- /dev/null +++ b/services/flysim/crates/fly-session-types/src/seed.rs @@ -0,0 +1,70 @@ +//! `seed-derivation-v1`: independent per-agent seeds from one recorded master seed. +//! +//! The specification is `docs/design/session-framework/seed-derivation-v1.md`; this is its +//! reference implementation, and `fixtures/seed-vectors.json` its test vectors, which the +//! TypeScript package reproduces. + +use sha2::{Digest as _, Sha256}; + +use crate::canonical; +use crate::scalar::{Result, err, is_id}; + +/// The algorithm identity. It is part of composition identity: changing any byte of the +/// derivation requires a new id. +pub const ALGORITHM: &str = "seed-derivation-v1"; + +/// The domain separation prefix hashed before the inputs. +pub const PREFIX: &str = "flybrain/seed-derivation-v1"; + +/// The SHA-256 of the derivation material for one agent, lowercase hex. +pub fn material_digest(master_seed: u64, agent_id: &str) -> Result { + Ok(canonical::sha256_hex(&material(master_seed, agent_id)?)) +} + +/// The exact bytes hashed: the prefix, the master seed as a canonical `U64` decimal string and +/// the agent id, each followed by one `\n`. +pub fn material(master_seed: u64, agent_id: &str) -> Result> { + if !is_id(agent_id) { + return err("seed derivation: agentId is not a valid id"); + } + Ok(format!("{PREFIX}\n{master_seed}\n{agent_id}\n").into_bytes()) +} + +/// The signed 32-bit seed `Agent.Initialize` takes for `agent_id`. +/// +/// The digest is read as eight big-endian `u32` lanes; the first nonzero lane becomes the +/// seed, reinterpreted as two's-complement `i32`. Skipping zero lanes keeps the seed usable +/// by an xorshift generator, whose state must not be zero. If every lane were zero the +/// material is rehashed with a counter suffix, which no observed input has needed. +pub fn agent_seed(master_seed: u64, agent_id: &str) -> Result { + let mut material = material(master_seed, agent_id)?; + for round in 0u32..4 { + if round > 0 { + material.extend_from_slice(format!("{round}\n").as_bytes()); + } + let digest = Sha256::digest(&material); + for lane in digest.chunks_exact(4) { + let word = u32::from_be_bytes([lane[0], lane[1], lane[2], lane[3]]); + if word != 0 { + return Ok(word as i32); + } + } + } + err("seed derivation: every lane of four digests was zero") +} + +/// The seeds of a whole composition, in the order the agent ids are given. +/// +/// Equal ids deliberately derive equal seeds: "Identical explicit seeds are allowed only when +/// the experiment intentionally declares them" (workers-v1 section 2), so a composition with a +/// repeated agent id is refused here rather than silently sharing a seed. +pub fn composition_seeds(master_seed: u64, agent_ids: &[String]) -> Result> { + crate::scalar::require_unique( + agent_ids.iter().map(String::as_str), + "seed derivation: agentIds", + )?; + agent_ids + .iter() + .map(|id| agent_seed(master_seed, id)) + .collect() +} diff --git a/services/flysim/crates/fly-session-types/src/trace.rs b/services/flysim/crates/fly-session-types/src/trace.rs new file mode 100644 index 0000000..da48f85 --- /dev/null +++ b/services/flysim/crates/fly-session-types/src/trace.rs @@ -0,0 +1,486 @@ +//! The trace format of step-v1 section 8, split into behaviour and operational metadata. +//! +//! Section 8 requires a record, for every transition, of the scope, the Prepare request ids, +//! the agent/profile ids, tick counts and remainders, decision digests, the complete batch id +//! and control digest, the acknowledged world boundary, observation producing boundaries, task +//! event/outcome ids in order, every Commit acknowledgment and the published boundary. It then +//! requires that sequential, concurrent and reversed runs "match, excluding wall time, request +//! ids and other explicitly operational metadata". +//! +//! So this record has two halves. [`TraceBehaviour`] is what must match: it is ordered by +//! agent id rather than by completion order, so a reversed dispatch produces an identical +//! value. [`TraceOperational`] is what section 8 requires recording but excludes from the +//! comparison: wall time, the domain request ids, the bus callIds and the delivery ids. +//! [`TransitionTrace::behaviour_equals`] compares only the first half, and +//! [`TransitionTrace::behaviour_diff`] names the fields that differ. + +use flybus::wire::Fields; +use serde_json::Value; + +use crate::canonical; +use crate::scalar::{ + BusCallId, DomainRequestId, DomainType, OwnerToken, RationalNs, Result, Scope, err, is_digest, + is_id, list, obj, require_unique, u64_json, +}; +use crate::workers::{MAX_AGENTS, MAX_RATE_ROLES}; + +/// One agent's behaviour in one transition. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TraceAgent { + pub agent_id: String, + pub profile_digest: String, + pub ticks_advanced: u64, + pub brain_ticks: u64, + pub remainder: RationalNs, + pub decision_digest: String, + /// The boundary this agent acknowledged in its Commit reply. + pub committed_step: u64, +} + +/// One view's producing boundary, as observed in this transition. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TraceObservation { + pub view_id: String, + pub produced_step: u64, +} + +/// The fields two runs of the same transition must agree on. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TraceBehaviour { + pub scope: Scope, + /// Sorted by agent id, never by completion order. + pub agents: Vec, + pub batch_id: String, + pub control_digest: String, + pub acknowledged_boundary: u64, + /// Sorted by view id. + pub observation_boundaries: Vec, + /// Task outcome ids in task order. + pub outcome_ids: Vec, + /// Task event ids in task order. + pub event_ids: Vec, + pub published_boundary: u64, +} + +impl TraceBehaviour { + /// Sorts the order-free collections, so a trace recorded in completion order compares + /// equal to one recorded in dispatch order. + pub fn normalized(&self) -> TraceBehaviour { + let mut out = self.clone(); + out.agents.sort_by(|a, b| a.agent_id.cmp(&b.agent_id)); + out.observation_boundaries + .sort_by(|a, b| a.view_id.cmp(&b.view_id)); + out + } + + pub fn digest(&self) -> Result { + canonical::digest_of(&self.normalized().to_json()) + } +} + +impl DomainType for TraceBehaviour { + const TYPE_NAME: &'static str = "TraceBehaviour"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "TraceBehaviour")?; + let scope = Scope::from_json(f.value("scope")?)?; + let agents = list(&mut f, "agents", 1, MAX_AGENTS, |v| { + let mut a = Fields::new(v, "TraceBehaviour.agents")?; + let agent_id = a.id("agentId")?; + let profile_digest = a.string("profileDigest")?.to_owned(); + let ticks_advanced = a.u64_string("ticksAdvanced")?; + let brain_ticks = a.u64_string("brainTicks")?; + let remainder = RationalNs::from_json(a.value("remainder")?)?; + let decision_digest = a.string("decisionDigest")?.to_owned(); + let committed_step = a.u64_string("committedStep")?; + a.finish()?; + Ok(TraceAgent { + agent_id, + profile_digest, + ticks_advanced, + brain_ticks, + remainder, + decision_digest, + committed_step, + }) + })?; + let batch_id = f.id("batchId")?; + let control_digest = f.string("controlDigest")?.to_owned(); + let acknowledged_boundary = f.u64_string("acknowledgedBoundary")?; + let observation_boundaries = list( + &mut f, + "observationBoundaries", + 0, + crate::media::MAX_VIEWS * 2, + |v| { + let mut o = Fields::new(v, "TraceBehaviour.observationBoundaries")?; + let view_id = o.id("viewId")?; + let produced_step = o.u64_string("producedStep")?; + o.finish()?; + Ok(TraceObservation { + view_id, + produced_step, + }) + }, + )?; + let outcome_ids = crate::scalar::id_list(&mut f, "outcomeIds", 0, MAX_RATE_ROLES)?; + let event_ids = crate::scalar::id_list(&mut f, "eventIds", 0, MAX_RATE_ROLES)?; + let published_boundary = f.u64_string("publishedBoundary")?; + f.finish()?; + let b = TraceBehaviour { + scope, + agents, + batch_id, + control_digest, + acknowledged_boundary, + observation_boundaries, + outcome_ids, + event_ids, + published_boundary, + }; + b.validate()?; + Ok(b) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("scope", self.scope.to_json()), + ( + "agents", + Value::Array( + self.agents + .iter() + .map(|a| { + obj(vec![ + ("agentId", a.agent_id.clone().into()), + ("profileDigest", a.profile_digest.clone().into()), + ("ticksAdvanced", u64_json(a.ticks_advanced)), + ("brainTicks", u64_json(a.brain_ticks)), + ("remainder", a.remainder.to_json()), + ("decisionDigest", a.decision_digest.clone().into()), + ("committedStep", u64_json(a.committed_step)), + ]) + }) + .collect(), + ), + ), + ("batchId", self.batch_id.clone().into()), + ("controlDigest", self.control_digest.clone().into()), + ("acknowledgedBoundary", u64_json(self.acknowledged_boundary)), + ( + "observationBoundaries", + Value::Array( + self.observation_boundaries + .iter() + .map(|o| { + obj(vec![ + ("viewId", o.view_id.clone().into()), + ("producedStep", u64_json(o.produced_step)), + ]) + }) + .collect(), + ), + ), + ( + "outcomeIds", + Value::Array(self.outcome_ids.iter().map(|i| i.clone().into()).collect()), + ), + ( + "eventIds", + Value::Array(self.event_ids.iter().map(|i| i.clone().into()).collect()), + ), + ("publishedBoundary", u64_json(self.published_boundary)), + ]) + } + + fn validate(&self) -> Result<()> { + self.scope.validate()?; + if self.agents.is_empty() || self.agents.len() > MAX_AGENTS { + return err("TraceBehaviour: 1..=4 agents"); + } + require_unique( + self.agents.iter().map(|a| a.agent_id.as_str()), + "TraceBehaviour.agents", + )?; + for agent in &self.agents { + if !is_id(&agent.agent_id) { + return err("TraceBehaviour: agentId is not a valid id"); + } + if !is_digest(&agent.profile_digest) || !is_digest(&agent.decision_digest) { + return err("TraceBehaviour: agent digests must be 64 lowercase hex digits"); + } + agent.remainder.validate()?; + if agent.committed_step != self.scope.step + 1 { + return err( + "TraceBehaviour: every commit acknowledgment is the transition's next boundary", + ); + } + } + if !is_id(&self.batch_id) { + return err("TraceBehaviour: batchId is not a valid id"); + } + if !is_digest(&self.control_digest) { + return err("TraceBehaviour: controlDigest must be 64 lowercase hex digits"); + } + if self.acknowledged_boundary != self.scope.step + 1 { + return err("TraceBehaviour: the acknowledged boundary is scope.step + 1"); + } + if self.published_boundary != self.acknowledged_boundary { + return err( + "TraceBehaviour: the published boundary is the boundary every agent committed", + ); + } + require_unique( + self.observation_boundaries + .iter() + .map(|o| o.view_id.as_str()), + "TraceBehaviour.observationBoundaries", + )?; + require_unique( + self.event_ids.iter().map(String::as_str), + "TraceBehaviour.eventIds", + )?; + require_unique( + self.outcome_ids.iter().map(String::as_str), + "TraceBehaviour.outcomeIds", + )?; + Ok(()) + } +} + +/// One agent's domain request id for one phase. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TraceRequest { + pub agent_id: String, + pub request_id: DomainRequestId, +} + +/// What step-v1 section 8 records but excludes from the comparison. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TraceOperational { + /// Wall time is for pacing, health and presentation only (step-v1 section 5). + pub wall_time_ns: u64, + pub prepare_request_ids: Vec, + pub advance_request_id: DomainRequestId, + pub commit_request_ids: Vec, + /// The transport correlation ids this transition happened to use. A safe retry changes + /// these and nothing in [`TraceBehaviour`]. + pub bus_call_ids: Vec, + pub delivery_ids: Vec, +} + +impl DomainType for TraceOperational { + const TYPE_NAME: &'static str = "TraceOperational"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "TraceOperational")?; + let wall_time_ns = f.u64_string("wallTimeNs")?; + let read_requests = |v: &Value| -> Result { + let mut r = Fields::new(v, "TraceOperational request")?; + let agent_id = r.id("agentId")?; + let request_id = DomainRequestId::read(&mut r, "requestId")?; + r.finish()?; + Ok(TraceRequest { + agent_id, + request_id, + }) + }; + let prepare_request_ids = list(&mut f, "prepareRequestIds", 1, MAX_AGENTS, read_requests)?; + let advance_request_id = DomainRequestId::read(&mut f, "advanceRequestId")?; + let commit_request_ids = list(&mut f, "commitRequestIds", 1, MAX_AGENTS, read_requests)?; + let bus_call_ids = list(&mut f, "busCallIds", 0, 64, |v| match v.as_str() { + Some(s) => BusCallId::parse(s), + None => err("every busCallId must be a string"), + })?; + let delivery_ids = list(&mut f, "deliveryIds", 0, 64, |v| match v.as_str() { + Some(s) => OwnerToken::parse(s), + None => err("every deliveryId must be a string"), + })?; + f.finish()?; + let o = TraceOperational { + wall_time_ns, + prepare_request_ids, + advance_request_id, + commit_request_ids, + bus_call_ids, + delivery_ids, + }; + o.validate()?; + Ok(o) + } + + fn to_json(&self) -> Value { + let requests = |items: &[TraceRequest]| { + Value::Array( + items + .iter() + .map(|r| { + obj(vec![ + ("agentId", r.agent_id.clone().into()), + ("requestId", r.request_id.to_json()), + ]) + }) + .collect(), + ) + }; + obj(vec![ + ("wallTimeNs", u64_json(self.wall_time_ns)), + ("prepareRequestIds", requests(&self.prepare_request_ids)), + ("advanceRequestId", self.advance_request_id.to_json()), + ("commitRequestIds", requests(&self.commit_request_ids)), + ( + "busCallIds", + Value::Array(self.bus_call_ids.iter().map(BusCallId::to_json).collect()), + ), + ( + "deliveryIds", + Value::Array( + self.delivery_ids + .iter() + .map(|t| Value::String(t.as_str().to_owned())) + .collect(), + ), + ), + ]) + } + + fn validate(&self) -> Result<()> { + require_unique( + self.prepare_request_ids.iter().map(|r| r.agent_id.as_str()), + "TraceOperational.prepareRequestIds", + )?; + require_unique( + self.commit_request_ids.iter().map(|r| r.agent_id.as_str()), + "TraceOperational.commitRequestIds", + )?; + require_unique( + self.bus_call_ids.iter().map(BusCallId::as_str), + "TraceOperational.busCallIds", + )?; + require_unique( + self.delivery_ids.iter().map(OwnerToken::as_str), + "TraceOperational.deliveryIds", + )?; + Ok(()) + } +} + +/// One transition's trace: behaviour plus operational metadata. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct TransitionTrace { + pub behaviour: TraceBehaviour, + pub operational: TraceOperational, +} + +impl TransitionTrace { + /// Behaviour equality: the comparison step-v1 section 8 asks for. + pub fn behaviour_equals(&self, other: &TransitionTrace) -> bool { + self.behaviour.normalized() == other.behaviour.normalized() + } + + /// The behaviour fields that differ, named. Empty when [`Self::behaviour_equals`] holds. + pub fn behaviour_diff(&self, other: &TransitionTrace) -> Vec { + let (a, b) = (self.behaviour.normalized(), other.behaviour.normalized()); + let mut out = Vec::new(); + if a.scope != b.scope { + out.push(format!("scope: {:?} vs {:?}", a.scope, b.scope)); + } + if a.batch_id != b.batch_id { + out.push(format!("batchId: {} vs {}", a.batch_id, b.batch_id)); + } + if a.control_digest != b.control_digest { + out.push("controlDigest differs".to_owned()); + } + if a.acknowledged_boundary != b.acknowledged_boundary { + out.push(format!( + "acknowledgedBoundary: {} vs {}", + a.acknowledged_boundary, b.acknowledged_boundary + )); + } + if a.published_boundary != b.published_boundary { + out.push(format!( + "publishedBoundary: {} vs {}", + a.published_boundary, b.published_boundary + )); + } + if a.observation_boundaries != b.observation_boundaries { + out.push("observationBoundaries differ".to_owned()); + } + if a.outcome_ids != b.outcome_ids { + out.push("outcomeIds differ".to_owned()); + } + if a.event_ids != b.event_ids { + out.push("eventIds differ".to_owned()); + } + let ids_a: Vec<&str> = a.agents.iter().map(|x| x.agent_id.as_str()).collect(); + let ids_b: Vec<&str> = b.agents.iter().map(|x| x.agent_id.as_str()).collect(); + if ids_a != ids_b { + out.push(format!( + "agents: [{}] vs [{}]", + ids_a.join(", "), + ids_b.join(", ") + )); + } else { + for (left, right) in a.agents.iter().zip(&b.agents) { + if left != right { + out.push(format!("agent {}: behaviour differs", left.agent_id)); + } + } + } + out + } + + /// Two whole runs agree on behaviour, transition by transition. + pub fn runs_equal(left: &[TransitionTrace], right: &[TransitionTrace]) -> bool { + left.len() == right.len() && left.iter().zip(right).all(|(a, b)| a.behaviour_equals(b)) + } +} + +impl DomainType for TransitionTrace { + const TYPE_NAME: &'static str = "TransitionTrace"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "TransitionTrace")?; + let behaviour = TraceBehaviour::from_json(f.value("behaviour")?)?; + let operational = TraceOperational::from_json(f.value("operational")?)?; + f.finish()?; + let t = TransitionTrace { + behaviour, + operational, + }; + t.validate()?; + Ok(t) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("behaviour", self.behaviour.to_json()), + ("operational", self.operational.to_json()), + ]) + } + + fn validate(&self) -> Result<()> { + self.behaviour.validate()?; + self.operational.validate()?; + let behaviour_agents: Vec<&str> = self + .behaviour + .agents + .iter() + .map(|a| a.agent_id.as_str()) + .collect(); + for phase in [ + &self.operational.prepare_request_ids, + &self.operational.commit_request_ids, + ] { + for request in phase { + if !behaviour_agents.contains(&request.agent_id.as_str()) { + return err(format!( + "TransitionTrace: request recorded for {:?}, which is not in the transition", + request.agent_id + )); + } + } + } + Ok(()) + } +} diff --git a/services/flysim/crates/fly-session-types/src/workers.rs b/services/flysim/crates/fly-session-types/src/workers.rs new file mode 100644 index 0000000..a62cdb7 --- /dev/null +++ b/services/flysim/crates/fly-session-types/src/workers.rs @@ -0,0 +1,2405 @@ +//! The closed enums and method payloads of workers-v1. +//! +//! Every bound stated in that document is a constant here, and every constant is named in the +//! canonical schema set, so a bound cannot be changed without changing `contractDigest`. + +use flybus::wire::Fields; +use serde_json::Value; + +use crate::media::{AudioDescriptor, MAX_VIEWS, ViewDescriptor, ViewRef, audio_list, view_list}; +use crate::scalar::{ + DomainRequestId, DomainType, RationalNs, Result, SchemaRef, TypedValue, constant, + constant_true, enumeration, err, finite, finite_in, i32_field, id_list, is_digest, is_id, list, + obj, require_same_order, require_unique, u64_json, +}; + +/// First session composition limit: 4 agents (ipc-v1 section 2). +pub const MAX_AGENTS: usize = 4; +/// First session composition limit: 4 ports. +pub const MAX_PORTS: usize = 4; +/// 64 rate roles per agent. +pub const MAX_RATE_ROLES: usize = 64; +/// Arrays of stimuli or rewards are bounded to 64 per operation (workers-v1 section 1). +pub const MAX_STIMULI: usize = 64; +/// Arrays of stimuli or rewards are bounded to 64 per operation. +pub const MAX_REWARDS: usize = 64; +/// Controller buttons: <=32, unique, fixed order. +pub const MAX_BUTTONS: usize = 32; +/// Controller axes: <=16. +pub const MAX_AXES: usize = 16; +/// Worker.Acknowledge carries 1..=16 request ids, and 16 bounds the unacknowledged replies. +pub const MAX_ACKNOWLEDGE: usize = 16; +/// `engineFrame` is a backend-defined counter of at most 64 characters. +pub const MAX_ENGINE_FRAME_LEN: usize = 64; +/// Negotiated capability ids. Not a stated bound; recorded in the schema set. +pub const MAX_CAPABILITIES: usize = 32; +/// Supported majors in Worker.Hello. Not a stated bound; recorded in the schema set. +pub const MAX_SUPPORTED_MAJORS: usize = 8; +/// Domain error messages are <=512 code points (ipc-v1 section 7). +pub const MAX_MESSAGE_CODE_POINTS: usize = 512; + +/// A worker's negotiated role. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum Role { + Agent, + Environment, + Coordinator, +} + +impl Role { + pub fn as_str(self) -> &'static str { + match self { + Role::Agent => "agent", + Role::Environment => "environment", + Role::Coordinator => "coordinator", + } + } + + pub fn parse(s: &str) -> Result { + match s { + "agent" => Ok(Role::Agent), + "environment" => Ok(Role::Environment), + "coordinator" => Ok(Role::Coordinator), + _ => err("role must be agent, environment or coordinator"), + } + } + + pub const ALL: &'static [&'static str] = &["agent", "environment", "coordinator"]; +} + +/// The worker phases of Worker.Status (ipc-v1 section 4). +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub enum WorkerState { + Uninitialized, + Ready, + Preparing, + Prepared, + Advancing, + Committing, + Capturing, + StagedRestore, + Restoring, + Failed, + Stopping, +} + +impl WorkerState { + pub const ALL: &'static [&'static str] = &[ + "uninitialized", + "ready", + "preparing", + "prepared", + "advancing", + "committing", + "capturing", + "staged-restore", + "restoring", + "failed", + "stopping", + ]; + + pub fn as_str(self) -> &'static str { + match self { + WorkerState::Uninitialized => "uninitialized", + WorkerState::Ready => "ready", + WorkerState::Preparing => "preparing", + WorkerState::Prepared => "prepared", + WorkerState::Advancing => "advancing", + WorkerState::Committing => "committing", + WorkerState::Capturing => "capturing", + WorkerState::StagedRestore => "staged-restore", + WorkerState::Restoring => "restoring", + WorkerState::Failed => "failed", + WorkerState::Stopping => "stopping", + } + } + + pub fn parse(s: &str) -> Result { + Ok(match s { + "uninitialized" => WorkerState::Uninitialized, + "ready" => WorkerState::Ready, + "preparing" => WorkerState::Preparing, + "prepared" => WorkerState::Prepared, + "advancing" => WorkerState::Advancing, + "committing" => WorkerState::Committing, + "capturing" => WorkerState::Capturing, + "staged-restore" => WorkerState::StagedRestore, + "restoring" => WorkerState::Restoring, + "failed" => WorkerState::Failed, + "stopping" => WorkerState::Stopping, + _ => return err("state is not one of the eleven worker phases"), + }) + } +} + +/// How an environment recovers. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Recovery { + ExactCheckpoint, + EpisodeRestart, +} + +impl Recovery { + pub const ALL: &'static [&'static str] = &["exact-checkpoint", "episode-restart"]; + + pub fn as_str(self) -> &'static str { + match self { + Recovery::ExactCheckpoint => "exact-checkpoint", + Recovery::EpisodeRestart => "episode-restart", + } + } + + pub fn parse(s: &str) -> Result { + match s { + "exact-checkpoint" => Ok(Recovery::ExactCheckpoint), + "episode-restart" => Ok(Recovery::EpisodeRestart), + _ => err("recovery must be exact-checkpoint or episode-restart"), + } + } +} + +/// How repeatable an environment claims to be. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Determinism { + FixedBuild, + Unverified, +} + +impl Determinism { + pub const ALL: &'static [&'static str] = &["fixed-build", "unverified"]; + + pub fn as_str(self) -> &'static str { + match self { + Determinism::FixedBuild => "fixed-build", + Determinism::Unverified => "unverified", + } + } + + pub fn parse(s: &str) -> Result { + match s { + "fixed-build" => Ok(Determinism::FixedBuild), + "unverified" => Ok(Determinism::Unverified), + _ => err("determinism must be fixed-build or unverified"), + } + } +} + +/// An axis range. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum AxisRange { + Bipolar, + Unit, +} + +impl AxisRange { + pub const ALL: &'static [&'static str] = &["bipolar", "unit"]; + + pub fn as_str(self) -> &'static str { + match self { + AxisRange::Bipolar => "bipolar", + AxisRange::Unit => "unit", + } + } + + pub fn parse(s: &str) -> Result { + match s { + "bipolar" => Ok(AxisRange::Bipolar), + "unit" => Ok(AxisRange::Unit), + _ => err("range must be bipolar or unit"), + } + } + + pub fn bounds(self) -> (f64, f64) { + match self { + AxisRange::Bipolar => (-1.0, 1.0), + AxisRange::Unit => (0.0, 1.0), + } + } + + pub fn contains(self, value: f64) -> bool { + let (lo, hi) = self.bounds(); + value.is_finite() && (lo..=hi).contains(&value) + } +} + +// --------------------------------------------------------------------------------------------- +// Shared data model (workers-v1 section 1) + +/// `AssetRef`: persistent installed content. Never a path or a URL, and never a transient +/// bus `ArtifactRef`. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct AssetRef { + pub id: String, + pub digest: String, + pub byte_length: u64, + pub format: String, +} + +impl DomainType for AssetRef { + const TYPE_NAME: &'static str = "AssetRef"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "AssetRef")?; + let id = f.id("id")?; + let digest = f.string("digest")?.to_owned(); + let byte_length = f.u64_string("byteLength")?; + let format = f.id("format")?; + f.finish()?; + let a = AssetRef { + id, + digest, + byte_length, + format, + }; + a.validate()?; + Ok(a) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("id", self.id.clone().into()), + ("digest", self.digest.clone().into()), + ("byteLength", u64_json(self.byte_length)), + ("format", self.format.clone().into()), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.id) { + return err("AssetRef: id is not a valid id"); + } + if !is_digest(&self.digest) { + return err("AssetRef: digest must be 64 lowercase hex digits"); + } + if self.byte_length == 0 { + return err("AssetRef: byteLength must be positive"); + } + if !is_id(&self.format) { + return err("AssetRef: format is not a valid id"); + } + Ok(()) + } +} + +/// `SensoryInput`: the views this agent may consume at one boundary, plus optional structured +/// input. A pixel-only profile rejects non-null structured input; that check needs the +/// profile, so it is [`SensoryInput::validate_for_profile`]. +#[derive(Clone, Debug, PartialEq)] +pub struct SensoryInput { + pub boundary: u64, + pub views: Vec, + pub structured: Option, +} + +impl SensoryInput { + /// Views and structured input are separate capabilities (workers-v1 section 1). + pub fn validate_for_profile(&self, structured_sensing: bool) -> Result<()> { + self.validate()?; + if self.structured.is_some() && !structured_sensing { + return err("SensoryInput: a pixel-only profile rejects non-null structured input"); + } + Ok(()) + } + + /// Required sensory views must be produced at the boundary the descriptor's declared + /// delay implies, and their artifacts must have the descriptor's byte shape. + pub fn validate_against(&self, descriptors: &[ViewDescriptor]) -> Result<()> { + for view in &self.views { + let descriptor = descriptors + .iter() + .find(|d| d.view_id == view.view_id) + .ok_or_else(|| { + crate::scalar::wire_err(format!( + "SensoryInput: view {:?} is not declared by the environment", + view.view_id + )) + })?; + view.validate_against(descriptor, Some(self.boundary))?; + } + Ok(()) + } +} + +impl DomainType for SensoryInput { + const TYPE_NAME: &'static str = "SensoryInput"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "SensoryInput")?; + let boundary = f.u64_string("boundary")?; + let views = view_list(&mut f, "views")?; + let structured = TypedValue::nullable_from_json(f.value("structured")?)?; + f.finish()?; + let s = SensoryInput { + boundary, + views, + structured, + }; + s.validate()?; + Ok(s) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("boundary", u64_json(self.boundary)), + ( + "views", + Value::Array(self.views.iter().map(ViewRef::to_json).collect()), + ), + ( + "structured", + TypedValue::nullable_to_json(self.structured.as_ref()), + ), + ]) + } + + fn validate(&self) -> Result<()> { + if self.views.len() > MAX_VIEWS { + return err("SensoryInput: at most 8 views per sensory input"); + } + require_unique( + self.views.iter().map(|v| v.view_id.as_str()), + "SensoryInput.views", + )?; + for view in &self.views { + view.validate()?; + if view.produced_step > self.boundary { + return err(format!( + "SensoryInput: view {:?} was produced after the observed boundary", + view.view_id + )); + } + } + if let Some(structured) = &self.structured { + structured.validate()?; + } + Ok(()) + } +} + +/// `Stimulus`: a profile-declared stimulation kind with a positive finite duration. +#[derive(Clone, Debug, PartialEq)] +pub struct Stimulus { + pub id: String, + pub kind_id: String, + pub duration_ms: f64, +} + +impl DomainType for Stimulus { + const TYPE_NAME: &'static str = "Stimulus"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "Stimulus")?; + let id = f.id("id")?; + let kind_id = f.id("kindId")?; + let duration_ms = finite(&mut f, "durationMs")?; + f.finish()?; + let s = Stimulus { + id, + kind_id, + duration_ms, + }; + s.validate()?; + Ok(s) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("id", self.id.clone().into()), + ("kindId", self.kind_id.clone().into()), + ("durationMs", Value::from(self.duration_ms)), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.id) || !is_id(&self.kind_id) { + return err("Stimulus: id and kindId must be valid ids"); + } + if !self.duration_ms.is_finite() || self.duration_ms <= 0.0 { + return err("Stimulus: durationMs must be finite and > 0"); + } + Ok(()) + } +} + +/// `Reward`: a finite value attributed to one task event. +#[derive(Clone, Debug, PartialEq)] +pub struct Reward { + pub event_id: String, + pub rule_id: String, + pub value: f64, +} + +impl Reward { + /// Shipped positive-only task profiles reject negatives (workers-v1 section 1). + pub fn validate_for_profile(&self, positive_only: bool) -> Result<()> { + self.validate()?; + if positive_only && self.value < 0.0 { + return err("Reward: this task profile is positive-only and rejects a negative value"); + } + Ok(()) + } +} + +impl DomainType for Reward { + const TYPE_NAME: &'static str = "Reward"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "Reward")?; + let event_id = f.id("eventId")?; + let rule_id = f.id("ruleId")?; + let v = finite(&mut f, "value")?; + f.finish()?; + let r = Reward { + event_id, + rule_id, + value: v, + }; + r.validate()?; + Ok(r) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("eventId", self.event_id.clone().into()), + ("ruleId", self.rule_id.clone().into()), + ("value", Value::from(self.value)), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.event_id) || !is_id(&self.rule_id) { + return err("Reward: eventId and ruleId must be valid ids"); + } + if !self.value.is_finite() { + return err("Reward: value must be finite"); + } + Ok(()) + } +} + +/// One tracked role's rate. +#[derive(Clone, Debug, PartialEq)] +pub struct RateSample { + pub role_id: String, + pub hz: f64, +} + +/// Learning telemetry. +#[derive(Clone, Debug, PartialEq)] +pub struct LearningTelemetry { + pub enabled: bool, + pub updates: u64, + pub changed: u64, + pub signal: f64, +} + +/// `AgentTelemetry`: rates in profile-defined order, unique by role id, at most 64. +#[derive(Clone, Debug, PartialEq)] +pub struct AgentTelemetry { + pub brain_ticks: u64, + pub population_rate_hz: f64, + pub rates: Vec, + pub learning: LearningTelemetry, +} + +impl AgentTelemetry { + /// Rates are "in profile-defined order": the check needs the profile's role list. + pub fn validate_against_roles(&self, role_order: &[String]) -> Result<()> { + self.validate()?; + require_same_order( + self.rates.iter().map(|r| r.role_id.as_str()), + role_order.iter().map(String::as_str), + "AgentTelemetry.rates", + ) + } +} + +impl DomainType for AgentTelemetry { + const TYPE_NAME: &'static str = "AgentTelemetry"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "AgentTelemetry")?; + let brain_ticks = f.u64_string("brainTicks")?; + let population_rate_hz = finite_in(&mut f, "populationRateHz", 0.0, f64::MAX)?; + let rates = list(&mut f, "rates", 0, MAX_RATE_ROLES, |v| { + let mut r = Fields::new(v, "AgentTelemetry.rates")?; + let role_id = r.id("roleId")?; + let hz = finite_in(&mut r, "hz", 0.0, f64::MAX)?; + r.finish()?; + Ok(RateSample { role_id, hz }) + })?; + let learning = { + let v = f.value("learning")?; + let mut l = Fields::new(v, "AgentTelemetry.learning")?; + let enabled = l.boolean("enabled")?; + let updates = l.u64_string("updates")?; + let changed = l.u64_string("changed")?; + let signal = finite(&mut l, "signal")?; + l.finish()?; + LearningTelemetry { + enabled, + updates, + changed, + signal, + } + }; + f.finish()?; + let t = AgentTelemetry { + brain_ticks, + population_rate_hz, + rates, + learning, + }; + t.validate()?; + Ok(t) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("brainTicks", u64_json(self.brain_ticks)), + ("populationRateHz", Value::from(self.population_rate_hz)), + ( + "rates", + Value::Array( + self.rates + .iter() + .map(|r| { + obj(vec![ + ("roleId", r.role_id.clone().into()), + ("hz", Value::from(r.hz)), + ]) + }) + .collect(), + ), + ), + ( + "learning", + obj(vec![ + ("enabled", Value::Bool(self.learning.enabled)), + ("updates", u64_json(self.learning.updates)), + ("changed", u64_json(self.learning.changed)), + ("signal", Value::from(self.learning.signal)), + ]), + ), + ]) + } + + fn validate(&self) -> Result<()> { + if self.rates.len() > MAX_RATE_ROLES { + return err("AgentTelemetry: at most 64 rate roles per agent"); + } + require_unique( + self.rates.iter().map(|r| r.role_id.as_str()), + "AgentTelemetry.rates", + )?; + if !self.population_rate_hz.is_finite() || self.population_rate_hz < 0.0 { + return err("AgentTelemetry: populationRateHz must be finite and nonnegative"); + } + for rate in &self.rates { + if !is_id(&rate.role_id) { + return err("AgentTelemetry: roleId is not a valid id"); + } + if !rate.hz.is_finite() || rate.hz < 0.0 { + return err("AgentTelemetry: rates must be finite and nonnegative"); + } + } + if self.learning.changed > self.learning.updates { + return err("AgentTelemetry: learning.changed cannot exceed learning.updates"); + } + if !self.learning.signal.is_finite() { + return err("AgentTelemetry: learning.signal must be finite"); + } + Ok(()) + } +} + +/// A bounded, unique, order-preserving stimulus array. +pub fn stimulus_list(f: &mut Fields<'_>, key: &'static str) -> Result> { + let items = list(f, key, 0, MAX_STIMULI, Stimulus::from_json)?; + require_unique(items.iter().map(|s| s.id.as_str()), key)?; + Ok(items) +} + +/// A bounded, unique, order-preserving reward array. +pub fn reward_list(f: &mut Fields<'_>, key: &'static str) -> Result> { + let items = list(f, key, 0, MAX_REWARDS, Reward::from_json)?; + require_unique(items.iter().map(|r| r.event_id.as_str()), key)?; + Ok(items) +} + +// --------------------------------------------------------------------------------------------- +// Agent methods (workers-v1 section 2) + +/// `Agent.Initialize` params. +#[derive(Clone, Debug, PartialEq)] +pub struct AgentInitializeParams { + pub agent_id: String, + pub profile: AssetRef, + pub seed: i32, + pub initial_input: SensoryInput, + pub initial_decision_context: TypedValue, + pub worker_threads: u64, +} + +impl DomainType for AgentInitializeParams { + const TYPE_NAME: &'static str = "AgentInitializeParams"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "AgentInitializeParams")?; + let agent_id = f.id("agentId")?; + let profile = AssetRef::from_json(f.value("profile")?)?; + let seed = i32_field(&mut f, "seed")?; + let initial_input = SensoryInput::from_json(f.value("initialInput")?)?; + let initial_decision_context = TypedValue::from_json(f.value("initialDecisionContext")?)?; + let worker_threads = f.int("workerThreads", 1, 4_096)?; + f.finish()?; + let p = AgentInitializeParams { + agent_id, + profile, + seed, + initial_input, + initial_decision_context, + worker_threads, + }; + p.validate()?; + Ok(p) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("agentId", self.agent_id.clone().into()), + ("profile", self.profile.to_json()), + ("seed", Value::from(i64::from(self.seed))), + ("initialInput", self.initial_input.to_json()), + ( + "initialDecisionContext", + self.initial_decision_context.to_json(), + ), + ("workerThreads", Value::from(self.worker_threads)), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.agent_id) { + return err("AgentInitializeParams: agentId is not a valid id"); + } + self.profile.validate()?; + self.initial_input.validate()?; + self.initial_decision_context.validate()?; + if self.worker_threads < 1 { + return err("AgentInitializeParams: workerThreads must be an integer >= 1"); + } + Ok(()) + } +} + +/// `Agent.Initialize` result. Scope is the new epoch at step 0, so `committedStep` is `"0"`. +#[derive(Clone, Debug, PartialEq)] +pub struct AgentInitializeResult { + pub agent_id: String, + pub profile_digest: String, + pub tick_duration: RationalNs, + pub warmup_ticks: u64, + pub committed_step: u64, + pub decision_context_digest: String, + pub telemetry: AgentTelemetry, +} + +impl DomainType for AgentInitializeResult { + const TYPE_NAME: &'static str = "AgentInitializeResult"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "AgentInitializeResult")?; + let agent_id = f.id("agentId")?; + let profile_digest = f.string("profileDigest")?.to_owned(); + let tick_duration = RationalNs::from_json(f.value("tickDuration")?)?; + let warmup_ticks = f.u64_string("warmupTicks")?; + 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 = AgentInitializeResult { + agent_id, + profile_digest, + tick_duration, + warmup_ticks, + committed_step, + decision_context_digest, + telemetry, + }; + r.validate()?; + Ok(r) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("agentId", self.agent_id.clone().into()), + ("profileDigest", self.profile_digest.clone().into()), + ("tickDuration", self.tick_duration.to_json()), + ("warmupTicks", u64_json(self.warmup_ticks)), + ("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("AgentInitializeResult: agentId is not a valid id"); + } + if !is_digest(&self.profile_digest) || !is_digest(&self.decision_context_digest) { + return err("AgentInitializeResult: digests must be 64 lowercase hex digits"); + } + self.tick_duration.validate()?; + self.tick_duration + .require_positive("AgentInitializeResult.tickDuration")?; + if self.committed_step != 0 { + return err("AgentInitializeResult: committedStep must be \"0\""); + } + self.telemetry.validate() + } +} + +/// `Agent.Prepare` params. +#[derive(Clone, Debug, PartialEq)] +pub struct PrepareParams { + pub agent_id: String, + pub profile_digest: String, + pub interval: RationalNs, + pub decision_context_digest: String, + pub pre_step_stimulations: Vec, +} + +impl DomainType for PrepareParams { + const TYPE_NAME: &'static str = "PrepareParams"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "PrepareParams")?; + let agent_id = f.id("agentId")?; + let profile_digest = f.string("profileDigest")?.to_owned(); + let interval = RationalNs::from_json(f.value("interval")?)?; + let decision_context_digest = f.string("decisionContextDigest")?.to_owned(); + let pre_step_stimulations = stimulus_list(&mut f, "preStepStimulations")?; + f.finish()?; + let p = PrepareParams { + agent_id, + profile_digest, + interval, + decision_context_digest, + pre_step_stimulations, + }; + p.validate()?; + Ok(p) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("agentId", self.agent_id.clone().into()), + ("profileDigest", self.profile_digest.clone().into()), + ("interval", self.interval.to_json()), + ( + "decisionContextDigest", + self.decision_context_digest.clone().into(), + ), + ( + "preStepStimulations", + Value::Array( + self.pre_step_stimulations + .iter() + .map(Stimulus::to_json) + .collect(), + ), + ), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.agent_id) { + return err("PrepareParams: agentId is not a valid id"); + } + if !is_digest(&self.profile_digest) || !is_digest(&self.decision_context_digest) { + return err("PrepareParams: digests must be 64 lowercase hex digits"); + } + self.interval.validate()?; + self.interval.require_positive("PrepareParams.interval")?; + if self.pre_step_stimulations.len() > MAX_STIMULI { + return err("PrepareParams: at most 64 stimuli per operation"); + } + require_unique( + self.pre_step_stimulations.iter().map(|s| s.id.as_str()), + "PrepareParams.preStepStimulations", + )?; + for stimulus in &self.pre_step_stimulations { + stimulus.validate()?; + } + Ok(()) + } +} + +/// `Agent.Prepare` result. +#[derive(Clone, Debug, PartialEq)] +pub struct PreparedDecision { + pub agent_id: String, + pub ticks_advanced: u64, + pub brain_ticks: u64, + pub remainder: RationalNs, + pub decision: TypedValue, +} + +impl PreparedDecision { + /// The remainder is always `>= 0` and `< one model tick` (step-v1 section 5). + pub fn validate_remainder(&self, tick_duration: &RationalNs) -> Result<()> { + tick_duration.require_positive("tickDuration")?; + if self.remainder >= *tick_duration { + return err("PreparedDecision: remainder must be less than one model tick"); + } + Ok(()) + } +} + +impl DomainType for PreparedDecision { + const TYPE_NAME: &'static str = "PreparedDecision"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "PreparedDecision")?; + let agent_id = f.id("agentId")?; + let ticks_advanced = f.u64_string("ticksAdvanced")?; + let brain_ticks = f.u64_string("brainTicks")?; + let remainder = RationalNs::from_json(f.value("remainder")?)?; + let decision = TypedValue::from_json(f.value("decision")?)?; + f.finish()?; + let d = PreparedDecision { + agent_id, + ticks_advanced, + brain_ticks, + remainder, + decision, + }; + d.validate()?; + Ok(d) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("agentId", self.agent_id.clone().into()), + ("ticksAdvanced", u64_json(self.ticks_advanced)), + ("brainTicks", u64_json(self.brain_ticks)), + ("remainder", self.remainder.to_json()), + ("decision", self.decision.to_json()), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.agent_id) { + return err("PreparedDecision: agentId is not a valid id"); + } + if self.ticks_advanced > self.brain_ticks { + return err("PreparedDecision: ticksAdvanced cannot exceed the total brainTicks"); + } + self.remainder.validate()?; + self.decision.validate() + } +} + +/// `Agent.Commit` params. `nextInput.boundary` is `k+1`, checked against the request scope by +/// [`CommitParams::validate_against_scope`]. +#[derive(Clone, Debug, PartialEq)] +pub struct CommitParams { + pub agent_id: String, + pub prepared_request_id: DomainRequestId, + pub next_input: SensoryInput, + pub next_decision_context: TypedValue, + pub rewards: Vec, + pub task_stimulations: Vec, +} + +impl CommitParams { + /// The commit of transition `k -> k+1` carries `scope.step = k` and the input for `k+1`. + pub fn validate_against_scope(&self, scope: &crate::scalar::Scope) -> Result<()> { + self.validate()?; + let expected = scope + .step + .checked_add(1) + .ok_or_else(|| crate::scalar::wire_err("CommitParams: step overflows U64"))?; + if self.next_input.boundary != expected { + return err(format!( + "CommitParams: nextInput.boundary must be {expected} for scope.step {}", + scope.step + )); + } + Ok(()) + } +} + +impl DomainType for CommitParams { + const TYPE_NAME: &'static str = "CommitParams"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "CommitParams")?; + let agent_id = f.id("agentId")?; + let prepared_request_id = DomainRequestId::read(&mut f, "preparedRequestId")?; + let next_input = SensoryInput::from_json(f.value("nextInput")?)?; + let next_decision_context = TypedValue::from_json(f.value("nextDecisionContext")?)?; + let rewards = reward_list(&mut f, "rewards")?; + let task_stimulations = stimulus_list(&mut f, "taskStimulations")?; + f.finish()?; + let p = CommitParams { + agent_id, + prepared_request_id, + next_input, + next_decision_context, + rewards, + task_stimulations, + }; + p.validate()?; + Ok(p) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("agentId", self.agent_id.clone().into()), + ("preparedRequestId", self.prepared_request_id.to_json()), + ("nextInput", self.next_input.to_json()), + ("nextDecisionContext", self.next_decision_context.to_json()), + ( + "rewards", + Value::Array(self.rewards.iter().map(Reward::to_json).collect()), + ), + ( + "taskStimulations", + Value::Array( + self.task_stimulations + .iter() + .map(Stimulus::to_json) + .collect(), + ), + ), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.agent_id) { + return err("CommitParams: agentId is not a valid id"); + } + self.next_input.validate()?; + self.next_decision_context.validate()?; + if self.rewards.len() > MAX_REWARDS || self.task_stimulations.len() > MAX_STIMULI { + return err("CommitParams: at most 64 rewards and 64 stimuli per operation"); + } + require_unique( + self.rewards.iter().map(|r| r.event_id.as_str()), + "CommitParams.rewards", + )?; + require_unique( + self.task_stimulations.iter().map(|s| s.id.as_str()), + "CommitParams.taskStimulations", + )?; + for reward in &self.rewards { + reward.validate()?; + } + for stimulus in &self.task_stimulations { + stimulus.validate()?; + } + Ok(()) + } +} + +/// `Agent.Commit` result. +#[derive(Clone, Debug, PartialEq)] +pub struct AgentCommitResult { + pub agent_id: String, + pub committed_step: u64, + pub decision_context_digest: String, + pub telemetry: AgentTelemetry, +} + +impl DomainType for AgentCommitResult { + const TYPE_NAME: &'static str = "AgentCommitResult"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "AgentCommitResult")?; + 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 = AgentCommitResult { + 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("AgentCommitResult: agentId is not a valid id"); + } + if !is_digest(&self.decision_context_digest) { + return err("AgentCommitResult: decisionContextDigest must be 64 lowercase hex digits"); + } + self.telemetry.validate() + } +} + +// --------------------------------------------------------------------------------------------- +// Environment methods (workers-v1 section 3) + +/// One declared axis. +#[derive(Clone, Debug, PartialEq)] +pub struct AxisSchema { + pub id: String, + pub range: AxisRange, + pub neutral: f64, +} + +/// `ControllerSchema`: buttons and axes in fixed order, unique, bounded. +#[derive(Clone, Debug, PartialEq)] +pub struct ControllerSchema { + pub schema: SchemaRef, + pub buttons: Vec, + pub axes: Vec, +} + +impl DomainType for ControllerSchema { + const TYPE_NAME: &'static str = "ControllerSchema"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "ControllerSchema")?; + let schema = SchemaRef::from_json(f.value("schema")?)?; + let buttons = id_list(&mut f, "buttons", 0, MAX_BUTTONS)?; + let axes = list(&mut f, "axes", 0, MAX_AXES, |v| { + let mut a = Fields::new(v, "ControllerSchema.axes")?; + let id = a.id("id")?; + let range = AxisRange::parse(&enumeration(&mut a, "range", AxisRange::ALL)?)?; + let (lo, hi) = range.bounds(); + let neutral = finite_in(&mut a, "neutral", lo, hi)?; + a.finish()?; + Ok(AxisSchema { id, range, neutral }) + })?; + f.finish()?; + let c = ControllerSchema { + schema, + buttons, + axes, + }; + c.validate()?; + Ok(c) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("schema", self.schema.to_json()), + ( + "buttons", + Value::Array(self.buttons.iter().map(|b| b.clone().into()).collect()), + ), + ( + "axes", + Value::Array( + self.axes + .iter() + .map(|a| { + obj(vec![ + ("id", a.id.clone().into()), + ("range", a.range.as_str().into()), + ("neutral", Value::from(a.neutral)), + ]) + }) + .collect(), + ), + ), + ]) + } + + fn validate(&self) -> Result<()> { + self.schema.validate()?; + if self.buttons.len() > MAX_BUTTONS { + return err("ControllerSchema: at most 32 buttons"); + } + if self.axes.len() > MAX_AXES { + return err("ControllerSchema: at most 16 axes"); + } + require_unique( + self.buttons.iter().map(String::as_str), + "ControllerSchema.buttons", + )?; + require_unique( + self.axes.iter().map(|a| a.id.as_str()), + "ControllerSchema.axes", + )?; + for button in &self.buttons { + if !is_id(button) { + return err("ControllerSchema: a button id is not a valid id"); + } + } + for axis in &self.axes { + if !is_id(&axis.id) { + return err("ControllerSchema: an axis id is not a valid id"); + } + if !axis.range.contains(axis.neutral) { + return err(format!( + "ControllerSchema: axis {:?} neutral must lie in its {} range", + axis.id, + axis.range.as_str() + )); + } + } + Ok(()) + } +} + +/// One button state in a port control. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct ButtonState { + pub id: String, + pub down: bool, +} + +/// One axis value in a port control. +#[derive(Clone, Debug, PartialEq)] +pub struct AxisValue { + pub id: String, + pub value: f64, +} + +/// `PortControl`: one port's complete control state for one transition. +#[derive(Clone, Debug, PartialEq)] +pub struct PortControl { + pub port_id: String, + pub buttons: Vec, + pub axes: Vec, +} + +impl PortControl { + /// "Every active port control must include every declared button and axis in descriptor + /// order. All IDs must match exactly; no duplicates, extra controls or omissions. Bipolar + /// axes are finite [-1,1], unit axes [0,1] ... Do not silently clamp an out-of-range + /// caller value." (workers-v1 section 3) + pub fn validate_against(&self, controls: &ControllerSchema) -> Result<()> { + self.validate()?; + require_same_order( + self.buttons.iter().map(|b| b.id.as_str()), + controls.buttons.iter().map(String::as_str), + "PortControl.buttons", + )?; + require_same_order( + self.axes.iter().map(|a| a.id.as_str()), + controls.axes.iter().map(|a| a.id.as_str()), + "PortControl.axes", + )?; + for (value, schema) in self.axes.iter().zip(&controls.axes) { + if !schema.range.contains(value.value) { + return err(format!( + "PortControl: axis {:?} value {} is outside its {} range and is refused, not clamped", + value.id, + value.value, + schema.range.as_str() + )); + } + } + Ok(()) + } +} + +impl DomainType for PortControl { + const TYPE_NAME: &'static str = "PortControl"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "PortControl")?; + let port_id = f.id("portId")?; + let buttons = list(&mut f, "buttons", 0, MAX_BUTTONS, |v| { + let mut b = Fields::new(v, "PortControl.buttons")?; + let id = b.id("id")?; + let down = b.boolean("down")?; + b.finish()?; + Ok(ButtonState { id, down }) + })?; + let axes = list(&mut f, "axes", 0, MAX_AXES, |v| { + let mut a = Fields::new(v, "PortControl.axes")?; + let id = a.id("id")?; + let value = finite(&mut a, "value")?; + a.finish()?; + Ok(AxisValue { id, value }) + })?; + f.finish()?; + let c = PortControl { + port_id, + buttons, + axes, + }; + c.validate()?; + Ok(c) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("portId", self.port_id.clone().into()), + ( + "buttons", + Value::Array( + self.buttons + .iter() + .map(|b| { + obj(vec![ + ("id", b.id.clone().into()), + ("down", Value::Bool(b.down)), + ]) + }) + .collect(), + ), + ), + ( + "axes", + Value::Array( + self.axes + .iter() + .map(|a| { + obj(vec![ + ("id", a.id.clone().into()), + ("value", Value::from(a.value)), + ]) + }) + .collect(), + ), + ), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.port_id) { + return err("PortControl: portId is not a valid id"); + } + if self.buttons.len() > MAX_BUTTONS || self.axes.len() > MAX_AXES { + return err("PortControl: at most 32 buttons and 16 axes"); + } + require_unique( + self.buttons.iter().map(|b| b.id.as_str()), + "PortControl.buttons", + )?; + require_unique(self.axes.iter().map(|a| a.id.as_str()), "PortControl.axes")?; + for axis in &self.axes { + if !axis.value.is_finite() { + return err("PortControl: axis values must be finite"); + } + } + Ok(()) + } +} + +/// One port and the controller schema it accepts. +#[derive(Clone, Debug, PartialEq)] +pub struct PortDescriptor { + pub port_id: String, + pub controls: ControllerSchema, +} + +/// `EnvironmentDescriptor`: the fixed shape of one world for one epoch. +#[derive(Clone, Debug, PartialEq)] +pub struct EnvironmentDescriptor { + pub backend_digest: String, + pub content_digest: String, + pub configuration_digest: String, + pub step_duration: RationalNs, + pub ports: Vec, + pub inspection_schema: SchemaRef, + pub views: Vec, + pub audio: Vec, + pub recovery: Recovery, + pub determinism: Determinism, +} + +impl EnvironmentDescriptor { + pub fn port(&self, port_id: &str) -> Option<&PortDescriptor> { + self.ports.iter().find(|p| p.port_id == port_id) + } + + pub fn view(&self, view_id: &str) -> Option<&ViewDescriptor> { + self.views.iter().find(|v| v.view_id == view_id) + } + + pub fn audio_stream(&self, stream_id: &str) -> Option<&AudioDescriptor> { + self.audio.iter().find(|a| a.stream_id == stream_id) + } + + /// One complete batch: every configured port exactly once, in descriptor order, each + /// control validated against its own schema (step-v1 section 3 phase B). + pub fn validate_batch(&self, controls: &[PortControl]) -> Result<()> { + require_same_order( + controls.iter().map(|c| c.port_id.as_str()), + self.ports.iter().map(|p| p.port_id.as_str()), + "Environment.Advance controls", + )?; + for (control, port) in controls.iter().zip(&self.ports) { + control.validate_against(&port.controls)?; + } + Ok(()) + } +} + +impl DomainType for EnvironmentDescriptor { + const TYPE_NAME: &'static str = "EnvironmentDescriptor"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "EnvironmentDescriptor")?; + let backend_digest = f.string("backendDigest")?.to_owned(); + let content_digest = f.string("contentDigest")?.to_owned(); + let configuration_digest = f.string("configurationDigest")?.to_owned(); + let step_duration = RationalNs::from_json(f.value("stepDuration")?)?; + let ports = list(&mut f, "ports", 1, MAX_PORTS, |v| { + let mut p = Fields::new(v, "EnvironmentDescriptor.ports")?; + let port_id = p.id("portId")?; + let controls = ControllerSchema::from_json(p.value("controls")?)?; + p.finish()?; + Ok(PortDescriptor { port_id, controls }) + })?; + let inspection_schema = SchemaRef::from_json(f.value("inspectionSchema")?)?; + let views = list(&mut f, "views", 0, MAX_VIEWS, ViewDescriptor::from_json)?; + let audio = list( + &mut f, + "audio", + 0, + crate::media::MAX_AUDIO_STREAMS, + AudioDescriptor::from_json, + )?; + let recovery = Recovery::parse(&enumeration(&mut f, "recovery", Recovery::ALL)?)?; + let determinism = + Determinism::parse(&enumeration(&mut f, "determinism", Determinism::ALL)?)?; + f.finish()?; + let d = EnvironmentDescriptor { + backend_digest, + content_digest, + configuration_digest, + step_duration, + ports, + inspection_schema, + views, + audio, + recovery, + determinism, + }; + d.validate()?; + Ok(d) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("backendDigest", self.backend_digest.clone().into()), + ("contentDigest", self.content_digest.clone().into()), + ( + "configurationDigest", + self.configuration_digest.clone().into(), + ), + ("stepDuration", self.step_duration.to_json()), + ( + "ports", + Value::Array( + self.ports + .iter() + .map(|p| { + obj(vec![ + ("portId", p.port_id.clone().into()), + ("controls", p.controls.to_json()), + ]) + }) + .collect(), + ), + ), + ("inspectionSchema", self.inspection_schema.to_json()), + ( + "views", + Value::Array(self.views.iter().map(ViewDescriptor::to_json).collect()), + ), + ( + "audio", + Value::Array(self.audio.iter().map(AudioDescriptor::to_json).collect()), + ), + ("recovery", self.recovery.as_str().into()), + ("determinism", self.determinism.as_str().into()), + ]) + } + + fn validate(&self) -> Result<()> { + for (what, digest) in [ + ("backendDigest", &self.backend_digest), + ("contentDigest", &self.content_digest), + ("configurationDigest", &self.configuration_digest), + ] { + if !is_digest(digest) { + return err(format!( + "EnvironmentDescriptor: {what} must be 64 lowercase hex digits" + )); + } + } + self.step_duration.validate()?; + self.step_duration + .require_positive("EnvironmentDescriptor.stepDuration")?; + if self.ports.is_empty() || self.ports.len() > MAX_PORTS { + return err("EnvironmentDescriptor: 1..=4 ports in the first composition"); + } + require_unique( + self.ports.iter().map(|p| p.port_id.as_str()), + "EnvironmentDescriptor.ports", + )?; + for port in &self.ports { + if !is_id(&port.port_id) { + return err("EnvironmentDescriptor: portId is not a valid id"); + } + port.controls.validate()?; + } + self.inspection_schema.validate()?; + if self.views.len() > MAX_VIEWS { + return err("EnvironmentDescriptor: at most 8 views"); + } + require_unique( + self.views.iter().map(|v| v.view_id.as_str()), + "EnvironmentDescriptor.views", + )?; + for view in &self.views { + view.validate()?; + } + require_unique( + self.audio.iter().map(|a| a.stream_id.as_str()), + "EnvironmentDescriptor.audio", + )?; + for stream in &self.audio { + stream.validate()?; + } + Ok(()) + } +} + +/// `Environment.Initialize` params. +#[derive(Clone, Debug, PartialEq)] +pub struct EnvironmentInitializeParams { + pub backend_config: AssetRef, + pub task_config: AssetRef, + pub episode_id: String, + pub port_bindings: Vec<(String, String)>, +} + +impl DomainType for EnvironmentInitializeParams { + const TYPE_NAME: &'static str = "EnvironmentInitializeParams"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "EnvironmentInitializeParams")?; + let backend_config = AssetRef::from_json(f.value("backendConfig")?)?; + let task_config = AssetRef::from_json(f.value("taskConfig")?)?; + let episode_id = f.id("episodeId")?; + let port_bindings = list(&mut f, "portBindings", 1, MAX_PORTS, |v| { + let mut b = Fields::new(v, "EnvironmentInitializeParams.portBindings")?; + let port_id = b.id("portId")?; + let agent_id = b.id("agentId")?; + b.finish()?; + Ok((port_id, agent_id)) + })?; + f.finish()?; + let p = EnvironmentInitializeParams { + backend_config, + task_config, + episode_id, + port_bindings, + }; + p.validate()?; + Ok(p) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("backendConfig", self.backend_config.to_json()), + ("taskConfig", self.task_config.to_json()), + ("episodeId", self.episode_id.clone().into()), + ( + "portBindings", + Value::Array( + self.port_bindings + .iter() + .map(|(port_id, agent_id)| { + obj(vec![ + ("portId", port_id.clone().into()), + ("agentId", agent_id.clone().into()), + ]) + }) + .collect(), + ), + ), + ]) + } + + fn validate(&self) -> Result<()> { + self.backend_config.validate()?; + self.task_config.validate()?; + if !is_id(&self.episode_id) { + return err("EnvironmentInitializeParams: episodeId is not a valid id"); + } + if self.port_bindings.is_empty() || self.port_bindings.len() > MAX_PORTS { + return err("EnvironmentInitializeParams: 1..=4 port bindings"); + } + require_unique( + self.port_bindings.iter().map(|(p, _)| p.as_str()), + "EnvironmentInitializeParams.portBindings portId", + )?; + require_unique( + self.port_bindings.iter().map(|(_, a)| a.as_str()), + "EnvironmentInitializeParams.portBindings agentId", + )?; + Ok(()) + } +} + +/// `WorldObservation`: one coherent world boundary. +#[derive(Clone, Debug, PartialEq)] +pub struct WorldObservation { + pub boundary: u64, + pub world_time: RationalNs, + pub engine_frame: Option, + pub sensory_views: Vec, + pub inspection: TypedValue, + pub broadcast_views: Vec, + pub audio: Vec, +} + +impl WorldObservation { + /// Byte shapes, producing boundaries and declared identities against the descriptor. + pub fn validate_against(&self, descriptor: &EnvironmentDescriptor) -> Result<()> { + self.validate()?; + if self.inspection.schema != descriptor.inspection_schema { + return err("WorldObservation: inspection must use the descriptor's inspectionSchema"); + } + for view in self.sensory_views.iter().chain(&self.broadcast_views) { + let declared = descriptor.view(&view.view_id).ok_or_else(|| { + crate::scalar::wire_err(format!( + "WorldObservation: view {:?} is not declared by the descriptor", + view.view_id + )) + })?; + view.validate_against(declared, Some(self.boundary))?; + } + for chunk in &self.audio { + let declared = descriptor.audio_stream(&chunk.stream_id).ok_or_else(|| { + crate::scalar::wire_err(format!( + "WorldObservation: audio stream {:?} is not declared by the descriptor", + chunk.stream_id + )) + })?; + chunk.validate_against(declared)?; + } + Ok(()) + } +} + +impl DomainType for WorldObservation { + const TYPE_NAME: &'static str = "WorldObservation"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "WorldObservation")?; + let boundary = f.u64_string("boundary")?; + let world_time = RationalNs::from_json(f.value("worldTime")?)?; + let engine_frame = + crate::scalar::nullable_bounded_string(&mut f, "engineFrame", MAX_ENGINE_FRAME_LEN)?; + let sensory_views = view_list(&mut f, "sensoryViews")?; + let inspection = TypedValue::from_json(f.value("inspection")?)?; + let broadcast_views = view_list(&mut f, "broadcastViews")?; + let audio = audio_list(&mut f, "audio")?; + f.finish()?; + let o = WorldObservation { + boundary, + world_time, + engine_frame, + sensory_views, + inspection, + broadcast_views, + audio, + }; + o.validate()?; + Ok(o) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("boundary", u64_json(self.boundary)), + ("worldTime", self.world_time.to_json()), + ( + "engineFrame", + self.engine_frame.clone().map_or(Value::Null, Value::String), + ), + ( + "sensoryViews", + Value::Array(self.sensory_views.iter().map(ViewRef::to_json).collect()), + ), + ("inspection", self.inspection.to_json()), + ( + "broadcastViews", + Value::Array(self.broadcast_views.iter().map(ViewRef::to_json).collect()), + ), + ( + "audio", + Value::Array( + self.audio + .iter() + .map(crate::media::AudioRef::to_json) + .collect(), + ), + ), + ]) + } + + fn validate(&self) -> Result<()> { + self.world_time.validate()?; + if let Some(frame) = &self.engine_frame + && frame.chars().count() > MAX_ENGINE_FRAME_LEN + { + return err("WorldObservation: engineFrame is at most 64 characters"); + } + if self.sensory_views.len() > MAX_VIEWS || self.broadcast_views.len() > MAX_VIEWS { + return err("WorldObservation: at most 8 views per list"); + } + require_unique( + self.sensory_views.iter().map(|v| v.view_id.as_str()), + "WorldObservation.sensoryViews", + )?; + require_unique( + self.broadcast_views.iter().map(|v| v.view_id.as_str()), + "WorldObservation.broadcastViews", + )?; + require_unique( + self.audio.iter().map(|a| a.stream_id.as_str()), + "WorldObservation.audio", + )?; + for view in self.sensory_views.iter().chain(&self.broadcast_views) { + view.validate()?; + if view.produced_step > self.boundary { + return err("WorldObservation: a view cannot be produced after the boundary"); + } + } + for chunk in &self.audio { + chunk.validate()?; + } + self.inspection.validate() + } +} + +/// `Environment.Initialize` result: boundary 0 with world time zero. +#[derive(Clone, Debug, PartialEq)] +pub struct EnvironmentInitializeResult { + pub descriptor: EnvironmentDescriptor, + pub observation: WorldObservation, +} + +impl DomainType for EnvironmentInitializeResult { + const TYPE_NAME: &'static str = "EnvironmentInitializeResult"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "EnvironmentInitializeResult")?; + let descriptor = EnvironmentDescriptor::from_json(f.value("descriptor")?)?; + let observation = WorldObservation::from_json(f.value("observation")?)?; + f.finish()?; + let r = EnvironmentInitializeResult { + descriptor, + observation, + }; + r.validate()?; + Ok(r) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("descriptor", self.descriptor.to_json()), + ("observation", self.observation.to_json()), + ]) + } + + fn validate(&self) -> Result<()> { + self.descriptor.validate()?; + self.observation.validate()?; + if self.observation.boundary != 0 { + return err("EnvironmentInitializeResult: the initial observation is boundary 0"); + } + if !self.observation.world_time.is_zero() { + return err("EnvironmentInitializeResult: initial worldTime is zero (0/1)"); + } + self.observation.validate_against(&self.descriptor) + } +} + +/// `Environment.Advance` params: exactly one complete batch. +#[derive(Clone, Debug, PartialEq)] +pub struct AdvanceParams { + pub batch_id: String, + pub controls: Vec, +} + +impl DomainType for AdvanceParams { + const TYPE_NAME: &'static str = "AdvanceParams"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "AdvanceParams")?; + let batch_id = f.id("batchId")?; + let controls = list(&mut f, "controls", 1, MAX_PORTS, PortControl::from_json)?; + f.finish()?; + let p = AdvanceParams { batch_id, controls }; + p.validate()?; + Ok(p) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("batchId", self.batch_id.clone().into()), + ( + "controls", + Value::Array(self.controls.iter().map(PortControl::to_json).collect()), + ), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.batch_id) { + return err("AdvanceParams: batchId is not a valid id"); + } + if self.controls.is_empty() || self.controls.len() > MAX_PORTS { + return err("AdvanceParams: 1..=4 port controls"); + } + require_unique( + self.controls.iter().map(|c| c.port_id.as_str()), + "AdvanceParams.controls", + )?; + for control in &self.controls { + control.validate()?; + } + Ok(()) + } +} + +/// `Environment.Advance` result. +#[derive(Clone, Debug, PartialEq)] +pub struct StepResult { + pub batch_id: String, + pub applied_from_step: u64, + pub next_step: u64, + pub applied_controls_digest: String, + pub observation: WorldObservation, +} + +impl StepResult { + /// The environment returns exactly boundary `k+1` with world time advanced by exactly one + /// `stepDuration` (workers-v1 section 3, step-v1 section 3 phase B). + pub fn validate_against( + &self, + descriptor: &EnvironmentDescriptor, + previous: &WorldObservation, + ) -> Result<()> { + self.validate()?; + self.observation.validate_against(descriptor)?; + let expected = previous.world_time.checked_add(&descriptor.step_duration)?; + if self.observation.world_time != expected { + return err("StepResult: worldTime must advance by exactly one stepDuration"); + } + if self.observation.boundary != previous.boundary + 1 { + return err("StepResult: the observation must be exactly the next boundary"); + } + Ok(()) + } +} + +impl DomainType for StepResult { + const TYPE_NAME: &'static str = "StepResult"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "StepResult")?; + let batch_id = f.id("batchId")?; + let applied_from_step = f.u64_string("appliedFromStep")?; + let next_step = f.u64_string("nextStep")?; + let applied_controls_digest = f.string("appliedControlsDigest")?.to_owned(); + let observation = WorldObservation::from_json(f.value("observation")?)?; + f.finish()?; + let r = StepResult { + batch_id, + applied_from_step, + next_step, + applied_controls_digest, + observation, + }; + r.validate()?; + Ok(r) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("batchId", self.batch_id.clone().into()), + ("appliedFromStep", u64_json(self.applied_from_step)), + ("nextStep", u64_json(self.next_step)), + ( + "appliedControlsDigest", + self.applied_controls_digest.clone().into(), + ), + ("observation", self.observation.to_json()), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.batch_id) { + return err("StepResult: batchId is not a valid id"); + } + if !is_digest(&self.applied_controls_digest) { + return err("StepResult: appliedControlsDigest must be 64 lowercase hex digits"); + } + if self.next_step != self.applied_from_step + 1 { + return err("StepResult: nextStep must be appliedFromStep + 1; one result is one step"); + } + self.observation.validate()?; + if self.observation.boundary != self.next_step { + return err("StepResult: the observation boundary must be nextStep"); + } + Ok(()) + } +} + +// --------------------------------------------------------------------------------------------- +// Common worker methods (ipc-v1 section 4, workers-v1 sections 1 and 6) + +/// `Worker.Hello` params. Scope is null. +#[derive(Clone, Debug, PartialEq)] +pub struct HelloParams { + pub session_id: String, + pub expected_worker_id: String, + pub role: Role, + pub supported_majors: Vec, +} + +impl DomainType for HelloParams { + const TYPE_NAME: &'static str = "HelloParams"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "HelloParams")?; + let session_id = f.id("sessionId")?; + let expected_worker_id = f.id("expectedWorkerId")?; + let role = Role::parse(&enumeration(&mut f, "role", Role::ALL)?)?; + let supported_majors = list( + &mut f, + "supportedMajors", + 1, + MAX_SUPPORTED_MAJORS, + |v| match v.as_u64() { + Some(n) if (1..=65_535).contains(&n) => Ok(n), + _ => err("every supported major must be an integer 1..=65535"), + }, + )?; + f.finish()?; + let p = HelloParams { + session_id, + expected_worker_id, + role, + supported_majors, + }; + p.validate()?; + Ok(p) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("sessionId", self.session_id.clone().into()), + ("expectedWorkerId", self.expected_worker_id.clone().into()), + ("role", self.role.as_str().into()), + ( + "supportedMajors", + Value::Array( + self.supported_majors + .iter() + .map(|n| Value::from(*n)) + .collect(), + ), + ), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.session_id) || !is_id(&self.expected_worker_id) { + return err("HelloParams: sessionId and expectedWorkerId must be valid ids"); + } + if self.supported_majors.is_empty() { + return err("HelloParams: supportedMajors must name at least one major"); + } + let mut seen: Vec = Vec::new(); + for major in &self.supported_majors { + if seen.contains(major) { + return err("HelloParams: supportedMajors must not repeat a major"); + } + seen.push(*major); + } + Ok(()) + } +} + +/// `Worker.Hello` result. v1 selects major 1, minor 0. +#[derive(Clone, Debug, PartialEq)] +pub struct HelloResult { + pub worker_id: String, + pub incarnation_id: String, + pub role: Role, + pub build_digest: String, + pub contract_digest: String, + pub capabilities: Vec, + pub max_agents: u64, + pub max_ports: u64, +} + +impl HelloResult { + /// Required capabilities are agent-step-v1 and world-step-v1 for their roles + /// (ipc-v1 section 4). + pub fn required_capability(role: Role) -> Option<&'static str> { + match role { + Role::Agent => Some("agent-step-v1"), + Role::Environment => Some("world-step-v1"), + Role::Coordinator => None, + } + } + + pub fn has(&self, capability: &str) -> bool { + self.capabilities.iter().any(|c| c == capability) + } +} + +impl DomainType for HelloResult { + const TYPE_NAME: &'static str = "HelloResult"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "HelloResult")?; + let selected_major = f.int("selectedMajor", 1, 1)?; + let selected_minor = f.int("selectedMinor", 0, 0)?; + debug_assert_eq!((selected_major, selected_minor), (1, 0)); + let worker_id = f.id("workerId")?; + let incarnation_id = f.id("incarnationId")?; + let role = Role::parse(&enumeration(&mut f, "role", Role::ALL)?)?; + let build_digest = f.string("buildDigest")?.to_owned(); + let contract_digest = f.string("contractDigest")?.to_owned(); + let capabilities = id_list(&mut f, "capabilities", 0, MAX_CAPABILITIES)?; + let (max_agents, max_ports) = { + let v = f.value("limits")?; + let mut l = Fields::new(v, "HelloResult.limits")?; + let max_agents = l.int("maxAgents", 1, MAX_AGENTS as u64)?; + let max_ports = l.int("maxPorts", 1, MAX_PORTS as u64)?; + l.finish()?; + (max_agents, max_ports) + }; + f.finish()?; + let r = HelloResult { + worker_id, + incarnation_id, + role, + build_digest, + contract_digest, + capabilities, + max_agents, + max_ports, + }; + r.validate()?; + Ok(r) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("selectedMajor", Value::from(1u64)), + ("selectedMinor", Value::from(0u64)), + ("workerId", self.worker_id.clone().into()), + ("incarnationId", self.incarnation_id.clone().into()), + ("role", self.role.as_str().into()), + ("buildDigest", self.build_digest.clone().into()), + ("contractDigest", self.contract_digest.clone().into()), + ( + "capabilities", + Value::Array(self.capabilities.iter().map(|c| c.clone().into()).collect()), + ), + ( + "limits", + obj(vec![ + ("maxAgents", Value::from(self.max_agents)), + ("maxPorts", Value::from(self.max_ports)), + ]), + ), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.worker_id) || !is_id(&self.incarnation_id) { + return err("HelloResult: workerId and incarnationId must be valid ids"); + } + if !is_digest(&self.build_digest) || !is_digest(&self.contract_digest) { + return err("HelloResult: buildDigest and contractDigest must be 64 hex digits"); + } + require_unique( + self.capabilities.iter().map(String::as_str), + "HelloResult.capabilities", + )?; + if let Some(required) = HelloResult::required_capability(self.role) + && !self.has(required) + { + return err(format!( + "HelloResult: a {} worker must advertise {required}", + self.role.as_str() + )); + } + if !(1..=MAX_AGENTS as u64).contains(&self.max_agents) + || !(1..=MAX_PORTS as u64).contains(&self.max_ports) + { + return err("HelloResult: the first composition allows at most 4 agents and 4 ports"); + } + Ok(()) + } +} + +/// `Worker.Status` result. +#[derive(Clone, Debug, PartialEq)] +pub struct StatusResult { + pub state: WorkerState, + pub current_scope: Option, + pub active_request_id: Option, + pub last_completed_request_id: Option, + pub last_batch_id: Option, + pub progress_counter: u64, +} + +impl DomainType for StatusResult { + const TYPE_NAME: &'static str = "StatusResult"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "StatusResult")?; + let state = WorkerState::parse(&enumeration(&mut f, "state", WorkerState::ALL)?)?; + let current_scope = crate::scalar::Scope::nullable_from_json(f.value("currentScope")?)?; + let active_request_id = DomainRequestId::read_nullable(&mut f, "activeRequestId")?; + let last_completed_request_id = + DomainRequestId::read_nullable(&mut f, "lastCompletedRequestId")?; + let last_batch_id = f.nullable_id("lastBatchId")?; + let progress_counter = f.u64_string("progressCounter")?; + f.finish()?; + let r = StatusResult { + state, + current_scope, + active_request_id, + last_completed_request_id, + last_batch_id, + progress_counter, + }; + r.validate()?; + Ok(r) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("state", self.state.as_str().into()), + ( + "currentScope", + crate::scalar::Scope::nullable_to_json(self.current_scope.as_ref()), + ), + ( + "activeRequestId", + self.active_request_id + .as_ref() + .map_or(Value::Null, DomainRequestId::to_json), + ), + ( + "lastCompletedRequestId", + self.last_completed_request_id + .as_ref() + .map_or(Value::Null, DomainRequestId::to_json), + ), + ( + "lastBatchId", + self.last_batch_id + .clone() + .map_or(Value::Null, Value::String), + ), + ("progressCounter", u64_json(self.progress_counter)), + ]) + } + + fn validate(&self) -> Result<()> { + if let Some(scope) = &self.current_scope { + scope.validate()?; + } + if self.state == WorkerState::Uninitialized && self.current_scope.is_some() { + return err("StatusResult: an uninitialized worker has a null currentScope"); + } + if let Some(batch) = &self.last_batch_id + && !is_id(batch) + { + return err("StatusResult: lastBatchId is not a valid id"); + } + Ok(()) + } +} + +/// `Worker.Acknowledge` params: 1..=16 retained lifecycle replies. +#[derive(Clone, Debug, PartialEq)] +pub struct AcknowledgeParams { + pub request_ids: Vec, +} + +impl DomainType for AcknowledgeParams { + const TYPE_NAME: &'static str = "AcknowledgeParams"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "AcknowledgeParams")?; + let request_ids = list(&mut f, "requestIds", 1, MAX_ACKNOWLEDGE, |v| { + match v.as_str() { + Some(s) => DomainRequestId::parse(s), + None => err("every requestId must be a string"), + } + })?; + f.finish()?; + let p = AcknowledgeParams { request_ids }; + p.validate()?; + Ok(p) + } + + fn to_json(&self) -> Value { + obj(vec![( + "requestIds", + Value::Array( + self.request_ids + .iter() + .map(DomainRequestId::to_json) + .collect(), + ), + )]) + } + + fn validate(&self) -> Result<()> { + if self.request_ids.is_empty() || self.request_ids.len() > MAX_ACKNOWLEDGE { + return err("AcknowledgeParams: requestIds carries 1..=16 ids"); + } + require_unique( + self.request_ids.iter().map(DomainRequestId::as_str), + "AcknowledgeParams.requestIds", + ) + } +} + +/// `Worker.Acknowledge` result. Already released or unknown ids are ignored, so the +/// acknowledged list is a subset of the request. +#[derive(Clone, Debug, PartialEq)] +pub struct AcknowledgeResult { + pub acknowledged: Vec, +} + +impl AcknowledgeResult { + pub fn validate_against(&self, params: &AcknowledgeParams) -> Result<()> { + self.validate()?; + for id in &self.acknowledged { + if !params.request_ids.contains(id) { + return err(format!( + "AcknowledgeResult: {} was not in the request", + id.as_str() + )); + } + } + Ok(()) + } +} + +impl DomainType for AcknowledgeResult { + const TYPE_NAME: &'static str = "AcknowledgeResult"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "AcknowledgeResult")?; + let acknowledged = list(&mut f, "acknowledged", 0, MAX_ACKNOWLEDGE, |v| { + match v.as_str() { + Some(s) => DomainRequestId::parse(s), + None => err("every acknowledged id must be a string"), + } + })?; + f.finish()?; + let r = AcknowledgeResult { acknowledged }; + r.validate()?; + Ok(r) + } + + fn to_json(&self) -> Value { + obj(vec![( + "acknowledged", + Value::Array( + self.acknowledged + .iter() + .map(DomainRequestId::to_json) + .collect(), + ), + )]) + } + + fn validate(&self) -> Result<()> { + if self.acknowledged.len() > MAX_ACKNOWLEDGE { + return err("AcknowledgeResult: at most 16 acknowledged ids"); + } + require_unique( + self.acknowledged.iter().map(DomainRequestId::as_str), + "AcknowledgeResult.acknowledged", + ) + } +} + +/// `Worker.Shutdown` params. +#[derive(Clone, Debug, PartialEq)] +pub struct ShutdownParams { + pub reason: String, +} + +impl DomainType for ShutdownParams { + const TYPE_NAME: &'static str = "ShutdownParams"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "ShutdownParams")?; + let reason = f.id("reason")?; + f.finish()?; + let p = ShutdownParams { reason }; + p.validate()?; + Ok(p) + } + + fn to_json(&self) -> Value { + obj(vec![("reason", self.reason.clone().into())]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.reason) { + return err("ShutdownParams: reason is not a valid id"); + } + Ok(()) + } +} + +/// `Worker.Shutdown` result. It does not imply saved state. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ShutdownResult; + +impl DomainType for ShutdownResult { + const TYPE_NAME: &'static str = "ShutdownResult"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "ShutdownResult")?; + constant_true(&mut f, "stopping")?; + f.finish()?; + Ok(ShutdownResult) + } + + fn to_json(&self) -> Value { + obj(vec![("stopping", Value::Bool(true))]) + } + + fn validate(&self) -> Result<()> { + Ok(()) + } +} + +// --------------------------------------------------------------------------------------------- +// Task outputs (workers-v1 section 4) + +/// A task event: `{id, kindId, sourceStep, agentId, payload}`, deterministic in order. +#[derive(Clone, Debug, PartialEq)] +pub struct TaskEvent { + pub id: String, + pub kind_id: String, + pub source_step: u64, + pub agent_id: Option, + pub payload: TypedValue, +} + +impl DomainType for TaskEvent { + const TYPE_NAME: &'static str = "TaskEvent"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "TaskEvent")?; + let id = f.id("id")?; + let kind_id = f.id("kindId")?; + let source_step = f.u64_string("sourceStep")?; + let agent_id = f.nullable_id("agentId")?; + let payload = TypedValue::from_json(f.value("payload")?)?; + f.finish()?; + let e = TaskEvent { + id, + kind_id, + source_step, + agent_id, + payload, + }; + e.validate()?; + Ok(e) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("id", self.id.clone().into()), + ("kindId", self.kind_id.clone().into()), + ("sourceStep", u64_json(self.source_step)), + ( + "agentId", + self.agent_id.clone().map_or(Value::Null, Value::String), + ), + ("payload", self.payload.to_json()), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.id) || !is_id(&self.kind_id) { + return err("TaskEvent: id and kindId must be valid ids"); + } + if let Some(agent_id) = &self.agent_id + && !is_id(agent_id) + { + return err("TaskEvent: agentId is not a valid id"); + } + self.payload.validate() + } +} + +/// `episodeRequest`: null, or a terminal request the coordinator may act on. +#[derive(Clone, Debug, PartialEq)] +pub struct EpisodeRequest { + pub reason: String, + pub outcome: TypedValue, +} + +impl DomainType for EpisodeRequest { + const TYPE_NAME: &'static str = "EpisodeRequest"; + + fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "EpisodeRequest")?; + constant(&mut f, "kind", "terminal")?; + let reason = f.id("reason")?; + let outcome = TypedValue::from_json(f.value("outcome")?)?; + f.finish()?; + let r = EpisodeRequest { reason, outcome }; + r.validate()?; + Ok(r) + } + + fn to_json(&self) -> Value { + obj(vec![ + ("kind", "terminal".into()), + ("reason", self.reason.clone().into()), + ("outcome", self.outcome.to_json()), + ]) + } + + fn validate(&self) -> Result<()> { + if !is_id(&self.reason) { + return err("EpisodeRequest: reason is not a valid id"); + } + self.outcome.validate() + } +} diff --git a/services/flysim/crates/fly-session-types/tests/canonical_json.rs b/services/flysim/crates/fly-session-types/tests/canonical_json.rs new file mode 100644 index 0000000..bd289dc --- /dev/null +++ b/services/flysim/crates/fly-session-types/tests/canonical_json.rs @@ -0,0 +1,192 @@ +//! Canonical JSON (RFC 8785) and the digest rules of ipc-v1 section 5. + +use fly_session_types::scalar::{DomainType, Scope}; +use fly_session_types::{canonical, fixtures}; +use serde_json::{Value, json}; + +#[test] +fn object_keys_are_sorted_by_utf16_code_unit() { + let value = json!({"b": 1, "a": 2, "A": 3, "\u{00e9}": 4, "\u{10400}": 5, "\u{ff21}": 6}); + assert_eq!( + canonical::canonicalize(&value).expect("canonicalizable"), + "{\"A\":3,\"a\":2,\"b\":1,\"\u{00e9}\":4,\"\u{10400}\":5,\"\u{ff21}\":6}", + "keys sort by UTF-16 code unit, so an astral key (leading surrogate D801) sorts \ + before U+FF21, which is where a JavaScript string sort puts it too and where a sort \ + by Unicode code point would not" + ); +} + +#[test] +fn numbers_print_the_way_ecmascript_prints_them() { + let file = fixtures::load("boundaries.json").expect("boundaries.json"); + for case in file.get("doubles").and_then(Value::as_array).expect("doubles") { + let value = case.get("value").expect("value"); + let accept = case.get("accept").and_then(Value::as_bool).expect("accept"); + let outcome = canonical::canonicalize(value); + assert_eq!( + outcome.is_ok(), + accept, + "{value}: {}", + fixtures::field(case, "reason").unwrap_or("") + ); + if let (Ok(text), Some(expected)) = (outcome, case.get("canonical").and_then(Value::as_str)) + { + assert_eq!(text, expected, "{value} must print as {expected}"); + } + } +} + +#[test] +fn strings_are_escaped_the_way_json_stringify_escapes_them() { + let value = json!({"s": "quote \" backslash \\ tab \t newline \n bell \u{7} del \u{7f} e\u{301}"}); + assert_eq!( + canonical::canonicalize(&value).expect("canonicalizable"), + "{\"s\":\"quote \\\" backslash \\\\ tab \\t newline \\n bell \\u0007 del \u{7f} e\u{301}\"}", + "only the escapes JSON.stringify emits, with lowercase hex" + ); +} + +#[test] +fn canonical_form_does_not_depend_on_the_input_formatting() { + let compact = br#"{"b":[1,2,{"y":true,"x":null}],"a":"z"}"#; + let pretty = br#"{ + "a" : "z", + "b": [ 1, 2, { "x": null, "y": true } ] + }"#; + let left = canonical::parse_strict(compact).expect("parses"); + let right = canonical::parse_strict(pretty).expect("parses"); + assert_eq!( + canonical::canonicalize(&left).expect("canonicalizable"), + canonical::canonicalize(&right).expect("canonicalizable") + ); + assert_eq!( + canonical::digest_of(&left).expect("digest"), + canonical::digest_of(&right).expect("digest"), + "whitespace and key order are not content" + ); +} + +#[test] +fn duplicate_keys_and_invalid_utf8_never_parse() { + assert!(canonical::parse_strict(br#"{"a":1,"a":2}"#).is_err()); + assert!(canonical::parse_strict(b"{\"a\":\"\xff\"}").is_err()); + assert!(canonical::parse_strict(br#"{"a":1} {"b":2}"#).is_err()); + assert!(canonical::parse_strict(br#"{"a":NaN}"#).is_err()); +} + +#[test] +fn an_envelope_over_64_kib_is_refused() { + let big = json!({"pad": "a".repeat(canonical::MAX_ENVELOPE_BYTES)}); + assert!(canonical::require_envelope_fit(&big, 0).is_err()); + let small = json!({"pad": "a"}); + let length = canonical::canonicalize(&small).expect("canonicalizable").len(); + assert_eq!( + canonical::require_envelope_fit(&small, canonical::MAX_ENVELOPE_BYTES - length) + .expect("fits exactly"), + canonical::MAX_ENVELOPE_BYTES + ); + assert!( + canonical::require_envelope_fit(&small, canonical::MAX_ENVELOPE_BYTES - length + 1) + .is_err(), + "one byte past the ceiling is refused" + ); +} + +#[test] +fn operation_keys_match_the_fixture_and_separate_the_operations_they_should() { + let file = fixtures::load("operations.json").expect("operations.json"); + let mut digests: Vec<(String, String)> = Vec::new(); + for case in file.get("keys").and_then(Value::as_array).expect("keys") { + let name = fixtures::field(case, "name").expect("name"); + let scope = Scope::from_json(case.get("scope").expect("scope")).expect("scope"); + let method = fixtures::field(case, "method").expect("method"); + let worker = fixtures::field(case, "workerId").expect("workerId"); + let key = canonical::OperationKey::new(scope, method, worker).expect("a key"); + let digest = key.digest().expect("digest"); + assert_eq!( + digest, + fixtures::field(case, "digest").expect("digest"), + "{name}: operation key digest must match the fixture" + ); + digests.push((name.to_owned(), digest)); + } + for (index, (name, digest)) in digests.iter().enumerate() { + for (other_name, other) in &digests[index + 1..] { + assert_ne!( + digest, other, + "{name} and {other_name} are different operations" + ); + } + } +} + +#[test] +fn canonical_bodies_match_the_fixture() { + let file = fixtures::load("operations.json").expect("operations.json"); + for case in file.get("bodies").and_then(Value::as_array).expect("bodies") { + let name = fixtures::field(case, "name").expect("name"); + let method = fixtures::field(case, "method").expect("method"); + let scope = match case.get("scope") { + Some(Value::Null) | None => None, + Some(v) => Some(Scope::from_json(v).expect("scope")), + }; + let params = case.get("params").expect("params"); + assert_eq!( + canonical::body_digest(method, scope.as_ref(), params).expect("digest"), + fixtures::field(case, "digest").expect("digest"), + "{name}: canonical body digest must match the fixture" + ); + } +} + +/// The pairs that decide whether a duplicate is a safe replay or a CONFLICT. +#[test] +fn operation_pairs_agree_with_the_fixture_about_sameness() { + let file = fixtures::load("operations.json").expect("operations.json"); + for case in file.get("pairs").and_then(Value::as_array).expect("pairs") { + let name = fixtures::field(case, "name").expect("name"); + let reason = fixtures::field(case, "reason").expect("reason"); + let worker = fixtures::field(case, "workerId").expect("workerId"); + let right_worker = fixtures::field(case, "rightWorkerId").unwrap_or(worker); + let side = |key: &str, worker: &str| { + let value = case.get(key).expect("side"); + let method = fixtures::field(value, "method").expect("method"); + let scope = Scope::from_json(value.get("scope").expect("scope")).expect("scope"); + let params = value.get("params").expect("params"); + let key_digest = canonical::OperationKey::new(scope.clone(), method, worker) + .expect("a key") + .digest() + .expect("digest"); + let body = canonical::body_digest(method, Some(&scope), params).expect("digest"); + (key_digest, body) + }; + let (left_key, left_body) = side("left", worker); + let (right_key, right_body) = side("right", right_worker); + assert_eq!( + left_key == right_key, + case.get("sameKey").and_then(Value::as_bool).expect("sameKey"), + "{name}: operation key sameness. {reason}" + ); + assert_eq!( + left_body == right_body, + case.get("sameBody").and_then(Value::as_bool).expect("sameBody"), + "{name}: canonical body sameness. {reason}" + ); + } +} + +#[test] +fn a_domain_body_can_never_carry_a_bus_identity() { + let file = fixtures::load("operations.json").expect("operations.json"); + for case in file.get("rejected").and_then(Value::as_array).expect("rejected") { + let name = fixtures::field(case, "name").expect("name"); + let method = fixtures::field(case, "method").expect("method"); + let scope = Scope::from_json(case.get("scope").expect("scope")).expect("scope"); + let params = case.get("params").expect("params"); + assert!( + canonical::body_digest(method, Some(&scope), params).is_err(), + "{name} must be refused: {}", + fixtures::field(case, "reason").unwrap_or("") + ); + } +} diff --git a/services/flysim/crates/fly-session-types/tests/checkpoint_envelope.rs b/services/flysim/crates/fly-session-types/tests/checkpoint_envelope.rs new file mode 100644 index 0000000..868e502 --- /dev/null +++ b/services/flysim/crates/fly-session-types/tests/checkpoint_envelope.rs @@ -0,0 +1,165 @@ +//! The `FLYSESS1` envelope layout: its fixture, its offsets and the corruptions it refuses. + +use fly_session_types::{canonical, checkpoint, fixtures}; +use serde_json::Value; + +fn envelope_bytes(file: &Value) -> Vec { + fixtures::decode_base64( + file.get("envelope") + .and_then(|e| e.get("base64")) + .and_then(Value::as_str) + .expect("base64"), + ) + .expect("base64") +} + +#[test] +fn the_fixture_envelope_decodes_to_its_recorded_layout() { + let file = fixtures::load("checkpoint-envelope.json").expect("checkpoint-envelope.json"); + let bytes = envelope_bytes(&file); + let envelope = checkpoint::decode(&bytes).expect("a valid envelope"); + checkpoint::validate_manifest(&envelope).expect("a complete manifest"); + + let layout = file["envelope"]["layout"].clone(); + assert_eq!(&bytes[0..8], checkpoint::MAGIC); + assert_eq!( + bytes.len().to_string(), + layout["totalBytes"].as_str().expect("totalBytes") + ); + assert_eq!( + envelope.layout.table_offset.to_string(), + layout["tableOffset"].as_str().expect("tableOffset") + ); + assert_eq!( + envelope.layout.manifest_bytes, + layout["manifestBytes"].as_u64().expect("manifestBytes") as u32 + ); + let entries = layout["entries"].as_array().expect("entries"); + assert_eq!(envelope.layout.entries.len(), entries.len()); + for (entry, recorded) in envelope.layout.entries.iter().zip(entries) { + assert_eq!(entry.name, recorded["name"].as_str().expect("name")); + assert_eq!( + entry.offset.to_string(), + recorded["offset"].as_str().expect("offset") + ); + assert_eq!( + entry.byte_length.to_string(), + recorded["byteLength"].as_str().expect("byteLength") + ); + assert_eq!( + checkpoint::hex(&entry.digest), + recorded["digest"].as_str().expect("digest") + ); + assert_eq!(entry.offset % 8, 0, "payloads start on an eight-byte boundary"); + } + + for payload in file["payloads"].as_array().expect("payloads") { + let name = payload["name"].as_str().expect("name"); + let expected = fixtures::decode_base64(payload["base64"].as_str().expect("base64")) + .expect("base64"); + assert_eq!( + envelope.payload(name).expect("a payload"), + expected.as_slice(), + "payload {name} must come back byte for byte" + ); + } + assert_eq!( + envelope.manifest, + file["manifest"], + "the manifest round trips as canonical JSON" + ); +} + +#[test] +fn every_recorded_corruption_is_refused() { + let file = fixtures::load("checkpoint-envelope.json").expect("checkpoint-envelope.json"); + let bytes = envelope_bytes(&file); + for case in file["corruption"].as_array().expect("corruption") { + let name = case["name"].as_str().expect("name"); + let offset = case["offset"].as_u64().expect("offset") as usize; + let mut corrupted = bytes.clone(); + corrupted[offset] ^= 0x01; + assert!( + checkpoint::decode(&corrupted).is_err(), + "{name} must be refused: {}", + case["reason"].as_str().unwrap_or("") + ); + } + let truncated = &bytes[..bytes.len() - 1]; + assert!( + checkpoint::decode(truncated).is_err(), + "a truncated envelope must be refused" + ); + assert!( + checkpoint::decode(&bytes[..8]).is_err(), + "a header alone is not an envelope" + ); +} + +#[test] +fn a_flysim01_envelope_is_not_read_as_a_session_checkpoint() { + // The historical envelope: magic, u32 manifest length, manifest, chunks, CRC32. + let mut legacy = Vec::new(); + legacy.extend_from_slice(b"FLYSIM01"); + let manifest = br#"{"schemaVersion":2,"chunks":[]}"#; + legacy.extend_from_slice(&(manifest.len() as u32).to_le_bytes()); + legacy.extend_from_slice(manifest); + legacy.extend_from_slice(&0u32.to_le_bytes()); + assert!( + checkpoint::decode(&legacy).is_err(), + "FLYSESS1 is a new format; the old reader stays separate" + ); +} + +#[test] +fn the_layout_is_deterministic_and_the_manifest_is_canonical() { + let manifest = canonical::parse_strict(br#"{"b":2,"a":1}"#).expect("parses"); + let payloads = vec![ + ("one".to_owned(), b"first".to_vec()), + ("two".to_owned(), vec![0u8; 9]), + ]; + let bytes = checkpoint::encode(&manifest, &payloads).expect("encode"); + let again = checkpoint::encode(&manifest, &payloads).expect("encode"); + assert_eq!(bytes, again, "the same inputs produce the same bytes"); + let envelope = checkpoint::decode(&bytes).expect("decode"); + let start = checkpoint::HEADER_BYTES; + let end = start + envelope.layout.manifest_bytes as usize; + assert_eq!( + std::str::from_utf8(&bytes[start..end]).expect("utf-8"), + "{\"a\":1,\"b\":2}", + "the manifest is stored as canonical JSON" + ); + assert_eq!(envelope.layout.entries[1].offset % 8, 0); + assert!( + checkpoint::encode( + &manifest, + &[("one".to_owned(), vec![]), ("one".to_owned(), vec![])] + ) + .is_err(), + "payload names are unique" + ); + assert!( + checkpoint::encode(&manifest, &[("One".to_owned(), vec![])]).is_err(), + "payload names are Ids, and the widening from letters-only is deliberate, not arbitrary" + ); + assert!( + checkpoint::encode(&manifest, &[("a".to_owned(), Vec::new())]).is_ok(), + "an empty payload is still a payload" + ); +} + +#[test] +fn a_manifest_missing_a_required_field_is_not_a_complete_checkpoint() { + let file = fixtures::load("checkpoint-envelope.json").expect("checkpoint-envelope.json"); + let full = file["manifest"].clone(); + for field in checkpoint::REQUIRED_MANIFEST_FIELDS { + let mut manifest = full.clone(); + manifest.as_object_mut().expect("object").remove(*field); + let bytes = checkpoint::encode(&manifest, &[]).expect("encode"); + let envelope = checkpoint::decode(&bytes).expect("decode"); + assert!( + checkpoint::validate_manifest(&envelope).is_err(), + "a manifest without {field:?} must be refused" + ); + } +} diff --git a/services/flysim/crates/fly-session-types/tests/common/mod.rs b/services/flysim/crates/fly-session-types/tests/common/mod.rs new file mode 100644 index 0000000..3d2458e --- /dev/null +++ b/services/flysim/crates/fly-session-types/tests/common/mod.rs @@ -0,0 +1,133 @@ +//! One place that knows how to read every type named by a fixture. + +use flybus::wire::WireError; +use fly_session_types::media::*; +use fly_session_types::publishing::*; +use fly_session_types::rpc::*; +use fly_session_types::scalar::*; +use fly_session_types::trace::*; +use fly_session_types::workers::*; +use serde_json::Value; + +/// Reads the value as `type_name`, re-runs its validate step and writes it back out. +/// +/// Every fixture assertion goes through this, so a type that reads a field but forgets to +/// write it back fails the round trip. +pub fn round_trip(type_name: &str, value: &Value) -> std::result::Result { + macro_rules! arm { + ($t:ty) => { + if type_name == <$t as DomainType>::TYPE_NAME { + let parsed = <$t as DomainType>::from_json(value)?; + parsed.validate()?; + return Ok(parsed.to_json()); + } + }; + } + arm!(Scope); + arm!(RationalNs); + arm!(SchemaRef); + arm!(TypedValue); + arm!(SessionRpcRequest); + arm!(SessionRpcSuccess); + arm!(SessionRpcFailure); + arm!(AssetRef); + arm!(SensoryInput); + arm!(Stimulus); + arm!(Reward); + arm!(AgentTelemetry); + arm!(AgentInitializeParams); + arm!(AgentInitializeResult); + arm!(PrepareParams); + arm!(PreparedDecision); + arm!(CommitParams); + arm!(AgentCommitResult); + arm!(ControllerSchema); + arm!(PortControl); + arm!(EnvironmentDescriptor); + arm!(EnvironmentInitializeParams); + arm!(EnvironmentInitializeResult); + arm!(WorldObservation); + arm!(AdvanceParams); + arm!(StepResult); + arm!(HelloParams); + arm!(HelloResult); + arm!(StatusResult); + arm!(AcknowledgeParams); + arm!(AcknowledgeResult); + arm!(ShutdownParams); + arm!(ShutdownResult); + arm!(TaskEvent); + arm!(EpisodeRequest); + arm!(ViewDescriptor); + arm!(ViewRef); + arm!(AudioDescriptor); + arm!(AudioRef); + arm!(CaptureParams); + arm!(CaptureResult); + arm!(StageRestoreParams); + arm!(StageRestoreResult); + arm!(ActivateRestoreParams); + arm!(ActivateRestoreResult); + arm!(SessionDescriptor); + arm!(CommittedSnapshot); + arm!(TraceBehaviour); + arm!(TraceOperational); + arm!(TransitionTrace); + Err(WireError(format!( + "no fixture reader for type {type_name:?}" + ))) +} + +/// The type names `round_trip` knows. +pub const READABLE_TYPES: &[&str] = &[ + "Scope", + "RationalNs", + "SchemaRef", + "TypedValue", + "SessionRpcRequest", + "SessionRpcSuccess", + "SessionRpcFailure", + "AssetRef", + "SensoryInput", + "Stimulus", + "Reward", + "AgentTelemetry", + "AgentInitializeParams", + "AgentInitializeResult", + "PrepareParams", + "PreparedDecision", + "CommitParams", + "AgentCommitResult", + "ControllerSchema", + "PortControl", + "EnvironmentDescriptor", + "EnvironmentInitializeParams", + "EnvironmentInitializeResult", + "WorldObservation", + "AdvanceParams", + "StepResult", + "HelloParams", + "HelloResult", + "StatusResult", + "AcknowledgeParams", + "AcknowledgeResult", + "ShutdownParams", + "ShutdownResult", + "TaskEvent", + "EpisodeRequest", + "ViewDescriptor", + "ViewRef", + "AudioDescriptor", + "AudioRef", + "CaptureParams", + "CaptureResult", + "StageRestoreParams", + "StageRestoreResult", + "ActivateRestoreParams", + "ActivateRestoreResult", + "SessionDescriptor", + "CommittedSnapshot", + "TraceBehaviour", + "TraceOperational", + "TransitionTrace", +]; diff --git a/services/flysim/crates/fly-session-types/tests/descriptor_checks.rs b/services/flysim/crates/fly-session-types/tests/descriptor_checks.rs new file mode 100644 index 0000000..cee4d49 --- /dev/null +++ b/services/flysim/crates/fly-session-types/tests/descriptor_checks.rs @@ -0,0 +1,78 @@ +//! Rules that need a descriptor in hand: complete batches, the observation delay rule, byte +//! shapes and descriptor agreement. + +use fly_session_types::fixtures; +use fly_session_types::publishing::{CommittedSnapshot, SessionDescriptor}; +use fly_session_types::scalar::{DomainType, Result}; +use fly_session_types::workers::{ + EnvironmentDescriptor, PortControl, SensoryInput, StepResult, WorldObservation, +}; + +#[test] +fn every_descriptor_check_lands_the_way_the_fixture_says() { + let file = fixtures::load("descriptor-checks.json").expect("descriptor-checks.json"); + let descriptor = + EnvironmentDescriptor::from_json(file.get("descriptor").expect("descriptor")).expect("descriptor"); + let delayed = EnvironmentDescriptor::from_json(file.get("delayedDescriptor").expect("delayed")) + .expect("delayed descriptor"); + let session = SessionDescriptor::from_json(file.get("sessionDescriptor").expect("session")) + .expect("session descriptor"); + let previous = WorldObservation::from_json(file.get("stepResultPrevious").expect("previous")) + .expect("previous observation"); + + for case in fixtures::cases(&file).expect("cases") { + let name = fixtures::field(case, "name").expect("name"); + let kind = fixtures::field(case, "kind").expect("kind"); + let reason = fixtures::field(case, "reason").unwrap_or(""); + let expect_accept = fixtures::field(case, "expect").expect("expect") == "accept"; + let value = case.get("value").expect("value"); + let outcome: Result<()> = match kind { + "portControl" => PortControl::from_json(value).and_then(|control| { + match descriptor.port(&control.port_id) { + Some(port) => control.validate_against(&port.controls), + None => fly_session_types::scalar::err("no such port"), + } + }), + "advanceControls" => value + .as_array() + .expect("an array of controls") + .iter() + .map(PortControl::from_json) + .collect::>>() + .and_then(|controls| descriptor.validate_batch(&controls)), + "sensoryInput" => SensoryInput::from_json(value) + .and_then(|input| input.validate_against(&descriptor.views)), + "sensoryInputDelayed" => { + SensoryInput::from_json(value).and_then(|input| input.validate_against(&delayed.views)) + } + "worldObservation" => WorldObservation::from_json(value) + .and_then(|observation| observation.validate_against(&descriptor)), + "stepResult" => StepResult::from_json(value) + .and_then(|result| result.validate_against(&descriptor, &previous)), + "snapshot" => CommittedSnapshot::from_json(value) + .and_then(|snapshot| snapshot.validate_against(&session)), + other => panic!("unknown descriptor check kind {other:?}"), + }; + assert_eq!( + outcome.is_ok(), + expect_accept, + "{name}: expected {}. {reason}. outcome: {outcome:?}", + if expect_accept { "accept" } else { "reject" } + ); + } +} + +/// The delay rule itself, stated once: `max(0, boundary - observationDelaySteps)`. +#[test] +fn the_required_producing_boundary_saturates_at_zero() { + let file = fixtures::load("descriptor-checks.json").expect("descriptor-checks.json"); + let delayed = EnvironmentDescriptor::from_json(file.get("delayedDescriptor").expect("delayed")) + .expect("delayed descriptor"); + let view = &delayed.views[0]; + assert_eq!(view.observation_delay_steps, 2); + assert_eq!(view.required_produced_step(0), 0); + assert_eq!(view.required_produced_step(1), 0); + assert_eq!(view.required_produced_step(2), 0); + assert_eq!(view.required_produced_step(3), 1); + assert_eq!(view.frame_bytes(), u64::from(160u32 * 4 * 144)); +} diff --git a/services/flysim/crates/fly-session-types/tests/encodings.rs b/services/flysim/crates/fly-session-types/tests/encodings.rs new file mode 100644 index 0000000..d2321e7 --- /dev/null +++ b/services/flysim/crates/fly-session-types/tests/encodings.rs @@ -0,0 +1,169 @@ +//! The scalar encodings agree with the bus's, and the four identities cannot be confused. + +use fly_session_types::fixtures; +use fly_session_types::scalar::{ + ArtifactIdentity, BusCallId, DomainRequestId, OwnerKind, OwnerToken, is_digest, is_id, parse_u64, +}; +use serde_json::Value; + +/// The domain `Id`, `U64` and `Digest` are the bus encodings, not a second opinion about them. +#[test] +fn domain_scalars_are_the_bus_scalars() { + let ids = [ + "a", + "fly-a", + "0", + "a.b_c-d", + "", + "A", + "-a", + ".a", + "a b", + "fly/a", + &"a".repeat(64), + &"a".repeat(65), + ]; + for id in ids { + assert_eq!( + is_id(id), + flybus::wire::is_id(id), + "Id encoding must agree with the bus for {id:?}" + ); + } + let numbers = [ + "0", + "1", + "18446744073709551615", + "18446744073709551616", + "01", + "", + "-1", + "1.0", + " 1", + ]; + for text in numbers { + assert_eq!( + parse_u64(text), + flybus::wire::parse_u64(text), + "U64 encoding must agree with the bus for {text:?}" + ); + } + let digests = [ + &"a".repeat(64), + &"0".repeat(64), + &"A".repeat(64), + &"g".repeat(64), + &"a".repeat(63), + ]; + for digest in digests { + assert_eq!( + is_digest(digest), + flybus::wire::is_digest(digest), + "Digest encoding must agree with the bus" + ); + } +} + +#[test] +fn u64_boundaries_reject_from_the_fixture() { + let file = fixtures::load("boundaries.json").expect("boundaries.json"); + let cases = file.get("u64").and_then(Value::as_array).expect("u64"); + for case in cases { + let text = fixtures::field(case, "text").expect("text"); + let accept = case.get("accept").and_then(Value::as_bool).expect("accept"); + assert_eq!( + parse_u64(text).is_some(), + accept, + "{text:?}: {}", + fixtures::field(case, "reason").unwrap_or("") + ); + } +} + +/// bus callId, domain requestId and delivery/hold owner tokens are four types. The fixture +/// says, for every spelling, which of them accept it: no string is accepted by two. +#[test] +fn the_four_identities_never_accept_each_others_spellings() { + let file = fixtures::load("identities.json").expect("identities.json"); + for case in fixtures::cases(&file).expect("cases") { + let text = fixtures::field(case, "text").expect("text"); + let call = case.get("busCallId").and_then(Value::as_bool).expect("busCallId"); + let request = case + .get("domainRequestId") + .and_then(Value::as_bool) + .expect("domainRequestId"); + let owner = case.get("ownerToken").expect("ownerToken"); + assert_eq!(BusCallId::parse(text).is_ok(), call, "busCallId {text:?}"); + assert_eq!( + DomainRequestId::parse(text).is_ok(), + request, + "domainRequestId {text:?}" + ); + match owner { + Value::Null => assert!( + OwnerToken::parse(text).is_err(), + "owner token {text:?} must be refused" + ), + Value::String(kind) => { + let parsed = OwnerToken::parse(text).expect("an owner token"); + let expected = match kind.as_str() { + "delivery" => OwnerKind::Delivery, + "hold" => OwnerKind::Hold, + other => panic!("unknown owner kind {other:?}"), + }; + assert_eq!(parsed.kind(), expected, "owner kind of {text:?}"); + } + other => panic!("unexpected ownerToken field {other:?}"), + } + let accepted = [call, request, OwnerToken::parse(text).is_ok()] + .iter() + .filter(|a| **a) + .count(); + assert!( + accepted <= 1, + "{text:?} is accepted by more than one identity type" + ); + } +} + +/// An artifact identity is the naming half of a bus ArtifactRef, and nothing else in these +/// contracts is one: an AssetRef is persistent installed content, not live bytes. +#[test] +fn artifact_identity_is_the_naming_half_of_an_artifact_ref() { + let file = fixtures::load("identities.json").expect("identities.json"); + let section = file.get("artifact").expect("artifact"); + let reference = + flybus::wire::ArtifactRef::from_json(section.get("ref").expect("ref")).expect("a ref"); + let identity = ArtifactIdentity::of(&reference); + identity.validate().expect("a valid identity"); + let expected = section.get("identity").expect("identity"); + assert_eq!(identity.store_id, expected["storeId"].as_str().unwrap()); + assert_eq!(identity.artifact_id, expected["artifactId"].as_str().unwrap()); + assert_eq!(identity.generation.to_string(), expected["generation"].as_str().unwrap()); + + let asset = fly_session_types::workers::AssetRef::from_json(section_asset(&file)).expect("asset"); + assert_ne!( + asset.id, identity.artifact_id, + "the fixture's asset and artifact are deliberately different things" + ); +} + +fn section_asset(file: &Value) -> &Value { + file.get("asset").expect("asset") +} + +/// A delivery id or hold token is connection-private. The domain reader has no field that +/// takes one, which is what `canonical::reject_bus_identities` enforces; here we only pin +/// that the two prefixes the bus issues are the two kinds this type knows. +#[test] +fn owner_tokens_come_in_exactly_two_kinds() { + assert_eq!(OwnerToken::delivery(7).as_str(), "dlv-7"); + assert_eq!(OwnerToken::hold(9).as_str(), "own-9"); + assert_eq!(OwnerToken::delivery(7).kind(), OwnerKind::Delivery); + assert_eq!(OwnerToken::hold(9).kind(), OwnerKind::Hold); + assert_eq!(BusCallId::from_serial(12).as_str(), "call-12"); + assert_eq!(DomainRequestId::from_serial(41).as_str(), "req-41"); + assert_eq!(DomainRequestId::from_serial(41).serial(), 41); +} + +use fly_session_types::scalar::DomainType as _; diff --git a/services/flysim/crates/fly-session-types/tests/payloads.rs b/services/flysim/crates/fly-session-types/tests/payloads.rs new file mode 100644 index 0000000..0cdd576 --- /dev/null +++ b/services/flysim/crates/fly-session-types/tests/payloads.rs @@ -0,0 +1,177 @@ +//! The `valid.json`, `invalid.json`, `raw.json` and `generated.json` fixtures. + +mod common; + +use fly_session_types::scalar::{DomainType, MAX_TYPED_VALUE_BYTES, SchemaRef, TypedValue}; +use fly_session_types::{canonical, fixtures}; +use serde_json::{Value, json}; + +use common::round_trip; + +#[test] +fn every_valid_case_round_trips_and_canonicalizes_to_its_recorded_bytes() { + let file = fixtures::load("valid.json").expect("valid.json"); + let cases = fixtures::cases(&file).expect("cases"); + for case in cases { + let name = fixtures::field(case, "name").expect("name"); + let type_name = fixtures::field(case, "type").expect("type"); + let value = case.get("value").expect("value"); + let written = round_trip(type_name, value) + .unwrap_or_else(|e| panic!("{name} ({type_name}) must be accepted: {e}")); + let canonical_in = canonical::canonicalize(value).expect("canonicalizable"); + let canonical_out = canonical::canonicalize(&written).expect("canonicalizable"); + assert_eq!( + canonical_in, canonical_out, + "{name}: reading and writing must preserve every field" + ); + assert_eq!( + canonical_in, + fixtures::field(case, "canonical").expect("canonical"), + "{name}: canonical JSON must match the fixture" + ); + assert_eq!( + canonical::sha256_hex(canonical_in.as_bytes()), + fixtures::field(case, "digest").expect("digest"), + "{name}: digest must match the fixture" + ); + } + assert!(cases.len() >= 70, "the valid fixture should stay broad"); +} + +#[test] +fn every_type_the_readers_know_appears_in_the_valid_fixture() { + let file = fixtures::load("valid.json").expect("valid.json"); + let cases = fixtures::cases(&file).expect("cases"); + let covered: Vec<&str> = cases + .iter() + .map(|case| fixtures::field(case, "type").expect("type")) + .collect(); + let missing: Vec<&&str> = common::READABLE_TYPES + .iter() + .filter(|t| !covered.contains(*t)) + .collect(); + assert!( + missing.is_empty(), + "every readable type needs at least one accepted fixture; missing {missing:?}" + ); +} + +#[test] +fn every_invalid_case_is_refused() { + let file = fixtures::load("invalid.json").expect("invalid.json"); + let cases = fixtures::cases(&file).expect("cases"); + for case in cases { + let name = fixtures::field(case, "name").expect("name"); + let type_name = fixtures::field(case, "type").expect("type"); + let reason = fixtures::field(case, "reason").expect("reason"); + let value = case.get("value").expect("value"); + let outcome = round_trip(type_name, value); + assert!( + outcome.is_err(), + "{name} ({type_name}) must be refused: {reason}" + ); + } + assert!(cases.len() >= 80, "the invalid fixture should stay broad"); +} + +#[test] +fn every_raw_byte_case_is_refused_before_or_during_validation() { + let file = fixtures::load("raw.json").expect("raw.json"); + for case in fixtures::cases(&file).expect("cases") { + let name = fixtures::field(case, "name").expect("name"); + let type_name = fixtures::field(case, "type").expect("type"); + let reason = fixtures::field(case, "reason").expect("reason"); + let bytes = fixtures::base64(case, "base64").unwrap_or_default(); + let outcome = canonical::parse_strict(&bytes).and_then(|value| { + round_trip(type_name, &value).map_err(|e| fly_session_types::scalar::wire_err(e.0)) + }); + assert!(outcome.is_err(), "{name} must be refused: {reason}"); + } +} + +/// The recipes in `generated.json`: payloads too large to store as fixtures. +#[test] +fn generated_boundary_cases_land_on_the_right_side_of_every_limit() { + let file = fixtures::load("generated.json").expect("generated.json"); + let pad_schema = SchemaRef::from_json(file.get("padSchema").expect("padSchema")).expect("schema"); + for case in fixtures::cases(&file).expect("cases") { + let name = fixtures::field(case, "name").expect("name"); + let kind = fixtures::field(case, "kind").expect("kind"); + let expect_accept = fixtures::field(case, "expect").expect("expect") == "accept"; + let outcome: Result<(), String> = match kind { + "padded-typed-value" => { + let pad = case.get("padCharacters").and_then(Value::as_u64).expect("pad") as usize; + TypedValue::new(pad_schema.clone(), json!({"pad": "a".repeat(pad)})) + .map(|_| ()) + .map_err(|e| e.0) + } + "padded-request" => { + let pad = case.get("padCharacters").and_then(Value::as_u64).expect("pad") as usize; + let total = case + .get("envelopeTotal") + .and_then(Value::as_u64) + .expect("envelopeTotal") as usize; + let request = json!({ + "requestId": "req-1", + "scope": Value::Null, + "params": {"pad": "a".repeat(pad)}, + }); + let body = round_trip("SessionRpcRequest", &request).expect("a request"); + let length = canonical::canonicalize(&body).expect("canonicalizable").len(); + canonical::require_envelope_fit(&body, total - length) + .map(|_| ()) + .map_err(|e| e.0) + } + "error-message" | "error-message-astral" => { + let points = case.get("codePoints").and_then(Value::as_u64).expect("codePoints") + as usize; + let character = if kind == "error-message" { 'x' } else { '\u{10400}' }; + let message: String = std::iter::repeat_n(character, points).collect(); + let failure = json!({ + "type": "error", + "requestId": "req-41", + "workerId": "fly-a", + "incarnationId": "inc-1", + "scope": Value::Null, + "error": {"code": "INTERNAL", "message": message, "mutation": "unknown"}, + }); + round_trip("SessionRpcFailure", &failure) + .map(|_| ()) + .map_err(|e| e.0) + } + other => panic!("unknown generated case kind {other:?}"), + }; + assert_eq!( + outcome.is_ok(), + expect_accept, + "{name}: expected {}, got {outcome:?}", + if expect_accept { "accept" } else { "reject" } + ); + } +} + +#[test] +fn a_typed_value_at_the_cap_is_accepted_and_one_byte_more_is_not() { + let schema = SchemaRef::new("pad.v1", 1, &canonical::sha256_hex(b"pad.v1")).expect("schema"); + let overhead = canonical::canonicalize( + &TypedValue::new(schema.clone(), json!({"pad": ""})) + .expect("empty") + .to_json(), + ) + .expect("canonicalizable") + .len(); + let at_cap = TypedValue::new( + schema.clone(), + json!({"pad": "a".repeat(MAX_TYPED_VALUE_BYTES - overhead)}), + ) + .expect("exactly at the cap"); + assert_eq!(at_cap.canonical_len().expect("length"), MAX_TYPED_VALUE_BYTES); + assert!( + TypedValue::new( + schema, + json!({"pad": "a".repeat(MAX_TYPED_VALUE_BYTES - overhead + 1)}) + ) + .is_err(), + "one byte over the cap must fail" + ); +} diff --git a/services/flysim/crates/fly-session-types/tests/rational.rs b/services/flysim/crates/fly-session-types/tests/rational.rs new file mode 100644 index 0000000..3781a88 --- /dev/null +++ b/services/flysim/crates/fly-session-types/tests/rational.rs @@ -0,0 +1,159 @@ +//! Checked rational arithmetic and the step-v1 section 5 tick accumulator. + +use fly_session_types::scalar::{DomainType, RationalNs}; +use fly_session_types::fixtures; +use serde_json::Value; + +fn rational(value: &Value) -> RationalNs { + RationalNs::from_json(value).expect("a valid rational") +} + +#[test] +fn the_accumulator_produces_the_fixture_tick_counts_and_remainders() { + let file = fixtures::load("rational.json").expect("rational.json"); + for case in file + .get("accumulator") + .and_then(Value::as_array) + .expect("accumulator") + { + let name = fixtures::field(case, "name").expect("name"); + let step = rational(case.get("stepDuration").expect("stepDuration")); + let tick = rational(case.get("tickDuration").expect("tickDuration")); + let mut accumulator = RationalNs::ZERO; + let mut total = 0u64; + for (index, expected) in case + .get("steps") + .and_then(Value::as_array) + .expect("steps") + .iter() + .enumerate() + { + accumulator = accumulator.checked_add(&step).expect("checked add"); + let (ticks, remainder) = accumulator.divide_floor(&tick).expect("checked divide"); + accumulator = remainder; + total += ticks; + assert_eq!( + ticks.to_string(), + fixtures::field(expected, "ticks").expect("ticks"), + "{name}: tick count at step {index}" + ); + assert_eq!( + remainder, + rational(expected.get("remainder").expect("remainder")), + "{name}: remainder at step {index}" + ); + assert!( + remainder < tick, + "{name}: the remainder is always less than one model tick" + ); + } + assert_eq!( + total.to_string(), + fixtures::field(case, "totalTicks").expect("totalTicks"), + "{name}: total ticks" + ); + } +} + +#[test] +fn checked_arithmetic_reduces_or_refuses() { + let file = fixtures::load("rational.json").expect("rational.json"); + for case in file.get("add").and_then(Value::as_array).expect("add") { + let outcome = rational(case.get("a").expect("a")).checked_add(&rational(case.get("b").expect("b"))); + match case.get("sum") { + Some(sum) => assert_eq!(outcome.expect("a sum"), rational(sum)), + None => assert!(outcome.is_err(), "the sum must overflow: {case}"), + } + } + for case in file + .get("subtract") + .and_then(Value::as_array) + .expect("subtract") + { + let outcome = + rational(case.get("a").expect("a")).checked_sub(&rational(case.get("b").expect("b"))); + match case.get("difference") { + Some(difference) => assert_eq!(outcome.expect("a difference"), rational(difference)), + None => assert!(outcome.is_err(), "the subtraction must fail: {case}"), + } + } + for case in file + .get("multiply") + .and_then(Value::as_array) + .expect("multiply") + { + let k: u64 = fixtures::field(case, "k") + .expect("k") + .parse() + .expect("a u64"); + let outcome = rational(case.get("a").expect("a")).checked_mul_u64(k); + match case.get("product") { + Some(product) => assert_eq!(outcome.expect("a product"), rational(product)), + None => assert!(outcome.is_err(), "the product must overflow: {case}"), + } + } + for case in file + .get("compare") + .and_then(Value::as_array) + .expect("compare") + { + let left = rational(case.get("a").expect("a")); + let right = rational(case.get("b").expect("b")); + let ordering = match fixtures::field(case, "ordering").expect("ordering") { + "less" => std::cmp::Ordering::Less, + "equal" => std::cmp::Ordering::Equal, + "greater" => std::cmp::Ordering::Greater, + other => panic!("unknown ordering {other:?}"), + }; + assert_eq!(left.cmp(&right), ordering, "{case}"); + } +} + +#[test] +fn zero_has_exactly_one_encoding_and_durations_must_be_positive() { + assert_eq!(RationalNs::ZERO, RationalNs::new(0, 1).expect("0/1")); + assert!(RationalNs::new(0, 2).is_err(), "zero is encoded 0/1"); + assert!(RationalNs::new(1, 0).is_err(), "denominators are positive"); + assert!(RationalNs::new(2, 4).is_err(), "fractions are reduced"); + assert!(RationalNs::ZERO.require_positive("worldTime").is_err()); + assert!( + RationalNs::new(1, 3) + .expect("1/3") + .require_positive("tickDuration") + .is_ok() + ); + assert!( + RationalNs::ZERO.divide_floor(&RationalNs::ZERO).is_err(), + "dividing by a zero tick is refused, not infinite" + ); +} + +#[test] +fn reduction_refuses_a_result_that_does_not_fit_u64() { + let big = RationalNs::new(u64::MAX, 1).expect("a whole number"); + assert!(big.checked_mul_u64(2).is_err()); + assert!(big.checked_add(&big).is_err()); + assert_eq!( + RationalNs::reduced(u128::from(u64::MAX) * 2, 2).expect("reduces back into range"), + big + ); +} + +/// The review case: cross-multiplying two reduced fractions near the `U64` maximum fits +/// `u128`, but adding the two products does not. Unchecked, that panics in debug and wraps in +/// release, after which the reduction returns a confidently wrong rational. +#[test] +fn adding_two_fractions_near_the_u64_maximum_is_refused_not_wrapped() { + let left = RationalNs::new(u64::MAX, u64::MAX - 1).expect("consecutive integers are coprime"); + let right = RationalNs::new(u64::MAX - 2, u64::MAX - 4).expect("two odd numbers differing by 2"); + let outcome = left.checked_add(&right); + let message = outcome.expect_err("the sum reaches about 2^129").0; + assert!( + message.contains("overflow"), + "the failure names the overflow rather than the reduction: {message}" + ); + assert!( + left.checked_add(&RationalNs::ZERO).is_ok(), + "the checked path still adds ordinary operands" + ); +} diff --git a/services/flysim/crates/fly-session-types/tests/schema_set.rs b/services/flysim/crates/fly-session-types/tests/schema_set.rs new file mode 100644 index 0000000..179ed34 --- /dev/null +++ b/services/flysim/crates/fly-session-types/tests/schema_set.rs @@ -0,0 +1,182 @@ +//! The canonical schema set, `contractDigest` and the freshness of every derived fixture. + +use fly_session_types::{canonical, fixtures, schema}; +use serde_json::Value; + +#[allow(dead_code, reason = "this file uses the readers' type list, not the readers")] +mod common; + +#[path = "../examples/update_fixtures.rs"] +#[allow(dead_code, reason = "the example's main is not used by the test that reuses its writers")] +mod updater; + +/// Every derived fixture is exactly what the updater writes today. If this fails, run +/// `cargo run -p fly-session-types --example update_fixtures` and review the diff. +#[test] +fn derived_fixtures_are_current() { + for (name, expected) in updater::derived() { + let path = fixtures::dir().join(&name); + let found = std::fs::read_to_string(&path).expect("a checked-in fixture"); + assert_eq!( + found, expected, + "{name} is stale; regenerate it with the update_fixtures example" + ); + } +} + +#[test] +fn the_contract_digest_is_the_digest_of_the_checked_in_schema_set() { + let text = fixtures::load_bytes("schema-set.json").expect("schema-set.json"); + let recorded = fixtures::load("contract-digest.json").expect("contract-digest.json"); + let expected = recorded + .get("contractDigest") + .and_then(Value::as_str) + .expect("contractDigest"); + assert_eq!(schema::contract_digest(), expected); + // The file is the canonical schema set plus one trailing newline. + assert_eq!( + canonical::sha256_hex(text.strip_suffix(b"\n").expect("trailing newline")), + expected, + "the digest is over the canonical schema set, byte for byte" + ); +} + +/// The digest comes from the schema declaration, not from the source text: reparsing the +/// checked-in file with different whitespace and key order gives the same digest. +#[test] +fn the_contract_digest_survives_reformatting() { + let bytes = fixtures::load_bytes("schema-set.json").expect("schema-set.json"); + let parsed = canonical::parse_strict(bytes.strip_suffix(b"\n").expect("newline")).expect("parses"); + let pretty = serde_json::to_vec_pretty(&parsed).expect("serializable"); + let reparsed = canonical::parse_strict(&pretty).expect("parses"); + assert_eq!( + canonical::digest_of(&reparsed).expect("digest"), + schema::contract_digest(), + "pretty printing the schema set does not change its digest" + ); + let shuffled = canonical::parse_strict( + br#"{"version":1,"contract":"fly-session-types-other"}"#, + ) + .expect("parses"); + assert_ne!( + canonical::digest_of(&shuffled).expect("digest"), + schema::contract_digest() + ); +} + +/// ... and it changes when a schema changes: a renamed field, a widened bound, one more enum +/// member or one fewer type all move the digest. +#[test] +fn the_contract_digest_changes_when_a_schema_changes() { + let baseline = schema::contract_digest(); + let mutate = |mutation: fn(&mut Value)| { + let mut set = schema::schema_set(); + mutation(&mut set); + canonical::digest_of(&set).expect("digest") + }; + let renamed_field = mutate(|set| { + set["types"][0]["fields"][0]["name"] = Value::String("sessionIdentifier".to_owned()); + }); + let widened_bound = mutate(|set| { + for limit in set["limits"].as_array_mut().expect("limits") { + if limit["name"] == Value::String("maxAgents".to_owned()) { + limit["value"] = Value::from(8u64); + } + } + }); + let extra_enum_member = mutate(|set| { + set["enums"][0]["members"] + .as_array_mut() + .expect("members") + .push(Value::String("s16le-interleaved".to_owned())); + }); + let dropped_type = mutate(|set| { + set["types"].as_array_mut().expect("types").pop(); + }); + let relaxed_constraint = mutate(|set| { + set["types"][0]["fields"][0]["constraint"] = Value::String("anything".to_owned()); + }); + for (what, digest) in [ + ("a renamed field", renamed_field), + ("a widened bound", widened_bound), + ("an extra enum member", extra_enum_member), + ("a dropped type", dropped_type), + ("a relaxed constraint", relaxed_constraint), + ] { + assert_ne!(digest, baseline, "{what} must change contractDigest"); + } +} + +/// Every type the crate can read is declared in the schema set, so a new payload type cannot +/// ship outside `contractDigest`. The list is the readers' own, not a copy of it. +#[test] +fn the_schema_set_names_every_type_the_crate_reads() { + let set = schema::schema_set(); + let names: Vec<&str> = set["types"] + .as_array() + .expect("types") + .iter() + .map(|t| t["name"].as_str().expect("name")) + .collect(); + let missing: Vec<&&str> = common::READABLE_TYPES + .iter() + .filter(|expected| !names.contains(*expected)) + .collect(); + assert!( + missing.is_empty(), + "every readable type must be in the schema set; missing {missing:?}" + ); + let mut sorted = names.clone(); + sorted.sort_unstable(); + assert_eq!(names, sorted, "the rendered set is sorted by type name"); + let mut unique = sorted.clone(); + unique.dedup(); + assert_eq!(unique.len(), names.len(), "no type is declared twice"); +} + +/// Every bound the schema set publishes is the constant the code enforces, and every bound +/// this crate chose rather than read from a document says so. +#[test] +fn published_limits_match_the_constants_and_name_their_source() { + let set = schema::schema_set(); + let limits = set["limits"].as_array().expect("limits"); + let find = |name: &str| -> u64 { + limits + .iter() + .find(|l| l["name"] == Value::String(name.to_owned())) + .and_then(|l| l["value"].as_u64()) + .unwrap_or_else(|| panic!("the schema set must publish {name}")) + }; + assert_eq!(find("maxAgents"), fly_session_types::workers::MAX_AGENTS as u64); + assert_eq!(find("maxPorts"), fly_session_types::workers::MAX_PORTS as u64); + assert_eq!( + find("maxRateRoles"), + fly_session_types::workers::MAX_RATE_ROLES as u64 + ); + assert_eq!(find("maxViews"), fly_session_types::media::MAX_VIEWS as u64); + assert_eq!( + find("maxTypedValueBytes"), + fly_session_types::scalar::MAX_TYPED_VALUE_BYTES as u64 + ); + assert_eq!( + find("maxEnvelopeBytes"), + canonical::MAX_ENVELOPE_BYTES as u64 + ); + let crate_chosen: Vec<&str> = limits + .iter() + .filter(|l| l["source"] == Value::String("crate".to_owned())) + .map(|l| l["name"].as_str().expect("name")) + .collect(); + assert_eq!( + crate_chosen, + [ + "maxAssets", + "maxAudioStreams", + "maxCapabilities", + "maxSnapshotEvents", + "maxSupportedMajors", + "maxSupportedStimuli", + ], + "a bound with no stated source must be declared as this crate's choice" + ); +} diff --git a/services/flysim/crates/fly-session-types/tests/seeds.rs b/services/flysim/crates/fly-session-types/tests/seeds.rs new file mode 100644 index 0000000..a5f32a0 --- /dev/null +++ b/services/flysim/crates/fly-session-types/tests/seeds.rs @@ -0,0 +1,120 @@ +//! `seed-derivation-v1` against its test vectors. + +use fly_session_types::{fixtures, seed}; +use serde_json::Value; + +#[test] +fn every_vector_derives_its_recorded_seed() { + let file = fixtures::load("seed-vectors.json").expect("seed-vectors.json"); + assert_eq!( + file.get("algorithm").and_then(Value::as_str), + Some(seed::ALGORITHM) + ); + let vectors = file.get("vectors").and_then(Value::as_array).expect("vectors"); + for case in vectors { + let master: u64 = fixtures::field(case, "masterSeed") + .expect("masterSeed") + .parse() + .expect("a U64"); + let agent = fixtures::field(case, "agentId").expect("agentId"); + assert_eq!( + String::from_utf8(seed::material(master, agent).expect("material")).expect("utf-8"), + fixtures::field(case, "material").expect("material"), + "the hashed material is part of the specification" + ); + assert_eq!( + seed::material_digest(master, agent).expect("digest"), + fixtures::field(case, "materialDigest").expect("materialDigest") + ); + assert_eq!( + i64::from(seed::agent_seed(master, agent).expect("seed")), + case.get("seed").and_then(Value::as_i64).expect("seed"), + "seed for {agent} under master {master}" + ); + } + assert!(vectors.len() >= 20, "keep the vector table broad"); +} + +#[test] +fn one_composition_gets_independent_seeds() { + let file = fixtures::load("seed-vectors.json").expect("seed-vectors.json"); + let composition = file.get("composition").expect("composition"); + let master: u64 = fixtures::field(composition, "masterSeed") + .expect("masterSeed") + .parse() + .expect("a U64"); + let ids: Vec = composition + .get("agentIds") + .and_then(Value::as_array) + .expect("agentIds") + .iter() + .map(|v| v.as_str().expect("an id").to_owned()) + .collect(); + let seeds = seed::composition_seeds(master, &ids).expect("seeds"); + let recorded: Vec = composition + .get("seeds") + .and_then(Value::as_array) + .expect("seeds") + .iter() + .map(|v| v.as_i64().expect("a seed")) + .collect(); + assert_eq!( + seeds.iter().map(|s| i64::from(*s)).collect::>(), + recorded + ); + let mut unique = seeds.clone(); + unique.sort_unstable(); + unique.dedup(); + assert_eq!(unique.len(), seeds.len(), "per-agent seeds are independent"); + assert!( + seeds.iter().all(|s| *s != 0), + "a zero seed would stall an xorshift generator" + ); +} + +#[test] +fn a_different_master_seed_or_agent_id_derives_a_different_seed() { + assert_ne!( + seed::agent_seed(0, "fly-a").expect("seed"), + seed::agent_seed(1, "fly-a").expect("seed") + ); + assert_ne!( + seed::agent_seed(0, "fly-a").expect("seed"), + seed::agent_seed(0, "fly-b").expect("seed") + ); + assert_eq!( + seed::agent_seed(7, "fly-a").expect("seed"), + seed::agent_seed(7, "fly-a").expect("seed"), + "the derivation is a function of its recorded inputs" + ); +} + +#[test] +fn invalid_inputs_are_refused_rather_than_normalized() { + let file = fixtures::load("seed-vectors.json").expect("seed-vectors.json"); + for case in file.get("invalid").and_then(Value::as_array).expect("invalid") { + let master: u64 = fixtures::field(case, "masterSeed") + .expect("masterSeed") + .parse() + .expect("a U64"); + if let Ok(agent) = fixtures::field(case, "agentId") { + assert!( + seed::agent_seed(master, agent).is_err(), + "{agent:?} must be refused: {}", + fixtures::field(case, "reason").unwrap_or("") + ); + } else { + let ids: Vec = case + .get("agentIds") + .and_then(Value::as_array) + .expect("agentIds") + .iter() + .map(|v| v.as_str().expect("an id").to_owned()) + .collect(); + assert!( + seed::composition_seeds(master, &ids).is_err(), + "a repeated agent id must be refused" + ); + } + } +} diff --git a/services/flysim/crates/fly-session-types/tests/traces.rs b/services/flysim/crates/fly-session-types/tests/traces.rs new file mode 100644 index 0000000..75103f6 --- /dev/null +++ b/services/flysim/crates/fly-session-types/tests/traces.rs @@ -0,0 +1,113 @@ +//! The step-v1 section 8 trace comparator: behaviour only. + +use fly_session_types::fixtures; +use fly_session_types::scalar::DomainType; +use fly_session_types::trace::TransitionTrace; +use serde_json::Value; + +fn trace(value: &Value) -> TransitionTrace { + TransitionTrace::from_json(value).expect("a valid trace") +} + +#[test] +fn every_variant_compares_the_way_the_fixture_says() { + let file = fixtures::load("traces.json").expect("traces.json"); + let baseline = trace(file.get("baseline").expect("baseline")); + for case in file + .get("variants") + .and_then(Value::as_array) + .expect("variants") + { + let name = fixtures::field(case, "name").expect("name"); + let variant = trace(case.get("trace").expect("trace")); + let expected = case + .get("behaviourEquals") + .and_then(Value::as_bool) + .expect("behaviourEquals"); + let equal = baseline.behaviour_equals(&variant); + let diff = baseline.behaviour_diff(&variant); + assert_eq!( + equal, expected, + "{name}: behaviour equality. differences: {diff:?}" + ); + assert_eq!( + diff.is_empty(), + expected, + "{name}: the diff must be empty exactly when the behaviour matches" + ); + if let Ok(needle) = fixtures::field(case, "diffContains") { + assert!( + diff.iter().any(|line| line.contains(needle)), + "{name}: the diff should name {needle:?}, got {diff:?}" + ); + } + if expected { + assert_eq!( + baseline.behaviour.digest().expect("digest"), + variant.behaviour.digest().expect("digest"), + "{name}: equal behaviour has one digest" + ); + } + } +} + +#[test] +fn a_whole_run_compares_transition_by_transition() { + let file = fixtures::load("traces.json").expect("traces.json"); + let baseline = trace(file.get("baseline").expect("baseline")); + let variants = file + .get("variants") + .and_then(Value::as_array) + .expect("variants"); + let reversed = trace(variants[0].get("trace").expect("trace")); + let changed = trace( + variants + .iter() + .find(|case| fixtures::field(case, "name").unwrap_or("") == "one extra neural tick") + .expect("the extra tick variant") + .get("trace") + .expect("trace"), + ); + assert!(TransitionTrace::runs_equal( + &[baseline.clone(), baseline.clone()], + &[reversed, baseline.clone()] + )); + assert!(!TransitionTrace::runs_equal( + std::slice::from_ref(&baseline), + &[changed] + )); + let longer = [baseline.clone(), baseline.clone()]; + assert!( + !TransitionTrace::runs_equal(std::slice::from_ref(&baseline), &longer), + "a run with more transitions is not the same run" + ); +} + +/// The operational half is recorded, and never part of the comparison. +#[test] +fn operational_metadata_is_recorded_and_excluded() { + let file = fixtures::load("traces.json").expect("traces.json"); + let baseline = trace(file.get("baseline").expect("baseline")); + assert_eq!(baseline.operational.bus_call_ids.len(), 3); + assert_eq!(baseline.operational.prepare_request_ids.len(), 2); + assert_eq!(baseline.operational.delivery_ids.len(), 2); + assert!(baseline.operational.wall_time_ns > 0); + let retried = trace( + file.get("variants") + .and_then(Value::as_array) + .expect("variants") + .iter() + .find(|case| { + fixtures::field(case, "name").unwrap_or("") + == "a safe retry with fresh bus callIds, delivery ids and wall time" + }) + .expect("the retry variant") + .get("trace") + .expect("trace"), + ); + assert_ne!( + baseline.operational, retried.operational, + "the retry really did change the operational half" + ); + assert!(baseline.behaviour_equals(&retried)); +} diff --git a/services/flysim/crates/fly-session/Cargo.toml b/services/flysim/crates/fly-session/Cargo.toml new file mode 100644 index 0000000..f2f110a --- /dev/null +++ b/services/flysim/crates/fly-session/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "fly-session" +version.workspace = true +edition = "2024" +rust-version.workspace = true +license.workspace = true +publish = false +description = "The lockstep session coordinator, its phase machine and a synthetic composition over flybus." + +[lib] +name = "fly_session" +path = "src/lib.rs" + +[dependencies] +# The domain contract (scalars, payloads, canonical digests, the trace format) and the bus. +# Everything else this crate needs is std or Tokio. +fly-session-types = { path = "../fly-session-types" } +flybus = { path = "../flybus" } + +serde_json = { workspace = true } +tokio = { version = "1", features = ["rt", "sync", "time", "macros"] } + +[dev-dependencies] +tempfile = "3" +tokio = { version = "1", features = ["rt-multi-thread", "macros", "time"] } diff --git a/services/flysim/crates/fly-session/README.md b/services/flysim/crates/fly-session/README.md new file mode 100644 index 0000000..6821bd6 --- /dev/null +++ b/services/flysim/crates/fly-session/README.md @@ -0,0 +1,157 @@ +# fly-session + +The lockstep session coordinator, its phase machine and a synthetic composition over +[`flybus`](../flybus). + +This crate is the SESSION-01 slice of the session-framework implementation guide: the +sequential transaction of `step-v1`, driven over the Flybus router, with small fake workers +standing in for a brain and an emulator. It contains no public controller API, no implicit +best-effort retry, no real emulator and no real brain. + +The domain scalars, method payloads, their validation, the canonical digests and the trace +format all come from [`fly-session-types`](../fly-session-types), the CONTRACT-01 crate. This +crate adds only what is not part of the type contract: a session-side error value, the +synthetic composition's schema and event-id derivations, and the coordinator-local +`ControllerIntent`, `PortBinding` and `AgentOutcome` that never cross the bus. + +```text +Ready(k) ─ Prepare all agents concurrently ────────────> every agent Prepared(k) + ─ one executor per agent, sorted agent-id order + ─ one complete port batch, descriptor port order + ─ exactly one Environment.Advance(k, batch) ──> boundary k+1 + ─ task.evaluate_transition, once + ─ Commit all agents concurrently ─────────────> every agent Ready(k+1) + ─ committed boundary k+1, publish, next Prepare allowed +``` + +## Layout + +| Module | Contents | +| --- | --- | +| `types` | A facade over the [`fly-session-types`](../fly-session-types) crate, plus the session-side additions a coordinator needs | +| `clock` | The `step-v1` section 5 rational tick accumulator and the coordinator's pacing | +| `phase` | The `step-v1` section 2 state machine as an explicit edge table | +| `dedup` | The `ipc-v1` section 5 operation keys, result caches and retention | +| `worker` | The worker dispatch shell: one service, the common `Worker.*` methods, admission | +| `agent` | A fake agent worker: seeded model, mutation counter, fixed readout stub | +| `environment` | The counter arena: one complete batch per advance, one native frame | +| `task` | The task and executor traits, the deterministic counter task, the identity executor | +| `rpc` | Domain calls: `req-` serials, incarnation pinning, the retry rule | +| `coordinator` | The transaction, the trace, the failure rules and the publication boundary | +| `harness` | The runnable composition: router, two agents, one arena, one coordinator | + +## What it implements + +- **The transaction, in order.** Prepare all agents concurrently; run each task-local executor + once in sorted agent-id order; assemble all configured port controls in descriptor port + order; send exactly one `Environment.Advance`; evaluate the task once; commit all agents + concurrently. The committed boundary moves only when every commit has succeeded. +- **The state machine**, including `Paused` and `Failed`, with every transition recorded. A + transition the `step-v1` section 2 table does not list returns `INVALID_PHASE`. +- **The committed boundary rule.** Only `Ready(k)` or `Paused(k)` is a committed boundary; a + snapshot publishes one of those and never an in-progress mix of new agent state and an old + world. +- **Time and pacing** with checked rational accumulation. A 60 Hz world with a 1 ms model tick + produces 16, 17, 17 ticks over three steps, totalling 50, with a remainder of exactly zero. + Wall time is only pacing: when behind, the coordinator omits the sleep and reports the lag. +- **Initialization, pause and episodes.** The environment initializes first, while stopped; + the task bootstraps; then the agents warm up with learning disabled. Nothing in bootstrap + advances the world or produces a gameplay reward. A pause arriving mid-step completes the + transition and pauses at its committed boundary. A terminal task event commits its final + rewards, then the session pauses; no worker resets itself. +- **The failure rules.** A partial commit fails the epoch; an uncertain Advance is resolved + against its original domain request id and never becomes a second batch; a worker + incarnation change invalidates the epoch. +- **Domain deduplication over bus calls.** Same key, request and body replays its cached + reply with fresh delivery ownership over retained artifacts; a changed body is `CONFLICT`; a + duplicate of a running operation is `IN_PROGRESS` for that bus call while the original + completes; an evicted record is `RESULT_EXPIRED`; a newly issued request naming an old step + is `STALE_STEP`. `Worker.Acknowledge` releases a domain result cache, which is not a bus + `delivery.consumed`. + +## API + +```rust +let harness = SessionHarness::start(Via::Unix, dir.path(), HarnessConfig::default()).await?; +harness.coordinator.bootstrap().await?; // Ready(0), world stopped at boundary 0 +let reports = harness.coordinator.run(3).await?; // three transitions +harness.coordinator.pause_handle().request(); // finish this transition, then pause +harness.coordinator.trace.behavior(); // the step-v1 section 8 behaviour trace +harness.shutdown().await; +``` + +- `Coordinator::dispatch` selects `Sequential`, `Concurrent` or `Reversed` per-agent dispatch. + All three must produce the same behaviour trace; that is a test. +- `Coordinator::injections` asks for one deliberate message fault at one step: a duplicate + Prepare or Commit, an abandoned Advance result, an altered control batch, or a consumed + result artifact followed by a replay. `injection_log` reports what came back. +- `Coordinator::probe_raw` sends one domain request as it stands and returns the worker's own + terminal outcome, without letting the answer change session state. +- `AgentFaults` and `EnvironmentFaults` ask a worker for a deliberate delay or failure. + +## The synthetic composition + +- **Agents.** A fake model is an LCG with an explicit seed and one counter of everything that + mutated it: ticks, stimulations, reinforcements and input installs. The worker reports that + counter as its `progressCounter`, which is how a test proves a duplicate repeated nothing. + The readout is a fixed stub: it reads bits of the current state, masked by the declared + available actions, and never changes its own weights or invents a default winner. +- **Environment.** A signed counter. `inc` adds one, `dec` subtracts one, and one bipolar + `bias` axis is carried and validated but does not move the world. Each observation seals one + immutable 4x4 RGBA frame whose bytes carry the counter, so an agent reading its sensory view + reads the world rather than a constant. +- **Task.** Rewards are the counter delta of each agent's own port control, with deterministic + event ids derived from epoch, source step, rule and ordinal. +- **Executors.** The stateless identity executor only, as v1 specifies. + +## Where this crate narrows or adds to the contract crate + +- **Required views.** `WorldObservation::validate_against` checks the views a result carries + against their descriptors. Requiring every *declared* view to be there at all is the + coordinator's Phase C check, so `verify_step_result` makes it: a missing required sensory + view fails the transition with `BUFFER_INVALID` rather than being replaced by an older frame. +- **`ControllerIntent`.** `workers-v1` section 4 calls the task and executor interfaces local + libraries, so their types live here rather than in the payload contract. An intent is a + `PortControl` without its port, and only the coordinator adds the port. +- **The phase machine.** `step-v1` section 2 is this crate's, not the contract crate's; the + trace's phase path is recorded beside the contract's `TransitionTrace`. The mid-step pause + it takes -- the transition finishes, then the session pauses at the boundary it just + committed -- is now written into the section 2 machine as a dated amendment. + +## Limitations + +- **Fake workers.** There is no neural model and no emulator. What is modelled exactly is the + ordering, the identity rules and the retry rules, not any numerical behaviour. +- **No state methods.** `State.Capture`, `State.StageRestore` and `State.ActivateRestore` are + STATE-01. The phase machine has their edges (`Capturing`, `Restoring`) and the workers do not + advertise them as implemented methods. +- **One process.** SESSION-01 runs every participant in one process over the same router. + SESSION-02 is the per-fly process split. +- **No audience input.** The admitted pre-step stimulation list exists and is always empty. +- **Pacing is coarse.** The pacing deadline rounds one step to whole nanoseconds for sleeping + only; simulation time stays rational and that rounding never re-enters the accumulator. + +## Tests + +```text +cargo test -p fly-session # unit + both integration suites +cargo run -p fly-session --example session # the runnable synthetic session +``` + +Every integration test runs over both transports, through the same router code: all but one +are generated twice by `both_transports!`, and +`sequential_concurrent_and_reversed_orders_agree` walks both transports inside one test +because it compares their behaviour traces against each other. + +- `tests/session.rs`: one world advance per complete batch; every agent Prepared before the + advance; one task evaluation per transition; every agent committed before the next Prepare or + any committed publication; the 16/17/17 tick profile with a zero remainder; a mid-step pause + completing its transition; bootstrap advancing nothing; the committed snapshot naming the + transition that just ended; a terminal episode pausing at its own boundary; `Worker.Status` + during a session; and sequential, concurrent and reversed dispatch producing one behaviour + trace. +- `tests/failures.rs`: a duplicate Prepare after a lost reply; a duplicate Commit; the same + batch with altered controls; a lost Advance result; a cached artifact consumed by its first + caller; one Commit failing after another succeeded; a replaced registration; a reply from + another incarnation; a world that advanced without sensory data; an exact duplicate of a + running operation; and an old-epoch operation. diff --git a/services/flysim/crates/fly-session/examples/session.rs b/services/flysim/crates/fly-session/examples/session.rs new file mode 100644 index 0000000..cc4e5e9 --- /dev/null +++ b/services/flysim/crates/fly-session/examples/session.rs @@ -0,0 +1,55 @@ +//! The synthetic sequential transaction, run over both transports and printed. +//! +//! ```text +//! cargo run -p fly-session --example session +//! ``` +//! +//! Two fake agents, one counter arena, one coordinator, one router. Nothing here needs a ROM, +//! a dataset, a GPU or a network. + +use fly_session::types::*; +use fly_session::harness::{HarnessConfig, SessionHarness, Via}; + +#[tokio::main(flavor = "multi_thread", worker_threads = 4)] +async fn main() { + for via in [Via::Memory, Via::Unix] { + let dir = tempfile::tempdir().expect("a temporary directory"); + let mut harness = SessionHarness::start(via, dir.path(), HarnessConfig::default()) + .await + .expect("the session starts"); + harness.coordinator.bootstrap().await.expect("bootstrap"); + println!("--- {via:?}: Ready(0) with the world stopped at boundary 0"); + let reports = harness.coordinator.run(3).await.expect("three transitions"); + + for (k, transition) in harness.coordinator.trace.transitions.iter().enumerate() { + let ticks: Vec = transition + .behaviour + .agents + .iter() + .map(|a| format!("{}={} ticks", a.agent_id, a.ticks_advanced)) + .collect(); + println!( + "step {k}: {} batch={} boundary={} events={}", + ticks.join(" "), + transition.behaviour.batch_id, + transition.behaviour.acknowledged_boundary, + transition.behaviour.event_ids.len() + ); + } + let progress = harness.coordinator.task_progress(); + println!( + "{via:?}: {} advances, counter {}, reward {}, {} publications, last boundary {}", + harness.coordinator.stats().advances, + progress.integer("counter").unwrap_or_default(), + progress.number("totalReward").unwrap_or_default(), + harness.coordinator.stats().publications, + reports.last().map(|r| r.boundary).unwrap_or_default(), + ); + // The behaviour trace is what two runs in different dispatch orders must agree on. + for line in harness.coordinator.trace.behavior() { + println!(" behaviour: {line}"); + } + harness.shutdown().await; + drop(dir); + } +} diff --git a/services/flysim/crates/fly-session/src/agent.rs b/services/flysim/crates/fly-session/src/agent.rs new file mode 100644 index 0000000..68a621b --- /dev/null +++ b/services/flysim/crates/fly-session/src/agent.rs @@ -0,0 +1,681 @@ +//! A small fake agent worker: an explicit seed, a mutation counter the tests read, and a fixed +//! readout stub. +//! +//! There is no neural model here and no attempt to imitate one. What it does model exactly is +//! the *ordering* `workers-v1` section 2 requires: Prepare applies pre-step stimulation, then +//! advances whole ticks, then decodes; Commit installs the next input, then applies task +//! stimulation, then reinforces once, and executes no tick at all. Every mutating step bumps +//! one counter, which is how a test proves a duplicate request changed nothing. + +use std::collections::BTreeMap; + +use serde_json::Value; + +use crate::clock::TickAccumulator; +use crate::dedup::OpClass; +use crate::task::{context_schema, decision_schema}; +// `crate::types` is this crate's facade over the shared `fly-session-types` crate; the +// glob keeps the contract's own names in sight instead of restating them. +use crate::types::*; +use crate::worker::{BoxFuture, HandlerCtx, HandlerReply, StatusCell, WorkerEndpoint}; + +/// The fake numerical model: a seeded stream and a count of everything that mutated it. +#[derive(Clone, Debug)] +pub struct FakeModel { + seed: i32, + state: u64, + mutations: u64, + ticks: u64, + stimulations: u64, + reinforcements: u64, + learning_enabled: bool, + learning_updates: u64, + learning_changed: u64, + last_signal: f64, + input_value: i64, + input_installs: u64, +} + +impl FakeModel { + /// A model at its initial state for `seed`. The seed is run configuration and state, and + /// the same seed always produces the same stream. + pub fn new(seed: i32) -> FakeModel { + FakeModel { + seed, + // Sign-extend so a negative seed is a distinct stream rather than a truncation. + state: (seed as i64 as u64) ^ 0x9e37_79b9_7f4a_7c15, + mutations: 0, + ticks: 0, + stimulations: 0, + reinforcements: 0, + learning_enabled: false, + learning_updates: 0, + learning_changed: 0, + last_signal: 0.0, + input_value: 0, + input_installs: 0, + } + } + + pub fn seed(&self) -> i32 { + self.seed + } + + /// Every mutation this model has taken: ticks, stimulations, reinforcements and installs. + /// + /// Tests read this to prove a duplicate request repeated nothing. + pub fn mutations(&self) -> u64 { + self.mutations + } + + pub fn ticks(&self) -> u64 { + self.ticks + } + + pub fn reinforcements(&self) -> u64 { + self.reinforcements + } + + pub fn stimulations(&self) -> u64 { + self.stimulations + } + + /// How many times the next sensory input was installed: once per Initialize and Commit. + pub fn input_installs(&self) -> u64 { + self.input_installs + } + + /// The scalar the encoder last installed. + pub fn input_value(&self) -> i64 { + self.input_value + } + + fn draw(&mut self) -> u64 { + self.state = self + .state + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1_442_695_040_888_963_407); + self.mutations += 1; + self.state + } + + /// Advances `ticks` whole model ticks. This is the only place ticks are executed. + fn advance(&mut self, ticks: u64) { + for _ in 0..ticks { + self.draw(); + self.ticks += 1; + } + } + + /// Applies one declared stimulus. The kind resolves through the profile; a caller never + /// names a neuron or a drive value. + fn stimulate(&mut self, stimulus: &Stimulus) { + let kind = u64::from_le_bytes({ + let d = digest_of_bytes(stimulus.kind_id.as_bytes()); + let bytes = d.as_bytes(); + let mut out = [0u8; 8]; + out.copy_from_slice(&bytes[..8]); + out + }); + self.state ^= kind ^ (stimulus.duration_ms.to_bits()); + self.mutations += 1; + self.stimulations += 1; + } + + /// Installs the next encoded sensory input for the following Prepare. + fn install_input(&mut self, value: i64) { + self.input_value = value; + self.state ^= value as u64; + self.mutations += 1; + self.input_installs += 1; + } + + /// Reinforces once at the current brain time. A zero sum still reinforces: the profile's + /// legacy-equivalent behaviour is not optimized away without evidence. + fn reinforce(&mut self, signal: f64) { + self.last_signal = signal; + self.reinforcements += 1; + self.mutations += 1; + if self.learning_enabled { + self.learning_updates += 1; + if signal != 0.0 { + self.learning_changed += 1; + self.state ^= signal.to_bits(); + } + } + } + + /// The fixed readout: a deterministic decode of the current state, masked by the declared + /// available actions. It never invents a default winner or changes its own weights. + fn readout(&self, available: &[String]) -> (bool, bool, f64) { + // Separate bits of one rotated word, because an LCG's low bits are too regular to + // read a decision off directly. + let draw = self.state.rotate_right(29); + let mut inc = draw & 1 == 1; + let mut dec = !inc && (draw >> 7) & 1 == 1; + if !available.iter().any(|a| a == "inc") { + inc = false; + } + if !available.iter().any(|a| a == "dec") { + dec = false; + } + // Exactly representable in f64, so a digest over the decision is stable. + let bias = (((draw >> 13) % 5) as f64 - 2.0) / 4.0; + (inc, dec, bias) + } + + fn telemetry(&self) -> AgentTelemetry { + let draw = self.state >> 29; + AgentTelemetry { + brain_ticks: self.ticks, + population_rate_hz: (draw % 1000) as f64 / 10.0, + rates: vec![ + RateSample { role_id: id("kc"), hz: (draw % 700) as f64 / 10.0 }, + RateSample { role_id: id("mbon"), hz: (draw % 310) as f64 / 10.0 }, + ], + learning: LearningTelemetry { + enabled: self.learning_enabled, + updates: self.learning_updates, + changed: self.learning_changed, + signal: self.last_signal, + }, + } + } +} + +/// Deliberate faults a test can ask this worker to produce. +#[derive(Clone, Debug, Default)] +pub struct AgentFaults { + /// Fail `Agent.Commit` at this step, after the next input was installed, so the coordinator + /// meets a partially applied mutation rather than a clean refusal. + pub fail_commit_at_step: Option, + /// Hold `Agent.Prepare` open for this long, to reorder completions. + pub prepare_delay_ms: u64, + /// Hold `Agent.Commit` open for this long. + pub commit_delay_ms: u64, +} + +/// One fake agent worker's configuration. +#[derive(Clone, Debug)] +pub struct AgentConfig { + pub session_id: Id, + pub agent_id: Id, + pub incarnation_id: Id, + pub tick_duration: RationalNs, + pub warmup_ticks: u64, + pub faults: AgentFaults, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +enum AgentPhase { + Uninitialized, + Ready(u64), + Prepared(u64), + Failed, +} + +/// The agent worker endpoint: model, sensor encoder and readout in one bundle. +pub struct FakeAgentWorker { + config: AgentConfig, + status: StatusCell, + phase: AgentPhase, + epoch: Option, + profile: Option, + accumulator: Option, + model: FakeModel, + context: Option, + context_digest: Option, + prepared: Option<(DomainRequestId, PreparedDecision)>, +} + +impl FakeAgentWorker { + pub fn new(config: AgentConfig) -> FakeAgentWorker { + FakeAgentWorker { + status: StatusCell::new(), + phase: AgentPhase::Uninitialized, + epoch: None, + profile: None, + accumulator: None, + model: FakeModel::new(0), + context: None, + context_digest: None, + prepared: None, + config, + } + } + + pub fn status(&self) -> StatusCell { + self.status.clone() + } + + fn check_epoch(&self, scope: &Scope) -> DomainResult<()> { + if scope.session_id != self.config.session_id { + return Err(DomainError::before( + ErrorCode::IdentityMismatch, + "this worker belongs to another session", + )); + } + match &self.epoch { + Some(epoch) if *epoch == scope.epoch => Ok(()), + Some(_) => Err(DomainError::before( + ErrorCode::StaleEpoch, + "this scope names an epoch this worker has left", + )), + None => Err(DomainError::before( + ErrorCode::InvalidPhase, + "this worker is uninitialized", + )), + } + } + + /// Encodes a sensory input into the one scalar the fake model consumes. + /// + /// Reading the pixels is what proves the attachment was a live owned handle rather than a + /// bare reference; a missing required view is an error, never zero input. + async fn encode(&self, ctx: &HandlerCtx<'_>, input: &SensoryInput) -> DomainResult { + input.validate().map_err(DomainError::invalid)?; + let mut total: i64 = 0; + for view in &input.views { + let name = format!("view.{}", view.view_id); + let artifact = ctx.artifact(&name)?; + if artifact.reference() != &view.pixels { + return Err(DomainError::before( + ErrorCode::BufferInvalid, + format!("attachment {name} is not the artifact the payload names"), + )); + } + let bytes = artifact.read_all().await.map_err(|e| { + DomainError::before( + ErrorCode::BufferInvalid, + format!("view {} could not be read: {}", view.view_id, e.message), + ) + })?; + if bytes.len() as u64 != view.pixels.byte_length { + return Err(DomainError::before( + ErrorCode::BufferInvalid, + format!("view {} is the wrong length", view.view_id), + )); + } + total += i64::from(bytes.first().copied().unwrap_or_default()); + } + if let Some(structured) = &input.structured { + total += structured.integer("counter").map_err(DomainError::invalid)?; + } + Ok(total) + } + + fn available_actions(context: &TypedValue) -> DomainResult> { + if context.schema != context_schema() { + return Err(DomainError::before( + ErrorCode::IdentityMismatch, + "the decision context does not carry the schema this profile allows", + )); + } + let list = context + .value + .get("available") + .and_then(Value::as_array) + .ok_or_else(|| DomainError::invalid("the decision context declares no actions"))?; + list.iter() + .map(|v| { + v.as_str() + .map(str::to_owned) + .ok_or_else(|| DomainError::invalid("an available action is not a string")) + }) + .collect() + } + + fn decision(&self, available: &[String]) -> TypedValue { + let (inc, dec, bias) = self.model.readout(available); + let intent = ControllerIntent { + buttons: vec![ + ButtonState { id: id("inc"), down: inc }, + ButtonState { id: id("dec"), down: dec }, + ], + axes: vec![AxisValue { id: id("bias"), value: bias }], + }; + TypedValue::new(decision_schema(), intent.to_json()) + .expect("a direct-control decision fits the contract") + } + + async fn initialize(&mut self, ctx: &HandlerCtx<'_>) -> DomainResult { + let scope = ctx.scope()?.clone(); + if self.phase != AgentPhase::Uninitialized { + return Err(DomainError::before( + ErrorCode::InvalidPhase, + "Agent.Initialize is only allowed on an uninitialized agent; restore uses the \ + state interface", + )); + } + if scope.step != 0 { + return Err(DomainError::before( + ErrorCode::FutureStep, + "Agent.Initialize uses the new epoch at step 0", + )); + } + if scope.session_id != self.config.session_id { + return Err(DomainError::before( + ErrorCode::IdentityMismatch, + "this worker belongs to another session", + )); + } + let params: AgentInitializeParams = ctx.params()?; + if params.agent_id != self.config.agent_id { + return Err(DomainError::before( + ErrorCode::IdentityMismatch, + "Agent.Initialize names another agent", + )); + } + if params.worker_threads == 0 { + return Err(DomainError::invalid("workerThreads must be >= 1")); + } + params.initial_decision_context.validate().map_err(DomainError::invalid)?; + let available = FakeAgentWorker::available_actions(¶ms.initial_decision_context)?; + // Everything is validated before the model is constructed. + let encoded = self.encode(ctx, ¶ms.initial_input).await?; + if params.initial_input.boundary != 0 { + return Err(DomainError::invalid("the initial input must observe boundary 0")); + } + + let mut accumulator = + TickAccumulator::new(self.config.tick_duration).map_err(DomainError::invalid)?; + let mut model = FakeModel::new(params.seed); + model.install_input(encoded); + // Warm-up runs with learning disabled and produces no gameplay reward or control. + model.advance(self.config.warmup_ticks); + accumulator.warm_up(self.config.warmup_ticks).map_err(|e| { + DomainError::new(ErrorCode::Internal, e, MutationCertainty::Applied) + })?; + // Calibration happens on settled rates, after warm-up. The readout is a pure read of + // the model, so calibrating it mutates nothing. + let _calibration = model.readout(&available); + model.learning_enabled = true; + + self.model = model; + self.accumulator = Some(accumulator); + self.epoch = Some(scope.epoch.clone()); + self.profile = Some(params.profile.clone()); + self.context_digest = Some(params.initial_decision_context.digest()); + self.context = Some(params.initial_decision_context); + self.phase = AgentPhase::Ready(0); + self.status.set_state(WorkerState::Ready); + self.status.set_scope(Some(scope.clone())); + self.status.advance_to(self.model.mutations()); + + let result = AgentInitializeResult { + agent_id: self.config.agent_id.clone(), + profile_digest: params.profile.digest.clone(), + tick_duration: self.config.tick_duration, + warmup_ticks: self.config.warmup_ticks, + committed_step: 0, + decision_context_digest: self.context_digest.clone().expect("just set"), + telemetry: self.model.telemetry(), + }; + Ok(HandlerReply::from(&result)) + } + + async fn prepare(&mut self, ctx: &HandlerCtx<'_>) -> DomainResult { + let scope = ctx.scope()?.clone(); + self.check_epoch(&scope)?; + let AgentPhase::Ready(k) = self.phase.clone() else { + return Err(DomainError::before( + ErrorCode::InvalidPhase, + format!("Agent.Prepare needs Ready(k); this worker is {:?}", self.phase), + )); + }; + if scope.step < k { + return Err(DomainError::before( + ErrorCode::StaleStep, + "Agent.Prepare names a step this worker has left", + )); + } + if scope.step > k { + return Err(DomainError::before( + ErrorCode::FutureStep, + "Agent.Prepare names a step beyond this worker's committed boundary", + )); + } + let params: PrepareParams = ctx.params()?; + if params.agent_id != self.config.agent_id { + return Err(DomainError::before( + ErrorCode::IdentityMismatch, + "Agent.Prepare names another agent", + )); + } + let profile = self.profile.as_ref().expect("initialized"); + if params.profile_digest != profile.digest { + return Err(DomainError::before( + ErrorCode::IdentityMismatch, + "Agent.Prepare names another profile", + )); + } + if Some(¶ms.decision_context_digest) != self.context_digest.as_ref() { + return Err(DomainError::before( + ErrorCode::IdentityMismatch, + "the cached decision context digest does not match", + )); + } + params.interval.validate().map_err(DomainError::invalid)?; + if params.pre_step_stimulations.len() > MAX_STIMULI { + return Err(DomainError::invalid("at most 64 pre-step stimulations")); + } + for stimulus in ¶ms.pre_step_stimulations { + stimulus.validate().map_err(DomainError::invalid)?; + } + let available = + FakeAgentWorker::available_actions(self.context.as_ref().expect("initialized"))?; + + self.status.set_state(WorkerState::Preparing); + if self.config.faults.prepare_delay_ms > 0 { + tokio::time::sleep(std::time::Duration::from_millis( + self.config.faults.prepare_delay_ms, + )) + .await; + } + + // 1. admitted pre-step stimulation, in deterministic command sequence order + for stimulus in ¶ms.pre_step_stimulations { + self.model.stimulate(stimulus); + } + // 2. advance the numerical model for the environment interval + let ticks = { + let accumulator = self.accumulator.as_mut().expect("initialized"); + accumulator.advance(¶ms.interval).map_err(|e| { + DomainError::new(ErrorCode::InvalidArgument, e, MutationCertainty::Applied) + })? + }; + self.model.advance(ticks); + // 3. read rates and perform the fixed readout with the declared decision context + let decision = self.decision(&available); + let (brain_ticks, remainder) = { + let accumulator = self.accumulator.as_ref().expect("initialized"); + (accumulator.brain_ticks(), accumulator.remainder()) + }; + let prepared = PreparedDecision { + agent_id: self.config.agent_id.clone(), + ticks_advanced: ticks, + brain_ticks, + remainder, + decision, + }; + self.prepared = Some((ctx.request.request_id.clone(), prepared.clone())); + self.phase = AgentPhase::Prepared(k); + self.status.set_state(WorkerState::Prepared); + self.status.advance_to(self.model.mutations()); + Ok(HandlerReply::from(&prepared)) + } + + async fn commit(&mut self, ctx: &HandlerCtx<'_>) -> DomainResult { + let scope = ctx.scope()?.clone(); + self.check_epoch(&scope)?; + let AgentPhase::Prepared(k) = self.phase.clone() else { + return Err(DomainError::before( + ErrorCode::InvalidPhase, + format!("Agent.Commit needs Prepared(k); this worker is {:?}", self.phase), + )); + }; + if scope.step != k { + return Err(DomainError::before( + if scope.step < k { ErrorCode::StaleStep } else { ErrorCode::FutureStep }, + "Agent.Commit must carry the step of its transition, not the new boundary", + )); + } + let params: CommitParams = ctx.params()?; + if params.agent_id != self.config.agent_id { + return Err(DomainError::before( + ErrorCode::IdentityMismatch, + "Agent.Commit names another agent", + )); + } + let (prepared_request, _) = self.prepared.as_ref().expect("prepared"); + if params.prepared_request_id != *prepared_request { + return Err(DomainError::before( + ErrorCode::IdentityMismatch, + "Agent.Commit does not match this worker's Prepare request", + )); + } + if params.next_input.boundary != k + 1 { + return Err(DomainError::invalid( + "the next sensory input must observe boundary k+1", + )); + } + if params.rewards.len() > MAX_REWARDS || params.task_stimulations.len() > MAX_STIMULI + { + return Err(DomainError::invalid("at most 64 rewards and 64 stimulations")); + } + for reward in ¶ms.rewards { + reward.validate().map_err(DomainError::invalid)?; + } + for stimulus in ¶ms.task_stimulations { + stimulus.validate().map_err(DomainError::invalid)?; + } + params.next_decision_context.validate().map_err(DomainError::invalid)?; + FakeAgentWorker::available_actions(¶ms.next_decision_context)?; + // The complete request and every required owned artifact are validated before anything + // is applied. + let encoded = self.encode(ctx, ¶ms.next_input).await?; + + self.status.set_state(WorkerState::Committing); + if self.config.faults.commit_delay_ms > 0 { + tokio::time::sleep(std::time::Duration::from_millis( + self.config.faults.commit_delay_ms, + )) + .await; + } + + // 1. encode and install the next sensory input for the following Prepare + self.model.install_input(encoded); + if self.config.faults.fail_commit_at_step == Some(k) { + // A deliberate fault after the input was installed: the brain is already mutated, + // so the coordinator has to recover the group rather than retry this agent. + self.phase = AgentPhase::Failed; + self.status.set_state(WorkerState::Failed); + return Err(DomainError::new( + ErrorCode::BackendFailure, + "injected commit failure after the next input was installed", + MutationCertainty::Applied, + )); + } + // 2. apply task-derived stimulation in returned event order + for stimulus in ¶ms.task_stimulations { + self.model.stimulate(stimulus); + } + // 3. sum this agent's rewards in returned event order and reinforce once + let mut signal = 0.0f64; + for reward in ¶ms.rewards { + signal += reward.value; + } + self.model.reinforce(signal); + // 4. retain the next decision context and acknowledge boundary k+1 + self.context_digest = Some(params.next_decision_context.digest()); + self.context = Some(params.next_decision_context); + self.prepared = None; + self.phase = AgentPhase::Ready(k + 1); + self.status.set_state(WorkerState::Ready); + self.status.set_scope(Some(scope_at(&scope.session_id, &scope.epoch, k + 1))); + self.status.advance_to(self.model.mutations()); + + let result = AgentCommitResult { + agent_id: self.config.agent_id.clone(), + committed_step: k + 1, + decision_context_digest: self.context_digest.clone().expect("just set"), + telemetry: self.model.telemetry(), + }; + Ok(HandlerReply::from(&result)) + } + + /// The model, for a test that wants its mutation counter directly. + pub fn model(&self) -> &FakeModel { + &self.model + } +} + +impl WorkerEndpoint for FakeAgentWorker { + fn worker_id(&self) -> Id { + self.config.agent_id.clone() + } + + fn incarnation_id(&self) -> Id { + self.config.incarnation_id.clone() + } + + fn session_id(&self) -> Id { + self.config.session_id.clone() + } + + fn role(&self) -> Role { + Role::Agent + } + + fn capabilities(&self) -> Vec { + vec![id("agent-step-v1"), id("pixel-observation-v1")] + } + + fn status_cell(&self) -> StatusCell { + self.status.clone() + } + + fn methods(&self) -> Vec<&'static str> { + vec!["Agent.Initialize", "Agent.Prepare", "Agent.Commit"] + } + + fn handle<'a>(&'a mut self, ctx: HandlerCtx<'a>) -> BoxFuture<'a, DomainResult> { + Box::pin(async move { + match ctx.method { + "Agent.Initialize" => self.initialize(&ctx).await, + "Agent.Prepare" => self.prepare(&ctx).await, + "Agent.Commit" => self.commit(&ctx).await, + other => Err(DomainError::before( + ErrorCode::Unsupported, + format!("{other} is not an agent method"), + )), + } + }) + } +} + +/// The retention class table an agent endpoint follows, for a caller that wants it. +pub fn agent_op_class(method: &str) -> Option { + match method { + "Agent.Initialize" => Some(OpClass::Lifecycle), + "Agent.Prepare" | "Agent.Commit" => Some(OpClass::StepMutation), + _ => None, + } +} + +/// A synthetic profile asset for one agent. The digest covers its effective identities. +pub fn synthetic_profile(agent_id: &Id, tick_duration: &RationalNs, warmup_ticks: u64) -> AssetRef { + let text = format!( + "arena-direct-v1\nagent={agent_id}\ntick={}/{}\nwarmup={warmup_ticks}\n", + tick_duration.numerator, tick_duration.denominator + ); + AssetRef { + id: id("arena-direct-v1"), + digest: digest_of_bytes(text.as_bytes()), + byte_length: text.len() as u64, + format: id("fly-profile-v1"), + } +} + +/// The per-agent contexts a bootstrap produced, keyed by agent id. +pub type Contexts = BTreeMap; diff --git a/services/flysim/crates/fly-session/src/clock.rs b/services/flysim/crates/fly-session/src/clock.rs new file mode 100644 index 0000000..080c12a --- /dev/null +++ b/services/flysim/crates/fly-session/src/clock.rs @@ -0,0 +1,223 @@ +//! Time and pacing: `step-v1` section 5. +//! +//! The accumulator is the contract crate's exact rational arithmetic, never rounded +//! nanoseconds. A 60 Hz world with a 1 ms model tick advances 16, 17, 17 ticks over its first +//! three steps and comes back to a remainder of exactly zero; rounding to microseconds does +//! not. + +use crate::types::{DomainError, DomainType, ErrorCode, MutationCertainty, RationalNs}; + +/// One agent's tick accumulator: its tick duration, its remainder and its executed count. +#[derive(Clone, Debug)] +pub struct TickAccumulator { + tick_duration: RationalNs, + remainder: RationalNs, + executed_ticks: u64, + warmup_offset: u64, +} + +impl TickAccumulator { + /// A fresh accumulator. The tick duration must be positive. + pub fn new(tick_duration: RationalNs) -> Result { + tick_duration.validate().map_err(|e| e.0)?; + tick_duration.require_positive("tick duration").map_err(|e| e.0)?; + Ok(TickAccumulator { + tick_duration, + remainder: RationalNs::ZERO, + executed_ticks: 0, + warmup_offset: 0, + }) + } + + pub fn tick_duration(&self) -> RationalNs { + self.tick_duration + } + + /// The persisted remainder: always >= 0 and < one model tick. + pub fn remainder(&self) -> RationalNs { + self.remainder + } + + /// Every tick this accumulator has executed, warm-up included. + pub fn executed_ticks(&self) -> u64 { + self.executed_ticks + } + + /// The warm-up ticks executed before the first gameplay transition. + pub fn warmup_offset(&self) -> u64 { + self.warmup_offset + } + + /// Accounts for `ticks` of warm-up. Warm-up does not consume an environment interval, so + /// it never touches the remainder. + pub fn warm_up(&mut self, ticks: u64) -> Result<(), String> { + self.warmup_offset = self + .warmup_offset + .checked_add(ticks) + .ok_or_else(|| "warm-up tick count overflows".to_owned())?; + self.executed_ticks = self + .executed_ticks + .checked_add(ticks) + .ok_or_else(|| "executed tick count overflows".to_owned())?; + Ok(()) + } + + /// Adds one environment interval and returns the whole ticks it covers. + /// + /// ```text + /// accumulator += environment step duration + /// ticks = floor(accumulator / model tick duration) + /// accumulator -= ticks * model tick duration + /// ``` + pub fn advance(&mut self, interval: &RationalNs) -> Result { + interval.validate().map_err(|e| e.0)?; + interval.require_positive("environment interval").map_err(|e| e.0)?; + let accumulated = self.remainder.checked_add(interval).map_err(|e| e.0)?; + let (ticks, remainder) = + accumulated.divide_floor(&self.tick_duration).map_err(|e| e.0)?; + debug_assert!( + remainder < self.tick_duration, + "the remainder must stay below one model tick" + ); + self.remainder = remainder; + self.executed_ticks = self + .executed_ticks + .checked_add(ticks) + .ok_or_else(|| "executed tick count overflows".to_owned())?; + Ok(ticks) + } + + pub fn brain_ticks(&self) -> u64 { + self.executed_ticks + } +} + +/// The coordinator's pacing authority: absolute deadlines after committed boundaries. +/// +/// Only one pacing authority may be active, so this is the coordinator's and the backend does +/// not throttle as well. When behind, it omits the sleep and reports the lag; it never skips a +/// world step or drops a neural tick. +#[derive(Clone, Debug)] +pub struct Pacing { + step_duration: RationalNs, + next_deadline: Option, + lag: std::time::Duration, + lagged_steps: u64, +} + +impl Pacing { + pub fn new(step_duration: RationalNs) -> Pacing { + Pacing { + step_duration, + next_deadline: None, + lag: std::time::Duration::ZERO, + lagged_steps: 0, + } + } + + /// The wall-clock period of one step, rounded for sleeping only. Simulation time stays + /// rational; this value is never fed back into the accumulator. + fn period(&self) -> std::time::Duration { + let ns = u128::from(self.step_duration.numerator) + / u128::from(self.step_duration.denominator).max(1); + std::time::Duration::from_nanos(u64::try_from(ns).unwrap_or(u64::MAX)) + } + + /// Waits until this step's deadline. Returns the lag if the deadline had already passed. + pub async fn wait(&mut self) -> Option { + let now = std::time::Instant::now(); + let deadline = self.next_deadline.unwrap_or(now); + let outcome = if deadline > now { + tokio::time::sleep_until(tokio::time::Instant::from_std(deadline)).await; + None + } else { + let behind = now.duration_since(deadline); + if !behind.is_zero() { + self.lag += behind; + self.lagged_steps += 1; + } + Some(behind) + }; + self.next_deadline = Some(deadline.max(now) + self.period()); + outcome + } + + pub fn total_lag(&self) -> std::time::Duration { + self.lag + } + + pub fn lagged_steps(&self) -> u64 { + self.lagged_steps + } +} + +/// Converts a whole tick count to the legacy f64 millisecond clock, refusing a run that has +/// left the exactly representable range. +pub fn ticks_to_legacy_millis(ticks: u64, tick_duration: &RationalNs) -> Result { + // 2^53 is the last integer f64 represents exactly; beyond it a millisecond clock starts + // skipping representable ticks, so the run is refused rather than silently rounded. + const EXACT_F64_INTEGERS: u64 = 1 << 53; + if ticks >= EXACT_F64_INTEGERS { + return Err(DomainError::new( + ErrorCode::InvalidArgument, + "tick count exceeds the range the legacy millisecond clock represents exactly", + MutationCertainty::None, + )); + } + let per_tick_ms = + tick_duration.numerator as f64 / (tick_duration.denominator as f64 * 1_000_000.0); + Ok(ticks as f64 * per_tick_ms) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{hz, millis}; + + #[test] + fn a_60_hz_world_with_a_1_ms_tick_runs_16_17_17() { + let step = hz(60).unwrap(); + let mut acc = TickAccumulator::new(millis(1).unwrap()).unwrap(); + let ticks: Vec = (0..3).map(|_| acc.advance(&step).unwrap()).collect(); + assert_eq!(ticks, vec![16, 17, 17]); + assert_eq!(ticks.iter().sum::(), 50); + assert!(acc.remainder().is_zero()); + } + + #[test] + fn the_remainder_stays_below_one_tick_and_never_goes_negative() { + let step = hz(60).unwrap(); + let tick = millis(1).unwrap(); + let mut acc = TickAccumulator::new(tick).unwrap(); + for _ in 0..600 { + acc.advance(&step).unwrap(); + assert!(acc.remainder() < tick); + } + // 600 steps of 1/60 s is exactly 10 s, which is 10,000 whole milliseconds. + assert_eq!(acc.executed_ticks(), 10_000); + assert!(acc.remainder().is_zero()); + } + + #[test] + fn warm_up_ticks_do_not_touch_the_remainder() { + let mut acc = TickAccumulator::new(millis(1).unwrap()).unwrap(); + acc.warm_up(25).unwrap(); + assert_eq!(acc.executed_ticks(), 25); + assert_eq!(acc.warmup_offset(), 25); + assert!(acc.remainder().is_zero()); + assert_eq!(acc.advance(&hz(60).unwrap()).unwrap(), 16); + } + + #[test] + fn a_zero_interval_is_refused_rather_than_silently_producing_no_ticks() { + let mut acc = TickAccumulator::new(millis(1).unwrap()).unwrap(); + assert!(acc.advance(&RationalNs::ZERO).is_err()); + } + + #[test] + fn the_legacy_millisecond_clock_refuses_a_run_beyond_its_exact_range() { + let tick = millis(1).unwrap(); + assert_eq!(ticks_to_legacy_millis(50, &tick).unwrap(), 50.0); + assert!(ticks_to_legacy_millis(1 << 53, &tick).is_err()); + } +} diff --git a/services/flysim/crates/fly-session/src/coordinator.rs b/services/flysim/crates/fly-session/src/coordinator.rs new file mode 100644 index 0000000..3f31c87 --- /dev/null +++ b/services/flysim/crates/fly-session/src/coordinator.rs @@ -0,0 +1,2110 @@ +//! The session coordinator: the transaction of `step-v1` section 3, exactly in order. +//! +//! Phase A prepares every agent concurrently. Phase B runs each executor once in sorted +//! agent-id order, assembles one complete port batch in descriptor order, and sends exactly +//! one `Environment.Advance`. Phase C evaluates the task once. Phase D commits every agent +//! concurrently, and only when all of them have succeeded does the committed boundary move, +//! the snapshot publish and the next Prepare become allowed. +//! +//! There is no implicit best-effort retry here. An uncertain call is resolved against the same +//! domain request id, and anything that cannot be resolved fails the epoch. + +use std::collections::{BTreeMap, BTreeSet}; + +use serde_json::{Map, Value, json}; + +use crate::clock::Pacing; +use crate::phase::{Phase, PhaseMachine}; +use crate::rpc::{self, DomainReply, Serials, WorkerRef}; +use crate::task::{ActionExecutor, Task}; +// `crate::types` is this crate's facade over the shared `fly-session-types` crate; the +// glob keeps the contract's own names in sight instead of restating them. +use crate::types::*; + +/// The order the coordinator dispatches and awaits its per-agent phases in. +/// +/// Completion order never affects port or action order, so all three must produce the same +/// behaviour trace. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum DispatchOrder { + /// Dispatch every agent, then await them all. + #[default] + Concurrent, + /// Dispatch and await one agent at a time, in sorted agent-id order. + Sequential, + /// Dispatch and await one agent at a time, in reverse sorted agent-id order. + Reversed, +} + +/// Deliberate message-level faults, injected at one step. +#[derive(Clone, Debug, Default)] +pub struct Injections { + /// The step these injections apply to. + pub at_step: u64, + /// Re-send this agent's Prepare on a new bus call with the original request id and body. + pub duplicate_prepare: Option, + /// Re-send this agent's Commit the same way. + pub duplicate_commit: Option, + /// Abandon the first Advance result and resolve the same operation afterwards. + pub lose_advance_result: bool, + /// Re-send the Advance with the original request id and an altered control batch. + pub altered_advance_controls: bool, + /// Read and release the Advance result's frame, then replay the same operation. + pub consume_advance_artifact_then_retry: bool, +} + +/// What an injection produced, for a test to assert on. +#[derive(Clone, Debug, PartialEq)] +pub struct InjectionOutcome { + pub what: String, + pub code: Option, + pub identical: bool, +} + +/// A pause request that can be set from outside the transaction. +/// +/// A request arriving mid-step means "finish this transition, then pause"; it is read once +/// the committed boundary has moved, never in the middle of one. +#[derive(Clone, Debug)] +pub struct PauseRequest(std::sync::Arc); + +impl PauseRequest { + pub fn request(&self) { + self.0.store(true, std::sync::atomic::Ordering::SeqCst); + } + + pub fn is_requested(&self) -> bool { + self.0.load(std::sync::atomic::Ordering::SeqCst) + } +} + +/// Why the epoch failed. +#[derive(Clone, Debug)] +pub struct SessionFailure { + pub error: DomainError, + pub phase: String, + pub detail: String, +} + +impl std::fmt::Display for SessionFailure { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} at {}: {}", self.detail, self.phase, self.error) + } +} + +impl std::error::Error for SessionFailure {} + +type Outcome = Result; + +/// The bus addresses this session publishes on. Chosen by the composition, not the router. +#[derive(Clone, Debug)] +pub struct Topics { + pub descriptor: String, + pub snapshots: String, + pub events: String, +} + +impl Topics { + pub fn for_session(session_id: &Id) -> Topics { + Topics { + descriptor: format!("session.{session_id}.descriptor"), + snapshots: format!("session.{session_id}.snapshots"), + events: format!("session.{session_id}.events"), + } + } +} + +/// One agent's session-side record: its port, its profile, its retained context. +pub struct AgentSlot { + pub worker: WorkerRef, + pub agent_id: Id, + pub port_id: Id, + pub profile: AssetRef, + pub seed: i32, + pub tick_duration: RationalNs, + pub warmup_ticks: u64, + pub committed_step: u64, + context: TypedValue, + context_digest: Digest, + prepared: Option, + prepare_request: Option, +} + +impl AgentSlot { + pub fn new( + worker: WorkerRef, + agent_id: Id, + port_id: Id, + profile: AssetRef, + seed: i32, + ) -> AgentSlot { + AgentSlot { + worker, + agent_id, + port_id, + profile, + seed, + tick_duration: RationalNs::ZERO, + warmup_ticks: 0, + committed_step: 0, + context: TypedValue::new(crate::task::context_schema(), Value::Object(Map::new())) + .expect("an empty context object is a valid typed value"), + context_digest: digest_of_bytes(b""), + prepared: None, + prepare_request: None, + } + } +} + +/// The session's counters, for the acceptance assertions. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub struct Stats { + pub advances: u64, + pub publications: u64, + pub prepares: u64, + pub commits: u64, +} + +pub struct Coordinator { + bus: flybus::Client, + session_id: Id, + epoch: Id, + episode_id: Id, + phases: PhaseMachine, + agents: Vec, + environment: WorkerRef, + descriptor: Option, + task: Box, + executors: BTreeMap>, + observation: Option, + /// Explicit holds on the current boundary's views, forwarded to every Commit and to + /// publication and dropped once the boundary is committed. + views: BTreeMap, + /// Holds on the boundary the world has just reached, before it is committed. + pending_views: BTreeMap, + serials: Serials, + topics: Topics, + pacing: Option, + /// Set by whoever asks for a normal pause, possibly while a transition is in flight. + pause: std::sync::Arc, + episode: Option, + lifecycle_acks: Vec<(WorkerRef, DomainRequestId)>, + stats: Stats, + /// The ordered actions this session took, for the ordering assertions. + pub audit: Vec, + pub trace: TraceLog, + pub dispatch: DispatchOrder, + pub injections: Injections, + pub injection_log: Vec, + /// How many times an exact duplicate met IN_PROGRESS while resolving an uncertain call. + pub in_progress_replies: u64, + started: std::time::Instant, + last_advance_request: Option, + last_commit_requests: Vec, +} + +impl Coordinator { + #[allow(clippy::too_many_arguments)] + pub fn new( + bus: flybus::Client, + session_id: Id, + epoch: Id, + episode_id: Id, + environment: WorkerRef, + agents: Vec, + task: Box, + executors: BTreeMap>, + ) -> Coordinator { + let mut agents = agents; + // Sorted agent-id order is the executor and control order, so it is fixed here once. + agents.sort_by(|a, b| a.agent_id.cmp(&b.agent_id)); + let topics = Topics::for_session(&session_id); + Coordinator { + bus, + session_id, + epoch, + episode_id, + phases: PhaseMachine::new(), + agents, + environment, + descriptor: None, + task, + executors, + observation: None, + views: BTreeMap::new(), + pending_views: BTreeMap::new(), + serials: Serials::default(), + topics, + pacing: None, + pause: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), + episode: None, + lifecycle_acks: Vec::new(), + stats: Stats::default(), + audit: Vec::new(), + trace: TraceLog::default(), + dispatch: DispatchOrder::default(), + injections: Injections::default(), + injection_log: Vec::new(), + in_progress_replies: 0, + started: std::time::Instant::now(), + last_advance_request: None, + last_commit_requests: Vec::new(), + } + } + + pub fn phase(&self) -> Phase { + self.phases.phase() + } + + pub fn stats(&self) -> Stats { + self.stats + } + + pub fn topics(&self) -> &Topics { + &self.topics + } + + pub fn epoch(&self) -> &Id { + &self.epoch + } + + pub fn descriptor(&self) -> Option<&EnvironmentDescriptor> { + self.descriptor.as_ref() + } + + pub fn observation(&self) -> Option<&WorldObservation> { + self.observation.as_ref() + } + + pub fn episode_request(&self) -> Option<&EpisodeRequest> { + self.episode.as_ref() + } + + /// The committed boundary, when the session is at one. + pub fn committed_boundary(&self) -> Option { + self.phases.phase().committed_boundary() + } + + pub fn agent_ids(&self) -> Vec { + self.agents.iter().map(|a| a.agent_id.clone()).collect() + } + + /// How many times the task evaluated a transition. + pub fn evaluations(&self) -> u64 { + self.task.evaluations() + } + + pub fn task_progress(&self) -> TypedValue { + self.task.progress() + } + + /// "Finish this transition, then pause." It never truncates a transition. + pub fn request_pause(&mut self) { + self.pause.store(true, std::sync::atomic::Ordering::SeqCst); + } + + /// A handle a supervisor can use to request a pause while a transition is in flight. + pub fn pause_handle(&self) -> PauseRequest { + PauseRequest(self.pause.clone()) + } + + pub fn pause_requested(&self) -> bool { + self.pause.load(std::sync::atomic::Ordering::SeqCst) + } + + /// Leaves a normal pause at its committed boundary. + pub fn resume(&mut self) -> Outcome<()> { + let Phase::Paused(k) = self.phases.phase() else { + return Err(self.fail_now( + DomainError::before(ErrorCode::InvalidPhase, "the session is not paused"), + "resume", + )); + }; + self.transition(Phase::Ready(k))?; + Ok(()) + } + + fn scope(&self, step: u64) -> Scope { + scope_at(&self.session_id, &self.epoch, step) + } + + fn transition(&mut self, next: Phase) -> Outcome<()> { + match self.phases.to(next) { + Ok((from, to)) => { + self.trace.phase(from, to); + Ok(()) + } + Err(error) => Err(self.fail_now(error, "phase")), + } + } + + /// Fails the epoch and records the transition to Failed. + fn fail_now(&mut self, error: DomainError, detail: &str) -> SessionFailure { + let phase = self.phases.phase().label(); + let (from, to) = self.phases.fail(); + self.trace.phase(from, to); + self.audit.push(format!("fail:{detail}")); + SessionFailure { error, phase, detail: detail.to_owned() } + } + + fn agent(&self, agent_id: &Id) -> Option<&AgentSlot> { + self.agents.iter().find(|a| a.agent_id == *agent_id) + } + + // --------------------------------------------------------------------------------------- + // Initialization + + /// Negotiates with every worker, initializes the environment and then the agents, and + /// establishes Ready(0). + /// + /// Nothing here advances the environment or produces a gameplay reward. + pub async fn bootstrap(&mut self) -> Outcome<()> { + self.hello_environment().await?; + for index in 0..self.agents.len() { + self.hello_agent(index).await?; + } + self.declare_topics().await?; + self.initialize_environment().await?; + self.bootstrap_task()?; + for index in 0..self.agents.len() { + self.initialize_agent(index).await?; + } + self.acknowledge_lifecycle().await?; + self.transition(Phase::Ready(0))?; + let pacing = self + .descriptor + .as_ref() + .map(|descriptor| Pacing::new(descriptor.step_duration)); + self.pacing = pacing; + self.publish_descriptor().await?; + self.publish_snapshot(0, &BTreeMap::new(), &[], &[]).await?; + Ok(()) + } + + async fn hello(&mut self, worker: WorkerRef, role: Role, required: &str) -> Outcome { + let params = HelloParams { + session_id: self.session_id.clone(), + expected_worker_id: worker.worker_id.clone(), + role, + supported_majors: vec![1], + }; + let reply = self + .call(&worker, "Worker.Hello", None, object(params.to_json()), &[], &[]) + .await?; + let result: HelloResult = reply.parse().map_err(|e| self.fail_now(e, "hello"))?; + if result.contract_digest != contract_digest() { + return Err(self.fail_now( + DomainError::before( + ErrorCode::IdentityMismatch, + "the worker implements another contract revision", + ), + "hello", + )); + } + if result.role != role || result.worker_id != worker.worker_id { + return Err(self.fail_now( + DomainError::before( + ErrorCode::IdentityMismatch, + "the worker is not the role or the worker the composition expected", + ), + "hello", + )); + } + let required = id(required); + if !result.capabilities.contains(&required) { + return Err(self.fail_now( + DomainError::before( + ErrorCode::Unsupported, + format!("the worker does not advertise {required}"), + ), + "hello", + )); + } + self.audit.push(format!("hello:{}", worker.worker_id)); + Ok(result.incarnation_id) + } + + async fn hello_environment(&mut self) -> Outcome<()> { + let worker = self.environment.clone(); + let incarnation = self.hello(worker, Role::Environment, "world-step-v1").await?; + self.environment.domain_incarnation = Some(incarnation); + Ok(()) + } + + async fn hello_agent(&mut self, index: usize) -> Outcome<()> { + let worker = self.agents[index].worker.clone(); + let incarnation = self.hello(worker, Role::Agent, "agent-step-v1").await?; + self.agents[index].worker.domain_incarnation = Some(incarnation); + Ok(()) + } + + async fn declare_topics(&mut self) -> Outcome<()> { + for (name, retained) in [ + (self.topics.descriptor.clone(), flybus::Retained::Latest), + (self.topics.snapshots.clone(), flybus::Retained::Latest), + (self.topics.events.clone(), flybus::Retained::None), + ] { + self.bus.declare_topic(&name, retained).await.map_err(|e| { + let error = DomainError::new( + ErrorCode::BackendFailure, + format!("declaring {name}: {}", e.message), + MutationCertainty::None, + ); + self.fail_now(error, "declare-topic") + })?; + } + Ok(()) + } + + async fn initialize_environment(&mut self) -> Outcome<()> { + let bindings: Vec = self + .agents + .iter() + .map(|a| PortBinding { port_id: a.port_id.clone(), agent_id: a.agent_id.clone() }) + .collect(); + let params = EnvironmentInitializeParams { + backend_config: crate::environment::synthetic_asset( + "counter-arena-backend", + "counter-arena-backend-v1", + ), + task_config: crate::environment::synthetic_asset( + "counter-arena-setup", + "counter-arena-setup-v1", + ), + episode_id: self.episode_id.clone(), + port_bindings: bindings.iter().map(PortBinding::pair).collect(), + }; + let worker = self.environment.clone(); + let scope = self.scope(0); + let reply = self + .call( + &worker, + "Environment.Initialize", + Some(scope), + object(params.to_json()), + &[], + &["view.arena".to_owned()], + ) + .await?; + let result: EnvironmentInitializeResult = + reply.parse().map_err(|e| self.fail_now(e, "environment-initialize"))?; + result + .descriptor + .validate() + .map_err(|e| self.fail_now(DomainError::invalid(e), "environment-descriptor"))?; + result + .observation + .validate_against(&result.descriptor) + .map_err(|e| self.fail_now(DomainError::invalid(e), "observation-0"))?; + if result.observation.boundary != 0 || !result.observation.world_time.is_zero() { + return Err(self.fail_now( + DomainError::invalid("boundary 0 must have world time zero"), + "observation-0", + )); + } + // Every port the descriptor declares must be bound to a configured agent, and every + // agent's port must exist. + let declared: BTreeSet = + result.descriptor.ports.iter().map(|p| p.port_id.clone()).collect(); + let assigned: BTreeSet = self.agents.iter().map(|a| a.port_id.clone()).collect(); + if declared != assigned { + return Err(self.fail_now( + DomainError::before( + ErrorCode::IdentityMismatch, + "the descriptor's ports and the composition's port assignment disagree", + ), + "port-assignment", + )); + } + self.views = reply.artifacts; + self.descriptor = Some(result.descriptor); + self.observation = Some(result.observation); + self.lifecycle_acks.push((worker, reply.request_id.clone())); + self.audit.push("environment.initialize".to_owned()); + Ok(()) + } + + fn bootstrap_task(&mut self) -> Outcome<()> { + let observation = self.observation.clone().expect("initialized"); + let bindings: Vec = self + .agents + .iter() + .map(|a| PortBinding { port_id: a.port_id.clone(), agent_id: a.agent_id.clone() }) + .collect(); + let bootstrap = self + .task + .bootstrap(&observation.inspection, &bindings) + .map_err(|e| self.fail_now(e, "task-bootstrap"))?; + for event in &bootstrap.events { + if event.source_step != 0 { + return Err(self.fail_now( + DomainError::invalid("a bootstrap event has source step 0"), + "task-bootstrap", + )); + } + } + for index in 0..self.agents.len() { + let agent_id = self.agents[index].agent_id.clone(); + let context = bootstrap.contexts.get(&agent_id).cloned().ok_or_else(|| { + DomainError::before( + ErrorCode::IdentityMismatch, + format!("the task bootstrapped no context for {agent_id}"), + ) + }); + let context = match context { + Ok(context) => context, + Err(e) => return Err(self.fail_now(e, "task-bootstrap")), + }; + self.agents[index].context_digest = context.digest(); + self.agents[index].context = context; + } + self.audit.push("task.bootstrap".to_owned()); + Ok(()) + } + + async fn initialize_agent(&mut self, index: usize) -> Outcome<()> { + let observation = self.observation.clone().expect("initialized"); + let slot_worker = self.agents[index].worker.clone(); + let params = AgentInitializeParams { + agent_id: self.agents[index].agent_id.clone(), + profile: self.agents[index].profile.clone(), + seed: self.agents[index].seed, + initial_input: self.sensory_input(&observation, 0), + initial_decision_context: self.agents[index].context.clone(), + worker_threads: 1, + }; + let attachments = self.view_attachments(); + let scope = self.scope(0); + let reply = { + let refs: Vec<(&str, &flybus::Artifact)> = + attachments.iter().map(|(n, a)| (n.as_str(), a)).collect(); + self.call(&slot_worker, "Agent.Initialize", Some(scope), object(params.to_json()), &refs, &[]) + .await? + }; + let result: AgentInitializeResult = + reply.parse().map_err(|e| self.fail_now(e, "agent-initialize"))?; + if result.committed_step != 0 { + return Err(self.fail_now( + DomainError::invalid("Agent.Initialize must establish committed step 0"), + "agent-initialize", + )); + } + if result.profile_digest != self.agents[index].profile.digest { + return Err(self.fail_now( + DomainError::before( + ErrorCode::IdentityMismatch, + "the agent initialized another profile", + ), + "agent-initialize", + )); + } + if result.decision_context_digest != self.agents[index].context_digest { + return Err(self.fail_now( + DomainError::before( + ErrorCode::IdentityMismatch, + "the agent retained another decision context", + ), + "agent-initialize", + )); + } + result + .telemetry + .validate() + .map_err(|e| self.fail_now(DomainError::invalid(e), "agent-initialize"))?; + self.agents[index].tick_duration = result.tick_duration; + self.agents[index].warmup_ticks = result.warmup_ticks; + self.agents[index].committed_step = 0; + self.lifecycle_acks.push((slot_worker, reply.request_id.clone())); + let agent_id = self.agents[index].agent_id.clone(); + self.audit.push(format!("agent.initialize:{agent_id}")); + Ok(()) + } + + /// Releases every lifecycle reply the workers are holding for us. + /// + /// This is the domain acknowledgment of `ipc-v1` section 5, which drops a worker's result + /// cache. It is not a bus `delivery.consumed`, which the SDK did when we dropped the + /// replies. + async fn acknowledge_lifecycle(&mut self) -> Outcome<()> { + let mut by_worker: BTreeMap)> = BTreeMap::new(); + for (worker, request_id) in std::mem::take(&mut self.lifecycle_acks) { + by_worker + .entry(worker.service.clone()) + .or_insert_with(|| (worker.clone(), Vec::new())) + .1 + .push(request_id); + } + for (worker, ids) in by_worker.into_values() { + let params = AcknowledgeParams { request_ids: ids.clone() }; + let reply = self + .call(&worker, "Worker.Acknowledge", None, object(params.to_json()), &[], &[]) + .await?; + let result: AcknowledgeResult = + reply.parse().map_err(|e| self.fail_now(e, "acknowledge"))?; + if result.acknowledged.len() != ids.len() { + return Err(self.fail_now( + DomainError::invalid("a worker did not acknowledge every lifecycle reply"), + "acknowledge", + )); + } + } + self.audit.push("acknowledge.lifecycle".to_owned()); + Ok(()) + } + + /// Queries one worker's status without waiting for its current mutation. + pub async fn status(&mut self, worker: &WorkerRef) -> Outcome { + let reply = self + .call(worker, "Worker.Status", None, Map::new(), &[], &[]) + .await?; + reply.parse().map_err(|e| self.fail_now(e, "status")) + } + + /// Asks one worker to stop. Only the configured supervisor may do this. + pub async fn shutdown(&mut self, worker: &WorkerRef, reason: &str) -> Outcome<()> { + let params = ShutdownParams { reason: id(reason) }; + let reply = self + .call(worker, "Worker.Shutdown", None, object(params.to_json()), &[], &[]) + .await?; + // A responsive worker reports `stopping: true`, which is the only shape the contract + // type reads at all. + let _: ShutdownResult = reply.parse().map_err(|e| self.fail_now(e, "shutdown"))?; + Ok(()) + } + + pub fn environment_ref(&self) -> &WorkerRef { + &self.environment + } + + pub fn agent_ref(&self, agent_id: &Id) -> Option<&WorkerRef> { + self.agent(agent_id).map(|slot| &slot.worker) + } +} + +// ------------------------------------------------------------------------------------------- +// One transaction + +/// What one completed transition reports back. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct StepReport { + /// The new committed boundary. + pub boundary: u64, + /// True when the session is now paused at that boundary. + pub paused: bool, + /// True when the task asked for a terminal episode transition. + pub terminal: bool, +} + +/// One per-agent bus call, ready to dispatch. +struct Job { + agent_id: Id, + worker: WorkerRef, + method: &'static str, + scope: Option, + params: Map, + attachments: Vec<(String, flybus::Artifact)>, + request_id: DomainRequestId, +} + +/// Issues one domain call with owned arguments, so it can run in its own task. +#[allow(clippy::too_many_arguments)] +async fn call_owned( + bus: flybus::Client, + worker: WorkerRef, + method: &'static str, + scope: Option, + params: Map, + attachments: Vec<(String, flybus::Artifact)>, + request_id: DomainRequestId, + want: Vec, +) -> Result { + let refs: Vec<(&str, &flybus::Artifact)> = + attachments.iter().map(|(n, a)| (n.as_str(), a)).collect(); + rpc::call(&bus, &worker, method, scope, params, &refs, request_id, &want).await +} + +impl Coordinator { + /// Takes the next request serial for this worker, calls, and checks + /// the reply's identity and echoed scope. + async fn call( + &mut self, + worker: &WorkerRef, + method: &'static str, + scope: Option, + params: Map, + attachments: &[(&str, &flybus::Artifact)], + want: &[String], + ) -> Outcome { + let request_id = self.serials.next(&worker.service); + let owned: Vec<(String, flybus::Artifact)> = attachments + .iter() + .map(|(n, a)| ((*n).to_owned(), (*a).clone())) + .collect(); + let reply = call_owned( + self.bus.clone(), + worker.clone(), + method, + scope.clone(), + params, + owned, + request_id, + want.to_vec(), + ) + .await; + let reply = match reply { + Ok(reply) => reply, + Err(e) => return Err(self.fail_now(e, method)), + }; + self.check_reply(worker, &reply, &scope, method)?; + match reply.result() { + Ok(_) => Ok(reply), + Err(e) => Err(self.fail_now(e, method)), + } + } + + /// The worker's identity and the echoed scope must both match, whatever the outcome was. + /// + /// A reply from another incarnation means every live participant belongs to an invalid + /// epoch, so it fails the epoch instead of being read as this session's answer. + fn check_reply( + &mut self, + worker: &WorkerRef, + reply: &DomainReply, + scope: &Option, + method: &'static str, + ) -> Outcome<()> { + let (worker_id, incarnation, echoed) = match &reply.outcome { + SessionRpcOutcome::Success(s) => { + (&s.worker_id, &s.incarnation_id, &s.scope) + } + SessionRpcOutcome::Failure(f) => { + (&f.worker_id, &f.incarnation_id, &f.scope) + } + }; + if *worker_id != worker.worker_id { + return Err(self.fail_now( + DomainError::before(ErrorCode::IdentityMismatch, "another worker answered"), + method, + )); + } + if let Some(expected) = &worker.domain_incarnation + && incarnation != expected + { + return Err(self.fail_now( + DomainError::before( + ErrorCode::IdentityMismatch, + "the reply comes from another worker incarnation", + ), + method, + )); + } + if echoed != scope { + return Err(self.fail_now( + DomainError::before(ErrorCode::IdentityMismatch, "the reply echoes another scope"), + method, + )); + } + if *reply.outcome.request_id() != reply.request_id { + return Err(self.fail_now( + DomainError::before( + ErrorCode::IdentityMismatch, + "the reply echoes another request id", + ), + method, + )); + } + Ok(()) + } + + /// Resolves an uncertain operation: a fresh bus call with the original domain request id + /// and body, pinned to the same service incarnation. It never issues a new request id and + /// never recomputes a decision. + #[allow(clippy::too_many_arguments)] + async fn resolve( + &mut self, + worker: &WorkerRef, + method: &'static str, + scope: Option, + params: Map, + attachments: Vec<(String, flybus::Artifact)>, + request_id: DomainRequestId, + want: &[String], + ) -> Outcome { + // The original may still be running, which answers IN_PROGRESS for this bus call and + // starts no second mutation. Waiting and asking again is the resolution, not a retry + // of the operation. + for attempt in 0..200u32 { + let reply = call_owned( + self.bus.clone(), + worker.clone(), + method, + scope.clone(), + params.clone(), + attachments.clone(), + request_id.clone(), + want.to_vec(), + ) + .await; + let reply = match reply { + Ok(reply) => reply, + Err(e) => return Err(self.fail_now(e, method)), + }; + self.check_reply(worker, &reply, &scope, method)?; + match reply.result() { + Ok(_) => return Ok(reply), + Err(e) if e.code == ErrorCode::InProgress => { + let _ = attempt; + self.in_progress_replies += 1; + tokio::time::sleep(std::time::Duration::from_millis(2)).await; + } + Err(e) => return Err(self.fail_now(e, method)), + } + } + Err(self.fail_now( + DomainError::new( + ErrorCode::BackendFailure, + "the uncertain operation never resolved", + MutationCertainty::Unknown, + ), + method, + )) + } + + /// Sends the same domain request again on a fresh bus call and reports what came back, + /// without letting the answer change session state. + #[allow(clippy::too_many_arguments)] + async fn probe_duplicate( + &mut self, + worker: &WorkerRef, + method: &'static str, + scope: Option, + params: Map, + attachments: Vec<(String, flybus::Artifact)>, + request_id: DomainRequestId, + expected: Option<&Value>, + what: &str, + ) { + let reply = call_owned( + self.bus.clone(), + worker.clone(), + method, + scope, + params, + attachments, + request_id, + Vec::new(), + ) + .await; + let outcome = match reply { + Ok(reply) => match reply.result() { + Ok(result) => InjectionOutcome { + what: what.to_owned(), + code: None, + identical: expected == Some(result), + }, + Err(e) => InjectionOutcome { + what: what.to_owned(), + code: Some(e.code), + identical: false, + }, + }, + Err(e) => InjectionOutcome { + what: what.to_owned(), + code: Some(e.code), + identical: false, + }, + }; + self.injection_log.push(outcome); + } + + /// Sends one domain request exactly as given and returns the worker's own terminal + /// outcome, without letting the answer change session state. + /// + /// This is how a supervisor or a test observes a worker's refusal -- a stale epoch, a + /// wrong phase -- rather than inferring it from a failed session. + pub async fn probe_raw( + &mut self, + worker: &WorkerRef, + method: &'static str, + scope: Option, + params: Value, + ) -> Result { + let params = match params { + Value::Object(m) => m, + _ => Map::new(), + }; + let request_id = self.serials.next(&worker.service); + let reply = call_owned( + self.bus.clone(), + worker.clone(), + method, + scope, + params, + Vec::new(), + request_id, + Vec::new(), + ) + .await?; + reply.result().cloned() + } + + /// The sensory input one agent is permitted to consume at `boundary`. + fn sensory_input(&self, observation: &WorldObservation, boundary: u64) -> SensoryInput { + SensoryInput { + boundary, + views: observation.sensory_views.clone(), + // This profile senses pixels only, so structured input stays null rather than + // smuggling inspection data into the neural path. + structured: None, + } + } + + fn view_attachments(&self) -> Vec<(String, flybus::Artifact)> { + self.views.iter().map(|(n, a)| (n.clone(), a.clone())).collect() + } + + /// Runs one set of per-agent jobs in the configured dispatch order. + async fn run_jobs( + &mut self, + jobs: Vec, + order: DispatchOrder, + ) -> Vec<(Id, Result, Option, WorkerRef, &'static str)> { + let mut out = Vec::new(); + match order { + DispatchOrder::Sequential | DispatchOrder::Reversed => { + let mut jobs = jobs; + if order == DispatchOrder::Reversed { + jobs.reverse(); + } + for job in jobs { + let reply = call_owned( + self.bus.clone(), + job.worker.clone(), + job.method, + job.scope.clone(), + job.params, + job.attachments, + job.request_id, + Vec::new(), + ) + .await; + out.push((job.agent_id, reply, job.scope, job.worker, job.method)); + } + } + DispatchOrder::Concurrent => { + let mut tasks = Vec::new(); + for job in jobs { + let bus = self.bus.clone(); + let agent_id = job.agent_id.clone(); + let worker = job.worker.clone(); + let scope = job.scope.clone(); + let method = job.method; + tasks.push(tokio::spawn(async move { + let reply = call_owned( + bus, + job.worker, + job.method, + job.scope, + job.params, + job.attachments, + job.request_id, + Vec::new(), + ) + .await; + (agent_id, reply, scope, worker, method) + })); + } + for task in tasks { + match task.await { + Ok(result) => out.push(result), + Err(e) => panic!("a dispatch task panicked: {e}"), + } + } + } + } + // Completion order never affects anything downstream, so the results are put back in + // sorted agent-id order here and nowhere else. + out.sort_by(|a, b| a.0.cmp(&b.0)); + out + } +} + +impl Coordinator { + /// One complete transition `k -> k+1`. + pub async fn step(&mut self) -> Outcome { + let Phase::Ready(k) = self.phases.phase() else { + return Err(self.fail_now( + DomainError::before( + ErrorCode::InvalidPhase, + "a transition starts only from a committed Ready boundary", + ), + "step", + )); + }; + if self.episode.is_some() { + return Err(self.fail_now( + DomainError::before( + ErrorCode::InvalidPhase, + "the task asked for a terminal episode; the episode policy runs first", + ), + "step", + )); + } + let descriptor = self.descriptor.clone().ok_or_else(|| { + DomainError::before(ErrorCode::InvalidPhase, "the session never bootstrapped") + }); + let descriptor = match descriptor { + Ok(descriptor) => descriptor, + Err(e) => return Err(self.fail_now(e, "step")), + }; + let old_observation = self.observation.clone().expect("bootstrapped"); + if let Some(pacing) = self.pacing.as_mut() { + // Wall time is only pacing. Being late omits the sleep and is reported; it never + // skips a world step or a neural tick. + pacing.wait().await; + } + + // ---- Phase A: prepare all agents concurrently + self.transition(Phase::Preparing(k))?; + let prepared = self.prepare_all(k, descriptor.step_duration).await?; + + // ---- Phase B: build and apply one complete batch + self.transition(Phase::Applying(k))?; + let controls = self.build_batch(k, &descriptor, &old_observation)?; + let batch_id = self.batch_id(k); + let step_result = self.advance(k, &batch_id, &controls).await?; + + // ---- Phase C: observe and evaluate the task once + self.transition(Phase::Observing(k + 1))?; + self.verify_step_result(k, &descriptor, &batch_id, &controls, &old_observation, &step_result)?; + let scope = self.scope(k); + let evaluation = self + .task + .evaluate_transition( + &scope, + &old_observation.inspection, + &step_result.observation.inspection, + &controls, + ) + .map_err(|e| { + // Task interpretation failed after prepared brains and the world already + // changed, so the epoch is failed rather than re-evaluated. + self.fail_now(e, "task-evaluate") + })?; + self.audit.push(format!("evaluate:{k}")); + let mut outcomes = evaluation.outcomes.clone(); + let mut next_contexts = evaluation.next_contexts.clone(); + for agent in self.agent_ids() { + if !outcomes.contains_key(&agent) || !next_contexts.contains_key(&agent) { + return Err(self.fail_now( + DomainError::before( + ErrorCode::IdentityMismatch, + format!("the task produced no outcome or context for {agent}"), + ), + "task-evaluate", + )); + } + } + + // ---- Phase D: commit all agent outcomes concurrently + self.transition(Phase::Committing(k))?; + let new_views: BTreeMap = step_result + .observation + .sensory_views + .iter() + .filter_map(|view| { + let name = format!("view.{}", view.view_id); + self.pending_views.get(&name).map(|a| (name, a.clone())) + }) + .collect(); + let commits = self + .commit_all(k, &step_result.observation, &mut outcomes, &mut next_contexts, &new_views) + .await?; + + // Only once every commit succeeded does the committed boundary move. + self.transition(Phase::Ready(k + 1))?; + for index in 0..self.agents.len() { + let agent_id = self.agents[index].agent_id.clone(); + let context = next_contexts.remove(&agent_id).expect("checked above"); + self.agents[index].context_digest = context.digest(); + self.agents[index].context = context; + self.agents[index].committed_step = k + 1; + } + // The previous boundary's handles are no longer needed; the new ones take over. + self.views = new_views; + self.pending_views.clear(); + self.observation = Some(step_result.observation.clone()); + self.stats.advances += 1; + + let event_ids: Vec = evaluation.events.iter().map(|e| e.id.clone()).collect(); + let decisions: BTreeMap = prepared + .iter() + .map(|(agent, decision)| (agent.clone(), decision.decision.clone())) + .collect(); + self.record_trace( + k, + &prepared, + &commits, + &controls, + &batch_id, + &step_result, + &outcomes, + &event_ids, + ); + // The prepared decisions and their request ids are needed by the trace, so they are + // released only after it has been recorded. + for slot in &mut self.agents { + slot.prepared = None; + slot.prepare_request = None; + } + self.publish_events(k + 1, &evaluation.events).await?; + self.publish_snapshot(k + 1, &decisions, &controls, &event_ids).await?; + + let terminal = evaluation.episode.is_some(); + if terminal { + // A terminal event's final rewards are committed; then the session pauses at this + // boundary and the declared episode policy runs. No worker resets itself. + self.episode = evaluation.episode.clone(); + } + let paused = self.pause_requested() || terminal; + if paused { + self.transition(Phase::Paused(k + 1))?; + self.pause.store(false, std::sync::atomic::Ordering::SeqCst); + self.audit.push(format!("pause:{}", k + 1)); + } + Ok(StepReport { boundary: k + 1, paused, terminal }) + } + + /// Runs `steps` transitions, stopping early at a pause or a terminal episode. + pub async fn run(&mut self, steps: u64) -> Outcome> { + let mut reports = Vec::new(); + for _ in 0..steps { + let report = self.step().await?; + let stop = report.paused; + reports.push(report); + if stop { + break; + } + } + Ok(reports) + } + + fn batch_id(&self, k: u64) -> Id { + parse_id(&format!("batch-{}-{k}", self.epoch)).expect("epoch and step make an Id") + } + + /// Phase A. Every agent sees the same environment interval and the same world boundary. + async fn prepare_all( + &mut self, + k: u64, + interval: RationalNs, + ) -> Outcome> { + let scope = self.scope(k); + // The task's per-agent decision contexts and the admitted pre-step stimulation list + // are frozen here; anything accepted later waits for the next boundary. + let mut jobs = Vec::new(); + let mut bodies = Vec::new(); + for index in 0..self.agents.len() { + let slot = &self.agents[index]; + let params = PrepareParams { + agent_id: slot.agent_id.clone(), + profile_digest: slot.profile.digest.clone(), + interval, + decision_context_digest: slot.context_digest.clone(), + // No audience input exists in the first synthetic composition. + pre_step_stimulations: Vec::::new(), + }; + let params = match params.to_json() { + Value::Object(m) => m, + _ => Map::new(), + }; + let request_id = self.serials.next(&slot.worker.service); + self.agents[index].prepare_request = Some(request_id.clone()); + let slot = &self.agents[index]; + bodies.push(( + slot.agent_id.clone(), + slot.worker.clone(), + params.clone(), + request_id.clone(), + )); + jobs.push(Job { + agent_id: slot.agent_id.clone(), + worker: slot.worker.clone(), + method: "Agent.Prepare", + scope: Some(scope.clone()), + params, + attachments: Vec::new(), + request_id, + }); + } + let results = self.run_jobs(jobs, self.dispatch).await; + let mut prepared = Vec::new(); + for (agent_id, reply, scope, worker, method) in results { + let reply = match reply { + Ok(reply) => reply, + // Some agents are already Prepared. Dispatch stops and the epoch fails; a + // prepared agent is never asked to prepare again. + Err(e) => return Err(self.fail_now(e, method)), + }; + self.check_reply(&worker, &reply, &scope, method)?; + let decision: PreparedDecision = match reply.parse() { + Ok(decision) => decision, + Err(e) => return Err(self.fail_now(e, method)), + }; + if decision.agent_id != agent_id { + return Err(self.fail_now( + DomainError::before( + ErrorCode::IdentityMismatch, + "a PreparedDecision names another agent", + ), + method, + )); + } + if decision.decision.schema != crate::task::decision_schema() { + return Err(self.fail_now( + DomainError::before( + ErrorCode::IdentityMismatch, + "the decision does not carry the profile's registered intent schema", + ), + method, + )); + } + self.audit.push(format!("prepared:{agent_id}@{k}")); + self.stats.prepares += 1; + prepared.push((agent_id, decision)); + } + prepared.sort_by(|a, b| a.0.cmp(&b.0)); + for index in 0..self.agents.len() { + let agent_id = self.agents[index].agent_id.clone(); + let decision = prepared + .iter() + .find(|(id, _)| *id == agent_id) + .map(|(_, decision)| decision.clone()); + self.agents[index].prepared = decision; + } + if self.agents.iter().any(|slot| slot.prepared.is_none()) { + return Err(self.fail_now( + DomainError::before( + ErrorCode::InvalidPhase, + "the batch is built only after every PreparedDecision has arrived", + ), + "prepare", + )); + } + + if self.injections.at_step == k + && let Some(target) = self.injections.duplicate_prepare.clone() + { + let expected = prepared + .iter() + .find(|(id, _)| *id == target) + .map(|(_, decision)| decision.to_json()); + if let Some((agent_id, worker, params, request_id)) = + bodies.into_iter().find(|(id, _, _, _)| *id == target) + { + let _ = agent_id; + self.probe_duplicate( + &worker, + "Agent.Prepare", + Some(self.scope(k)), + params, + Vec::new(), + request_id, + expected.as_ref(), + "duplicate-prepare", + ) + .await; + } + } + Ok(prepared) + } + + /// Phase B steps 1 to 3: validate, run each executor once, assemble the complete batch. + fn build_batch( + &mut self, + k: u64, + descriptor: &EnvironmentDescriptor, + observation: &WorldObservation, + ) -> Outcome> { + let scope = self.scope(k); + let progress = self.task.progress(); + let clock = observation.world_time; + let mut intents: BTreeMap = BTreeMap::new(); + // Sorted agent-id order, never completion order. + for index in 0..self.agents.len() { + let agent_id = self.agents[index].agent_id.clone(); + let port_id = self.agents[index].port_id.clone(); + let decision = self.agents[index] + .prepared + .clone() + .expect("every agent is Prepared before the batch is built"); + let executor = self.executors.get_mut(&agent_id).ok_or_else(|| { + DomainError::before( + ErrorCode::IdentityMismatch, + format!("{agent_id} has no configured action executor"), + ) + }); + let executor = match executor { + Ok(executor) => executor, + Err(e) => return Err(self.fail_now(e, "executor")), + }; + let applied = executor.apply( + &scope, + &decision.decision, + &observation.inspection, + &progress, + &clock, + ); + let (intent, _events) = match applied { + Ok(applied) => applied, + Err(e) => return Err(self.fail_now(e, "executor")), + }; + // Only the coordinator assigns a port. + if intents.insert(port_id.clone(), intent.at_port(&port_id)).is_some() { + return Err(self.fail_now( + DomainError::before( + ErrorCode::IdentityMismatch, + format!("port {port_id} is claimed by two agents"), + ), + "executor", + )); + } + } + // All configured port controls, in descriptor port order; duplicates and omissions are + // refused rather than filled in. + let mut controls = Vec::with_capacity(descriptor.ports.len()); + for port in &descriptor.ports { + let control = intents.remove(&port.port_id).ok_or_else(|| { + DomainError::before( + ErrorCode::IdentityMismatch, + format!("port {} has no control in this batch", port.port_id), + ) + }); + let control = match control { + Ok(control) => control, + Err(e) => return Err(self.fail_now(e, "batch")), + }; + if let Err(e) = port.controls.check(&control) { + return Err(self.fail_now(DomainError::invalid(e), "batch")); + } + controls.push(control); + } + if !intents.is_empty() { + return Err(self.fail_now( + DomainError::before( + ErrorCode::IdentityMismatch, + "the batch names a port the descriptor does not declare", + ), + "batch", + )); + } + Ok(controls) + } + + /// Phase B step 4: exactly one `Environment.Advance` per complete batch. + async fn advance( + &mut self, + k: u64, + batch_id: &Id, + controls: &[PortControl], + ) -> Outcome { + let scope = self.scope(k); + let params = AdvanceParams { + batch_id: batch_id.clone(), + controls: controls.to_vec(), + }; + let params = match params.to_json() { + Value::Object(m) => m, + _ => Map::new(), + }; + let worker = self.environment.clone(); + let request_id = self.serials.next(&worker.service); + self.last_advance_request = Some(request_id.clone()); + let want = vec!["view.arena".to_owned()]; + self.audit.push(format!("advance:{k}")); + + let injected = self.injections.at_step == k; + let reply = if injected && self.injections.lose_advance_result { + // The result is lost after the world already stepped: the call is abandoned + // mid-flight, so its execution outcome is unknown. Further dispatch stops and the + // same operation is resolved; a new batch is never sent. + let pending = self + .bus + .call( + &worker.service, + Some(&worker.bus_incarnation), + "Environment.Advance", + object( + SessionRpcRequest { + request_id: request_id.clone(), + scope: Some(scope.clone()), + params: Value::Object(params.clone()), + } + .to_json(), + ), + &[], + ) + .await + .map_err(|e| { + let error = DomainError::new( + ErrorCode::BackendFailure, + format!("Environment.Advance: bus {:?}", e.code), + MutationCertainty::Unknown, + ); + self.fail_now(error, "advance") + })?; + // The loss has to happen *after* the world stepped, so the injection waits for + // the worker to report the operation before abandoning the call. A status probe + // is exactly what the uncertain-call procedure does first anyway. + let mut knows = false; + for _ in 0..500u32 { + let status = self.status(&worker).await?; + knows = status.last_batch_id.as_ref() == Some(batch_id) + || status.active_request_id.as_ref() == Some(&request_id) + || status.last_completed_request_id.as_ref() == Some(&request_id); + if knows { + break; + } + tokio::time::sleep(std::time::Duration::from_millis(2)).await; + } + self.injection_log.push(InjectionOutcome { + what: "status-after-loss".to_owned(), + code: None, + identical: knows, + }); + let state = pending.cancel().await.ok(); + drop(pending); + // Cancellation after dispatch cannot undo the work: the execution outcome is + // uncertain, which is the case this injects. + self.injection_log.push(InjectionOutcome { + what: "lost-advance-result".to_owned(), + code: None, + identical: state != Some(flybus::CancelState::CancelledBeforeDispatch), + }); + self.resolve( + &worker, + "Environment.Advance", + Some(scope.clone()), + params.clone(), + Vec::new(), + request_id.clone(), + &want, + ) + .await? + } else { + let reply = call_owned( + self.bus.clone(), + worker.clone(), + "Environment.Advance", + Some(scope.clone()), + params.clone(), + Vec::new(), + request_id.clone(), + want.clone(), + ) + .await; + let reply = match reply { + Ok(reply) => reply, + Err(e) => return Err(self.fail_now(e, "advance")), + }; + self.check_reply(&worker, &reply, &Some(scope.clone()), "advance")?; + match reply.result() { + Ok(_) => reply, + Err(e) => return Err(self.fail_now(e, "advance")), + } + }; + + let result: StepResult = match reply.parse() { + Ok(result) => result, + Err(e) => return Err(self.fail_now(e, "advance")), + }; + self.pending_views = reply.artifacts; + + if injected && self.injections.altered_advance_controls { + // The same request id with a different body: a conflict, never a second world + // mutation. + let mut altered = controls.to_vec(); + if let Some(control) = altered.first_mut() + && let Some(button) = control.buttons.first_mut() + { + button.down = !button.down; + } + let altered_params = object( + AdvanceParams { batch_id: batch_id.clone(), controls: altered }.to_json(), + ); + self.probe_duplicate( + &worker, + "Environment.Advance", + Some(scope.clone()), + altered_params, + Vec::new(), + request_id.clone(), + None, + "altered-advance-controls", + ) + .await; + } + + if injected && self.injections.consume_advance_artifact_then_retry { + // The first caller consumes the result's frame, then replays the operation. The + // endpoint's cache owns its own hold, so the replay still has valid bytes. + let first = match self.pending_views.get("view.arena") { + Some(artifact) => artifact.read_all().await.ok(), + None => None, + }; + self.pending_views.clear(); + let replay = self + .resolve( + &worker, + "Environment.Advance", + Some(scope.clone()), + params.clone(), + Vec::new(), + request_id.clone(), + &want, + ) + .await?; + let again = match replay.artifacts.get("view.arena") { + Some(artifact) => artifact.read_all().await.ok(), + None => None, + }; + self.injection_log.push(InjectionOutcome { + what: "cached-artifact-after-consumption".to_owned(), + code: None, + identical: first.is_some() && first == again, + }); + self.pending_views = replay.artifacts; + } + Ok(result) + } +} + +impl Coordinator { + /// Phase C's verification: batch identity, boundary, cadence, schema and required views. + #[allow(clippy::too_many_arguments)] + fn verify_step_result( + &mut self, + k: u64, + descriptor: &EnvironmentDescriptor, + batch_id: &Id, + controls: &[PortControl], + old: &WorldObservation, + result: &StepResult, + ) -> Outcome<()> { + if result.batch_id != *batch_id { + return Err(self.fail_now( + DomainError::before(ErrorCode::IdentityMismatch, "the result names another batch"), + "step-result", + )); + } + if result.applied_from_step != k || result.next_step != k + 1 { + return Err(self.fail_now( + DomainError::before( + ErrorCode::IdentityMismatch, + "the result does not describe exactly this transition", + ), + "step-result", + )); + } + if result.applied_controls_digest != controls_digest(controls) { + return Err(self.fail_now( + DomainError::before( + ErrorCode::IdentityMismatch, + "the applied control digest is not the batch that was requested", + ), + "step-result", + )); + } + if result.observation.boundary != k + 1 { + return Err(self.fail_now( + DomainError::before(ErrorCode::IdentityMismatch, "the observation is not k+1"), + "step-result", + )); + } + // Fixed cadence within an epoch: exactly one step duration of world time. + let expected_time = old + .world_time + .checked_add(&descriptor.step_duration) + .map_err(DomainError::invalid); + let expected_time = match expected_time { + Ok(time) => time, + Err(e) => return Err(self.fail_now(e, "step-result")), + }; + if result.observation.world_time != expected_time { + return Err(self.fail_now( + DomainError::before( + ErrorCode::IdentityMismatch, + "world time did not advance by exactly one step duration", + ), + "step-result", + )); + } + if result.observation.inspection.schema != descriptor.inspection_schema { + return Err(self.fail_now( + DomainError::before( + ErrorCode::IdentityMismatch, + "the inspection payload is not the declared schema", + ), + "step-result", + )); + } + // A missing required sensory input is never silently replaced by an older frame. + // The contract's validator checks the views that are present against their + // descriptors; requiring each declared view to be there at all is the coordinator's + // Phase C check, so it is made here. + for view in &descriptor.views { + let want = required_produced_step(view, result.observation.boundary); + let got = result + .observation + .sensory_views + .iter() + .find(|given| given.view_id == view.view_id); + match got { + Some(given) if given.produced_step == want => {} + Some(_) => { + return Err(self.fail_now( + DomainError::new( + ErrorCode::BufferInvalid, + format!( + "view {} did not come from the boundary its declared delay requires", + view.view_id + ), + MutationCertainty::Unknown, + ), + "step-result", + )); + } + None => { + return Err(self.fail_now( + DomainError::new( + ErrorCode::BufferInvalid, + format!("required sensory view {} is missing", view.view_id), + MutationCertainty::Unknown, + ), + "step-result", + )); + } + } + } + if let Err(e) = result.observation.validate_against(descriptor) { + return Err(self.fail_now( + DomainError::new(ErrorCode::BufferInvalid, e, MutationCertainty::Unknown), + "step-result", + )); + } + for view in &result.observation.sensory_views { + let name = format!("view.{}", view.view_id); + match self.pending_views.get(&name) { + Some(artifact) if artifact.reference() == &view.pixels => {} + _ => { + return Err(self.fail_now( + DomainError::new( + ErrorCode::BufferInvalid, + format!("required view {} arrived without a live owned handle", view.view_id), + MutationCertainty::Unknown, + ), + "step-result", + )); + } + } + } + Ok(()) + } + + /// Phase D. Every agent commits before the next Prepare or any committed publication. + async fn commit_all( + &mut self, + k: u64, + observation: &WorldObservation, + outcomes: &mut BTreeMap, + next_contexts: &mut BTreeMap, + views: &BTreeMap, + ) -> Outcome> { + let scope = self.scope(k); + let attachments: Vec<(String, flybus::Artifact)> = + views.iter().map(|(n, a)| (n.clone(), a.clone())).collect(); + let mut jobs = Vec::new(); + let mut bodies = Vec::new(); + for index in 0..self.agents.len() { + let agent_id = self.agents[index].agent_id.clone(); + let prepared_request = self.agents[index] + .prepare_request + .clone() + .expect("every agent prepared"); + let outcome = outcomes.get(&agent_id).cloned().unwrap_or_default(); + let next_context = next_contexts.get(&agent_id).cloned().expect("checked"); + let params = CommitParams { + agent_id: agent_id.clone(), + prepared_request_id: prepared_request.clone(), + next_input: self.sensory_input(observation, k + 1), + next_decision_context: next_context, + rewards: outcome.rewards.clone(), + task_stimulations: outcome.stimulations.clone(), + }; + let params = match params.to_json() { + Value::Object(m) => m, + _ => Map::new(), + }; + let request_id = self.serials.next(&self.agents[index].worker.service); + let worker = self.agents[index].worker.clone(); + bodies.push(( + agent_id.clone(), + worker.clone(), + params.clone(), + request_id.clone(), + )); + jobs.push(Job { + agent_id, + worker, + method: "Agent.Commit", + scope: Some(scope.clone()), + params, + attachments: attachments.clone(), + request_id, + }); + } + self.last_commit_requests = bodies + .iter() + .map(|(agent_id, _, _, request_id)| TraceRequest { + agent_id: agent_id.clone(), + request_id: request_id.clone(), + }) + .collect(); + let results = self.run_jobs(jobs, self.dispatch).await; + let mut commits = Vec::new(); + let mut first_failure = None; + for (agent_id, reply, scope, worker, method) in results { + match reply { + Ok(reply) => { + self.check_reply(&worker, &reply, &scope, method)?; + match reply.result() { + Ok(_) => {} + Err(e) => { + first_failure = Some(first_failure.unwrap_or(e)); + continue; + } + } + let result: AgentCommitResult = match reply.parse() { + Ok(result) => result, + Err(e) => return Err(self.fail_now(e, method)), + }; + if result.committed_step != k + 1 { + return Err(self.fail_now( + DomainError::before( + ErrorCode::IdentityMismatch, + "an agent acknowledged another boundary", + ), + method, + )); + } + let expected = next_contexts.get(&agent_id).expect("checked").digest(); + if result.decision_context_digest != expected { + return Err(self.fail_now( + DomainError::before( + ErrorCode::IdentityMismatch, + "an agent retained another decision context", + ), + method, + )); + } + if let Err(e) = result.telemetry.validate() { + return Err(self.fail_now(DomainError::invalid(e), method)); + } + self.audit.push(format!("committed:{agent_id}@{k}")); + self.stats.commits += 1; + commits.push((agent_id, result)); + } + Err(e) => { + first_failure = Some(first_failure.unwrap_or(e)); + } + } + } + if let Some(error) = first_failure { + // One Commit failed after others succeeded. There is no partial-match + // continuation: the epoch is failed and the group recovers together. + let _ = bodies; + return Err(self.fail_now(error, "commit")); + } + if commits.len() != self.agents.len() { + return Err(self.fail_now( + DomainError::before( + ErrorCode::InvalidPhase, + "no next world step until every agent has committed", + ), + "commit", + )); + } + + if self.injections.at_step == k + && let Some(target) = self.injections.duplicate_commit.clone() + { + let expected = commits + .iter() + .find(|(id, _)| *id == target) + .map(|(_, result)| result.to_json()); + if let Some((_, worker, params, request_id)) = + bodies.into_iter().find(|(id, _, _, _)| *id == target) + { + self.probe_duplicate( + &worker, + "Agent.Commit", + Some(self.scope(k)), + params, + attachments.clone(), + request_id, + expected.as_ref(), + "duplicate-commit", + ) + .await; + } + } + commits.sort_by(|a, b| a.0.cmp(&b.0)); + Ok(commits) + } + + /// Records the `step-v1` section 8 trace for this transition. + /// Records the `step-v1` section 8 trace for this transition. + /// + /// Behaviour and operational metadata are separated by the contract type: the behaviour is + /// what a reordered run must reproduce exactly, and the request ids, batch correlation and + /// wall time are recorded beside it rather than inside it. + #[allow(clippy::too_many_arguments)] + fn record_trace( + &mut self, + k: u64, + prepared: &[(Id, PreparedDecision)], + commits: &[(Id, AgentCommitResult)], + controls: &[PortControl], + batch_id: &Id, + result: &StepResult, + outcomes: &BTreeMap, + event_ids: &[Id], + ) { + let mut agents = Vec::new(); + let mut outcome_ids = Vec::new(); + let mut prepare_request_ids = Vec::new(); + for (agent_id, decision) in prepared { + let slot = self.agent(agent_id).expect("configured"); + let committed = commits + .iter() + .find(|(id, _)| id == agent_id) + .map(|(_, result)| result.committed_step) + .unwrap_or(0); + agents.push(TraceAgent { + agent_id: agent_id.clone(), + profile_digest: slot.profile.digest.clone(), + ticks_advanced: decision.ticks_advanced, + brain_ticks: decision.brain_ticks, + remainder: decision.remainder, + decision_digest: decision.decision.digest(), + committed_step: committed, + }); + if let Some(request_id) = slot.prepare_request.clone() { + prepare_request_ids.push(TraceRequest { agent_id: agent_id.clone(), request_id }); + } + // Outcome ids in task order: the reward events this agent was routed. + if let Some(outcome) = outcomes.get(agent_id) { + for reward in &outcome.rewards { + outcome_ids.push(reward.event_id.clone()); + } + } + } + let mut observation_boundaries: Vec = result + .observation + .sensory_views + .iter() + .map(|view| TraceObservation { + view_id: view.view_id.clone(), + produced_step: view.produced_step, + }) + .collect(); + observation_boundaries.sort_by(|a, b| a.view_id.cmp(&b.view_id)); + let behaviour = TraceBehaviour { + scope: self.scope(k), + agents, + batch_id: batch_id.clone(), + control_digest: controls_digest(controls), + acknowledged_boundary: result.next_step, + observation_boundaries, + outcome_ids, + event_ids: event_ids.to_vec(), + published_boundary: k + 1, + }; + let operational = TraceOperational { + // Wall time is for pacing, health and presentation only. + wall_time_ns: u64::try_from(self.started.elapsed().as_nanos()).unwrap_or(u64::MAX), + prepare_request_ids, + advance_request_id: self + .last_advance_request + .clone() + .unwrap_or_else(|| DomainRequestId::from_serial(0)), + commit_request_ids: self.last_commit_requests.clone(), + // SESSION-01 does not record transport correlation ids; a safe retry changes them + // and nothing in the behaviour above. + bus_call_ids: Vec::new(), + delivery_ids: Vec::new(), + }; + self.trace.transition(TransitionTrace { behaviour, operational }); + } + + // ----------------------------------------------------------------------------------- + // Publication + + async fn publish( + &mut self, + topic: &str, + payload: Map, + attachments: Vec<(String, flybus::Artifact)>, + ) -> Outcome<()> { + let refs: Vec<(&str, &flybus::Artifact)> = + attachments.iter().map(|(n, a)| (n.as_str(), a)).collect(); + match self.bus.publish(topic, payload, &refs).await { + Ok(_) => Ok(()), + Err(e) => { + // A disconnected or backpressured observer never stalls the world; only a + // real resource fault reaches here, and it fails the epoch honestly. + let error = DomainError::new( + ErrorCode::BackendFailure, + format!("publishing {topic}: {}", e.message), + MutationCertainty::None, + ); + Err(self.fail_now(error, "publish")) + } + } + } + + async fn publish_descriptor(&mut self) -> Outcome<()> { + let descriptor = self.descriptor.clone().expect("bootstrapped"); + let agents: Vec = self + .agents + .iter() + .map(|slot| { + json!({ + "agentId": slot.agent_id.as_str(), + "portId": slot.port_id.as_str(), + "profileDigest": slot.profile.digest.as_str(), + "tickDuration": slot.tick_duration.to_json(), + "warmupTicks": slot.warmup_ticks.to_string(), + }) + }) + .collect(); + let payload = json!({ + "sessionId": self.session_id.as_str(), + "revision": "1", + "compositionDigest": self.composition_digest().as_str(), + "schedulerId": "lockstep-v1", + "environment": descriptor.to_json(), + "taskSchema": self.task.schema().to_json(), + "agents": agents, + }); + let topic = self.topics.descriptor.clone(); + self.publish(&topic, match payload { + Value::Object(m) => m, + _ => Map::new(), + }, Vec::new()) + .await + } + + /// The composition identity: session, epoch, agents, ports and the contract revision. + pub fn composition_digest(&self) -> Digest { + let mut text = format!( + "fly-session/composition-v1\nsession={}\nepoch={}\ncontract={}\n", + self.session_id, + self.epoch, + contract_digest() + ); + for slot in &self.agents { + text.push_str(&format!( + "agent={} port={} profile={}\n", + slot.agent_id, slot.port_id, slot.profile.digest + )); + } + digest_of_bytes(text.as_bytes()) + } + + async fn publish_events(&mut self, source_step: u64, events: &[TaskEvent]) -> Outcome<()> { + if events.is_empty() { + return Ok(()); + } + let payload = json!({ + "sessionId": self.session_id.as_str(), + "epoch": self.epoch.as_str(), + "sourceStep": source_step.to_string(), + "events": Value::Array(events.iter().map(DomainType::to_json).collect()), + }); + let topic = self.topics.events.clone(); + self.publish(&topic, match payload { + Value::Object(m) => m, + _ => Map::new(), + }, Vec::new()) + .await + } + + /// Publishes the committed boundary. Never an in-progress mix of new agent state and an + /// old world, and never before every agent has committed. + async fn publish_snapshot( + &mut self, + boundary: u64, + decisions: &BTreeMap, + controls: &[PortControl], + event_ids: &[Id], + ) -> Outcome<()> { + if !self.phases.phase().is_committed_boundary() { + return Err(self.fail_now( + DomainError::before( + ErrorCode::InvalidPhase, + "a snapshot represents a committed boundary only", + ), + "publish", + )); + } + let observation = self.observation.clone().expect("bootstrapped"); + let agents: Vec = self + .agents + .iter() + .map(|slot| { + let control = controls + .iter() + .find(|c| c.port_id == slot.port_id) + .map(|c| c.to_json()); + json!({ + "agentId": slot.agent_id.as_str(), + "selectedDecision": decisions + .get(&slot.agent_id) + .map(|d| d.to_json()), + "appliedControls": control, + "committedStep": slot.committed_step.to_string(), + }) + }) + .collect(); + let payload = json!({ + "descriptorRevision": "1", + "publisherIncarnation": self.bus.info().connection_id.clone(), + "scope": self.scope(boundary).to_json(), + "episodeId": self.episode_id.as_str(), + "sequence": self.stats.publications.to_string(), + "worldTime": observation.world_time.to_json(), + "agents": agents, + "progress": self.task.progress().to_json(), + "media": json!({ + "views": Value::Array(observation.broadcast_views.iter().map(DomainType::to_json).collect()), + "audio": [], + }), + "eventIds": event_ids.iter().map(Id::as_str).collect::>(), + }); + let attachments = self.view_attachments(); + let topic = self.topics.snapshots.clone(); + self.publish( + &topic, + match payload { + Value::Object(m) => m, + _ => Map::new(), + }, + attachments, + ) + .await?; + self.stats.publications += 1; + self.audit.push(format!("publish:{boundary}")); + Ok(()) + } +} diff --git a/services/flysim/crates/fly-session/src/dedup.rs b/services/flysim/crates/fly-session/src/dedup.rs new file mode 100644 index 0000000..5a0c4c9 --- /dev/null +++ b/services/flysim/crates/fly-session/src/dedup.rs @@ -0,0 +1,446 @@ +//! Domain deduplication and the result cache of `ipc-v1` section 5. +//! +//! The operation key for a step mutation is `(sessionId, epoch, step, method, workerId)`, and +//! there is at most one Prepare, Commit or Advance for it. The cache decides, *before* any +//! phase check or artifact dereference, whether an arriving request is the original, a safe +//! replay, a conflicting change, a duplicate of something still running, or a retry whose +//! result is gone. +//! +//! A cached reply owns its artifacts through explicit holds, so a replay is still valid after +//! the first caller consumed its delivery. Dropping the record drops those holds. + +use std::collections::{BTreeMap, VecDeque}; + +// `crate::types` is this crate's facade over the shared `fly-session-types` crate; the +// glob keeps the contract's own names in sight instead of restating them. +use crate::types::*; + +/// `ipc-v1` section 5: unacknowledged lifecycle replies are bounded at 16, then BUSY. +pub const MAX_UNACKNOWLEDGED: usize = 16; +/// `ipc-v1` section 5: Status and Acknowledge keep a cache of their last 16 replies. +pub const MAX_READONLY_REPLIES: usize = 16; +/// `ipc-v1` section 5: keep the current and the immediately previous step's records. +pub const RETAINED_STEPS: u64 = 2; + +/// Which retention rule an operation follows. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum OpClass { + /// Prepare, Commit, Advance: keyed by scope and retained for two steps. + StepMutation, + /// Initialize and capture: retained until `Worker.Acknowledge`. + Lifecycle, + /// Status and Acknowledge: a small last-16 reply cache, no mutation key. + ReadOnly, +} + +/// A step mutation's identity. Not a bus call id and not a batch id. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord)] +pub struct OperationKey { + pub session_id: Id, + pub epoch: Id, + pub step: u64, + pub method: String, + pub worker_id: Id, +} + +/// A terminal domain reply plus the artifact holds that keep its attachments readable. +#[derive(Clone)] +pub struct CachedReply { + pub outcome: SessionRpcOutcome, + pub artifacts: Vec<(String, flybus::Artifact)>, +} + +impl CachedReply { + pub fn new(outcome: SessionRpcOutcome) -> CachedReply { + CachedReply { outcome, artifacts: Vec::new() } + } + + pub fn with_artifacts( + outcome: SessionRpcOutcome, + artifacts: Vec<(String, flybus::Artifact)>, + ) -> CachedReply { + CachedReply { outcome, artifacts } + } + + /// The attachment list for a fresh `rpc.reply`, over the same immutable bytes. + pub fn attachments(&self) -> Vec<(&str, &flybus::Artifact)> { + self.artifacts.iter().map(|(n, a)| (n.as_str(), a)).collect() + } +} + +impl std::fmt::Debug for CachedReply { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("CachedReply") + .field("outcome", &self.outcome) + .field("artifacts", &self.artifacts.len()) + .finish() + } +} + +/// What the cache decided about an arriving request. +#[derive(Debug)] +pub enum Admission { + /// The original: run it, then `record` its reply. + Execute, + /// A safe replay: return this reply again, with fresh delivery ownership. + Replay(CachedReply), + /// A terminal domain error for this bus call, with no second mutation started. + Refuse(DomainError), +} + +#[derive(Clone, Debug)] +struct Record { + request_id: DomainRequestId, + body: Digest, + reply: CachedReply, +} + +/// One worker's domain request cache. +pub struct ResultCache { + steps: BTreeMap, + active: BTreeMap, + lifecycle: BTreeMap, + lifecycle_order: VecDeque, + readonly: VecDeque<(String, CachedReply)>, + highest_serial: Option, + step_watermark: Option, + /// Serials retired by `Worker.Acknowledge`; reuse below this is refused without keeping a + /// tombstone per request. + acknowledged_watermark: Option, +} + +impl Default for ResultCache { + fn default() -> ResultCache { + ResultCache::new() + } +} + +impl ResultCache { + pub fn new() -> ResultCache { + ResultCache { + steps: BTreeMap::new(), + active: BTreeMap::new(), + lifecycle: BTreeMap::new(), + lifecycle_order: VecDeque::new(), + readonly: VecDeque::new(), + highest_serial: None, + step_watermark: None, + acknowledged_watermark: None, + } + } + + /// Decides what to do with an arriving request. Identity is checked before phase. + pub fn admit( + &mut self, + class: OpClass, + key: &OperationKey, + request: DomainRequestId, + body: &Digest, + ) -> Admission { + match class { + OpClass::StepMutation => self.admit_step(key, request, body), + OpClass::Lifecycle => self.admit_lifecycle(request, body), + OpClass::ReadOnly => Admission::Execute, + } + } + + fn admit_step(&mut self, key: &OperationKey, request: DomainRequestId, body: &Digest) -> Admission { + if let Some(record) = self.steps.get(key) { + if record.request_id == request && record.body == *body { + return Admission::Replay(record.reply.clone()); + } + return Admission::Refuse(DomainError::new( + ErrorCode::Conflict, + "this operation key already holds a different request or body", + // The earlier result stands; refusing the duplicate undoes nothing. + MutationCertainty::None, + )); + } + if !self.active.is_empty() && !self.active.contains_key(key) { + // One mutation executes at a time. A second, different one is refused before + // admission rather than queued behind the first. + return Admission::Refuse(DomainError::before( + ErrorCode::Busy, + "another step mutation is already executing on this worker", + )); + } + if let Some((active_request, active_body)) = self.active.get(key) { + if *active_request == request && *active_body == *body { + // The duplicate bus call started no work at all, so its certainty is none; + // it must not be mistaken for the original operation's failure. + return Admission::Refuse(DomainError::before( + ErrorCode::InProgress, + "the original operation is still executing; this duplicate started no work", + )); + } + return Admission::Refuse(DomainError::new( + ErrorCode::Conflict, + "an operation with a different body is active for this key", + MutationCertainty::None, + )); + } + // No record and nothing active. Eviction must never re-enable execution, so a step + // below the retention window is refused on the serial and step watermarks. + if let Some(watermark) = self.step_watermark + && key.step + RETAINED_STEPS <= watermark + { + let issued = self.highest_serial.is_some_and(|h| request.serial() <= h); + return Admission::Refuse(if issued { + DomainError::new( + ErrorCode::ResultExpired, + "the retained result for this request is gone; it is never recomputed", + MutationCertainty::Unknown, + ) + } else { + DomainError::before( + ErrorCode::StaleStep, + "a newly issued request cannot name a step the worker has left behind", + ) + }); + } + if let Some(watermark) = self.acknowledged_watermark + && request.serial() <= watermark + { + return Admission::Refuse(DomainError::new( + ErrorCode::ResultExpired, + "this request serial was acknowledged and cannot be reused", + MutationCertainty::Unknown, + )); + } + self.begin(key.clone(), request, body.clone()); + Admission::Execute + } + + fn admit_lifecycle(&mut self, request: DomainRequestId, body: &Digest) -> Admission { + let id = request.as_str().to_owned(); + if let Some(record) = self.lifecycle.get(&id) { + if record.body == *body { + return Admission::Replay(record.reply.clone()); + } + return Admission::Refuse(DomainError::before( + ErrorCode::Conflict, + "this lifecycle request id already holds a different body", + )); + } + if let Some(watermark) = self.acknowledged_watermark + && request.serial() <= watermark + { + return Admission::Refuse(DomainError::new( + ErrorCode::ResultExpired, + "this request serial was acknowledged and cannot be reused", + MutationCertainty::Unknown, + )); + } + if self.lifecycle.len() >= MAX_UNACKNOWLEDGED { + return Admission::Refuse(DomainError::before( + ErrorCode::Busy, + "16 lifecycle replies are unacknowledged; acknowledge some before sending more", + )); + } + Admission::Execute + } + + fn begin(&mut self, key: OperationKey, request: DomainRequestId, body: Digest) { + self.highest_serial = Some(self.highest_serial.map_or(request.serial(), |h| h.max(request.serial()))); + self.step_watermark = Some(self.step_watermark.map_or(key.step, |w| w.max(key.step))); + self.active.insert(key, (request, body)); + } + + /// Stores a terminal reply for a step mutation and prunes what has aged out. + pub fn record( + &mut self, + key: OperationKey, + request: DomainRequestId, + body: Digest, + reply: CachedReply, + ) { + self.active.remove(&key); + let step = key.step; + self.steps.insert(key, Record { request_id: request, body, reply }); + self.step_watermark = Some(self.step_watermark.map_or(step, |w| w.max(step))); + self.prune(); + } + + /// Stores a lifecycle reply, retained until `Worker.Acknowledge`. + pub fn record_lifecycle(&mut self, request: DomainRequestId, body: Digest, reply: CachedReply) { + let id = request.as_str().to_owned(); + self.highest_serial = Some(self.highest_serial.map_or(request.serial(), |h| h.max(request.serial()))); + if self.lifecycle.insert(id.clone(), Record { request_id: request, body, reply }).is_none() + { + self.lifecycle_order.push_back(id); + } + } + + /// Stores a read-only reply in the last-16 cache. + pub fn record_readonly(&mut self, request: DomainRequestId, reply: CachedReply) { + let id = request.as_str().to_owned(); + self.readonly.retain(|(existing, _)| *existing != id); + self.readonly.push_back((id, reply)); + while self.readonly.len() > MAX_READONLY_REPLIES { + self.readonly.pop_front(); + } + } + + /// Releases an operation that did not mutate anything, so the key stays free. + pub fn abandon(&mut self, key: &OperationKey) { + self.active.remove(key); + } + + /// `Worker.Acknowledge`: drops those lifecycle records, ignoring unknown ids, and raises + /// the serial watermark so an acknowledged id cannot be reused. + pub fn acknowledge(&mut self, ids: &[DomainRequestId]) -> Vec { + let mut out = Vec::new(); + for id in ids { + if let Some(record) = self.lifecycle.remove(id.as_str()) { + self.lifecycle_order.retain(|existing| existing != id.as_str()); + self.acknowledged_watermark = Some( + self.acknowledged_watermark + .map_or(record.request_id.serial(), |w| w.max(record.request_id.serial())), + ); + out.push(id.clone()); + } + } + out + } + + pub fn unacknowledged(&self) -> usize { + self.lifecycle.len() + } + + pub fn retained_steps(&self) -> usize { + self.steps.len() + } + + pub fn has_active(&self) -> bool { + !self.active.is_empty() + } + + /// Deliberately drops a step record, so a retry meets RESULT_EXPIRED instead of a replay. + pub fn expire_step(&mut self, key: &OperationKey) -> bool { + self.steps.remove(key).is_some() + } + + fn prune(&mut self) { + let Some(watermark) = self.step_watermark else { return }; + let floor = watermark.saturating_sub(RETAINED_STEPS - 1); + self.steps.retain(|key, _| key.step >= floor); + } +} + +#[cfg(test)] +mod tests { + use serde_json::Value; + + use super::*; + + fn key(step: u64, method: &str) -> OperationKey { + OperationKey { + session_id: id("demo"), + epoch: id("e1"), + step, + method: method.to_owned(), + worker_id: id("fly-a"), + } + } + + fn reply(tag: &str) -> CachedReply { + let mut result = serde_json::Map::new(); + result.insert("tag".into(), tag.into()); + CachedReply::new(SessionRpcOutcome::Success(SessionRpcSuccess { + request_id: DomainRequestId::from_serial(1), + worker_id: id("fly-a"), + incarnation_id: id("inc-1"), + scope: Some(scope_at("demo", "e1", 0)), + result: Value::Object(result), + })) + } + + fn body(s: &str) -> Digest { + digest_of_bytes(s.as_bytes()) + } + + #[test] + fn the_same_key_request_and_body_replays() { + let mut c = ResultCache::new(); + let k = key(0, "Agent.Prepare"); + assert!(matches!(c.admit(OpClass::StepMutation, &k, DomainRequestId::from_serial(1), &body("a")), Admission::Execute)); + c.record(k.clone(), DomainRequestId::from_serial(1), body("a"), reply("first")); + match c.admit(OpClass::StepMutation, &k, DomainRequestId::from_serial(1), &body("a")) { + Admission::Replay(r) => { + assert_eq!(r.outcome.result().unwrap()["tag"], "first"); + } + other => panic!("wanted a replay, got {other:?}"), + } + } + + #[test] + fn a_changed_body_for_a_recorded_key_is_a_conflict() { + let mut c = ResultCache::new(); + let k = key(0, "Environment.Advance"); + c.admit(OpClass::StepMutation, &k, DomainRequestId::from_serial(1), &body("a")); + c.record(k.clone(), DomainRequestId::from_serial(1), body("a"), reply("first")); + match c.admit(OpClass::StepMutation, &k, DomainRequestId::from_serial(1), &body("b")) { + Admission::Refuse(e) => assert_eq!(e.code, ErrorCode::Conflict), + other => panic!("wanted CONFLICT, got {other:?}"), + } + } + + #[test] + fn a_duplicate_while_the_original_runs_is_in_progress() { + let mut c = ResultCache::new(); + let k = key(0, "Agent.Prepare"); + c.admit(OpClass::StepMutation, &k, DomainRequestId::from_serial(1), &body("a")); + match c.admit(OpClass::StepMutation, &k, DomainRequestId::from_serial(1), &body("a")) { + Admission::Refuse(e) => assert_eq!(e.code, ErrorCode::InProgress), + other => panic!("wanted IN_PROGRESS, got {other:?}"), + } + } + + #[test] + fn an_evicted_step_gives_result_expired_and_a_fresh_serial_gives_stale_step() { + let mut c = ResultCache::new(); + for step in 0..4u64 { + let k = key(step, "Agent.Prepare"); + c.admit(OpClass::StepMutation, &k, DomainRequestId::from_serial(step + 1), &body("a")); + c.record(k, DomainRequestId::from_serial(step + 1), body("a"), reply("x")); + } + assert_eq!(c.retained_steps(), 2); + // req-1 named step 0, whose record is long gone. + match c.admit(OpClass::StepMutation, &key(0, "Agent.Prepare"), DomainRequestId::from_serial(1), &body("a")) { + Admission::Refuse(e) => assert_eq!(e.code, ErrorCode::ResultExpired), + other => panic!("wanted RESULT_EXPIRED, got {other:?}"), + } + // A serial above the highest issued is a new operation naming an old step. + match c.admit(OpClass::StepMutation, &key(0, "Agent.Prepare"), DomainRequestId::from_serial(99), &body("a")) { + Admission::Refuse(e) => assert_eq!(e.code, ErrorCode::StaleStep), + other => panic!("wanted STALE_STEP, got {other:?}"), + } + } + + #[test] + fn lifecycle_replies_are_bounded_and_released_by_acknowledge() { + let mut c = ResultCache::new(); + for serial in 1..=MAX_UNACKNOWLEDGED as u64 { + assert!(matches!( + c.admit(OpClass::Lifecycle, &key(0, "Agent.Initialize"), DomainRequestId::from_serial(serial), &body("a")), + Admission::Execute + )); + c.record_lifecycle(DomainRequestId::from_serial(serial), body("a"), reply("init")); + } + match c.admit(OpClass::Lifecycle, &key(0, "Agent.Initialize"), DomainRequestId::from_serial(99), &body("a")) { + Admission::Refuse(e) => assert_eq!(e.code, ErrorCode::Busy), + other => panic!("wanted BUSY, got {other:?}"), + } + let dropped = c.acknowledge(&[ + DomainRequestId::from_serial(1), + DomainRequestId::from_serial(404), + ]); + assert_eq!(dropped, vec![DomainRequestId::from_serial(1)]); + assert_eq!(c.unacknowledged(), MAX_UNACKNOWLEDGED - 1); + // The acknowledged serial cannot come back. + match c.admit(OpClass::Lifecycle, &key(0, "Agent.Initialize"), DomainRequestId::from_serial(1), &body("a")) { + Admission::Refuse(e) => assert_eq!(e.code, ErrorCode::ResultExpired), + other => panic!("wanted RESULT_EXPIRED, got {other:?}"), + } + } +} diff --git a/services/flysim/crates/fly-session/src/environment.rs b/services/flysim/crates/fly-session/src/environment.rs new file mode 100644 index 0000000..deb8d21 --- /dev/null +++ b/services/flysim/crates/fly-session/src/environment.rs @@ -0,0 +1,454 @@ +//! The counter arena: one environment worker with no emulator behind it. +//! +//! It holds a signed counter, applies one complete control batch per Advance, advances exactly +//! one interval, and returns boundary `k+1` with its world time advanced by `stepDuration`. It +//! never advances while waiting for the next request, and it does not free-run during agent +//! initialization. + +use std::collections::BTreeSet; +use std::io::Write; + +use crate::task::{controller_schema_ref, inspection, inspection_schema}; +// `crate::types` is this crate's facade over the shared `fly-session-types` crate; the +// glob keeps the contract's own names in sight instead of restating them. +use crate::types::*; +use crate::worker::{BoxFuture, HandlerCtx, HandlerReply, StatusCell, WorkerEndpoint}; + +/// The arena's view: a 4x4 RGBA8 tile whose bytes carry the counter. +pub const VIEW_WIDTH: u64 = 4; +pub const VIEW_HEIGHT: u64 = 4; + +/// Deliberate faults a test can ask the environment for. +#[derive(Clone, Debug, Default)] +pub struct EnvironmentFaults { + /// Hold `Environment.Advance` open for this long, after the world already moved. + pub advance_delay_ms: u64, + /// Drop the required sensory view from the result at this boundary, so the coordinator + /// meets a world that advanced with no usable sensory data. + pub omit_view_at_boundary: Option, +} + +#[derive(Clone, Debug)] +pub struct EnvironmentConfig { + pub session_id: Id, + pub worker_id: Id, + pub incarnation_id: Id, + /// The world's fixed reduced step duration. 60 Hz is `1/60` s. + pub step_duration: RationalNs, + pub ports: Vec, + pub faults: EnvironmentFaults, +} + +/// The counter environment endpoint. +pub struct CounterEnvironment { + config: EnvironmentConfig, + status: StatusCell, + epoch: Option, + episode_id: Option, + descriptor: Option, + bindings: Vec, + boundary: u64, + counter: i64, + world_time: RationalNs, + advances: u64, + batches: BTreeSet, +} + +impl CounterEnvironment { + pub fn new(config: EnvironmentConfig) -> CounterEnvironment { + CounterEnvironment { + status: StatusCell::new(), + epoch: None, + episode_id: None, + descriptor: None, + bindings: Vec::new(), + boundary: 0, + counter: 0, + world_time: RationalNs::ZERO, + advances: 0, + batches: BTreeSet::new(), + config, + } + } + + pub fn status(&self) -> StatusCell { + self.status.clone() + } + + /// How many intervals this world has advanced. One complete batch advances it once. + pub fn advances(&self) -> u64 { + self.advances + } + + pub fn counter(&self) -> i64 { + self.counter + } + + pub fn boundary(&self) -> u64 { + self.boundary + } + + /// The controller every port of this arena declares: two buttons and one bipolar axis. + pub fn controller_schema() -> ControllerSchema { + ControllerSchema { + schema: controller_schema_ref(), + buttons: vec![id("inc"), id("dec")], + axes: vec![AxisSchema { + id: id("bias"), + range: AxisRange::Bipolar, + neutral: 0.0, + }], + } + } + + pub fn view_descriptor() -> ViewDescriptor { + ViewDescriptor { + view_id: id("arena"), + width: VIEW_WIDTH, + height: VIEW_HEIGHT, + row_stride: VIEW_WIDTH * 4, + pixel_aspect_numerator: 1, + pixel_aspect_denominator: 1, + observation_delay_steps: 0, + } + } + + fn build_descriptor(&self) -> DomainResult { + let descriptor = EnvironmentDescriptor { + backend_digest: digest_of_bytes(b"counter-arena-backend-v1"), + content_digest: digest_of_bytes(b"counter-arena-content-v1"), + configuration_digest: digest_of_bytes( + format!( + "counter-arena-config-v1\nstep={}/{}\nports={}\n", + self.config.step_duration.numerator, + self.config.step_duration.denominator, + self.config.ports.len() + ) + .as_bytes(), + ), + step_duration: self.config.step_duration, + ports: self + .config + .ports + .iter() + .map(|port_id| PortDescriptor { + port_id: port_id.clone(), + controls: CounterEnvironment::controller_schema(), + }) + .collect(), + inspection_schema: inspection_schema(), + views: vec![CounterEnvironment::view_descriptor()], + audio: Vec::new(), + recovery: Recovery::ExactCheckpoint, + determinism: Determinism::FixedBuild, + }; + descriptor.validate().map_err(DomainError::invalid)?; + Ok(descriptor) + } + + /// Seals one immutable native frame for the current counter and returns the handle. + async fn render( + &self, + ctx: &HandlerCtx<'_>, + ) -> DomainResult<(ViewRef, flybus::Artifact)> { + let descriptor = CounterEnvironment::view_descriptor(); + let len = descriptor.byte_length(); + let mut writer = ctx + .client + .artifacts() + .allocate(len, "image/x-rgba") + .await + .map_err(|e| { + DomainError::new( + ErrorCode::BackendFailure, + format!("frame allocation failed: {}", e.message), + MutationCertainty::Applied, + ) + })?; + // Every pixel carries the counter's low byte, so an agent reading the frame reads the + // world rather than a constant. + let byte = (self.counter & 0xff) as u8; + writer + .write_all(&vec![byte; len as usize]) + .map_err(|e| { + DomainError::new( + ErrorCode::BackendFailure, + format!("frame write failed: {e}"), + MutationCertainty::Applied, + ) + })?; + let artifact = writer.seal().await.map_err(|e| { + DomainError::new( + ErrorCode::BackendFailure, + format!("frame seal failed: {}", e.message), + MutationCertainty::Applied, + ) + })?; + let view = ViewRef { + view_id: descriptor.view_id.clone(), + produced_step: descriptor.required_produced_step(self.boundary), + pixels: artifact.reference().clone(), + }; + Ok((view, artifact)) + } + + async fn observation( + &self, + ctx: &HandlerCtx<'_>, + ) -> DomainResult<(WorldObservation, Vec<(String, flybus::Artifact)>)> { + let omit = self.config.faults.omit_view_at_boundary == Some(self.boundary); + let (views, attachments) = if omit { + (Vec::new(), Vec::new()) + } else { + let (view, artifact) = self.render(ctx).await?; + let name = format!("view.{}", view.view_id); + (vec![view], vec![(name, artifact)]) + }; + let observation = WorldObservation { + boundary: self.boundary, + world_time: self.world_time, + engine_frame: Some(self.boundary.to_string()), + sensory_views: views.clone(), + inspection: inspection(self.counter, self.boundary), + // The same immutable object serves the broadcast view; nothing is rendered twice. + broadcast_views: views, + audio: Vec::new(), + }; + Ok((observation, attachments)) + } + + async fn initialize(&mut self, ctx: &HandlerCtx<'_>) -> DomainResult { + let scope = ctx.scope()?.clone(); + if self.descriptor.is_some() { + return Err(DomainError::before( + ErrorCode::InvalidPhase, + "this environment is already initialized", + )); + } + if scope.session_id != self.config.session_id { + return Err(DomainError::before( + ErrorCode::IdentityMismatch, + "this environment belongs to another session", + )); + } + if scope.step != 0 { + return Err(DomainError::before( + ErrorCode::FutureStep, + "Environment.Initialize uses the new epoch at step 0", + )); + } + let params: EnvironmentInitializeParams = ctx.params()?; + if params.port_bindings.len() > MAX_PORTS { + return Err(DomainError::invalid("at most 4 ports in the first composition")); + } + let mut seen = BTreeSet::new(); + for (port_id, _agent_id) in ¶ms.port_bindings { + if !self.config.ports.contains(port_id) { + return Err(DomainError::before( + ErrorCode::IdentityMismatch, + format!("port {port_id} is not a port of this arena"), + )); + } + if !seen.insert(port_id.clone()) { + return Err(DomainError::invalid(format!("port {port_id} is bound twice"))); + } + } + let descriptor = self.build_descriptor()?; + self.epoch = Some(scope.epoch.clone()); + self.episode_id = Some(params.episode_id.clone()); + self.bindings = params + .port_bindings + .iter() + .map(|(port_id, agent_id)| PortBinding { + port_id: port_id.clone(), + agent_id: agent_id.clone(), + }) + .collect(); + self.boundary = 0; + self.counter = 0; + self.world_time = RationalNs::ZERO; + self.batches.clear(); + self.descriptor = Some(descriptor.clone()); + // The world is stopped when O[0] goes out and cannot free-run while the brains boot. + self.status.set_state(WorkerState::Ready); + self.status.set_scope(Some(scope.clone())); + self.status.progress(1); + + let (observation, attachments) = self.observation(ctx).await?; + let result = EnvironmentInitializeResult { descriptor, observation }; + let mut reply = HandlerReply::from(&result); + reply.artifacts = attachments; + Ok(reply) + } + + async fn advance(&mut self, ctx: &HandlerCtx<'_>) -> DomainResult { + let scope = ctx.scope()?.clone(); + let Some(descriptor) = self.descriptor.clone() else { + return Err(DomainError::before( + ErrorCode::InvalidPhase, + "this environment is uninitialized", + )); + }; + if scope.session_id != self.config.session_id { + return Err(DomainError::before( + ErrorCode::IdentityMismatch, + "this environment belongs to another session", + )); + } + match &self.epoch { + Some(epoch) if *epoch == scope.epoch => {} + _ => { + return Err(DomainError::before( + ErrorCode::StaleEpoch, + "this scope names an epoch this environment has left", + )); + } + } + if scope.step != self.boundary { + return Err(DomainError::before( + if scope.step < self.boundary { + ErrorCode::StaleStep + } else { + ErrorCode::FutureStep + }, + "Environment.Advance must name the boundary the world is at", + )); + } + let params: AdvanceParams = ctx.params()?; + // Batch ids are unique within an epoch; reusing one for a different request or step is + // a conflict, not a second world mutation. + if self.batches.contains(¶ms.batch_id) { + return Err(DomainError::before( + ErrorCode::Conflict, + format!("batch {} was already applied in this epoch", params.batch_id), + )); + } + self.check_batch(&descriptor, ¶ms.controls)?; + let digest = controls_digest(¶ms.controls); + + let applied_from = self.boundary; + // Apply the complete batch to its interval and advance exactly one framework step. + let mut delta = 0i64; + for control in ¶ms.controls { + delta += crate::task::CounterTask::delta_of(control); + } + self.counter += delta; + self.boundary += 1; + self.world_time = self + .world_time + .checked_add(&descriptor.step_duration) + .map_err(|e| DomainError::new(ErrorCode::Internal, e, MutationCertainty::Applied))?; + self.advances += 1; + self.batches.insert(params.batch_id.clone()); + self.status.set_batch(params.batch_id.clone()); + self.status.set_scope(Some(scope_at( + &scope.session_id, + &scope.epoch, + self.boundary, + ))); + self.status.progress(1); + + if self.config.faults.advance_delay_ms > 0 { + tokio::time::sleep(std::time::Duration::from_millis( + self.config.faults.advance_delay_ms, + )) + .await; + } + + let (observation, attachments) = self.observation(ctx).await?; + // The record of batch id, result and next boundary exists before the acknowledgment. + let result = StepResult { + batch_id: params.batch_id, + applied_from_step: applied_from, + next_step: self.boundary, + applied_controls_digest: digest, + observation, + }; + let mut reply = HandlerReply::from(&result); + reply.artifacts = attachments; + Ok(reply) + } + + /// Every active port must appear exactly once, with every declared button and axis in + /// descriptor order. Uncontrolled ports were configured neutral before the epoch. + fn check_batch( + &self, + descriptor: &EnvironmentDescriptor, + controls: &[PortControl], + ) -> DomainResult<()> { + if controls.len() != descriptor.ports.len() { + return Err(DomainError::invalid(format!( + "the batch has {} port controls; the descriptor declares {}", + controls.len(), + descriptor.ports.len() + ))); + } + for (declared, given) in descriptor.ports.iter().zip(controls) { + if declared.port_id != given.port_id { + return Err(DomainError::invalid(format!( + "port {} is out of descriptor order; expected {}", + given.port_id, declared.port_id + ))); + } + declared.controls.check(given).map_err(DomainError::invalid)?; + } + Ok(()) + } +} + +impl WorkerEndpoint for CounterEnvironment { + fn worker_id(&self) -> Id { + self.config.worker_id.clone() + } + + fn incarnation_id(&self) -> Id { + self.config.incarnation_id.clone() + } + + fn session_id(&self) -> Id { + self.config.session_id.clone() + } + + fn role(&self) -> Role { + Role::Environment + } + + fn capabilities(&self) -> Vec { + vec![ + id("world-step-v1"), + id("pixel-observation-v1"), + id("checkpoint-v1"), + ] + } + + fn status_cell(&self) -> StatusCell { + self.status.clone() + } + + fn methods(&self) -> Vec<&'static str> { + vec!["Environment.Initialize", "Environment.Advance"] + } + + fn handle<'a>(&'a mut self, ctx: HandlerCtx<'a>) -> BoxFuture<'a, DomainResult> { + Box::pin(async move { + match ctx.method { + "Environment.Initialize" => self.initialize(&ctx).await, + "Environment.Advance" => self.advance(&ctx).await, + other => Err(DomainError::before( + ErrorCode::Unsupported, + format!("{other} is not an environment method"), + )), + } + }) + } +} + +/// A synthetic backend/task configuration asset for the arena. +pub fn synthetic_asset(asset_id: &str, body: &str) -> AssetRef { + AssetRef { + id: id(asset_id), + digest: digest_of_bytes(body.as_bytes()), + byte_length: body.len() as u64, + format: id("fly-config-v1"), + } +} diff --git a/services/flysim/crates/fly-session/src/harness.rs b/services/flysim/crates/fly-session/src/harness.rs new file mode 100644 index 0000000..429ed59 --- /dev/null +++ b/services/flysim/crates/fly-session/src/harness.rs @@ -0,0 +1,381 @@ +//! The runnable synthetic composition: one router, two fake agents, one counter arena and one +//! coordinator, over either transport. +//! +//! All participants use router semantics even when colocated, so the in-memory and +//! Unix-socket runs exercise the same code. The caller owns the store root directory, which +//! keeps this module free of a temporary-directory dependency. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; +use std::sync::Mutex; +use std::sync::atomic::{AtomicU64, Ordering}; + +use flybus::{ + Client, ClientConfig, Grants, Pattern, Policy, Router, RouterConfig, ServiceConfig, Transport, + UnixListenerHandle, +}; + +use crate::agent::{AgentConfig, AgentFaults, FakeAgentWorker, synthetic_profile}; +use crate::coordinator::{AgentSlot, Coordinator}; +use crate::environment::{CounterEnvironment, EnvironmentConfig, EnvironmentFaults}; +use crate::rpc::WorkerRef; +use crate::task::{ActionExecutor, CounterTask, IdentityExecutor, Terminal}; +// `crate::types` is this crate's facade over the shared `fly-session-types` crate; the +// glob keeps the contract's own names in sight instead of restating them. +use crate::types::*; +use crate::worker::{StatusCell, WorkerHandle, serve}; + +/// Which transport the session runs over. Both must produce the same behaviour. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Via { + Memory, + Unix, +} + +/// One agent in the composition. +#[derive(Clone, Debug)] +pub struct AgentSpec { + pub agent_id: Id, + pub port_id: Id, + /// An explicit seed. The first synthetic composition supports hand-selected seeds; the + /// derivation algorithm is specified before the real agent slice. + pub seed: i32, + pub faults: AgentFaults, +} + +/// The composition the harness builds. +#[derive(Clone, Debug)] +pub struct HarnessConfig { + pub session_id: Id, + pub epoch: Id, + pub episode_id: Id, + pub agents: Vec, + /// The world's cadence. 60 Hz with a 1 ms tick is the `step-v1` section 5 example. + pub step_hz: u64, + pub tick_ms: u64, + pub warmup_ticks: u64, + pub terminal: Terminal, + pub environment_faults: EnvironmentFaults, +} + +impl Default for HarnessConfig { + fn default() -> HarnessConfig { + HarnessConfig { + session_id: id("demo"), + epoch: id("e1"), + episode_id: id("ep1"), + agents: vec![ + AgentSpec { + agent_id: id("fly-a"), + port_id: id("p1"), + seed: 7, + faults: AgentFaults::default(), + }, + AgentSpec { + agent_id: id("fly-b"), + port_id: id("p2"), + seed: 11, + faults: AgentFaults::default(), + }, + ], + step_hz: 60, + tick_ms: 1, + warmup_ticks: 10, + terminal: Terminal::Never, + environment_faults: EnvironmentFaults::default(), + } + } +} + +const ENV_SERVICE: &str = "env.arena"; +const ENV_CLIENT: &str = "environment"; +const ENV_WORKER: &str = "arena"; + +fn agent_service(agent_id: &Id) -> String { + format!("agent.{agent_id}") +} + +fn agent_client(agent_id: &Id) -> String { + format!("worker-{agent_id}") +} + +fn grants(f: impl FnOnce(&mut Grants)) -> Grants { + let mut g = Grants::default(); + f(&mut g); + g +} + +/// Makes a connection for one launcher-bound participant, over the chosen transport. +struct Connector { + router: Router, + via: Via, + store_root: PathBuf, + sockets: PathBuf, + next_socket: AtomicU64, + listeners: Mutex>, +} + +impl Connector { + async fn client(&self, id: &str) -> Result { + let transport = match self.via { + Via::Memory => self.router.connect_in_memory_as(id), + Via::Unix => { + let n = self.next_socket.fetch_add(1, Ordering::Relaxed); + let path = self.sockets.join(format!("{id}-{n}.sock")); + let listener = self.router.listen_unix_as(&path, id).await.map_err(|e| { + flybus::BusError::new(flybus::ErrorCode::RouterLost, format!("listen: {e}")) + })?; + let transport = Transport::unix(&path).await.map_err(|e| { + flybus::BusError::new(flybus::ErrorCode::RouterLost, format!("connect: {e}")) + })?; + self.listeners.lock().expect("not poisoned").push(listener); + transport + } + }; + Client::connect(transport, ClientConfig::new(id, &self.store_root)).await + } +} + +/// What a restarted worker looks like from the outside: a new registration and a new +/// incarnation, both different from the ones the coordinator pinned. +#[derive(Clone, Debug)] +pub struct Restarted { + pub service: String, + pub service_incarnation: String, + pub incarnation_id: Id, +} + +/// A running synthetic session. +pub struct SessionHarness { + pub coordinator: Coordinator, + pub environment: WorkerHandle, + pub agents: BTreeMap, + pub config: HarnessConfig, + pub via: Via, + connector: Connector, + observers: Mutex>, +} + +impl SessionHarness { + /// Builds the router, the workers and the coordinator. Nothing has stepped yet. + pub async fn start( + via: Via, + root: &Path, + config: HarnessConfig, + ) -> Result { + let store_root = root.join("store"); + let sockets = root.join("sockets"); + std::fs::create_dir_all(&sockets).expect("the caller owns a writable directory"); + + let mut policy = Policy::closed() + .client( + "coordinator", + grants(|g| { + g.call = vec![Pattern::prefix("agent."), Pattern::prefix("env.")]; + g.publish = vec![Pattern::prefix("session.")]; + g.manage_topics = vec![Pattern::prefix("session.")]; + }), + ) + .client(ENV_CLIENT, grants(|g| g.register = vec![Pattern::exact(ENV_SERVICE)])) + .client("observer", grants(|g| g.subscribe = vec![Pattern::prefix("session.")])); + for spec in &config.agents { + let service = agent_service(&spec.agent_id); + policy = policy.client( + &agent_client(&spec.agent_id), + grants(|g| g.register = vec![Pattern::exact(&service)]), + ); + // A replacement worker connects under its own client id, so a restart is visibly a + // new participant rather than a silent reattachment to the active epoch. + policy = policy.client( + &format!("{}-r2", agent_client(&spec.agent_id)), + grants(|g| g.register = vec![Pattern::exact(&service)]), + ); + } + let mut router_config = RouterConfig::new(&store_root); + router_config.policy = policy; + let router = Router::new(router_config).map_err(|e| { + flybus::BusError::new(flybus::ErrorCode::StoreFailure, format!("router: {e}")) + })?; + let connector = Connector { + router, + via, + store_root, + sockets, + next_socket: AtomicU64::new(0), + listeners: Mutex::new(Vec::new()), + }; + + let step_duration = hz(config.step_hz).expect("a positive cadence"); + let tick_duration = millis(config.tick_ms).expect("a positive tick"); + + // The environment first: it owns the world and the descriptor. + let env_client = connector.client(ENV_CLIENT).await?; + let env_service = env_client.register(ENV_SERVICE, ServiceConfig::default()).await?; + let env_incarnation = env_service.incarnation().to_owned(); + let environment = serve( + env_client, + env_service, + CounterEnvironment::new(EnvironmentConfig { + session_id: config.session_id.clone(), + worker_id: id(ENV_WORKER), + incarnation_id: id("arena-inc-1"), + step_duration, + ports: config.agents.iter().map(|a| a.port_id.clone()).collect(), + faults: config.environment_faults.clone(), + }), + ); + + let mut slots = Vec::new(); + let mut agents = BTreeMap::new(); + for spec in &config.agents { + let service_name = agent_service(&spec.agent_id); + let client = connector.client(&agent_client(&spec.agent_id)).await?; + let service = client.register(&service_name, ServiceConfig::default()).await?; + let incarnation = service.incarnation().to_owned(); + let handle = serve( + client, + service, + FakeAgentWorker::new(AgentConfig { + session_id: config.session_id.clone(), + agent_id: spec.agent_id.clone(), + incarnation_id: parse_id(&format!("{}-inc-1", spec.agent_id)) + .expect("an agent id plus a suffix is an Id"), + tick_duration, + warmup_ticks: config.warmup_ticks, + faults: spec.faults.clone(), + }), + ); + slots.push(AgentSlot::new( + WorkerRef::new(&service_name, &incarnation, &spec.agent_id), + spec.agent_id.clone(), + spec.port_id.clone(), + synthetic_profile(&spec.agent_id, &tick_duration, config.warmup_ticks), + spec.seed, + )); + agents.insert(spec.agent_id.clone(), handle); + } + + let coordinator_client = connector.client("coordinator").await?; + let executors: BTreeMap> = config + .agents + .iter() + .map(|spec| { + (spec.agent_id.clone(), Box::new(IdentityExecutor) as Box) + }) + .collect(); + let coordinator = Coordinator::new( + coordinator_client, + config.session_id.clone(), + config.epoch.clone(), + config.episode_id.clone(), + WorkerRef::new(ENV_SERVICE, &env_incarnation, &id(ENV_WORKER)), + slots, + Box::new(CounterTask::new(&config.epoch, config.terminal)), + executors, + ); + + Ok(SessionHarness { + coordinator, + environment, + agents, + config, + via, + connector, + observers: Mutex::new(Vec::new()), + }) + } + + pub fn router(&self) -> &Router { + &self.connector.router + } + + /// A client for `id`, connected the same way every participant is. + pub async fn client(&self, id: &str) -> Result { + self.connector.client(id).await + } + + /// An extra subscriber, for a test that watches the published boundaries. + pub async fn observer(&self) -> Result { + let client = self.connector.client("observer").await?; + self.observers.lock().expect("not poisoned").push(client.clone()); + Ok(client) + } + + /// Replaces one agent's worker with a fresh incarnation, as a restore would. + /// + /// The coordinator still pins the old registration, so its next call to that agent fails + /// rather than silently reaching another brain. + pub async fn restart_agent(&mut self, agent_id: &Id) -> Result { + let tick_duration = millis(self.config.tick_ms).expect("a positive tick"); + if let Some(old) = self.agents.remove(agent_id) { + old.stop().await; + } + let service_name = agent_service(agent_id); + let client = self.connector.client(&format!("{}-r2", agent_client(agent_id))).await?; + let service = loop { + match client.register(&service_name, ServiceConfig::default()).await { + Ok(service) => break service, + Err(e) if e.code == flybus::ErrorCode::Conflict => { + // The old registration is released when its connection finishes closing. + tokio::time::sleep(std::time::Duration::from_millis(5)).await; + } + Err(e) => return Err(e), + } + }; + let spec = self + .config + .agents + .iter() + .find(|spec| spec.agent_id == *agent_id) + .expect("a configured agent") + .clone(); + let incarnation_id = + parse_id(&format!("{agent_id}-inc-2")).expect("an agent id plus a suffix is an Id"); + let restarted = Restarted { + service: service_name, + service_incarnation: service.incarnation().to_owned(), + incarnation_id: incarnation_id.clone(), + }; + let handle = serve( + client, + service, + FakeAgentWorker::new(AgentConfig { + session_id: self.config.session_id.clone(), + agent_id: agent_id.clone(), + incarnation_id, + tick_duration, + warmup_ticks: self.config.warmup_ticks, + faults: spec.faults, + }), + ); + self.agents.insert(agent_id.clone(), handle); + Ok(restarted) + } + + /// The agent worker's progress counter, which is its fake model's mutation count. + pub fn agent_mutations(&self, agent_id: &Id) -> u64 { + self.agents.get(agent_id).map(WorkerHandle::progress_counter).unwrap_or_default() + } + + pub fn environment_mutations(&self) -> u64 { + self.environment.progress_counter() + } + + pub fn agent_status(&self, agent_id: &Id) -> Option { + self.agents.get(agent_id).map(|handle| handle.status.clone()) + } + + /// Stops every worker and closes the router. + pub async fn shutdown(self) { + let SessionHarness { coordinator, environment, agents, connector, observers, .. } = self; + drop(coordinator); + environment.stop().await; + for (_, handle) in agents { + handle.stop().await; + } + for observer in observers.into_inner().expect("not poisoned") { + observer.close().await; + } + connector.router.shutdown(); + } +} diff --git a/services/flysim/crates/fly-session/src/lib.rs b/services/flysim/crates/fly-session/src/lib.rs new file mode 100644 index 0000000..7594b52 --- /dev/null +++ b/services/flysim/crates/fly-session/src/lib.rs @@ -0,0 +1,41 @@ +//! `fly-session`: the lockstep session coordinator and a synthetic composition over Flybus. +//! +//! This crate is the SESSION-01 slice of the session-framework implementation guide: the +//! sequential transaction of [`step-v1`], driven over the [`flybus`] router, with small fake +//! workers standing in for a brain and an emulator. +//! +//! ```text +//! Coordinator ── Agent.Prepare ──> agent workers (fake model + fixed readout stub) +//! ── Environment.Advance ──> environment (a counter arena, no emulator) +//! ── task.evaluate_transition (once) +//! ── Agent.Commit ──> agent workers +//! ── committed snapshot ──> session..snapshots +//! ``` +//! +//! Every arrow is a Flybus RPC to an incarnation-pinned service, with domain request ids and +//! the result caches of `ipc-v1` section 5 in front of every mutation. Nothing here contains a +//! public controller API, an implicit best-effort retry, a real emulator or a real brain. +//! +//! The domain types come from the CONTRACT-01 crate [`fly_session_types`]; [`types`] is a +//! facade over it plus the few session-side additions a coordinator needs. +//! +//! [`step-v1`]: https://example.invalid/step-v1 + +pub mod agent; +pub mod clock; +pub mod coordinator; +pub mod dedup; +pub mod environment; +pub mod harness; +pub mod phase; +pub mod rpc; +pub mod task; +pub mod worker; + +// CONTRACT-01 owns the domain types; `types` is a facade over its crate plus the few +// session-side additions a coordinator needs. +pub mod types; +pub use fly_session_types; + +pub use coordinator::{Coordinator, DispatchOrder, Injections, SessionFailure, StepReport}; +pub use phase::{Phase, PhaseMachine}; diff --git a/services/flysim/crates/fly-session/src/phase.rs b/services/flysim/crates/fly-session/src/phase.rs new file mode 100644 index 0000000..f528112 --- /dev/null +++ b/services/flysim/crates/fly-session/src/phase.rs @@ -0,0 +1,223 @@ +//! The session state machine of `step-v1` section 2, as an explicit edge table. +//! +//! ```text +//! Starting -> Ready(k) -> Preparing(k) -> Applying(k) -> Observing(k+1) +//! ^ | +//! +---------------- Ready(k+1) <- Committing(k) +//! +//! Ready(k) -> Paused(k) -> Ready(k) +//! Ready(k) / Paused(k) -> Capturing(k) -> same boundary +//! any unresolved partial failure -> Failed -> Restoring(new epoch) -> Paused(k) +//! terminal episode -> Paused(k) -> Resetting(new epoch) -> Ready(0) +//! ``` +//! +//! A transition the table does not list is a bug, not a recoverable condition, so it returns +//! INVALID_PHASE rather than being silently applied. + +use crate::types::{DomainError, ErrorCode}; + +/// Where the session is. The number is the committed boundary the phase belongs to. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Phase { + Starting, + Ready(u64), + Preparing(u64), + Applying(u64), + /// `Observing(k+1)`: the world has reached `k+1` but nothing is committed yet. + Observing(u64), + /// `Committing(k)`: completing the transition `k -> k+1`. + Committing(u64), + Paused(u64), + Capturing(u64), + Failed, + /// `Restoring(k)`: installing a coherent checkpoint of boundary `k` under a new epoch. + Restoring(u64), + /// `Resetting(k)`: leaving boundary `k` for a new epoch and episode at step 0. + Resetting(u64), +} + +impl Phase { + pub fn label(&self) -> String { + match self { + Phase::Starting => "Starting".to_owned(), + Phase::Ready(k) => format!("Ready({k})"), + Phase::Preparing(k) => format!("Preparing({k})"), + Phase::Applying(k) => format!("Applying({k})"), + Phase::Observing(k) => format!("Observing({k})"), + Phase::Committing(k) => format!("Committing({k})"), + Phase::Paused(k) => format!("Paused({k})"), + Phase::Capturing(k) => format!("Capturing({k})"), + Phase::Failed => "Failed".to_owned(), + Phase::Restoring(k) => format!("Restoring({k})"), + Phase::Resetting(k) => format!("Resetting({k})"), + } + } + + /// The committed boundary, where the phase has one. `Observing(k+1)` does not: the world + /// has moved but nothing is committed, so the committed boundary is still `k`. + pub fn committed_boundary(&self) -> Option { + match self { + Phase::Ready(k) | Phase::Paused(k) | Phase::Capturing(k) => Some(*k), + _ => None, + } + } + + /// Only a committed boundary is eligible for a checkpoint or a normal pause. + pub fn is_committed_boundary(&self) -> bool { + matches!(self, Phase::Ready(_) | Phase::Paused(_)) + } +} + +/// The phase, plus the edge check that guards every change to it. +#[derive(Clone, Debug)] +pub struct PhaseMachine { + phase: Phase, + /// Where a `Capturing(k)` must return to. + capture_origin: Option, +} + +impl Default for PhaseMachine { + fn default() -> PhaseMachine { + PhaseMachine::new() + } +} + +impl PhaseMachine { + pub fn new() -> PhaseMachine { + PhaseMachine { phase: Phase::Starting, capture_origin: None } + } + + pub fn phase(&self) -> Phase { + self.phase + } + + /// True when `next` is an edge of the section 2 machine. + pub fn allows(&self, next: Phase) -> bool { + use Phase::*; + // Any unresolved partial failure fails the epoch, from wherever the session was. + if next == Failed { + return self.phase != Failed; + } + match (self.phase, next) { + (Starting, Ready(0)) => true, + (Ready(k), Preparing(j)) => k == j, + (Preparing(k), Applying(j)) => k == j, + (Applying(k), Observing(j)) => j == k + 1, + (Observing(j), Committing(k)) => j == k + 1, + (Committing(k), Ready(j)) => j == k + 1, + (Ready(k), Paused(j)) => k == j, + (Paused(k), Ready(j)) => k == j, + (Ready(k), Capturing(j)) | (Paused(k), Capturing(j)) => k == j, + // Capturing returns to the boundary it came from, and only to that one. + (Capturing(k), Ready(j)) => k == j && self.capture_origin == Some(Ready(k)), + (Capturing(k), Paused(j)) => k == j && self.capture_origin == Some(Paused(k)), + (Failed, Restoring(_)) => true, + (Restoring(k), Paused(j)) => k == j, + (Paused(k), Resetting(j)) => k == j, + (Resetting(_), Ready(0)) => true, + _ => false, + } + } + + /// Applies an edge, reporting the old and new labels for the trace. + pub fn to(&mut self, next: Phase) -> Result<(String, String), DomainError> { + if !self.allows(next) { + return Err(DomainError::before( + ErrorCode::InvalidPhase, + format!("{} cannot move to {}", self.phase.label(), next.label()), + )); + } + let from = self.phase.label(); + if matches!(next, Phase::Capturing(_)) { + self.capture_origin = Some(self.phase); + } else if !matches!(self.phase, Phase::Capturing(_)) { + self.capture_origin = None; + } + self.phase = next; + Ok((from, next.label())) + } + + /// Fails the epoch from wherever the session was. + pub fn fail(&mut self) -> (String, String) { + let from = self.phase.label(); + self.phase = Phase::Failed; + self.capture_origin = None; + (from, self.phase.label()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn the_happy_path_walks_the_section_2_diagram() { + let mut m = PhaseMachine::new(); + m.to(Phase::Ready(0)).unwrap(); + m.to(Phase::Preparing(0)).unwrap(); + m.to(Phase::Applying(0)).unwrap(); + m.to(Phase::Observing(1)).unwrap(); + m.to(Phase::Committing(0)).unwrap(); + m.to(Phase::Ready(1)).unwrap(); + assert_eq!(m.phase(), Phase::Ready(1)); + } + + #[test] + fn a_step_cannot_be_skipped_or_rewound() { + let mut m = PhaseMachine::new(); + m.to(Phase::Ready(0)).unwrap(); + assert!(m.to(Phase::Preparing(1)).is_err()); + m.to(Phase::Preparing(0)).unwrap(); + assert!(m.to(Phase::Observing(1)).is_err()); + m.to(Phase::Applying(0)).unwrap(); + assert!(m.to(Phase::Observing(0)).is_err()); + } + + #[test] + fn only_a_committed_boundary_pauses_or_captures() { + let mut m = PhaseMachine::new(); + m.to(Phase::Ready(0)).unwrap(); + m.to(Phase::Preparing(0)).unwrap(); + assert!(m.to(Phase::Paused(0)).is_err()); + assert!(m.to(Phase::Capturing(0)).is_err()); + assert!(!Phase::Preparing(0).is_committed_boundary()); + assert!(Phase::Ready(0).is_committed_boundary()); + assert!(Phase::Paused(3).is_committed_boundary()); + } + + #[test] + fn a_capture_returns_to_the_boundary_it_came_from() { + let mut m = PhaseMachine::new(); + m.to(Phase::Ready(0)).unwrap(); + m.to(Phase::Paused(0)).unwrap(); + m.to(Phase::Capturing(0)).unwrap(); + assert!(m.to(Phase::Ready(0)).is_err()); + m.to(Phase::Paused(0)).unwrap(); + } + + #[test] + fn failure_leads_to_restore_and_then_to_a_paused_boundary() { + let mut m = PhaseMachine::new(); + m.to(Phase::Ready(0)).unwrap(); + m.to(Phase::Preparing(0)).unwrap(); + m.fail(); + assert_eq!(m.phase(), Phase::Failed); + assert!(m.to(Phase::Ready(0)).is_err()); + m.to(Phase::Restoring(0)).unwrap(); + m.to(Phase::Paused(0)).unwrap(); + } + + #[test] + fn an_episode_reset_leaves_a_pause_and_lands_on_step_zero() { + let mut m = PhaseMachine::new(); + m.to(Phase::Ready(0)).unwrap(); + m.to(Phase::Preparing(0)).unwrap(); + m.to(Phase::Applying(0)).unwrap(); + m.to(Phase::Observing(1)).unwrap(); + m.to(Phase::Committing(0)).unwrap(); + m.to(Phase::Ready(1)).unwrap(); + m.to(Phase::Paused(1)).unwrap(); + m.to(Phase::Resetting(1)).unwrap(); + m.to(Phase::Ready(0)).unwrap(); + } +} diff --git a/services/flysim/crates/fly-session/src/rpc.rs b/services/flysim/crates/fly-session/src/rpc.rs new file mode 100644 index 0000000..11a4477 --- /dev/null +++ b/services/flysim/crates/fly-session/src/rpc.rs @@ -0,0 +1,139 @@ +//! Domain RPC over Flybus: `req-` serials, incarnation pinning and the retry rule. +//! +//! A domain retry keeps its `requestId` and body and takes a fresh bus `callId`. Nothing here +//! retries on its own: an uncertain call is resolved by the caller, which is the coordinator. + +use std::collections::BTreeMap; + +use serde_json::{Map, Value}; + +// `crate::types` is this crate's facade over the shared `fly-session-types` crate; the +// glob keeps the contract's own names in sight instead of restating them. +use crate::types::*; + +/// A named worker endpoint, pinned to one bus registration and one domain incarnation. +#[derive(Clone, Debug)] +pub struct WorkerRef { + pub service: String, + /// The bus `serviceIncarnation` every call pins. Not a worker process id. + pub bus_incarnation: String, + pub worker_id: Id, + /// The domain `incarnationId` Hello negotiated, once it has. + pub domain_incarnation: Option, +} + +impl WorkerRef { + pub fn new(service: &str, bus_incarnation: &str, worker_id: &Id) -> WorkerRef { + WorkerRef { + service: service.to_owned(), + bus_incarnation: bus_incarnation.to_owned(), + worker_id: worker_id.clone(), + domain_incarnation: None, + } + } +} + +/// One terminal domain reply and the artifacts it brought. +pub struct DomainReply { + pub outcome: SessionRpcOutcome, + pub request_id: DomainRequestId, + pub artifacts: BTreeMap, +} + +impl DomainReply { + /// The success `result`, or the domain error. + pub fn result(&self) -> Result<&Value, DomainError> { + outcome_result(&self.outcome) + } + + /// Reads and validates the success `result` as a method payload. + pub fn parse(&self) -> Result { + let result = outcome_result(&self.outcome)?; + T::from_json(result).map_err(|e| DomainError::invalid(format!("unreadable result: {e}"))) + } +} + +/// Issues one domain call and waits for its terminal reply. +/// +/// `want_artifacts` names the attachments to extract before the result delivery is dropped. +/// A bus-level failure is not a domain failure: it is reported with the dispatch certainty the +/// bus gave, because a caller-side timeout must not imply that nothing was mutated. +#[allow(clippy::too_many_arguments)] +pub async fn call( + bus: &flybus::Client, + target: &WorkerRef, + method: &str, + scope: Option, + params: Map, + attachments: &[(&str, &flybus::Artifact)], + request_id: DomainRequestId, + want_artifacts: &[String], +) -> Result { + let request = SessionRpcRequest { request_id: request_id.clone(), scope, params: Value::Object(params) }; + let mut pending = bus + .call( + &target.service, + Some(&target.bus_incarnation), + method, + object(request.to_json()), + attachments, + ) + .await + .map_err(|e| bus_error(method, &e))?; + let result = pending.result().await.map_err(|e| bus_error(method, &e))?; + let outcome = SessionRpcOutcome::from_json(&Value::Object(result.outcome().clone())) + .map_err(|e| DomainError::invalid(format!("{method}: {e}")))?; + let mut artifacts = BTreeMap::new(); + for name in want_artifacts { + if let Ok(artifact) = result.artifact(name) { + // An independent explicit hold, so the handle outlives this delivery and can be + // forwarded to several Commit calls and to publication. + match artifact.retain().await { + Ok(hold) => { + artifacts.insert(name.clone(), hold); + } + Err(_) => { + artifacts.insert(name.clone(), artifact); + } + } + } + } + drop(result); + Ok(DomainReply { outcome, request_id, artifacts }) +} + +/// Maps a bus failure onto a domain error, preserving how certain the mutation is. +fn bus_error(method: &str, e: &flybus::BusError) -> DomainError { + let mutation = match e.dispatch { + flybus::Dispatch::NotDispatched => MutationCertainty::None, + flybus::Dispatch::Dispatched | flybus::Dispatch::Unknown => MutationCertainty::Unknown, + }; + let code = match e.code { + flybus::ErrorCode::TargetChanged | flybus::ErrorCode::NoService => { + ErrorCode::IdentityMismatch + } + flybus::ErrorCode::Backpressure | flybus::ErrorCode::QuotaExceeded => ErrorCode::Busy, + flybus::ErrorCode::ArtifactGone + | flybus::ErrorCode::ArtifactUnsealed + | flybus::ErrorCode::OwnerInvalid + | flybus::ErrorCode::ArtifactMismatch => ErrorCode::BufferInvalid, + _ => ErrorCode::BackendFailure, + }; + DomainError::new(code, format!("{method}: bus {:?}: {}", e.code, e.message), mutation) +} + +/// Per-worker request serials. A newly issued operation takes the next one; a retry does not. +#[derive(Clone, Debug, Default)] +pub struct Serials(BTreeMap); + +impl Serials { + pub fn next(&mut self, service: &str) -> DomainRequestId { + let slot = self.0.entry(service.to_owned()).or_insert(0); + *slot += 1; + DomainRequestId::from_serial(*slot) + } + + pub fn highest(&self, service: &str) -> u64 { + self.0.get(service).copied().unwrap_or_default() + } +} diff --git a/services/flysim/crates/fly-session/src/task.rs b/services/flysim/crates/fly-session/src/task.rs new file mode 100644 index 0000000..b48e909 --- /dev/null +++ b/services/flysim/crates/fly-session/src/task.rs @@ -0,0 +1,358 @@ +//! Coordinator-local task and executor interfaces (`workers-v1` section 4), plus the +//! deterministic counter task and the identity executor the synthetic composition uses. +//! +//! These are library interfaces, not extra bus services. The task interprets inspection data +//! and asks for outcomes; the executor translates a selected decision using read-only current +//! game state and task progress; the coordinator orders and applies the results. Nothing here +//! writes a controller or neural state directly. + +use std::collections::BTreeMap; + +use serde_json::{Map, Value, json}; + +// `crate::types` is this crate's facade over the shared `fly-session-types` crate; the +// glob keeps the contract's own names in sight instead of restating them. +use crate::types::*; + +/// The schemas the synthetic arena composition registers. +pub fn inspection_schema() -> SchemaRef { + synthetic_schema("arena.inspection.v1", 1) +} + +pub fn decision_schema() -> SchemaRef { + synthetic_schema("arena.decision.v1", 1) +} + +pub fn context_schema() -> SchemaRef { + synthetic_schema("arena.context.v1", 1) +} + +pub fn progress_schema() -> SchemaRef { + synthetic_schema("arena.progress.v1", 1) +} + +pub fn event_schema() -> SchemaRef { + synthetic_schema("arena.event.v1", 1) +} + +pub fn episode_schema() -> SchemaRef { + synthetic_schema("arena.episode.v1", 1) +} + +pub fn controller_schema_ref() -> SchemaRef { + synthetic_schema("arena.controller.v1", 1) +} + +/// What `Task.bootstrap` produced. +#[derive(Clone, Debug)] +pub struct Bootstrap { + pub contexts: BTreeMap, + pub progress: TypedValue, + pub events: Vec, +} + +/// What `Task.evaluate_transition` produced, for exactly one transition. +#[derive(Clone, Debug)] +pub struct Evaluation { + /// Every configured agent has an entry, including an empty one. + pub outcomes: BTreeMap, + pub next_contexts: BTreeMap, + pub progress: TypedValue, + pub events: Vec, + pub episode: Option, +} + +/// A checkpointable task ledger and the two evaluation entry points. +pub trait Task: Send { + fn schema(&self) -> SchemaRef; + + /// Called once, at boundary 0, before any agent is initialized. + fn bootstrap( + &mut self, + initial_inspection: &TypedValue, + bindings: &[PortBinding], + ) -> DomainResult; + + /// Called exactly once per acknowledged world step, never against a later observation. + fn evaluate_transition( + &mut self, + scope: &Scope, + old_inspection: &TypedValue, + new_inspection: &TypedValue, + applied_controls: &[PortControl], + ) -> DomainResult; + + fn progress(&self) -> TypedValue; + + /// How many times `evaluate_transition` has run. A transition must evaluate once. + fn evaluations(&self) -> u64; +} + +/// Translates one selected decision into a controller intent, with no port assignment. +pub trait ActionExecutor: Send { + fn apply( + &mut self, + scope: &Scope, + decision: &TypedValue, + current_game_state: &TypedValue, + progress: &TypedValue, + clock: &RationalNs, + ) -> DomainResult<(ControllerIntent, Vec)>; +} + +/// The only executor v1 supports: it passes a direct-control decision through unchanged. +#[derive(Clone, Debug, Default)] +pub struct IdentityExecutor; + +impl ActionExecutor for IdentityExecutor { + fn apply( + &mut self, + _scope: &Scope, + decision: &TypedValue, + _current_game_state: &TypedValue, + _progress: &TypedValue, + _clock: &RationalNs, + ) -> DomainResult<(ControllerIntent, Vec)> { + if decision.schema != decision_schema() { + return Err(DomainError::before( + ErrorCode::IdentityMismatch, + "the decision does not carry the profile's registered intent schema", + )); + } + let intent = ControllerIntent::from_json(&decision.value) + .map_err(|e| DomainError::invalid(format!("decision: {e}")))?; + Ok((intent, Vec::new())) + } +} + +/// When the counter task asks for a terminal episode transition. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum Terminal { + /// The episode runs until the application stops it. + #[default] + Never, + /// The counter reached this value or higher. + Counter(i64), + /// This many transitions were evaluated. + AfterTransitions(u64), +} + +/// The deterministic counter task: rewards come from the arena counter each agent moved. +pub struct CounterTask { + epoch: Id, + agents: Vec, + bindings: Vec, + transitions: u64, + evaluations: u64, + total_reward: f64, + counter: i64, + terminal: Terminal, +} + +impl CounterTask { + pub fn new(epoch: &Id, terminal: Terminal) -> CounterTask { + CounterTask { + epoch: epoch.clone(), + agents: Vec::new(), + bindings: Vec::new(), + transitions: 0, + evaluations: 0, + total_reward: 0.0, + counter: 0, + terminal, + } + } + + fn context(&self, step: u64, boot: bool) -> TypedValue { + TypedValue::new(context_schema(), json!({ + "available": ["inc", "dec"], + "boot": boot, + "step": step, + })) + .expect("a synthetic typed value fits the contract") + } + + fn progress_value(&self) -> TypedValue { + TypedValue::new(progress_schema(), json!({ + "counter": self.counter, + "transitions": self.transitions, + "totalReward": self.total_reward, + })) + .expect("a synthetic typed value fits the contract") + } + + /// The counter delta one port control asks for: `inc` adds one, `dec` subtracts one. + /// + /// This is the task's reading of a control, kept identical to the environment's rule so a + /// reward describes the transition the world actually took. + pub fn delta_of(control: &PortControl) -> i64 { + let mut delta = 0; + for button in &control.buttons { + if button.down { + match button.id.as_str() { + "inc" => delta += 1, + "dec" => delta -= 1, + _ => {} + } + } + } + delta + } + + fn agent_of_port(&self, port_id: &Id) -> Option<&Id> { + self.bindings.iter().find(|b| b.port_id == *port_id).map(|b| &b.agent_id) + } +} + +impl Task for CounterTask { + fn schema(&self) -> SchemaRef { + progress_schema() + } + + fn bootstrap( + &mut self, + initial_inspection: &TypedValue, + bindings: &[PortBinding], + ) -> DomainResult { + if initial_inspection.schema != inspection_schema() { + return Err(DomainError::before( + ErrorCode::IdentityMismatch, + "the environment's inspection schema is not the one this task reads", + )); + } + self.counter = initial_inspection.integer("counter").map_err(DomainError::invalid)?; + self.bindings = bindings.to_vec(); + self.agents = bindings.iter().map(|b| b.agent_id.clone()).collect(); + self.agents.sort(); + let contexts = self + .agents + .iter() + .map(|agent| (agent.clone(), self.context(0, true))) + .collect(); + // A bootstrap event has sourceStep 0 and carries no reward: warm-up produces no + // gameplay outcome at all. + let events = vec![TaskEvent { + id: event_id(&self.epoch, 0, "bootstrap", 0), + kind_id: id("arena.bootstrap"), + source_step: 0, + agent_id: None, + payload: TypedValue::new(event_schema(), json!({"counter": self.counter})) + .expect("a synthetic typed value fits the contract"), + }]; + Ok(Bootstrap { contexts, progress: self.progress_value(), events }) + } + + fn evaluate_transition( + &mut self, + scope: &Scope, + old_inspection: &TypedValue, + new_inspection: &TypedValue, + applied_controls: &[PortControl], + ) -> DomainResult { + let old = old_inspection.integer("counter").map_err(DomainError::invalid)?; + let new = new_inspection.integer("counter").map_err(DomainError::invalid)?; + let source_step = scope.step + 1; + self.evaluations += 1; + self.transitions += 1; + self.counter = new; + + let mut outcomes: BTreeMap = self + .agents + .iter() + .map(|agent| (agent.clone(), AgentOutcome::default())) + .collect(); + let mut events = Vec::new(); + let mut ordinal = 0u32; + // Controls arrive in descriptor port order, so the reward order is deterministic. + for control in applied_controls { + let Some(agent) = self.agent_of_port(&control.port_id).cloned() else { + return Err(DomainError::before( + ErrorCode::IdentityMismatch, + format!("port {} is bound to no agent", control.port_id), + )); + }; + let delta = CounterTask::delta_of(control); + let event = event_id(&self.epoch, source_step, "counter-delta", ordinal); + ordinal += 1; + events.push(TaskEvent { + id: event.clone(), + kind_id: id("arena.counter-delta"), + source_step, + agent_id: Some(agent.clone()), + payload: TypedValue::new(event_schema(), json!({"delta": delta, "counter": new})) + .expect("a synthetic typed value fits the contract"), + }); + let outcome = outcomes.get_mut(&agent).ok_or_else(|| { + DomainError::before( + ErrorCode::IdentityMismatch, + format!("port {} names agent {agent}, which is not configured", control.port_id), + ) + })?; + // A shipped positive-only profile would reject a negative value; this task is + // signed on purpose, so the profile that consumes it declares signed rewards. + outcome.rewards.push(Reward { + event_id: event, + rule_id: id("counter-delta"), + value: delta as f64, + }); + self.total_reward += delta as f64; + // One declared stimulus when the counter moved past a multiple of five, so the + // stimulation path is exercised without depending on reward. + if delta != 0 && new.rem_euclid(5) == 0 { + outcome.stimulations.push(Stimulus { + id: event_id(&self.epoch, source_step, "milestone", ordinal), + kind_id: id("arena.milestone"), + duration_ms: 4.0, + }); + } + } + if new != old + applied_controls.iter().map(CounterTask::delta_of).sum::() { + return Err(DomainError::new( + ErrorCode::BackendFailure, + "the world did not move by the batch the task read", + MutationCertainty::Unknown, + )); + } + + let next_contexts = self + .agents + .iter() + .map(|agent| (agent.clone(), self.context(source_step, false))) + .collect(); + let terminal = match self.terminal { + Terminal::Never => false, + Terminal::Counter(target) => new >= target, + Terminal::AfterTransitions(n) => self.transitions >= n, + }; + // The contract's `EpisodeRequest` is terminal by construction: `kind` is a constant. + let episode = terminal.then(|| EpisodeRequest { + reason: id("counter-target"), + outcome: TypedValue::new(episode_schema(), json!({"counter": new, "transitions": self.transitions})) + .expect("a synthetic typed value fits the contract"), + }); + Ok(Evaluation { + outcomes, + next_contexts, + progress: self.progress_value(), + events, + episode, + }) + } + + fn progress(&self) -> TypedValue { + self.progress_value() + } + + fn evaluations(&self) -> u64 { + self.evaluations + } +} + +/// The inspection value the counter environment publishes. +pub fn inspection(counter: i64, boundary: u64) -> TypedValue { + let mut value = Map::new(); + value.insert("counter".into(), counter.into()); + value.insert("boundary".into(), boundary.into()); + TypedValue::new(inspection_schema(), Value::Object(value)) + .expect("the arena inspection fits the contract") +} diff --git a/services/flysim/crates/fly-session/src/types.rs b/services/flysim/crates/fly-session/src/types.rs new file mode 100644 index 0000000..c52c276 --- /dev/null +++ b/services/flysim/crates/fly-session/src/types.rs @@ -0,0 +1,518 @@ +//! The domain types this crate uses, all from the shared `fly-session-types` crate. +//! +//! CONTRACT-01 owns the scalars, the method payloads, their validation, the canonical digests +//! and the trace format. This module is only a facade over that crate plus the few things a +//! coordinator needs that are not part of the contract: a session-side error value, the +//! synthetic composition's schema and event-id derivations, and a log of recorded traces. +//! +//! `Id` and `Digest` are type aliases, because the shared crate carries both as validated +//! `String`s from `flybus::wire` rather than forking the encodings into newtypes. + +use serde_json::{Map, Value}; + +pub use fly_session_types::ArtifactRef; +pub use fly_session_types::canonical::{ + self, OperationKey, body_digest, canonicalize, digest_of, sha256_hex, +}; +pub use fly_session_types::media::{AudioDescriptor, AudioRef, ViewDescriptor, ViewRef}; +pub use fly_session_types::rpc::{ + ErrorCode, MutationCertainty, SessionRpcFailure, SessionRpcOutcome, SessionRpcRequest, + SessionRpcSuccess, +}; +pub use fly_session_types::scalar::{ + ArtifactIdentity, BusCallId, DomainRequestId, DomainType, MAX_TYPED_VALUE_BYTES, RationalNs, + SchemaRef, Scope, TypedValue, is_digest, is_id, +}; +pub use fly_session_types::schema::contract_digest; +pub use fly_session_types::trace::{ + TraceAgent, TraceBehaviour, TraceObservation, TraceOperational, TraceRequest, TransitionTrace, +}; +pub use fly_session_types::workers::{ + AcknowledgeParams, AcknowledgeResult, AdvanceParams, AgentCommitResult, AgentInitializeParams, + AgentInitializeResult, AgentTelemetry, AssetRef, AxisRange, AxisSchema, AxisValue, ButtonState, + CommitParams, ControllerSchema, Determinism, EnvironmentDescriptor, + EnvironmentInitializeParams, EnvironmentInitializeResult, EpisodeRequest, HelloParams, + HelloResult, LearningTelemetry, MAX_ACKNOWLEDGE, MAX_AGENTS, MAX_PORTS, MAX_RATE_ROLES, + MAX_REWARDS, MAX_STIMULI, PortControl, PortDescriptor, PrepareParams, PreparedDecision, + RateSample, Recovery, Reward, Role, SensoryInput, ShutdownParams, ShutdownResult, StatusResult, + StepResult, Stimulus, TaskEvent, WorkerState, WorldObservation, +}; + +/// A validated `Id`: `^[a-z0-9][a-z0-9._-]{0,63}$`, the bus encoding the contracts reuse. +pub type Id = String; + +/// 64 lowercase hexadecimal digits: a SHA-256. +pub type Digest = String; + +/// An `Id` from a trusted literal. It panics on a malformed one, which is a bug in the +/// composition rather than a runtime condition. +pub fn id(s: &str) -> Id { + assert!(is_id(s), "{s:?} is not a valid Id"); + s.to_owned() +} + +/// An `Id` from an untrusted string. +pub fn parse_id(s: &str) -> Result { + if is_id(s) { + Ok(s.to_owned()) + } else { + Err(format!("{s:?} is not a valid Id")) + } +} + +/// The SHA-256 of some bytes, hex-encoded. +pub fn digest_of_bytes(bytes: &[u8]) -> Digest { + sha256_hex(bytes) +} + +/// A `Scope` from trusted composition values. +pub fn scope_at(session_id: &str, epoch: &str, step: u64) -> Scope { + Scope::new(session_id, epoch, step).expect("a composition scope is valid") +} + +/// `hz` steps per second as an exact nanosecond duration. +pub fn hz(hz: u64) -> Result { + RationalNs::reduced(1_000_000_000, u128::from(hz)).map_err(|e| e.0) +} + +/// A whole number of milliseconds as an exact nanosecond duration. +pub fn millis(ms: u64) -> Result { + RationalNs::reduced(u128::from(ms) * 1_000_000, 1).map_err(|e| e.0) +} + +/// A schema reference whose digest is derived from its own name and version, so the synthetic +/// composition has stable identities without a schema registry file. +pub fn synthetic_schema(name: &str, version: u16) -> SchemaRef { + let digest = digest_of_bytes(format!("fly-session-schema-v1\n{name}\n{version}\n").as_bytes()); + SchemaRef::new(name, version, &digest).expect("a synthetic schema reference is valid") +} + +/// A deterministic task event id from epoch, source step, rule and ordinal. +pub fn event_id(epoch: &str, source_step: u64, rule: &str, ordinal: u32) -> Id { + let digest = digest_of_bytes(format!("{epoch}\n{source_step}\n{rule}\n{ordinal}\n").as_bytes()); + id(&format!("ev-{}", &digest[..16])) +} + +/// The digest of a validated, canonical control batch, in descriptor port order. +pub fn controls_digest(controls: &[PortControl]) -> Digest { + let array = Value::Array(controls.iter().map(DomainType::to_json).collect()); + digest_of(&array).expect("a validated control batch canonicalizes") +} + +/// The canonical digest of a typed value, schema identity included. +pub fn typed_digest(value: &TypedValue) -> Digest { + digest_of(&value.to_json()).expect("a validated typed value canonicalizes") +} + +/// The byte length one view's artifact must have. +pub fn view_byte_length(descriptor: &ViewDescriptor) -> u64 { + descriptor.row_stride * descriptor.height +} + +/// The boundary a required sensory view must have been produced at. +pub fn required_produced_step(descriptor: &ViewDescriptor, boundary: u64) -> u64 { + boundary.saturating_sub(descriptor.observation_delay_steps) +} + +/// Reads a finite number out of a typed value. +pub fn typed_number(value: &TypedValue, key: &str) -> Result { + value + .value + .get(key) + .and_then(Value::as_f64) + .filter(|v| v.is_finite()) + .ok_or_else(|| format!("typed value has no finite number {key:?}")) +} + +/// Reads an integer out of a typed value. +pub fn typed_integer(value: &TypedValue, key: &str) -> Result { + value + .value + .get(key) + .and_then(Value::as_i64) + .ok_or_else(|| format!("typed value has no integer {key:?}")) +} + +/// Builds a typed value from a JSON object, refusing one the contract would reject. +pub fn typed(schema: SchemaRef, value: Value) -> Result { + TypedValue::new(schema, value).map_err(|e| e.0) +} + +/// The object form of a payload, for a bus `payload` or `outcome` field. +pub fn object(value: Value) -> Map { + match value { + Value::Object(m) => m, + _ => Map::new(), + } +} + +/// `ipc-v1` section 7: the domain error a worker returns, with its mutation certainty. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct DomainError { + pub code: ErrorCode, + pub message: String, + pub mutation: MutationCertainty, +} + +/// `ipc-v1` section 7: messages are at most 512 code points and carry no raw memory. +pub const MAX_ERROR_MESSAGE_CHARS: usize = 512; + +impl DomainError { + pub fn new( + code: ErrorCode, + message: impl std::fmt::Display, + mutation: MutationCertainty, + ) -> DomainError { + let message: String = message.to_string(); + let message = if message.chars().count() > MAX_ERROR_MESSAGE_CHARS { + message.chars().take(MAX_ERROR_MESSAGE_CHARS).collect() + } else { + message + }; + DomainError { code, message, mutation } + } + + /// An error raised before anything was mutated. + pub fn before(code: ErrorCode, message: impl std::fmt::Display) -> DomainError { + DomainError::new(code, message, MutationCertainty::None) + } + + pub fn invalid(message: impl std::fmt::Display) -> DomainError { + DomainError::before(ErrorCode::InvalidArgument, message) + } +} + +impl std::fmt::Display for DomainError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "{} ({}): {}", self.code.as_str(), self.mutation.as_str(), self.message) + } +} + +impl std::error::Error for DomainError {} + +pub type DomainResult = Result; + +/// The success `result` of a terminal outcome, or its domain error. +pub fn outcome_result(outcome: &SessionRpcOutcome) -> DomainResult<&Value> { + match outcome { + SessionRpcOutcome::Success(s) => Ok(&s.result), + SessionRpcOutcome::Failure(f) => Err(DomainError::new( + f.code, + f.message.clone(), + f.mutation, + )), + } +} + +/// The worker id and incarnation a terminal outcome came from, and the scope it echoes. +pub fn outcome_identity( + outcome: &SessionRpcOutcome, +) -> (&Id, &Id, &Option) { + match outcome { + SessionRpcOutcome::Success(s) => (&s.worker_id, &s.incarnation_id, &s.scope), + SessionRpcOutcome::Failure(f) => (&f.worker_id, &f.incarnation_id, &f.scope), + } +} + +/// One session phase transition, recorded whether or not it ends a step. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PhaseTransition { + pub from: String, + pub to: String, +} + +/// Everything a run recorded: its phase transitions and its completed transitions. +/// +/// The transitions are the contract's [`TransitionTrace`]; the phase path is this crate's own, +/// because the state machine lives here and not in the type contract. +#[derive(Clone, Debug, Default)] +pub struct TraceLog { + pub phases: Vec, + pub transitions: Vec, +} + +impl TraceLog { + pub fn phase(&mut self, from: String, to: String) { + self.phases.push(PhaseTransition { from, to }); + } + + pub fn transition(&mut self, trace: TransitionTrace) { + self.transitions.push(trace); + } + + /// The behaviour of every transition, in order, with operational metadata excluded. + /// + /// This is what `step-v1` section 8 compares across dispatch and completion orders. + pub fn behavior(&self) -> Vec { + self.transitions + .iter() + .map(|t| { + canonicalize(&t.behaviour.to_json()) + .expect("a recorded behaviour canonicalizes") + }) + .collect() + } + + /// The phase path, as `from -> to` strings. + pub fn phase_path(&self) -> Vec { + self.phases.iter().map(|p| format!("{} -> {}", p.from, p.to)).collect() + } +} + +// ------------------------------------------------------------------------------------------- +// Coordinator-local types +// +// `workers-v1` section 4 calls these library interfaces rather than payloads, so the contract +// crate does not carry them: they never cross the bus. + +/// What an executor returns: buttons and axes, with no port assignment. +/// +/// The coordinator supplies the port, which is why this is not a [`PortControl`]. +#[derive(Clone, Debug, PartialEq)] +pub struct ControllerIntent { + pub buttons: Vec, + pub axes: Vec, +} + +impl ControllerIntent { + /// The canonical JSON of the intent: exactly a `PortControl` without its port. + pub fn to_json(&self) -> Value { + let buttons = self + .buttons + .iter() + .map(|b| { + let mut m = Map::new(); + m.insert("id".into(), b.id.clone().into()); + m.insert("down".into(), Value::Bool(b.down)); + Value::Object(m) + }) + .collect(); + let axes = self + .axes + .iter() + .map(|a| { + let mut m = Map::new(); + m.insert("id".into(), a.id.clone().into()); + m.insert("value".into(), Value::from(a.value)); + Value::Object(m) + }) + .collect(); + let mut m = Map::new(); + m.insert("buttons".into(), Value::Array(buttons)); + m.insert("axes".into(), Value::Array(axes)); + Value::Object(m) + } + + /// Reads an intent out of a decision payload. + pub fn from_json(value: &Value) -> Result { + let control = Value::Object({ + let mut m = object(value.clone()); + m.insert("portId".into(), "p0".into()); + m + }); + let control = PortControl::from_json(&control).map_err(|e| e.0)?; + Ok(ControllerIntent { buttons: control.buttons, axes: control.axes }) + } + + /// Binds this intent to a port, which only the coordinator may do. + pub fn at_port(self, port_id: &str) -> PortControl { + PortControl { + port_id: port_id.to_owned(), + buttons: self.buttons, + axes: self.axes, + } + } +} + +/// One port-to-agent assignment. It crosses the bus as a pair inside +/// [`EnvironmentInitializeParams`]; inside the session it is named. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct PortBinding { + pub port_id: Id, + pub agent_id: Id, +} + +impl PortBinding { + pub fn pair(&self) -> (Id, Id) { + (self.port_id.clone(), self.agent_id.clone()) + } +} + +/// Everything the task routes to one agent for this transition. Always explicit, even empty. +#[derive(Clone, Debug, Default, PartialEq)] +pub struct AgentOutcome { + pub rewards: Vec, + pub stimulations: Vec, +} + +/// Every button up and every axis at its declared neutral. +/// +/// Uncontrolled ports are configured neutral before the epoch, not supplied ad hoc. +pub fn neutral_control(schema: &ControllerSchema, port_id: &str) -> PortControl { + PortControl { + port_id: port_id.to_owned(), + buttons: schema + .buttons + .iter() + .map(|id| ButtonState { id: id.clone(), down: false }) + .collect(), + axes: schema + .axes + .iter() + .map(|a| AxisValue { id: a.id.clone(), value: a.neutral }) + .collect(), + } +} + +// ------------------------------------------------------------------------------------------- +// Session-side conveniences over the contract types +// +// These are extension traits rather than forks: the data and the rules stay in the contract +// crate, and these only spell out the readings a coordinator and its workers keep needing. + +/// Reading a typed value the way the synthetic composition writes it. +pub trait TypedValueExt { + /// The canonical digest of the whole typed value, schema identity included. + fn digest(&self) -> Digest; + fn number(&self, key: &str) -> Result; + fn integer(&self, key: &str) -> Result; +} + +impl TypedValueExt for TypedValue { + fn digest(&self) -> Digest { + typed_digest(self) + } + + fn number(&self, key: &str) -> Result { + typed_number(self, key) + } + + fn integer(&self, key: &str) -> Result { + typed_integer(self, key) + } +} + +/// Checking and building one port's complete controller state. +pub trait ControllerSchemaExt { + /// Checks that `control` names every declared button and axis, in descriptor order, with + /// every value inside its range. + fn check(&self, control: &PortControl) -> Result<(), String>; + /// Every button up and every axis at its declared neutral. + fn neutral(&self, port_id: &str) -> PortControl; +} + +impl ControllerSchemaExt for ControllerSchema { + fn check(&self, control: &PortControl) -> Result<(), String> { + control.validate_against(self).map_err(|e| e.0) + } + + fn neutral(&self, port_id: &str) -> PortControl { + neutral_control(self, port_id) + } +} + +/// The byte shape and producing boundary one declared view requires. +pub trait ViewDescriptorExt { + fn byte_length(&self) -> u64; + fn required_produced_step(&self, boundary: u64) -> u64; +} + +impl ViewDescriptorExt for ViewDescriptor { + fn byte_length(&self) -> u64 { + view_byte_length(self) + } + + fn required_produced_step(&self, boundary: u64) -> u64 { + required_produced_step(self, boundary) + } +} + +/// `RationalNs` readings the session uses. +pub trait RationalExt { + fn is_positive(&self) -> bool; +} + +impl RationalExt for RationalNs { + fn is_positive(&self) -> bool { + !self.is_zero() + } +} + +/// The next step of a scope, which is the only arithmetic a coordinator does on one. +pub trait ScopeExt { + fn next(&self) -> Scope; +} + +impl ScopeExt for Scope { + fn next(&self) -> Scope { + scope_at(&self.session_id, &self.epoch, self.step + 1) + } +} + +/// The `Id` form of a domain request id, for a payload field that carries it as a string. +pub trait DomainRequestIdExt { + fn id(&self) -> Id; +} + +impl DomainRequestIdExt for DomainRequestId { + fn id(&self) -> Id { + self.as_str().to_owned() + } +} + +/// Carrying a terminal domain outcome in a bus `outcome` object. +pub trait SessionRpcOutcomeExt: Sized { + fn to_outcome(&self) -> Map; + fn from_outcome(outcome: &Map) -> Result; + fn result(&self) -> DomainResult<&Value>; +} + +impl SessionRpcOutcomeExt for SessionRpcOutcome { + fn to_outcome(&self) -> Map { + object(self.to_json()) + } + + fn from_outcome(outcome: &Map) -> Result { + SessionRpcOutcome::from_json(&Value::Object(outcome.clone())).map_err(|e| e.0) + } + + fn result(&self) -> DomainResult<&Value> { + outcome_result(self) + } +} + +/// One domain failure, ready to send. +pub fn failure_outcome( + request_id: &DomainRequestId, + worker_id: &str, + incarnation_id: &str, + scope: Option, + error: DomainError, +) -> SessionRpcOutcome { + SessionRpcOutcome::Failure(SessionRpcFailure { + request_id: request_id.clone(), + worker_id: worker_id.to_owned(), + incarnation_id: incarnation_id.to_owned(), + scope, + code: error.code, + message: error.message, + mutation: error.mutation, + }) +} + +/// One domain success, ready to send. +pub fn success_outcome( + request_id: &DomainRequestId, + worker_id: &str, + incarnation_id: &str, + scope: Option, + result: Map, +) -> SessionRpcOutcome { + SessionRpcOutcome::Success(SessionRpcSuccess { + request_id: request_id.clone(), + worker_id: worker_id.to_owned(), + incarnation_id: incarnation_id.to_owned(), + scope, + result: Value::Object(result), + }) +} diff --git a/services/flysim/crates/fly-session/src/worker.rs b/services/flysim/crates/fly-session/src/worker.rs new file mode 100644 index 0000000..7f0c92e --- /dev/null +++ b/services/flysim/crates/fly-session/src/worker.rs @@ -0,0 +1,695 @@ +//! The worker dispatch shell: one Flybus service, the common `Worker.*` methods, and the +//! domain deduplication of `ipc-v1` section 5 in front of every mutation. +//! +//! The shell owns request admission order and the result cache. An endpoint owns the +//! mutation. Exactly one mutation runs at a time -- the endpoint sits behind its own mutex -- +//! while `Worker.Status` is answered from a small shared cell, so a status query never waits +//! for a numerical operation and never advances the progress counter itself. + +use std::future::Future; +use std::pin::Pin; +use std::sync::{Arc, Mutex}; + +use serde_json::{Map, Value}; + +use crate::dedup::{Admission, CachedReply, OpClass, OperationKey, ResultCache}; +// `crate::types` is this crate's facade over the shared `fly-session-types` crate; the +// glob keeps the contract's own names in sight instead of restating them. +use crate::types::*; + +/// The build identity a worker reports in Hello. It is not a profile digest. +pub fn build_digest() -> Digest { + digest_of_bytes(b"fly-session/synthetic-workers-v1") +} + +/// The status a worker reports, kept outside the endpoint mutex so `Worker.Status` stays +/// responsive while a mutation runs. +#[derive(Clone)] +pub struct StatusCell(Arc>); + +struct StatusInner { + state: WorkerState, + current_scope: Option, + active_request_id: Option, + last_completed_request_id: Option, + last_batch_id: Option, + progress_counter: u64, +} + +impl Default for StatusCell { + fn default() -> StatusCell { + StatusCell::new() + } +} + +impl StatusCell { + pub fn new() -> StatusCell { + StatusCell(Arc::new(Mutex::new(StatusInner { + state: WorkerState::Uninitialized, + current_scope: None, + active_request_id: None, + last_completed_request_id: None, + last_batch_id: None, + progress_counter: 0, + }))) + } + + fn with(&self, f: impl FnOnce(&mut StatusInner) -> T) -> T { + let mut inner = self.0.lock().expect("the status cell is never poisoned"); + f(&mut inner) + } + + pub fn set_state(&self, state: WorkerState) { + self.with(|s| s.state = state); + } + + pub fn state(&self) -> WorkerState { + self.with(|s| s.state) + } + + pub fn set_scope(&self, scope: Option) { + self.with(|s| s.current_scope = scope); + } + + pub fn set_active(&self, request_id: Option) { + self.with(|s| s.active_request_id = request_id); + } + + pub fn set_completed(&self, request_id: DomainRequestId) { + self.with(|s| { + s.active_request_id = None; + s.last_completed_request_id = Some(request_id); + }); + } + + pub fn set_batch(&self, batch_id: Id) { + self.with(|s| s.last_batch_id = Some(batch_id)); + } + + /// Records computational or phase progress. A status query never calls this. + pub fn progress(&self, by: u64) { + self.with(|s| s.progress_counter = s.progress_counter.saturating_add(by)); + } + + /// Raises the progress counter to `value`, which lets a worker report its model's own + /// mutation count as its progress. It never moves backwards. + pub fn advance_to(&self, value: u64) { + self.with(|s| s.progress_counter = s.progress_counter.max(value)); + } + + pub fn progress_counter(&self) -> u64 { + self.with(|s| s.progress_counter) + } + + pub fn snapshot(&self) -> StatusResult { + self.with(|s| StatusResult { + state: s.state, + current_scope: s.current_scope.clone(), + active_request_id: s.active_request_id.clone(), + last_completed_request_id: s.last_completed_request_id.clone(), + last_batch_id: s.last_batch_id.clone(), + progress_counter: s.progress_counter, + }) + } +} + +/// What a handler produced: a domain `result` object and the artifacts it attaches. +pub struct HandlerReply { + pub result: Map, + pub artifacts: Vec<(String, flybus::Artifact)>, + /// True when a failure left the endpoint mutated; the shell reports it as such. + pub mutated: bool, +} + +impl HandlerReply { + pub fn new(result: Map) -> HandlerReply { + HandlerReply { result, artifacts: Vec::new(), mutated: true } + } + + pub fn with_artifacts( + result: Map, + artifacts: Vec<(String, flybus::Artifact)>, + ) -> HandlerReply { + HandlerReply { result, artifacts, mutated: true } + } + + /// The canonical JSON of one method result. + pub fn from(value: &T) -> HandlerReply { + HandlerReply::new(object(value.to_json())) + } +} + +/// Everything a handler is given: the parsed domain request and the bus request behind it. +pub struct HandlerCtx<'a> { + pub method: &'a str, + pub request: &'a SessionRpcRequest, + pub client: &'a flybus::Client, + pub incoming: &'a flybus::Request, +} + +impl HandlerCtx<'_> { + /// Reads and validates `params` as a method payload, reporting INVALID_ARGUMENT. + /// + /// The contract crate does the reading, so a misspelled required field fails here rather + /// than silently defaulting. + pub fn params(&self) -> DomainResult { + T::from_json(&self.request.params) + .map_err(|e| DomainError::invalid(format!("{}: {e}", self.method))) + } + + /// The scope the request must carry. + pub fn scope(&self) -> DomainResult<&Scope> { + self.request + .scope + .as_ref() + .ok_or_else(|| DomainError::invalid(format!("{} requires a scope", self.method))) + } + + /// An owned handle on one of the request's declared attachments. + pub fn artifact(&self, name: &str) -> DomainResult { + self.incoming.artifact(name).map_err(|e| { + DomainError::before( + ErrorCode::BufferInvalid, + format!("attachment {name:?} is missing or unowned: {}", e.message), + ) + }) + } +} + +/// A handler's boxed future, so the endpoint trait stays dyn-compatible. +pub type BoxFuture<'a, T> = Pin + Send + 'a>>; + +/// One worker's domain behaviour. The shell owns everything else. +pub trait WorkerEndpoint: Send + 'static { + fn worker_id(&self) -> Id; + fn incarnation_id(&self) -> Id; + fn session_id(&self) -> Id; + fn role(&self) -> Role; + fn capabilities(&self) -> Vec; + fn status_cell(&self) -> StatusCell; + + /// The domain methods this endpoint implements, beyond the common `Worker.*` set. + /// Anything else returns UNSUPPORTED without entering the endpoint. + fn methods(&self) -> Vec<&'static str>; + + fn handle<'a>(&'a mut self, ctx: HandlerCtx<'a>) -> BoxFuture<'a, DomainResult>; +} + +/// A running worker: its bus client, its service and the task serving it. +pub struct WorkerHandle { + pub worker_id: Id, + pub incarnation_id: Id, + pub service_name: String, + pub service_incarnation: String, + pub status: StatusCell, + pub cache: Arc>, + task: tokio::task::JoinHandle<()>, + client: flybus::Client, +} + +impl WorkerHandle { + /// The worker's own progress counter, which is the fake model's mutation count. + pub fn progress_counter(&self) -> u64 { + self.status.progress_counter() + } + + pub fn state(&self) -> WorkerState { + self.status.state() + } + + /// Stops serving and closes the worker's bus connection, which drops its registration and + /// every owner it held. A later reply from it can attach to nothing. + pub async fn stop(self) { + self.task.abort(); + let _ = self.task.await; + self.client.close().await; + } +} + +/// Registers `service_name` and serves `endpoint` on it until the service ends or Shutdown. +pub fn serve( + client: flybus::Client, + service: flybus::Service, + endpoint: E, +) -> WorkerHandle { + let worker_id = endpoint.worker_id(); + let incarnation_id = endpoint.incarnation_id(); + let status = endpoint.status_cell(); + let service_name = service.name().to_owned(); + let service_incarnation = service.incarnation().to_owned(); + let cache = Arc::new(tokio::sync::Mutex::new(ResultCache::new())); + let task = tokio::spawn(run(client.clone(), service, Arc::new(tokio::sync::Mutex::new(endpoint)), cache.clone())); + WorkerHandle { + worker_id, + incarnation_id, + service_name, + service_incarnation, + status, + cache, + task, + client, + } +} + +async fn run( + client: flybus::Client, + mut service: flybus::Service, + endpoint: Arc>, + cache: Arc>, +) { + // Identity and capabilities are fixed for the endpoint's lifetime, so the shell reads them + // once and never takes the endpoint mutex to answer Hello or Status. + let (worker_id, incarnation_id, session_id, role, capabilities, status, methods) = { + let e = endpoint.lock().await; + ( + e.worker_id(), + e.incarnation_id(), + e.session_id(), + e.role(), + e.capabilities(), + e.status_cell(), + e.methods(), + ) + }; + let mut running: Vec> = Vec::new(); + while let Some(incoming) = service.next().await { + let method = incoming.method().to_owned(); + let responder = incoming.responder(); + let request = match SessionRpcRequest::from_json(&Value::Object( + incoming.payload().clone(), + )) { + Ok(request) => request, + Err(e) => { + // A malformed envelope has no usable requestId, so the reply names req-0. + let failure = failure( + &DomainRequestId::from_serial(0), + &worker_id, + &incarnation_id, + None, + DomainError::invalid(format!("{method}: {e}")), + ); + let _ = responder.reply(failure.to_outcome(), &[]).await; + continue; + } + }; + // The contract type already validated the `req-` form when it read the envelope. + let serial = request.request_id.clone(); + + // The common methods never enter the endpoint mutex, so they answer during a mutation. + match method.as_str() { + "Worker.Hello" => { + let outcome = hello( + &request, + &session_id, + &worker_id, + &incarnation_id, + role, + &capabilities, + ); + let _ = responder.reply(outcome.to_outcome(), &[]).await; + continue; + } + "Worker.Status" => { + let result = status.snapshot().to_json(); + let outcome = success( + &request, + &worker_id, + &incarnation_id, + object(result), + ); + let _ = responder.reply(outcome.to_outcome(), &[]).await; + continue; + } + "Worker.Acknowledge" => { + let outcome = match acknowledge(&request, &cache).await { + Ok(result) => success(&request, &worker_id, &incarnation_id, result), + Err(e) => { + failure(&request.request_id, &worker_id, &incarnation_id, request.scope.clone(), e) + } + }; + let _ = responder.reply(outcome.to_outcome(), &[]).await; + continue; + } + "Worker.Shutdown" => { + let outcome = match request.params.get("reason") { + Some(_) => { + match ShutdownParams::from_json(&request.params) { + Ok(_) => { + status.set_state(WorkerState::Stopping); + Ok(ShutdownResult) + } + Err(e) => Err(DomainError::invalid(format!("Worker.Shutdown: {e}"))), + } + } + None => Err(DomainError::invalid("Worker.Shutdown requires a reason")), + }; + match outcome { + Ok(result) => { + let value = result.to_json(); + let outcome = + success(&request, &worker_id, &incarnation_id, object(value)); + let _ = responder.reply(outcome.to_outcome(), &[]).await; + return; + } + Err(e) => { + let outcome = failure( + &request.request_id, + &worker_id, + &incarnation_id, + request.scope.clone(), + e, + ); + let _ = responder.reply(outcome.to_outcome(), &[]).await; + continue; + } + } + } + _ => {} + } + + let class = if methods.contains(&method.as_str()) { + classify_default(&method) + } else { + None + }; + let Some(class) = class else { + let outcome = failure( + &request.request_id, + &worker_id, + &incarnation_id, + request.scope.clone(), + DomainError::before(ErrorCode::Unsupported, format!("{method} is not supported")), + ); + let _ = responder.reply(outcome.to_outcome(), &[]).await; + continue; + }; + + let body = match request.body_digest(&method) { + Ok(body) => body, + Err(e) => { + let outcome = failure( + &request.request_id, + &worker_id, + &incarnation_id, + request.scope.clone(), + DomainError::invalid(format!("{method}: {e}")), + ); + let _ = responder.reply(outcome.to_outcome(), &[]).await; + continue; + } + }; + let key = match (class, &request.scope) { + (OpClass::StepMutation, Some(scope)) => OperationKey { + session_id: scope.session_id.clone(), + epoch: scope.epoch.clone(), + step: scope.step, + method: method.clone(), + worker_id: worker_id.clone(), + }, + (OpClass::StepMutation, None) => { + let outcome = failure( + &request.request_id, + &worker_id, + &incarnation_id, + None, + DomainError::invalid(format!("{method} requires a scope")), + ); + let _ = responder.reply(outcome.to_outcome(), &[]).await; + continue; + } + _ => OperationKey { + session_id: session_id.clone(), + epoch: id("lifecycle"), + step: 0, + method: method.clone(), + worker_id: worker_id.clone(), + }, + }; + + // Retained request identity is checked before phase checks and before any attachment + // is dereferenced, because a duplicate may arrive after its input delivery was + // consumed and needs only the cached result. + let admission = { + let mut c = cache.lock().await; + c.admit(class, &key, serial.clone(), &body) + }; + match admission { + Admission::Replay(reply) => { + let _ = responder.reply(reply.outcome.to_outcome(), &reply.attachments()).await; + continue; + } + Admission::Refuse(e) => { + let outcome = failure( + &request.request_id, + &worker_id, + &incarnation_id, + request.scope.clone(), + e, + ); + let _ = responder.reply(outcome.to_outcome(), &[]).await; + continue; + } + Admission::Execute => {} + } + + status.set_active(Some(request.request_id.clone())); + // The mutation runs in its own task so the shell keeps reading. That is what lets an + // exact duplicate arriving mid-execution be refused with IN_PROGRESS while the + // original bus call still completes normally. The endpoint mutex, not this loop, + // enforces one mutation at a time. + running.retain(|task| !task.is_finished()); + running.push(tokio::spawn(execute( + client.clone(), + endpoint.clone(), + cache.clone(), + status.clone(), + worker_id.clone(), + incarnation_id.clone(), + class, + key, + serial, + body, + method, + request, + incoming, + ))); + } + for task in running { + let _ = task.await; + } +} + +/// Runs one admitted mutation, records its reply and answers the bus call. +#[allow(clippy::too_many_arguments)] +async fn execute( + client: flybus::Client, + endpoint: Arc>, + cache: Arc>, + status: StatusCell, + worker_id: Id, + incarnation_id: Id, + class: OpClass, + key: OperationKey, + serial: DomainRequestId, + body: Digest, + method: String, + request: SessionRpcRequest, + incoming: flybus::Request, +) { + let responder = incoming.responder(); + let outcome = { + // One mutation at a time: the endpoint mutex is the worker's simulation lock, and it is + // never held across a bus round trip taken by anything else. + let mut e = endpoint.lock().await; + let ctx = HandlerCtx { + method: &method, + request: &request, + client: &client, + incoming: &incoming, + }; + e.handle(ctx).await + }; + match outcome { + Ok(reply) => { + let outcome = success(&request, &worker_id, &incarnation_id, reply.result.clone()); + let mut holds = Vec::with_capacity(reply.artifacts.len()); + for (name, artifact) in &reply.artifacts { + // The cache owns its own hold, so a replay survives the first caller + // consuming its delivery. + match artifact.retain().await { + Ok(hold) => holds.push((name.clone(), hold)), + Err(_) => holds.push((name.clone(), artifact.clone())), + } + } + let cached = CachedReply::with_artifacts(outcome.clone(), holds); + { + let mut c = cache.lock().await; + match class { + OpClass::StepMutation => c.record(key, serial, body, cached), + OpClass::Lifecycle => c.record_lifecycle(serial, body, cached), + OpClass::ReadOnly => c.record_readonly(serial, cached), + } + } + status.set_completed(request.request_id.clone()); + let attachments: Vec<(&str, &flybus::Artifact)> = + reply.artifacts.iter().map(|(n, a)| (n.as_str(), a)).collect(); + let _ = responder.reply(outcome.to_outcome(), &attachments).await; + } + Err(e) => { + if e.mutation == MutationCertainty::None { + // Nothing happened, so the key stays free for the corrected request. + let mut c = cache.lock().await; + c.abandon(&key); + } else { + let cached = CachedReply::new(failure_outcome( + &request.request_id, + &worker_id, + &incarnation_id, + request.scope.clone(), + e.clone(), + )); + let mut c = cache.lock().await; + if class == OpClass::StepMutation { + c.record(key, serial, body, cached); + } else { + c.abandon(&key); + } + status.set_state(WorkerState::Failed); + } + status.set_active(None); + let outcome = failure( + &request.request_id, + &worker_id, + &incarnation_id, + request.scope.clone(), + e, + ); + let _ = responder.reply(outcome.to_outcome(), &[]).await; + } + } +} + +/// The retention class of every method name the contracts define. +fn classify_default(method: &str) -> Option { + match method { + "Agent.Prepare" | "Agent.Commit" | "Environment.Advance" => Some(OpClass::StepMutation), + "Agent.Initialize" | "Environment.Initialize" => Some(OpClass::Lifecycle), + "Worker.Hello" | "Worker.Status" | "Worker.Acknowledge" | "Worker.Shutdown" => { + Some(OpClass::ReadOnly) + } + _ => None, + } +} + +fn success( + request: &SessionRpcRequest, + worker_id: &Id, + incarnation_id: &Id, + result: Map, +) -> SessionRpcOutcome { + success_outcome( + &request.request_id, + worker_id, + incarnation_id, + request.scope.clone(), + result, + ) +} + +fn failure( + request_id: &DomainRequestId, + worker_id: &Id, + incarnation_id: &Id, + scope: Option, + error: DomainError, +) -> SessionRpcOutcome { + failure_outcome(request_id, worker_id, incarnation_id, scope, error) +} + +fn hello( + request: &SessionRpcRequest, + session_id: &Id, + worker_id: &Id, + incarnation_id: &Id, + role: Role, + capabilities: &[Id], +) -> SessionRpcOutcome { + let params: HelloParams = match HelloParams::from_json(&request.params) { + Ok(params) => params, + Err(e) => { + return failure( + &request.request_id, + worker_id, + incarnation_id, + None, + DomainError::invalid(format!("Worker.Hello: {e}")), + ); + } + }; + if request.scope.is_some() { + return failure( + &request.request_id, + worker_id, + incarnation_id, + None, + DomainError::invalid("Worker.Hello has no scope"), + ); + } + if params.session_id != *session_id + || params.expected_worker_id != *worker_id + || params.role != role + { + return failure( + &request.request_id, + worker_id, + incarnation_id, + None, + DomainError::before( + ErrorCode::IdentityMismatch, + "this worker is not the session, worker or role the caller expected", + ), + ); + } + if !params.supported_majors.contains(&1) { + return failure( + &request.request_id, + worker_id, + incarnation_id, + None, + DomainError::before(ErrorCode::Unsupported, "no common major version"), + ); + } + let result = HelloResult { + worker_id: worker_id.clone(), + incarnation_id: incarnation_id.clone(), + role, + build_digest: build_digest(), + contract_digest: contract_digest(), + capabilities: capabilities.to_vec(), + max_agents: MAX_AGENTS as u64, + max_ports: MAX_PORTS as u64, + }; + success( + request, + worker_id, + incarnation_id, + object(result.to_json()), + ) +} + +async fn acknowledge( + request: &SessionRpcRequest, + cache: &Arc>, +) -> DomainResult> { + let params: AcknowledgeParams = AcknowledgeParams::from_json(&request.params) + .map_err(|e| DomainError::invalid(format!("Worker.Acknowledge: {e}")))?; + if params.request_ids.is_empty() || params.request_ids.len() > MAX_ACKNOWLEDGE { + return Err(DomainError::invalid("Worker.Acknowledge takes 1..=16 request ids")); + } + let acknowledged = { + let mut c = cache.lock().await; + c.acknowledge(¶ms.request_ids) + }; + let result = AcknowledgeResult { acknowledged }; + Ok(object(result.to_json())) +} diff --git a/services/flysim/crates/fly-session/tests/common/mod.rs b/services/flysim/crates/fly-session/tests/common/mod.rs new file mode 100644 index 0000000..c95912c --- /dev/null +++ b/services/flysim/crates/fly-session/tests/common/mod.rs @@ -0,0 +1,96 @@ +//! Shared test fixture: the synthetic session on a temporary store, over either transport. + +#![allow(dead_code)] + +use std::time::Duration; + +use fly_session::harness::{HarnessConfig, SessionHarness, Via}; +use fly_session::types::*; + +pub const WAIT: Duration = Duration::from_secs(20); + +/// Generates one test per transport from an `async fn name(via: Via)`. +#[macro_export] +macro_rules! both_transports { + ($($name:ident),* $(,)?) => { + mod in_memory { + $( + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn $name() { + super::$name($crate::common::via_memory()).await + } + )* + } + mod unix_socket { + $( + #[tokio::test(flavor = "multi_thread", worker_threads = 4)] + async fn $name() { + super::$name($crate::common::via_unix()).await + } + )* + } + }; +} + +pub fn via_memory() -> Via { + Via::Memory +} + +pub fn via_unix() -> Via { + Via::Unix +} + +/// A started session plus the temporary directory its store lives in. +pub struct Fixture { + pub dir: tempfile::TempDir, + pub harness: SessionHarness, +} + +impl Fixture { + pub async fn shutdown(self) { + let Fixture { dir, harness } = self; + harness.shutdown().await; + drop(dir); + } +} + +pub async fn fixture(via: Via, config: HarnessConfig) -> Fixture { + let dir = tempfile::tempdir().expect("a temporary directory"); + let harness = SessionHarness::start(via, dir.path(), config) + .await + .expect("the synthetic session starts"); + Fixture { dir, harness } +} + +/// The default two-agent composition: 60 Hz world, 1 ms model tick. +pub async fn default_fixture(via: Via) -> Fixture { + fixture(via, HarnessConfig::default()).await +} + +pub fn fly_a() -> Id { + id("fly-a") +} + +pub fn fly_b() -> Id { + id("fly-b") +} + +/// The index of an audit entry, or a panic naming what was missing. +pub fn at(audit: &[String], what: &str) -> usize { + audit + .iter() + .position(|entry| entry == what) + .unwrap_or_else(|| panic!("the audit has no {what:?}: {audit:?}")) +} + +pub fn count(audit: &[String], what: &str) -> usize { + audit.iter().filter(|entry| *entry == what).count() +} + +/// Fails the test rather than hanging, so a missed reply is a failure and not a stuck job. +pub async fn within(what: &str, f: impl std::future::Future) -> T { + match tokio::time::timeout(WAIT, f).await { + Ok(v) => v, + Err(_) => panic!("{what}: timed out"), + } +} diff --git a/services/flysim/crates/fly-session/tests/failures.rs b/services/flysim/crates/fly-session/tests/failures.rs new file mode 100644 index 0000000..60e7b66 --- /dev/null +++ b/services/flysim/crates/fly-session/tests/failures.rs @@ -0,0 +1,373 @@ +//! The failure-injection rows of the implementation guide's section 4 that apply to +//! SESSION-01, plus the `step-v1` section 7 rules they enforce. +//! +//! Most rows are proved by comparing an injected run with a clean run of the same +//! composition: same seeds, same cadence, same number of steps. If the injected run's +//! behaviour trace, model mutation counts and world counter are identical, then the injected +//! message added no tick, no RNG draw, no stimulation, no reward and no world step. + +mod common; + +use common::{Fixture, at, fly_a, fly_b, within}; +use fly_session::agent::AgentFaults; +use fly_session::coordinator::Injections; +use fly_session::environment::EnvironmentFaults; +use fly_session::harness::{HarnessConfig, Via}; +use fly_session::phase::Phase; +use fly_session::types::*; + +both_transports!( + a_duplicate_prepare_after_a_lost_reply_repeats_nothing, + a_duplicate_commit_replays_without_a_second_reinforcement, + the_same_batch_with_altered_controls_conflicts, + a_lost_advance_result_resolves_the_same_operation, + a_cached_artifact_consumed_by_its_first_caller_survives_a_retry, + one_commit_failing_after_another_succeeds_fails_the_epoch, + a_replaced_registration_is_not_silently_reached, + a_reply_from_another_incarnation_is_rejected, + a_world_that_advanced_without_sensory_data_fails_the_transition, + an_exact_duplicate_of_a_running_operation_is_in_progress, + an_old_epoch_operation_is_refused_with_stale_epoch, +); + +const STEPS: u64 = 4; +const INJECT_AT: u64 = 2; + +/// What a run of the standard composition produced. +struct Run { + behaviour: Vec, + mutations: Vec<(Id, u64)>, + counter: i64, + advances: u64, + injections: Vec, + in_progress: u64, +} + +async fn run_with(via: Via, injections: Injections) -> Run { + let mut f = clean_fixture(via).await; + f.harness.coordinator.injections = injections; + within("bootstrap", f.harness.coordinator.bootstrap()).await.unwrap(); + within("run", f.harness.coordinator.run(STEPS)).await.unwrap(); + let run = Run { + behaviour: f.harness.coordinator.trace.behavior(), + mutations: vec![ + (fly_a(), f.harness.agent_mutations(&fly_a())), + (fly_b(), f.harness.agent_mutations(&fly_b())), + ], + counter: f + .harness + .coordinator + .task_progress() + .integer("counter") + .unwrap(), + advances: f.harness.coordinator.stats().advances, + injections: f.harness.coordinator.injection_log.clone(), + in_progress: f.harness.coordinator.in_progress_replies, + }; + f.shutdown().await; + run +} + +async fn clean_fixture(via: Via) -> Fixture { + common::fixture(via, HarnessConfig::default()).await +} + +fn assert_same(injected: &Run, clean: &Run, what: &str) { + assert_eq!(injected.behaviour, clean.behaviour, "{what}: behaviour trace"); + assert_eq!(injected.mutations, clean.mutations, "{what}: model mutations"); + assert_eq!(injected.counter, clean.counter, "{what}: world counter"); + assert_eq!(injected.advances, clean.advances, "{what}: world advances"); + assert_eq!(injected.advances, STEPS, "{what}: one advance per batch"); + assert_eq!(clean.in_progress, 0, "{what}: a clean run never meets a duplicate"); +} + +/// Row: duplicate Prepare after a lost reply. No extra ticks, RNG draws, stimulation or +/// decode, and the cached decision comes back unchanged. +async fn a_duplicate_prepare_after_a_lost_reply_repeats_nothing(via: Via) { + let clean = run_with(via, Injections::default()).await; + let injected = run_with( + via, + Injections { + at_step: INJECT_AT, + duplicate_prepare: Some(fly_a()), + ..Injections::default() + }, + ) + .await; + let probe = injected + .injections + .iter() + .find(|o| o.what == "duplicate-prepare") + .expect("the duplicate was sent"); + assert_eq!(probe.code, None, "a safe replay is a success, not an error"); + assert!(probe.identical, "the replay returned the same decision, ticks and remainder"); + assert_same(&injected, &clean, "duplicate prepare"); +} + +/// Row: the same Commit again. It replays its cached reply and reinforces nothing twice. +async fn a_duplicate_commit_replays_without_a_second_reinforcement(via: Via) { + let clean = run_with(via, Injections::default()).await; + let injected = run_with( + via, + Injections { + at_step: INJECT_AT, + duplicate_commit: Some(fly_b()), + ..Injections::default() + }, + ) + .await; + let probe = injected + .injections + .iter() + .find(|o| o.what == "duplicate-commit") + .expect("the duplicate was sent"); + assert_eq!(probe.code, None); + assert!(probe.identical, "the replay returned the same committed step and telemetry"); + assert_same(&injected, &clean, "duplicate commit"); +} + +/// Row: the same batch with altered controls. A conflict, never a second world mutation. +async fn the_same_batch_with_altered_controls_conflicts(via: Via) { + let clean = run_with(via, Injections::default()).await; + let injected = run_with( + via, + Injections { + at_step: INJECT_AT, + altered_advance_controls: true, + ..Injections::default() + }, + ) + .await; + let probe = injected + .injections + .iter() + .find(|o| o.what == "altered-advance-controls") + .expect("the altered batch was sent"); + assert_eq!(probe.code, Some(ErrorCode::Conflict)); + assert_same(&injected, &clean, "altered controls"); +} + +/// Row: the Advance result is lost after the world stepped. The same operation is resolved +/// against its original request id; no new batch is ever sent. +async fn a_lost_advance_result_resolves_the_same_operation(via: Via) { + let clean = run_with(via, Injections::default()).await; + let injected = run_with( + via, + Injections { at_step: INJECT_AT, lose_advance_result: true, ..Injections::default() }, + ) + .await; + assert!( + injected + .injections + .iter() + .any(|o| o.what == "lost-advance-result" && o.identical), + "the call was abandoned after dispatch, with an uncertain outcome" + ); + assert!( + injected.injections.iter().any(|o| o.what == "status-after-loss" && o.identical), + "the uncertain call was probed with Status before being resolved" + ); + assert_same(&injected, &clean, "lost advance result"); +} + +/// Row: the cached RPC artifact is consumed by its first caller. The endpoint's domain cache +/// still owns it, so the retry gets valid bytes. +async fn a_cached_artifact_consumed_by_its_first_caller_survives_a_retry(via: Via) { + let clean = run_with(via, Injections::default()).await; + let injected = run_with( + via, + Injections { + at_step: INJECT_AT, + consume_advance_artifact_then_retry: true, + ..Injections::default() + }, + ) + .await; + let probe = injected + .injections + .iter() + .find(|o| o.what == "cached-artifact-after-consumption") + .expect("the frame was read, released and replayed"); + assert!(probe.identical, "the replayed frame has the same bytes as the consumed one"); + assert_same(&injected, &clean, "cached artifact"); +} + +/// Row: one Commit fails after another succeeds. No next world step, and the epoch fails +/// rather than continuing with a partial match. +async fn one_commit_failing_after_another_succeeds_fails_the_epoch(via: Via) { + let mut config = HarnessConfig::default(); + // fly-a commits quickly and succeeds; fly-b fails after its next input was installed. + config.agents[1].faults = + AgentFaults { fail_commit_at_step: Some(1), commit_delay_ms: 15, ..AgentFaults::default() }; + let mut f = common::fixture(via, config).await; + within("bootstrap", f.harness.coordinator.bootstrap()).await.unwrap(); + within("step", f.harness.coordinator.step()).await.unwrap(); + let err = within("failing step", f.harness.coordinator.step()) + .await + .expect_err("the epoch fails when one commit fails"); + assert_eq!(err.error.code, ErrorCode::BackendFailure); + assert_eq!(err.error.mutation, MutationCertainty::Applied); + assert_eq!(f.harness.coordinator.phase(), Phase::Failed); + + // The world took the step whose commits failed, and it takes no further step. + let advances = f.harness.coordinator.stats().advances; + assert_eq!(advances, 1, "the failed transition never reached a committed boundary"); + let env = f.harness.coordinator.environment_ref().clone(); + let status = within("status", f.harness.coordinator.status(&env)).await.unwrap(); + assert_eq!(status.current_scope.as_ref().unwrap().step, 2); + let again = f.harness.coordinator.step().await.expect_err("no step from Failed"); + assert_eq!(again.error.code, ErrorCode::InvalidPhase); + let status = within("status", f.harness.coordinator.status(&env)).await.unwrap(); + assert_eq!( + status.current_scope.unwrap().step, + 2, + "no world step follows a partial commit" + ); + + // The agent that succeeded is at the new boundary; the one that failed reports Failed. + let a = f.harness.coordinator.agent_ref(&fly_a()).cloned().unwrap(); + let status = within("status", f.harness.coordinator.status(&a)).await.unwrap(); + assert_eq!(status.current_scope.unwrap().step, 2); + assert_eq!( + f.harness.agent_status(&fly_b()).unwrap().state(), + WorkerState::Failed + ); + // Nothing was published for the boundary that failed to commit. + let audit = f.harness.coordinator.audit.clone(); + assert!(!audit.iter().any(|entry| entry == "publish:2")); + assert!(at(&audit, "publish:1") < at(&audit, "advance:1")); + f.shutdown().await; +} + +/// Row: an old worker is replaced. The coordinator pinned the old registration, so its next +/// call fails rather than silently reaching another brain. +async fn a_replaced_registration_is_not_silently_reached(via: Via) { + let mut f = clean_fixture(via).await; + within("bootstrap", f.harness.coordinator.bootstrap()).await.unwrap(); + within("step", f.harness.coordinator.step()).await.unwrap(); + let restarted = f.harness.restart_agent(&fly_b()).await.unwrap(); + let pinned = f.harness.coordinator.agent_ref(&fly_b()).cloned().unwrap(); + assert_ne!( + restarted.service_incarnation, pinned.bus_incarnation, + "a replacement registration is a new incarnation" + ); + let err = within("step after restart", f.harness.coordinator.step()) + .await + .expect_err("the pinned incarnation is gone"); + assert_eq!(err.error.code, ErrorCode::IdentityMismatch); + assert_eq!(f.harness.coordinator.phase(), Phase::Failed); + assert_eq!(f.harness.coordinator.stats().advances, 1, "no world step under a lost pin"); + f.shutdown().await; +} + +/// Row: a reply carrying another domain incarnation is rejected, even when the bus route is +/// live and answering. +async fn a_reply_from_another_incarnation_is_rejected(via: Via) { + let mut f = clean_fixture(via).await; + within("bootstrap", f.harness.coordinator.bootstrap()).await.unwrap(); + let old = f.harness.coordinator.agent_ref(&fly_b()).cloned().unwrap(); + let restarted = f.harness.restart_agent(&fly_b()).await.unwrap(); + // Follow the new registration, but keep pinning the incarnation the old worker negotiated. + let stale = fly_session::rpc::WorkerRef { + service: restarted.service.clone(), + bus_incarnation: restarted.service_incarnation.clone(), + worker_id: fly_b(), + domain_incarnation: old.domain_incarnation.clone(), + }; + assert_ne!(old.domain_incarnation, Some(restarted.incarnation_id.clone())); + let err = within("status", f.harness.coordinator.status(&stale)) + .await + .expect_err("the replacement is not the negotiated incarnation"); + assert_eq!(err.error.code, ErrorCode::IdentityMismatch); + assert_eq!(f.harness.coordinator.phase(), Phase::Failed); + f.shutdown().await; +} + +/// `step-v1` section 7: the world advanced but the required sensory data is unavailable. The +/// transition fails; nothing is rewarded or continued on guessed input. +async fn a_world_that_advanced_without_sensory_data_fails_the_transition(via: Via) { + let config = HarnessConfig { + environment_faults: EnvironmentFaults { + omit_view_at_boundary: Some(1), + ..EnvironmentFaults::default() + }, + ..HarnessConfig::default() + }; + let mut f = common::fixture(via, config).await; + within("bootstrap", f.harness.coordinator.bootstrap()).await.unwrap(); + let err = within("step", f.harness.coordinator.step()) + .await + .expect_err("a missing required view is not silently replaced"); + assert_eq!(err.error.code, ErrorCode::BufferInvalid); + assert_eq!(f.harness.coordinator.phase(), Phase::Failed); + // The task never interpreted the transition, so nothing was rewarded. + assert_eq!(f.harness.coordinator.evaluations(), 0); + assert_eq!( + f.harness.coordinator.task_progress().number("totalReward").unwrap(), + 0.0 + ); + let audit = f.harness.coordinator.audit.clone(); + assert!(!audit.iter().any(|entry| entry.starts_with("committed:"))); + assert!(!audit.iter().any(|entry| entry == "publish:1")); + f.shutdown().await; +} + +/// `ipc-v1` section 5: an exact duplicate arriving while the original is still executing gets +/// IN_PROGRESS for that bus call, and the original completes normally. +async fn an_exact_duplicate_of_a_running_operation_is_in_progress(via: Via) { + let config = HarnessConfig { + environment_faults: EnvironmentFaults { + // The world moves, then the reply is held, so the resolution attempt lands while + // the original operation is still active. + advance_delay_ms: 120, + ..EnvironmentFaults::default() + }, + ..HarnessConfig::default() + }; + let mut f = common::fixture(via, config).await; + f.harness.coordinator.injections = + Injections { at_step: 0, lose_advance_result: true, ..Injections::default() }; + within("bootstrap", f.harness.coordinator.bootstrap()).await.unwrap(); + within("step", f.harness.coordinator.step()).await.unwrap(); + assert!( + f.harness.coordinator.in_progress_replies > 0, + "the duplicate met the original still running" + ); + // And the original still completed: exactly one world step, at one boundary. + assert_eq!(f.harness.coordinator.stats().advances, 1); + assert_eq!(f.harness.coordinator.observation().unwrap().boundary, 1); + f.shutdown().await; +} + +/// A live worker under this epoch refuses an operation naming another one. +async fn an_old_epoch_operation_is_refused_with_stale_epoch(via: Via) { + let mut f = clean_fixture(via).await; + let harness = &mut f.harness; + within("bootstrap", harness.coordinator.bootstrap()).await.unwrap(); + within("step", harness.coordinator.step()).await.unwrap(); + + // The agent is live and initialized under epoch e1. An operation naming another epoch is + // refused as stale rather than applied to this brain. + let worker = harness.coordinator.agent_ref(&fly_a()).cloned().unwrap(); + let scope = scope_at("demo", "e0", 1); + let params = serde_json::json!({ + "agentId": "fly-a", + "profileDigest": digest_of_bytes(b"whatever").to_string(), + "interval": {"numerator": "16666667", "denominator": "1"}, + "decisionContextDigest": digest_of_bytes(b"whatever").to_string(), + "preStepStimulations": [], + }); + let bus = harness.client("coordinator2").await; + // The launcher grants no second coordinator, so the old-epoch probe goes through the + // session's own client instead. + assert!(bus.is_err(), "an unconfigured client id is refused before it can route"); + let err = within( + "stale epoch", + harness.coordinator.probe_raw(&worker, "Agent.Prepare", Some(scope), params), + ) + .await + .expect_err("an old epoch cannot mutate this worker"); + assert_eq!(err.code, ErrorCode::StaleEpoch); + assert_eq!(harness.coordinator.stats().advances, 1); + f.shutdown().await; +} diff --git a/services/flysim/crates/fly-session/tests/session.rs b/services/flysim/crates/fly-session/tests/session.rs new file mode 100644 index 0000000..feb5d7f --- /dev/null +++ b/services/flysim/crates/fly-session/tests/session.rs @@ -0,0 +1,404 @@ +//! SESSION-01 acceptance: the synthetic sequential transaction, over both transports. +//! +//! Every test here is one of the acceptance bullets of the implementation guide's SESSION-01 +//! slice, or one of the initialization, pause and episode rules of `step-v1` section 6. + +mod common; + +use std::collections::BTreeMap; + +use common::{Fixture, at, count, default_fixture, fixture, fly_a, fly_b, within}; +use fly_session::coordinator::DispatchOrder; +use fly_session::harness::{AgentSpec, HarnessConfig, Via}; +use fly_session::phase::Phase; +use fly_session::types::*; +use fly_session::agent::AgentFaults; + +both_transports!( + one_world_advance_per_complete_batch, + every_agent_is_prepared_before_the_world_advances, + the_task_evaluates_each_transition_once, + every_agent_commits_before_the_next_prepare_or_publication, + a_60_hz_world_with_a_1_ms_tick_runs_16_17_17, + a_pause_mid_step_completes_the_step_and_pauses_at_the_boundary, + bootstrap_cannot_advance_the_world_or_produce_a_reward, + the_committed_snapshot_names_the_boundary_that_just_ended, + a_terminal_episode_pauses_at_its_own_boundary, + status_answers_with_the_committed_boundary, + a_worker_refuses_a_second_initialize, + a_single_agent_composition_runs_the_same_transaction, +); + +const STEPS: u64 = 3; + +/// One `Environment.Advance` per complete batch, and one boundary per advance. +async fn one_world_advance_per_complete_batch(via: Via) { + let mut f = default_fixture(via).await; + within("bootstrap", f.harness.coordinator.bootstrap()).await.unwrap(); + let before = f.harness.environment_mutations(); + let reports = within("run", f.harness.coordinator.run(STEPS)).await.unwrap(); + assert_eq!(reports.len() as u64, STEPS); + assert_eq!(f.harness.coordinator.stats().advances, STEPS); + assert_eq!(f.harness.coordinator.phase(), Phase::Ready(STEPS)); + assert_eq!( + f.harness.coordinator.observation().unwrap().boundary, + STEPS, + "the world is at exactly one boundary per batch" + ); + // The environment's progress counter moves once per advance and not otherwise. + assert_eq!(f.harness.environment_mutations() - before, STEPS); + assert_eq!(count(&f.harness.coordinator.audit, "advance:0"), 1); + assert_eq!(f.harness.coordinator.trace.transitions.len() as u64, STEPS); + f.shutdown().await; +} + +/// Every agent reaches Prepared before the batch is built and the world advances. +async fn every_agent_is_prepared_before_the_world_advances(via: Via) { + // Different completion delays, so "all prepared" cannot be an accident of timing. + let mut config = HarnessConfig::default(); + config.agents[0].faults = AgentFaults { prepare_delay_ms: 15, ..AgentFaults::default() }; + config.agents[1].faults = AgentFaults { prepare_delay_ms: 1, ..AgentFaults::default() }; + let mut f = fixture(via, config).await; + within("bootstrap", f.harness.coordinator.bootstrap()).await.unwrap(); + within("run", f.harness.coordinator.run(STEPS)).await.unwrap(); + let audit = f.harness.coordinator.audit.clone(); + for k in 0..STEPS { + let advance = at(&audit, &format!("advance:{k}")); + for agent in [fly_a(), fly_b()] { + let prepared = at(&audit, &format!("prepared:{agent}@{k}")); + assert!( + prepared < advance, + "{agent} must be Prepared({k}) before the world advances: {audit:?}" + ); + } + } + f.shutdown().await; +} + +/// The task's transition evaluation runs exactly once per acknowledged world step. +async fn the_task_evaluates_each_transition_once(via: Via) { + let mut f = default_fixture(via).await; + within("bootstrap", f.harness.coordinator.bootstrap()).await.unwrap(); + assert_eq!(f.harness.coordinator.evaluations(), 0, "bootstrap evaluates no transition"); + within("run", f.harness.coordinator.run(STEPS)).await.unwrap(); + assert_eq!(f.harness.coordinator.evaluations(), STEPS); + let audit = f.harness.coordinator.audit.clone(); + for k in 0..STEPS { + assert_eq!(count(&audit, &format!("evaluate:{k}")), 1); + } + f.shutdown().await; +} + +/// No agent starts the next Prepare, and nothing is published as committed, until every agent +/// has committed this transition. +async fn every_agent_commits_before_the_next_prepare_or_publication(via: Via) { + let mut config = HarnessConfig::default(); + config.agents[0].faults = AgentFaults { commit_delay_ms: 12, ..AgentFaults::default() }; + let mut f = fixture(via, config).await; + within("bootstrap", f.harness.coordinator.bootstrap()).await.unwrap(); + within("run", f.harness.coordinator.run(STEPS)).await.unwrap(); + let audit = f.harness.coordinator.audit.clone(); + for k in 0..STEPS { + let publish = at(&audit, &format!("publish:{}", k + 1)); + for agent in [fly_a(), fly_b()] { + let committed = at(&audit, &format!("committed:{agent}@{k}")); + assert!( + committed < publish, + "{agent} must commit before boundary {} is published: {audit:?}", + k + 1 + ); + if k + 1 < STEPS { + let next = at(&audit, &format!("prepared:{agent}@{}", k + 1)); + for other in [fly_a(), fly_b()] { + let other_commit = at(&audit, &format!("committed:{other}@{k}")); + assert!( + other_commit < next, + "{other} must commit step {k} before {agent} prepares {}: {audit:?}", + k + 1 + ); + } + } + } + } + assert_eq!(f.harness.coordinator.stats().publications, STEPS + 1, "one per boundary, plus 0"); + f.shutdown().await; +} + +/// `step-v1` section 5: a 60 Hz world with a 1 ms model tick runs 16, 17, 17 ticks over three +/// steps, totalling 50, with a remainder of exactly zero. +async fn a_60_hz_world_with_a_1_ms_tick_runs_16_17_17(via: Via) { + let mut f = default_fixture(via).await; + within("bootstrap", f.harness.coordinator.bootstrap()).await.unwrap(); + within("run", f.harness.coordinator.run(3)).await.unwrap(); + let transitions = &f.harness.coordinator.trace.transitions; + assert_eq!(transitions.len(), 3); + for agent in [fly_a(), fly_b()] { + let ticks: Vec = transitions + .iter() + .map(|t| { + t.behaviour + .agents + .iter() + .find(|a| a.agent_id == agent) + .expect("the agent is in every transition") + .ticks_advanced + }) + .collect(); + assert_eq!(ticks, vec![16, 17, 17], "{agent} tick profile"); + assert_eq!(ticks.iter().sum::(), 50); + let last = transitions + .last() + .unwrap() + .behaviour + .agents + .iter() + .find(|a| a.agent_id == agent) + .unwrap(); + assert!(last.remainder.is_zero(), "{agent} remainder after three steps"); + // Warm-up ticks are counted too, so brainTicks is warm-up plus the 50 gameplay ticks. + assert_eq!(last.brain_ticks, 50 + f.harness.config.warmup_ticks); + } + f.shutdown().await; +} + +/// A pause arriving mid-step means "finish this transition, then pause", and it pauses at the +/// committed boundary rather than truncating anything. +async fn a_pause_mid_step_completes_the_step_and_pauses_at_the_boundary(via: Via) { + let mut config = HarnessConfig::default(); + // Both agents hold their Commit open, so the pause request lands inside the transition. + config.agents[0].faults = AgentFaults { commit_delay_ms: 40, ..AgentFaults::default() }; + config.agents[1].faults = AgentFaults { commit_delay_ms: 60, ..AgentFaults::default() }; + let mut f = fixture(via, config).await; + within("bootstrap", f.harness.coordinator.bootstrap()).await.unwrap(); + within("step", f.harness.coordinator.step()).await.unwrap(); + + // A supervisor asks for a pause while the second transition is still running. + let handle = f.harness.coordinator.pause_handle(); + let asked = tokio::spawn(async move { + tokio::time::sleep(std::time::Duration::from_millis(10)).await; + handle.request(); + }); + let report = within("paused step", f.harness.coordinator.step()).await.unwrap(); + asked.await.unwrap(); + + // The transition completed and the session paused at its committed boundary. + assert_eq!(report.boundary, 2); + assert!(report.paused); + assert_eq!(f.harness.coordinator.phase(), Phase::Paused(2)); + assert!(f.harness.coordinator.phase().is_committed_boundary()); + assert_eq!(f.harness.coordinator.stats().advances, 2, "the pause truncated no transition"); + let audit = f.harness.coordinator.audit.clone(); + let pause = at(&audit, "pause:2"); + for agent in [fly_a(), fly_b()] { + assert!(at(&audit, &format!("committed:{agent}@1")) < pause); + } + assert!(at(&audit, "publish:2") < pause); + + // A paused worker retains its state and answers Status; the world does not advance. + let env = f.harness.coordinator.environment_ref().clone(); + let status = within("status", f.harness.coordinator.status(&env)).await.unwrap(); + assert_eq!(status.current_scope.unwrap().step, 2); + assert_eq!(f.harness.coordinator.stats().advances, 2); + + f.harness.coordinator.resume().unwrap(); + assert_eq!(f.harness.coordinator.phase(), Phase::Ready(2)); + let report = within("resumed step", f.harness.coordinator.step()).await.unwrap(); + assert_eq!(report.boundary, 3); + assert!(!report.paused, "the pause request was consumed by the pause it caused"); + f.shutdown().await; +} + +/// Bootstrap and warm-up mutate the fake brains but cannot advance the environment or produce +/// a gameplay reward. +async fn bootstrap_cannot_advance_the_world_or_produce_a_reward(via: Via) { + let mut f = default_fixture(via).await; + within("bootstrap", f.harness.coordinator.bootstrap()).await.unwrap(); + assert_eq!(f.harness.coordinator.phase(), Phase::Ready(0)); + let observation = f.harness.coordinator.observation().unwrap(); + assert_eq!(observation.boundary, 0); + assert!(observation.world_time.is_zero()); + assert_eq!(f.harness.coordinator.stats().advances, 0); + assert_eq!(f.harness.coordinator.evaluations(), 0); + // The environment advanced nothing, so its status is still at boundary 0 with no batch. + let env = f.harness.coordinator.environment_ref().clone(); + let status = within("status", f.harness.coordinator.status(&env)).await.unwrap(); + assert_eq!(status.current_scope.as_ref().unwrap().step, 0); + assert!(status.last_batch_id.is_none(), "no batch was ever applied"); + // Warm-up did run, with learning disabled, so the models did mutate. + for agent in [fly_a(), fly_b()] { + assert!( + f.harness.agent_mutations(&agent) >= f.harness.config.warmup_ticks, + "warm-up ticks are real mutations" + ); + } + // And the task ledger has no reward yet. + let progress = f.harness.coordinator.task_progress(); + assert_eq!(progress.number("totalReward").unwrap(), 0.0); + assert_eq!(progress.integer("transitions").unwrap(), 0); + f.shutdown().await; +} + +/// The published snapshot represents the committed boundary and labels the transition that +/// just ended. +async fn the_committed_snapshot_names_the_boundary_that_just_ended(via: Via) { + let mut f = default_fixture(via).await; + within("bootstrap", f.harness.coordinator.bootstrap()).await.unwrap(); + // The snapshots topic retains its latest value, so a subscriber joining after boundary 0 + // still replays it before the boundaries that follow. + let observer = f.harness.observer().await.unwrap(); + let topic = f.harness.coordinator.topics().snapshots.clone(); + let mut subscription = observer + .subscribe( + &topic, + flybus::SubscriptionConfig::bounded().in_flight(8).replay(true), + ) + .await + .unwrap(); + within("run", f.harness.coordinator.run(2)).await.unwrap(); + + let mut boundaries = Vec::new(); + for _ in 0..3 { + let message = within("snapshot", subscription.next()).await.expect("a snapshot"); + let payload = message.payload().clone(); + let step: u64 = payload["scope"]["step"].as_str().unwrap().parse().unwrap(); + let decisions_present = payload["agents"] + .as_array() + .unwrap() + .iter() + .all(|a| !a["selectedDecision"].is_null()); + boundaries.push((step, decisions_present)); + // The frame the snapshot names travels as an owned attachment. + if step > 0 { + let frame = message.artifact("view.arena").expect("the published frame"); + assert_eq!(frame.reference().byte_length, 4 * 4 * 4); + } + } + assert_eq!(boundaries[0], (0, false), "boundary 0 has no decision or control"); + assert_eq!(boundaries[1], (1, true)); + assert_eq!(boundaries[2], (2, true)); + f.shutdown().await; +} + +/// A terminal task event is evaluated, its rewards committed once, and the session pauses at +/// that boundary before any further gameplay transition. +async fn a_terminal_episode_pauses_at_its_own_boundary(via: Via) { + let config = HarnessConfig { + terminal: fly_session::task::Terminal::AfterTransitions(3), + ..HarnessConfig::default() + }; + let mut f = fixture(via, config).await; + within("bootstrap", f.harness.coordinator.bootstrap()).await.unwrap(); + let reports = within("run", f.harness.coordinator.run(5)).await.unwrap(); + let last = reports.last().unwrap(); + assert!(last.terminal, "the counter task asked for a terminal transition"); + assert!(last.paused); + assert_eq!(f.harness.coordinator.phase(), Phase::Paused(last.boundary)); + assert!(f.harness.coordinator.episode_request().is_some()); + // No worker resets itself, and no further gameplay transition is allowed. + let err = f.harness.coordinator.step().await.expect_err("no transition after terminal"); + assert_eq!(err.error.code, ErrorCode::InvalidPhase); + f.shutdown().await; +} + +/// `Worker.Status` answers with the worker's own committed boundary and progress. +async fn status_answers_with_the_committed_boundary(via: Via) { + let mut f = default_fixture(via).await; + within("bootstrap", f.harness.coordinator.bootstrap()).await.unwrap(); + within("run", f.harness.coordinator.run(2)).await.unwrap(); + for agent in [fly_a(), fly_b()] { + let worker = f.harness.coordinator.agent_ref(&agent).cloned().unwrap(); + let status = within("status", f.harness.coordinator.status(&worker)).await.unwrap(); + assert_eq!(status.state, WorkerState::Ready); + assert_eq!(status.current_scope.unwrap().step, 2); + let before = status.progress_counter; + // A status query is not progress. + let again = within("status", f.harness.coordinator.status(&worker)).await.unwrap(); + assert_eq!(again.progress_counter, before); + } + f.shutdown().await; +} + +/// `Agent.Initialize` is allowed only on an uninitialized agent. +async fn a_worker_refuses_a_second_initialize(via: Via) { + let mut f = default_fixture(via).await; + within("bootstrap", f.harness.coordinator.bootstrap()).await.unwrap(); + let err = within("second bootstrap", f.harness.coordinator.bootstrap()) + .await + .expect_err("the environment is already initialized"); + assert_eq!(err.error.code, ErrorCode::InvalidPhase); + f.shutdown().await; +} + +// ------------------------------------------------------------------------------------------- +// Dispatch order equivalence: this one builds its own fixtures per order. + +/// `step-v1` section 8: sequential, concurrent and reversed dispatch and completion orders all +/// produce the same behaviour trace, excluding request ids and other operational metadata. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn sequential_concurrent_and_reversed_orders_agree() { + let mut behaviours: BTreeMap> = BTreeMap::new(); + for via in [Via::Memory, Via::Unix] { + for order in [ + DispatchOrder::Sequential, + DispatchOrder::Concurrent, + DispatchOrder::Reversed, + ] { + let mut config = HarnessConfig::default(); + // Deliberately unequal completion times, so a concurrent run really does finish + // out of dispatch order. + config.agents[0].faults = + AgentFaults { prepare_delay_ms: 12, commit_delay_ms: 0, ..AgentFaults::default() }; + config.agents[1].faults = + AgentFaults { prepare_delay_ms: 0, commit_delay_ms: 9, ..AgentFaults::default() }; + let mut f = fixture(via, config).await; + f.harness.coordinator.dispatch = order; + within("bootstrap", f.harness.coordinator.bootstrap()).await.unwrap(); + within("run", f.harness.coordinator.run(4)).await.unwrap(); + let behaviour = f.harness.coordinator.trace.behavior(); + assert_eq!(behaviour.len(), 4); + behaviours.insert(format!("{via:?}/{order:?}"), behaviour); + // The operational metadata is recorded but is not part of the comparison. + // The operational metadata is recorded beside the behaviour, not inside it. + let operational = &f.harness.coordinator.trace.transitions[0].operational; + assert_eq!(operational.prepare_request_ids.len(), 2); + assert_eq!(operational.commit_request_ids.len(), 2); + f.shutdown().await; + } + } + let mut iter = behaviours.iter(); + let (first_name, first) = iter.next().expect("at least one run"); + for (name, behaviour) in iter { + assert_eq!( + behaviour, first, + "{name} produced a different behaviour trace from {first_name}" + ); + } +} + +/// A one-agent composition still runs the same transaction, so the barrier is not two-agent +/// specific. +async fn a_single_agent_composition_runs_the_same_transaction(via: Via) { + let config = HarnessConfig { + agents: vec![AgentSpec { + agent_id: id("fly-a"), + port_id: id("p1"), + seed: 7, + faults: AgentFaults::default(), + }], + ..HarnessConfig::default() + }; + let mut f: Fixture = fixture(via, config).await; + within("bootstrap", f.harness.coordinator.bootstrap()).await.unwrap(); + within("run", f.harness.coordinator.run(3)).await.unwrap(); + assert_eq!(f.harness.coordinator.stats().advances, 3); + let ticks: Vec = f + .harness + .coordinator + .trace + .transitions + .iter() + .map(|t| t.behaviour.agents[0].ticks_advanced) + .collect(); + assert_eq!(ticks, vec![16, 17, 17]); + f.shutdown().await; +}