diff --git a/services/flysim/Cargo.lock b/services/flysim/Cargo.lock index 1d0a7cc..bb2bbe5 100644 --- a/services/flysim/Cargo.lock +++ b/services/flysim/Cargo.lock @@ -420,15 +420,23 @@ dependencies = [ name = "fly-session" version = "0.1.1" dependencies = [ + "fly-session-types", "flybus", - "ryu-js", - "serde", "serde_json", - "sha2", "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 92956b3..dec6a38 100644 --- a/services/flysim/Cargo.toml +++ b/services/flysim/Cargo.toml @@ -1,10 +1,11 @@ [workspace] resolver = "3" members = [ + "crates/fly-session", + "crates/fly-session-types", "crates/flybrain-core", "crates/flybrain-gb", "crates/flybus", - "crates/fly-session", "crates/flysim", ] 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..e211549 --- /dev/null +++ b/services/flysim/crates/fly-session-types/fixtures/boundaries.json @@ -0,0 +1,153 @@ +{ + "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": 0.1, + "canonical": "0.1", + "accept": true, + "reason": "" + }, + { + "value": 1e+21, + "canonical": "1e+21", + "accept": true, + "reason": "ECMAScript switches to exponent notation at 1e21" + }, + { + "value": 1e-07, + "canonical": "1e-7", + "accept": true, + "reason": "" + }, + { + "value": 5e-324, + "canonical": "5e-324", + "accept": true, + "reason": "the smallest subnormal double" + }, + { + "value": 1.7976931348623157e+308, + "canonical": "1.7976931348623157e+308", + "accept": true, + "reason": "the largest finite double" + }, + { + "value": 9007199254740991, + "canonical": "9007199254740991", + "accept": true, + "reason": "the largest exactly representable integer" + }, + { + "value": 9007199254740993, + "canonical": null, + "accept": false, + "reason": "past the exact integer range, canonical JSON refuses it" + }, + { + "value": 0.30000000000000004, + "canonical": "0.30000000000000004", + "accept": true, + "reason": "shortest round-tripping form, not a rounded one" + } + ] +} \ 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..f32a9ce --- /dev/null +++ b/services/flysim/crates/fly-session-types/fixtures/rational.json @@ -0,0 +1,249 @@ +{ + "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" + } + ], + "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" + } + ], + "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..8b34210 --- /dev/null +++ b/services/flysim/crates/fly-session-types/src/canonical.rs @@ -0,0 +1,273 @@ +//! 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. +//! +//! Numbers outside the exactly representable double range are refused rather than rounded: +//! every counter and clock in these contracts is a `U64` decimal string, so a JSON number +//! larger than 2^53-1 is a schema error, not something to canonicalize approximately. + +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 a finite exactly representable one. +pub fn finite_double(n: &Number) -> Option { + if let Some(u) = n.as_u64() { + return (u <= MAX_EXACT_INTEGER as u64).then_some(u as f64); + } + if let Some(i) = n.as_i64() { + return (i >= -MAX_EXACT_INTEGER).then_some(i as f64); + } + n.as_f64().filter(|v| v.is_finite()) +} + +/// `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..7bfc24d --- /dev/null +++ b/services/flysim/crates/fly-session-types/src/scalar.rs @@ -0,0 +1,725 @@ +//! 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 +} + +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(()) + } + + pub fn checked_add(&self, other: &RationalNs) -> Result { + let n = u128::from(self.numerator) * u128::from(other.denominator) + + u128::from(other.numerator) * u128::from(self.denominator); + let d = u128::from(self.denominator) * u128::from(other.denominator); + RationalNs::reduced(n, d) + } + + pub fn checked_sub(&self, other: &RationalNs) -> Result { + let left = u128::from(self.numerator) * u128::from(other.denominator); + let right = u128::from(other.numerator) * u128::from(self.denominator); + if right > left { + return err("RationalNs: subtraction would be negative"); + } + let d = u128::from(self.denominator) * u128::from(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 = u128::from(self.numerator) * u128::from(tick.denominator); + let d = u128::from(self.denominator) * u128::from(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..e73341a --- /dev/null +++ b/services/flysim/crates/fly-session-types/tests/rational.rs @@ -0,0 +1,140 @@ +//! 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 + ); +} 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..5943def --- /dev/null +++ b/services/flysim/crates/fly-session-types/tests/schema_set.rs @@ -0,0 +1,187 @@ +//! The canonical schema set, `contractDigest` and the freshness of every derived fixture. + +use fly_session_types::{canonical, fixtures, schema}; +use serde_json::Value; + +#[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"); + } +} + +#[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(); + for expected in [ + "Scope", + "RationalNs", + "TypedValue", + "SessionRpcRequest", + "SessionRpcFailure", + "PrepareParams", + "StepResult", + "ViewRef", + "AudioRef", + "CaptureResult", + "SessionDescriptor", + "CommittedSnapshot", + "TraceBehaviour", + "TraceOperational", + ] { + assert!(names.contains(&expected), "the schema set must name {expected}"); + } + 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)); +}