From d2b916602eca8250f82755d1f8fe7b5ab5b16899 Mon Sep 17 00:00:00 2001 From: acamilo Date: Tue, 22 Sep 2026 11:27:06 +0000 Subject: [PATCH 1/2] feat(session-types): the fly-session-types crate: domain scalars, closed enums, method payloads, canonical JSON and digests The CONTRACT-01 type definitions, committed early so the session slice can build on them while the fixtures and cross-language tests are still being written. - scalar: Scope, RationalNs (reduced, positive denominator, 0/1 zero, checked arithmetic and the step-v1 accumulator), SchemaRef, TypedValue with its 32-KiB canonical-JSON cap, and the four identities that must not be confused (bus callId, domain requestId, artifact identity, delivery/hold owner token) as distinct types. Id, U64 and Digest call into flybus::wire instead of forking the encodings. - canonical: RFC 8785 canonical JSON, SHA-256 digests, the operation key and the canonical body rules of ipc-v1 section 5, with bus identities refused in a body. - rpc, workers, media, publishing: the payloads of ipc-v1, workers-v1, state-media-v1 and publishing-v1, each with a validate step for the documented ranges, uniqueness, ordering and bounds. - schema: the canonical schema set, whose digest is contractDigest, declared as data so source formatting cannot change it. - trace: the step-v1 section 8 record, behaviour separated from operational metadata. - seed, checkpoint: the seed derivation and FLYSESS1 envelope layout. --- services/flysim/Cargo.lock | 10 + services/flysim/Cargo.toml | 8 +- .../crates/fly-session-types/Cargo.toml | 16 + .../crates/fly-session-types/src/canonical.rs | 273 ++ .../fly-session-types/src/checkpoint.rs | 368 +++ .../crates/fly-session-types/src/fixtures.rs | 108 + .../crates/fly-session-types/src/lib.rs | 56 + .../crates/fly-session-types/src/media.rs | 716 +++++ .../fly-session-types/src/publishing.rs | 450 +++ .../crates/fly-session-types/src/rpc.rs | 420 +++ .../crates/fly-session-types/src/scalar.rs | 725 +++++ .../crates/fly-session-types/src/schema.rs | 1109 ++++++++ .../crates/fly-session-types/src/seed.rs | 70 + .../crates/fly-session-types/src/trace.rs | 486 ++++ .../crates/fly-session-types/src/workers.rs | 2405 +++++++++++++++++ 15 files changed, 7219 insertions(+), 1 deletion(-) create mode 100644 services/flysim/crates/fly-session-types/Cargo.toml create mode 100644 services/flysim/crates/fly-session-types/src/canonical.rs create mode 100644 services/flysim/crates/fly-session-types/src/checkpoint.rs create mode 100644 services/flysim/crates/fly-session-types/src/fixtures.rs create mode 100644 services/flysim/crates/fly-session-types/src/lib.rs create mode 100644 services/flysim/crates/fly-session-types/src/media.rs create mode 100644 services/flysim/crates/fly-session-types/src/publishing.rs create mode 100644 services/flysim/crates/fly-session-types/src/rpc.rs create mode 100644 services/flysim/crates/fly-session-types/src/scalar.rs create mode 100644 services/flysim/crates/fly-session-types/src/schema.rs create mode 100644 services/flysim/crates/fly-session-types/src/seed.rs create mode 100644 services/flysim/crates/fly-session-types/src/trace.rs create mode 100644 services/flysim/crates/fly-session-types/src/workers.rs diff --git a/services/flysim/Cargo.lock b/services/flysim/Cargo.lock index b2a05e3..7915fa9 100644 --- a/services/flysim/Cargo.lock +++ b/services/flysim/Cargo.lock @@ -416,6 +416,16 @@ dependencies = [ "serde", ] +[[package]] +name = "fly-session-types" +version = "0.1.1" +dependencies = [ + "flybus", + "ryu-js", + "serde_json", + "sha2", +] + [[package]] name = "flybrain-core" version = "0.1.1" diff --git a/services/flysim/Cargo.toml b/services/flysim/Cargo.toml index 79b775e..68d2cdd 100644 --- a/services/flysim/Cargo.toml +++ b/services/flysim/Cargo.toml @@ -1,6 +1,12 @@ [workspace] resolver = "3" -members = ["crates/flybrain-core", "crates/flybrain-gb", "crates/flybus", "crates/flysim"] +members = [ + "crates/fly-session-types", + "crates/flybrain-core", + "crates/flybrain-gb", + "crates/flybus", + "crates/flysim", +] [workspace.package] version = "0.1.1" diff --git a/services/flysim/crates/fly-session-types/Cargo.toml b/services/flysim/crates/fly-session-types/Cargo.toml new file mode 100644 index 0000000..bcb29c8 --- /dev/null +++ b/services/flysim/crates/fly-session-types/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "fly-session-types" +version.workspace = true +edition = "2024" +rust-version.workspace = true +license.workspace = true +publish = false +description = "Session domain scalars, closed enums, method payloads, canonical JSON digests and the step trace format (session-framework CONTRACT-01)." + +[dependencies] +# The bus owns the Id/U64/Digest encodings, strict JSON and ArtifactRef; this crate reuses +# them rather than forking their semantics. +flybus = { path = "../flybus" } +ryu-js.workspace = true +serde_json = { workspace = true } +sha2 = { workspace = true } diff --git a/services/flysim/crates/fly-session-types/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() + } +} From 1652afad11335c30a0227fde5981706b895b067a Mon Sep 17 00:00:00 2001 From: acamilo Date: Tue, 22 Sep 2026 11:41:04 +0000 Subject: [PATCH 2/2] test(session-types): the CONTRACT-01 fixtures, cross-checks and their Rust tests fixtures/ is the shared contract corpus: accepted payloads with their canonical JSON and digest, refused payloads with the rule each breaks, raw byte sequences that must not even parse, recipes for the payloads too large to store, the U64 and double boundaries, rational arithmetic vectors, the identity matrix, descriptor cross-checks, operation keys and bodies, trace comparison variants, the schema set and its digest, seed vectors and one FLYSESS1 envelope. packages/session-types loads the same files. Tests: 45 across nine files. Round trips preserve every field and reproduce the recorded canonical bytes; duplicate keys, invalid UTF-8, NaN, trailing data, oversize typed values and oversize envelopes fail; U64 and double boundaries reject on the right side; the four identities never accept one another spellings; the accumulator produces 16, 17, 17 and a zero remainder; the trace comparator ignores wall time, request ids, callIds and delivery ids and notices every behaviour change; the contract digest survives reformatting and moves when a field, bound, enum member or type changes. Also: a crate README, and the update_fixtures example that regenerates the derived files. --- .../flysim/crates/fly-session-types/README.md | 103 + .../examples/update_fixtures.rs | 290 ++ .../fixtures/boundaries.json | 153 + .../fixtures/checkpoint-envelope.json | 195 + .../fixtures/contract-digest.json | 9 + .../fixtures/descriptor-checks.json | 2085 +++++++++ .../fly-session-types/fixtures/generated.json | 74 + .../fixtures/identities.json | 111 + .../fly-session-types/fixtures/invalid.json | 3900 +++++++++++++++++ .../fixtures/operations.json | 667 +++ .../fly-session-types/fixtures/rational.json | 249 ++ .../fly-session-types/fixtures/raw.json | 77 + .../fixtures/schema-set.json | 1 + .../fixtures/seed-vectors.json | 185 + .../fly-session-types/fixtures/traces.json | 1141 +++++ .../fly-session-types/fixtures/valid.json | 2787 ++++++++++++ .../fly-session-types/tests/canonical_json.rs | 192 + .../tests/checkpoint_envelope.rs | 165 + .../fly-session-types/tests/common/mod.rs | 133 + .../tests/descriptor_checks.rs | 78 + .../fly-session-types/tests/encodings.rs | 169 + .../fly-session-types/tests/payloads.rs | 177 + .../fly-session-types/tests/rational.rs | 140 + .../fly-session-types/tests/schema_set.rs | 187 + .../crates/fly-session-types/tests/seeds.rs | 120 + .../crates/fly-session-types/tests/traces.rs | 113 + 26 files changed, 13501 insertions(+) create mode 100644 services/flysim/crates/fly-session-types/README.md create mode 100644 services/flysim/crates/fly-session-types/examples/update_fixtures.rs create mode 100644 services/flysim/crates/fly-session-types/fixtures/boundaries.json create mode 100644 services/flysim/crates/fly-session-types/fixtures/checkpoint-envelope.json create mode 100644 services/flysim/crates/fly-session-types/fixtures/contract-digest.json create mode 100644 services/flysim/crates/fly-session-types/fixtures/descriptor-checks.json create mode 100644 services/flysim/crates/fly-session-types/fixtures/generated.json create mode 100644 services/flysim/crates/fly-session-types/fixtures/identities.json create mode 100644 services/flysim/crates/fly-session-types/fixtures/invalid.json create mode 100644 services/flysim/crates/fly-session-types/fixtures/operations.json create mode 100644 services/flysim/crates/fly-session-types/fixtures/rational.json create mode 100644 services/flysim/crates/fly-session-types/fixtures/raw.json create mode 100644 services/flysim/crates/fly-session-types/fixtures/schema-set.json create mode 100644 services/flysim/crates/fly-session-types/fixtures/seed-vectors.json create mode 100644 services/flysim/crates/fly-session-types/fixtures/traces.json create mode 100644 services/flysim/crates/fly-session-types/fixtures/valid.json create mode 100644 services/flysim/crates/fly-session-types/tests/canonical_json.rs create mode 100644 services/flysim/crates/fly-session-types/tests/checkpoint_envelope.rs create mode 100644 services/flysim/crates/fly-session-types/tests/common/mod.rs create mode 100644 services/flysim/crates/fly-session-types/tests/descriptor_checks.rs create mode 100644 services/flysim/crates/fly-session-types/tests/encodings.rs create mode 100644 services/flysim/crates/fly-session-types/tests/payloads.rs create mode 100644 services/flysim/crates/fly-session-types/tests/rational.rs create mode 100644 services/flysim/crates/fly-session-types/tests/schema_set.rs create mode 100644 services/flysim/crates/fly-session-types/tests/seeds.rs create mode 100644 services/flysim/crates/fly-session-types/tests/traces.rs 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/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)); +}