From 4effc6020cf784e9a8303bd63a243dcd99d7606a Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 22 Sep 2026 17:34:20 +0000 Subject: [PATCH 01/20] session: the publication boundary, observer isolation and the repair path publishing-v1 over the same bus: a declared delivery policy per topic, named publication outcomes, the bounded event batch, application-owned state and cues, a read-only descriptor query service and a fake multi-agent consumer. The session publishes the contract types rather than an ad-hoc payload, so a descriptor says what the workers attested to and a snapshot is checked against it before it is published and again when it is read: every frame comes from the boundary its declared delay implies, every handle is the artifact its reference names, and an observer's refusal takes no world step and fences no epoch. AgentInitializeResult gains graph (datasetDigest, indexDigest, neuronCount, rateRoles, supportedStimuli), without which no AgentDescriptor field in publishing-v1 section 3 had a source. Dated amendments to workers-v1 section 2, publishing-v1 section 2 and state-media-v1 section 3. --- .../design/session-framework/publishing-v1.md | 10 + .../session-framework/state-media-v1.md | 15 + docs/design/session-framework/workers-v1.md | 20 + packages/session-types/src/publishing.ts | 4 +- packages/session-types/src/workers.ts | 29 + .../fixtures/contract-digest.json | 6 +- .../fly-session-types/fixtures/invalid.json | 259 +++ .../fixtures/schema-set.json | 2 +- .../fly-session-types/fixtures/valid.json | 17 +- .../fly-session-types/src/publishing.rs | 5 +- .../crates/fly-session-types/src/schema.rs | 16 + .../crates/fly-session-types/src/workers.rs | 96 +- services/flysim/crates/fly-session/README.md | 45 + .../flysim/crates/fly-session/src/agent.rs | 59 +- services/flysim/crates/fly-session/src/cli.rs | 1 + .../crates/fly-session/src/coordinator.rs | 472 ++++-- .../flysim/crates/fly-session/src/harness.rs | 101 +- .../flysim/crates/fly-session/src/launcher.rs | 8 + services/flysim/crates/fly-session/src/lib.rs | 1 + .../flysim/crates/fly-session/src/media.rs | 12 + .../flysim/crates/fly-session/src/publish.rs | 1425 +++++++++++++++++ .../flysim/crates/fly-session/src/types.rs | 5 +- .../crates/fly-session/tests/publishing.rs | 1099 +++++++++++++ 23 files changed, 3568 insertions(+), 139 deletions(-) create mode 100644 services/flysim/crates/fly-session/src/publish.rs create mode 100644 services/flysim/crates/fly-session/tests/publishing.rs diff --git a/docs/design/session-framework/publishing-v1.md b/docs/design/session-framework/publishing-v1.md index 01cd30b..c1ebce2 100644 --- a/docs/design/session-framework/publishing-v1.md +++ b/docs/design/session-framework/publishing-v1.md @@ -42,6 +42,16 @@ Example addresses (chosen by composition, not recognized by router code): | `app.pokemon.cues` | Pub/sub: application narrative/presentation events under declared delivery policy | | `app.pokemon` | RPC: application queries/admission, e.g. restore UI state or request a supported effect | +**Amendment, 2026-09-22 (PUBLISH-01).** The repair path above needs exact methods, and +"exact methods require session API schemas" left the row unbuildable. The session registers +one **read-only** service, `session..query`, with exactly two methods, both ordinary +[session RPCs](ipc-v1.md) answering from what the session already published: +`Session.GetDescriptor` takes an optional `{revision: U64}` and returns that `SessionDescriptor` +or, with no revision, the newest; `Session.GetSnapshot` takes no parameters and returns the +latest `CommittedSnapshot`. A revision the session never published is `IDENTITY_MISMATCH`, not +an empty answer. Nothing on this service mutates, selects a participant or reaches a worker, so +it is not the controller API section 7 rules out; adding a third method that did would be. + Descriptor revisions and scope link observations to schemas. Cross-topic ordering is not guaranteed; a subscriber receiving an unknown descriptor revision must fetch it through the application/session query contract or buffer a bounded number of snapshots, not infer shape. diff --git a/docs/design/session-framework/state-media-v1.md b/docs/design/session-framework/state-media-v1.md index 2669786..3342ab8 100644 --- a/docs/design/session-framework/state-media-v1.md +++ b/docs/design/session-framework/state-media-v1.md @@ -117,6 +117,21 @@ If it violates configured resource policy, disconnect/restart that observer inst live data or silently skipping simulation input. Global store exhaustion is an explicit fault or pause condition; the router cannot guess that a particular live object is disposable. +**Amendment, 2026-09-22 (PUBLISH-01).** "Disconnect/restart that observer" names an action +no participant can take under [Flybus v1](bus-v1.md). Section 5 there makes publish admission +all or nothing -- "for a bounded subscriber overflow, reject the **whole** publish; no partial +fan-out or retained-latest update" -- and the router exposes no per-subscriber eviction, so a +session meeting a full bounded queue cannot drop that one subscriber and deliver to the rest. +The realisable reading, which the session now implements, is three-part: observation topics +are published `latest`, and a latest subscriber can never refuse a publication (it loses its +own queued value and is told how many by `replaced`); a bounded subscriber's refusal, which +`bus-v1` section 6 explicitly permits, is a named and counted publication outcome that takes +no world step, stalls nothing and fences no epoch, and the exact value stays recoverable +through the [publishing-v1](publishing-v1.md) section 2 query path; and disconnecting the +offender is an operator action against the topic the ledger names, not something the session +performs. A per-subscriber drop would need a router operation Flybus v1 does not have, and +inventing one here would be a transport change written into the wrong document. + No coordinator tracks per-reader socket acknowledgments or calls a producer's reclaim method. The SDK and bus perform that bookkeeping. File-backed immutable mappings are safe after unlink; physical pages disappear when all OS mappings close. Pooled reuse is deferred until diff --git a/docs/design/session-framework/workers-v1.md b/docs/design/session-framework/workers-v1.md index 996d5cb..9692dc6 100644 --- a/docs/design/session-framework/workers-v1.md +++ b/docs/design/session-framework/workers-v1.md @@ -96,9 +96,29 @@ interface AgentInitializeResult { warmupTicks: U64; committedStep: U64; // committedStep == "0" decisionContextDigest: Digest; telemetry: AgentTelemetry; + graph: AgentGraph; +} +interface AgentGraph { + datasetDigest: Digest; indexDigest: Digest; neuronCount: U64; + rateRoles: Id[]; // <=64, unique; AgentTelemetry.rates is in this order + supportedStimuli: Id[]; // <=64, unique; an undeclared kind is UNSUPPORTED } ``` +**Amendment, 2026-09-22 (PUBLISH-01).** `AgentInitializeResult` gains `graph`, because +[publishing-v1](publishing-v1.md) section 3 requires `datasetDigest`, `indexDigest`, +`neuronCount`, `rateRoles` and `supportedStimuli` in every published `AgentDescriptor` and no +worker method carried any of them. Without this the only available source is the composition +that asked for the agent, so a descriptor could only ever agree with itself and the section 3 +rule that "geometry/spike mapping requires indexDigest, not merely the same number of neurons" +would have nothing to compare. Initialize is where the agent has just loaded its dataset and +built its index, so the attestation belongs there. `rateRoles` is the "profile-defined order" +section 1 already requires `AgentTelemetry.rates` to be in, and the result is refused when the +two disagree; `supportedStimuli` is the profile capability section 1 already requires a +stimulus kind to resolve through, and a kind outside it is refused with `UNSUPPORTED` before +the model is touched. It changes `contractDigest`, which [session RPC](ipc-v1.md) section 4 +already provides for. + **Amendment, 2026-09-22 (SESSION-02).** `HelloResult.limits` gains `workerThreads`, an integer >=1 reporting the allocation the launcher started that worker within, because "within launcher allocation" above had no wire-level proof: the launcher passes the number to diff --git a/packages/session-types/src/publishing.ts b/packages/session-types/src/publishing.ts index 855460a..a117650 100644 --- a/packages/session-types/src/publishing.ts +++ b/packages/session-types/src/publishing.ts @@ -16,6 +16,7 @@ import { type EnvironmentDescriptor, MAX_AGENTS, MAX_RATE_ROLES, + MAX_SUPPORTED_STIMULI, type PortControl, findPort, readAgentTelemetry, @@ -26,8 +27,9 @@ import { validateTelemetryRoles, } from './workers'; +export { MAX_SUPPORTED_STIMULI } from './workers'; + /** Not stated by a document; this crate's choices, published in the schema set. */ -export const MAX_SUPPORTED_STIMULI = 64; export const MAX_ASSETS = 64; export const MAX_SNAPSHOT_EVENTS = 64; diff --git a/packages/session-types/src/workers.ts b/packages/session-types/src/workers.ts index 6f93a9d..904c5f2 100644 --- a/packages/session-types/src/workers.ts +++ b/packages/session-types/src/workers.ts @@ -35,6 +35,8 @@ import { readSchemaRef, readTypedValue, readNullableTypedValue } from './common' export const MAX_AGENTS = 4; export const MAX_PORTS = 4; export const MAX_RATE_ROLES = 64; +/** Declared stimulus kinds per agent. Not a stated bound; recorded in the schema set. */ +export const MAX_SUPPORTED_STIMULI = 64; export const MAX_STIMULI = 64; export const MAX_REWARDS = 64; export const MAX_BUTTONS = 32; @@ -257,6 +259,14 @@ export interface AgentInitializeParams { workerThreads: number; } +export interface AgentGraph { + datasetDigest: Digest; + indexDigest: Digest; + neuronCount: U64; + rateRoles: Id[]; + supportedStimuli: Id[]; +} + export interface AgentInitializeResult { agentId: Id; profileDigest: Digest; @@ -265,6 +275,7 @@ export interface AgentInitializeResult { committedStep: U64; decisionContextDigest: Digest; telemetry: AgentTelemetry; + graph: AgentGraph; } export interface PrepareParams { @@ -313,6 +324,21 @@ export function readAgentInitializeParams(value: unknown): AgentInitializeParams return params; } +export function readAgentGraph(value: unknown): AgentGraph { + const reader = new Reader(value, 'AgentGraph'); + const graph: AgentGraph = { + datasetDigest: reader.digest('datasetDigest'), + indexDigest: reader.digest('indexDigest'), + neuronCount: reader.u64('neuronCount'), + rateRoles: reader.idList('rateRoles', 0, MAX_RATE_ROLES), + supportedStimuli: reader.idList('supportedStimuli', 0, MAX_SUPPORTED_STIMULI), + }; + reader.finish(); + requireUnique(graph.rateRoles, 'AgentGraph.rateRoles'); + requireUnique(graph.supportedStimuli, 'AgentGraph.supportedStimuli'); + return graph; +} + export function readAgentInitializeResult(value: unknown): AgentInitializeResult { const reader = new Reader(value, 'AgentInitializeResult'); const result: AgentInitializeResult = { @@ -323,12 +349,15 @@ export function readAgentInitializeResult(value: unknown): AgentInitializeResult committedStep: reader.u64('committedStep'), decisionContextDigest: reader.digest('decisionContextDigest'), telemetry: readAgentTelemetry(reader.value('telemetry')), + graph: readAgentGraph(reader.value('graph')), }; reader.finish(); requirePositiveRational(result.tickDuration, 'AgentInitializeResult.tickDuration'); if (u64(result.committedStep) !== 0n) { fail('AgentInitializeResult: committedStep must be "0"'); } + // The rates a worker reports and the role order it declares are one statement. + validateTelemetryRoles(result.telemetry, result.graph.rateRoles); return result; } diff --git a/services/flysim/crates/fly-session-types/fixtures/contract-digest.json b/services/flysim/crates/fly-session-types/fixtures/contract-digest.json index 8845d36..36c1f10 100644 --- a/services/flysim/crates/fly-session-types/fixtures/contract-digest.json +++ b/services/flysim/crates/fly-session-types/fixtures/contract-digest.json @@ -1,9 +1,9 @@ { "description": "contractDigest is the SHA-256 of the canonical schema set in schema-set.json.", - "contractDigest": "d8f29a49b5df05ad8f75f7f5790a3f8cde9c5ad23a685137474c649c3c9da36d", + "contractDigest": "fa1f9671c098e0e5fe2d2ad9642d10eae39c6e751760eb1db7b51d5575898509", "schemaSetVersion": 1, - "schemaSetBytes": 26814, - "types": 53, + "schemaSetBytes": 27407, + "types": 54, "enums": 11, "limits": 26 } diff --git a/services/flysim/crates/fly-session-types/fixtures/invalid.json b/services/flysim/crates/fly-session-types/fixtures/invalid.json index cbce2b4..0b6bcff 100644 --- a/services/flysim/crates/fly-session-types/fixtures/invalid.json +++ b/services/flysim/crates/fly-session-types/fixtures/invalid.json @@ -2746,6 +2746,19 @@ "changed": "2", "signal": 0.5 } + }, + "graph": { + "datasetDigest": "6c0af1f0784ef63a393ee77d614e8246c625051360f3f1a48838374c5d355b52", + "indexDigest": "52a7a9b441f686ebea398e3d65dbaa87b4fde4f8b2231fff951700548f6a4e42", + "neuronCount": "139255", + "rateRoles": [ + "kenyon", + "mbon" + ], + "supportedStimuli": [ + "sugar", + "shock" + ] } }, "reason": "initialization establishes Ready(0)" @@ -2782,10 +2795,256 @@ "changed": "2", "signal": 0.5 } + }, + "graph": { + "datasetDigest": "6c0af1f0784ef63a393ee77d614e8246c625051360f3f1a48838374c5d355b52", + "indexDigest": "52a7a9b441f686ebea398e3d65dbaa87b4fde4f8b2231fff951700548f6a4e42", + "neuronCount": "139255", + "rateRoles": [ + "kenyon", + "mbon" + ], + "supportedStimuli": [ + "sugar", + "shock" + ] } }, "reason": "durations are positive" }, + { + "name": "agent initialize result without a graph identity", + "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 + } + } + }, + "reason": "publishing-v1 3 needs datasetDigest, indexDigest, neuronCount, rateRoles and supportedStimuli, and only the worker knows them" + }, + { + "name": "agent initialize result whose index digest is not a digest", + "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 + } + }, + "graph": { + "datasetDigest": "6c0af1f0784ef63a393ee77d614e8246c625051360f3f1a48838374c5d355b52", + "indexDigest": "not-a-digest", + "neuronCount": "139255", + "rateRoles": [ + "kenyon", + "mbon" + ], + "supportedStimuli": [ + "sugar", + "shock" + ] + } + }, + "reason": "digests are 64 lowercase hex digits" + }, + { + "name": "agent initialize result whose rates are not in the declared role order", + "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 + } + }, + "graph": { + "datasetDigest": "6c0af1f0784ef63a393ee77d614e8246c625051360f3f1a48838374c5d355b52", + "indexDigest": "52a7a9b441f686ebea398e3d65dbaa87b4fde4f8b2231fff951700548f6a4e42", + "neuronCount": "139255", + "rateRoles": [ + "mbon", + "kenyon" + ], + "supportedStimuli": [ + "sugar", + "shock" + ] + } + }, + "reason": "AgentTelemetry.rates is in graph.rateRoles order" + }, + { + "name": "agent initialize result declaring a role it reports no rate for", + "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 + } + }, + "graph": { + "datasetDigest": "6c0af1f0784ef63a393ee77d614e8246c625051360f3f1a48838374c5d355b52", + "indexDigest": "52a7a9b441f686ebea398e3d65dbaa87b4fde4f8b2231fff951700548f6a4e42", + "neuronCount": "139255", + "rateRoles": [ + "kenyon", + "mbon", + "pn" + ], + "supportedStimuli": [ + "sugar", + "shock" + ] + } + }, + "reason": "AgentTelemetry.rates is exactly graph.rateRoles" + }, + { + "name": "agent initialize result repeating a supported stimulus", + "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 + } + }, + "graph": { + "datasetDigest": "6c0af1f0784ef63a393ee77d614e8246c625051360f3f1a48838374c5d355b52", + "indexDigest": "52a7a9b441f686ebea398e3d65dbaa87b4fde4f8b2231fff951700548f6a4e42", + "neuronCount": "139255", + "rateRoles": [ + "kenyon", + "mbon" + ], + "supportedStimuli": [ + "sugar", + "sugar" + ] + } + }, + "reason": "supportedStimuli is unique" + }, { "name": "hello result for an agent without agent-step-v1", "type": "HelloResult", diff --git a/services/flysim/crates/fly-session-types/fixtures/schema-set.json b/services/flysim/crates/fly-session-types/fixtures/schema-set.json index e1a175f..6477910 100644 --- a/services/flysim/crates/fly-session-types/fixtures/schema-set.json +++ b/services/flysim/crates/fly-session-types/fixtures/schema-set.json @@ -1 +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},{"name":"maxWorkerThreads","source":"workers-v1 2","value":4096}],"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, 1..=4 ports, and the launcher allocation this worker runs in","kind":"{maxAgents:int,maxPorts:int,workerThreads: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} +{"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},{"name":"maxWorkerThreads","source":"workers-v1 2","value":4096}],"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":"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":"<= 64, unique","kind":"array","name":"supportedStimuli","required":true}],"name":"AgentGraph","source":"workers-v1 2"},{"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},{"constraint":"rates are in graph.rateRoles order","kind":"AgentGraph","name":"graph","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, 1..=4 ports, and the launcher allocation this worker runs in","kind":"{maxAgents:int,maxPorts:int,workerThreads: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/valid.json b/services/flysim/crates/fly-session-types/fixtures/valid.json index dea3a64..814d738 100644 --- a/services/flysim/crates/fly-session-types/fixtures/valid.json +++ b/services/flysim/crates/fly-session-types/fixtures/valid.json @@ -470,11 +470,24 @@ "changed": "2", "signal": 0.5 } + }, + "graph": { + "datasetDigest": "6c0af1f0784ef63a393ee77d614e8246c625051360f3f1a48838374c5d355b52", + "indexDigest": "52a7a9b441f686ebea398e3d65dbaa87b4fde4f8b2231fff951700548f6a4e42", + "neuronCount": "139255", + "rateRoles": [ + "kenyon", + "mbon" + ], + "supportedStimuli": [ + "sugar", + "shock" + ] } }, "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" + "canonical": "{\"agentId\":\"fly-a\",\"committedStep\":\"0\",\"decisionContextDigest\":\"ea7792a26f405e2ae9c6f49ca93bbe6076ceac0a1fc53d83426c7d7f2d9377e4\",\"graph\":{\"datasetDigest\":\"6c0af1f0784ef63a393ee77d614e8246c625051360f3f1a48838374c5d355b52\",\"indexDigest\":\"52a7a9b441f686ebea398e3d65dbaa87b4fde4f8b2231fff951700548f6a4e42\",\"neuronCount\":\"139255\",\"rateRoles\":[\"kenyon\",\"mbon\"],\"supportedStimuli\":[\"sugar\",\"shock\"]},\"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": "707803be4867dc2f0f3d4bccedfaa7b97b1e52b9af78d21ae6957caba1a7e3f8" }, { "name": "prepare params", diff --git a/services/flysim/crates/fly-session-types/src/publishing.rs b/services/flysim/crates/fly-session-types/src/publishing.rs index b90d8e5..562c913 100644 --- a/services/flysim/crates/fly-session-types/src/publishing.rs +++ b/services/flysim/crates/fly-session-types/src/publishing.rs @@ -15,8 +15,9 @@ 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; +/// Declared stimulus kinds per agent, re-exported from its defining module. +pub use crate::workers::MAX_SUPPORTED_STIMULI; + /// 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. diff --git a/services/flysim/crates/fly-session-types/src/schema.rs b/services/flysim/crates/fly-session-types/src/schema.rs index 6317620..44eb58f 100644 --- a/services/flysim/crates/fly-session-types/src/schema.rs +++ b/services/flysim/crates/fly-session-types/src/schema.rs @@ -438,6 +438,22 @@ pub const SCHEMAS: &[TypeSchema] = &[ req("committedStep", "U64", "\"0\""), req("decisionContextDigest", "Digest", ""), req("telemetry", "AgentTelemetry", ""), + req("graph", "AgentGraph", "rates are in graph.rateRoles order"), + ], + }, + TypeSchema { + name: "AgentGraph", + source: "workers-v1 2", + fields: &[ + req("datasetDigest", "Digest", ""), + req( + "indexDigest", + "Digest", + "geometry mapping needs this, not neuronCount", + ), + req("neuronCount", "U64", ""), + req("rateRoles", "array", "<= 64, unique"), + req("supportedStimuli", "array", "<= 64, unique"), ], }, TypeSchema { diff --git a/services/flysim/crates/fly-session-types/src/workers.rs b/services/flysim/crates/fly-session-types/src/workers.rs index 7ff4d07..9a785c9 100644 --- a/services/flysim/crates/fly-session-types/src/workers.rs +++ b/services/flysim/crates/fly-session-types/src/workers.rs @@ -19,6 +19,8 @@ pub const MAX_AGENTS: usize = 4; pub const MAX_PORTS: usize = 4; /// 64 rate roles per agent. pub const MAX_RATE_ROLES: usize = 64; +/// Declared stimulus kinds per agent. Not a stated bound; recorded in the schema set. +pub const MAX_SUPPORTED_STIMULI: 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. @@ -706,6 +708,92 @@ pub struct AgentInitializeResult { pub committed_step: u64, pub decision_context_digest: String, pub telemetry: AgentTelemetry, + /// The graph identity this agent actually loaded, which is what a descriptor publishes. + /// + /// `publishing-v1` section 3 requires `datasetDigest`, `indexDigest`, `neuronCount`, + /// `rateRoles` and `supportedStimuli` in every `AgentDescriptor`, and before the + /// 2026-09-22 amendment to `workers-v1` section 2 no worker method carried them: a + /// coordinator could only have restated its own configuration. The worker attests + /// instead, so a fly that built another index is a visible mismatch rather than a + /// descriptor that agrees with itself. + pub graph: AgentGraph, +} + +/// What one agent's loaded graph is, as the agent reports it. +/// +/// `neuronCount` does not identify a mapping: "geometry/spike mapping requires indexDigest, +/// not merely the same number of neurons" (publishing-v1 section 3), so both travel and a +/// consumer compares the digest. +#[derive(Clone, Debug, PartialEq)] +pub struct AgentGraph { + pub dataset_digest: String, + pub index_digest: String, + pub neuron_count: u64, + /// The profile-defined rate-role order. `AgentTelemetry.rates` is in exactly this order. + pub rate_roles: Vec, + pub supported_stimuli: Vec, +} + +impl AgentGraph { + pub fn from_json(value: &Value) -> Result { + let mut f = Fields::new(value, "AgentGraph")?; + let dataset_digest = f.string("datasetDigest")?.to_owned(); + let index_digest = f.string("indexDigest")?.to_owned(); + let neuron_count = f.u64_string("neuronCount")?; + let rate_roles = id_list(&mut f, "rateRoles", 0, MAX_RATE_ROLES)?; + let supported_stimuli = id_list(&mut f, "supportedStimuli", 0, MAX_SUPPORTED_STIMULI)?; + f.finish()?; + let g = AgentGraph { + dataset_digest, + index_digest, + neuron_count, + rate_roles, + supported_stimuli, + }; + g.validate()?; + Ok(g) + } + + pub fn to_json(&self) -> Value { + obj(vec![ + ("datasetDigest", self.dataset_digest.clone().into()), + ("indexDigest", self.index_digest.clone().into()), + ("neuronCount", u64_json(self.neuron_count)), + ( + "rateRoles", + Value::Array(self.rate_roles.iter().map(|r| r.clone().into()).collect()), + ), + ( + "supportedStimuli", + Value::Array( + self.supported_stimuli + .iter() + .map(|s| s.clone().into()) + .collect(), + ), + ), + ]) + } + + pub fn validate(&self) -> Result<()> { + if !is_digest(&self.dataset_digest) || !is_digest(&self.index_digest) { + return err("AgentGraph: datasetDigest and indexDigest must be 64 lowercase hex digits"); + } + if self.rate_roles.len() > MAX_RATE_ROLES { + return err("AgentGraph: at most 64 rate roles"); + } + if self.supported_stimuli.len() > MAX_SUPPORTED_STIMULI { + return err("AgentGraph: at most 64 supported stimuli"); + } + require_unique( + self.rate_roles.iter().map(String::as_str), + "AgentGraph.rateRoles", + )?; + require_unique( + self.supported_stimuli.iter().map(String::as_str), + "AgentGraph.supportedStimuli", + ) + } } impl DomainType for AgentInitializeResult { @@ -720,6 +808,7 @@ impl DomainType for AgentInitializeResult { let committed_step = f.u64_string("committedStep")?; let decision_context_digest = f.string("decisionContextDigest")?.to_owned(); let telemetry = AgentTelemetry::from_json(f.value("telemetry")?)?; + let graph = AgentGraph::from_json(f.value("graph")?)?; f.finish()?; let r = AgentInitializeResult { agent_id, @@ -729,6 +818,7 @@ impl DomainType for AgentInitializeResult { committed_step, decision_context_digest, telemetry, + graph, }; r.validate()?; Ok(r) @@ -746,6 +836,7 @@ impl DomainType for AgentInitializeResult { self.decision_context_digest.clone().into(), ), ("telemetry", self.telemetry.to_json()), + ("graph", self.graph.to_json()), ]) } @@ -762,7 +853,10 @@ impl DomainType for AgentInitializeResult { if self.committed_step != 0 { return err("AgentInitializeResult: committedStep must be \"0\""); } - self.telemetry.validate() + self.graph.validate()?; + // The rates a worker reports and the role order it declares are one statement, so a + // descriptor built from the second can never mislabel the first. + self.telemetry.validate_against_roles(&self.graph.rate_roles) } } diff --git a/services/flysim/crates/fly-session/README.md b/services/flysim/crates/fly-session/README.md index 8e114e2..c3d0ca9 100644 --- a/services/flysim/crates/fly-session/README.md +++ b/services/flysim/crates/fly-session/README.md @@ -39,6 +39,7 @@ Ready(k) ─ Prepare all agents concurrently ─────────── | `environment` | The counter arena: one complete batch per advance, one native frame | | `task` | The task and executor traits, the deterministic counter task, the identity executor | | `rpc` | Domain calls: `req-` serials, incarnation pinning, the retry rule | +| `publish` | The publication boundary: declared delivery policies, named publication outcomes, the bounded event batch, the read-only repair service, an application channel and a fake multi-agent consumer | | `coordinator` | The transaction, the trace, the failure rules and the publication boundary | | `launcher` | The supervisor: thread budget, identities, start, health check, reap | | `metrics` | Latency percentiles and the machine's core and memory counters | @@ -192,6 +193,29 @@ harness.shutdown().await; it takes -- the transition finishes, then the session pauses at the boundary it just committed -- is now written into the section 2 machine as a dated amendment. +## The publication boundary + +`publishing-v1` on the same bus, with nothing added to the router: + +| Address | Delivery | Contents | +| --- | --- | --- | +| `session..descriptor` | retained latest | `SessionDescriptor`, built from what each participant attested to | +| `session..snapshots` | retained latest | `CommittedSnapshot` plus the boundary's media handles | +| `session..events` | bounded, depth 64 | the transition's task events, with a `droppedBefore` count | +| `session..query` | RPC, read-only | `Session.GetDescriptor`, `Session.GetSnapshot` | +| `.state`, `.cues` | the application's own | whatever the experience needs, under the application's schema | + +Every publication returns a named outcome: `Accepted`, `RefusedByObserver` or `Faulted`. Only +`BACKPRESSURE` is an observer's refusal, and a refusal takes no world step, stalls nothing and +fences no epoch -- it is counted per topic in the ledger and the exact value stays readable +through the query service. Anything else is the session's own fault and fails the epoch. A +snapshot is checked before it is published and again when it is read: every frame comes from +the boundary its declared delay implies, every handle is the artifact its reference names, +audio never goes backwards, and the snapshot agrees with the descriptor revision it names. + +What is **not** here: the approved public v2 wire schemas and the stage adapters that speak +them. `implementation.md` sequences those after this slice and together with each other. + ## Limitations - **Fake workers.** There is no neural model and no emulator. What is modelled exactly is the @@ -200,6 +224,14 @@ harness.shutdown().await; STATE-01. The phase machine has their edges (`Capturing`, `Restoring`) and the workers do not advertise them as implemented methods. - **No audience input.** The admitted pre-step stimulation list exists and is always empty. +- **One descriptor revision.** A revision changes when the composition does, and the only + in-session path to that is a group restore into a fresh epoch, which is STATE-01's. The + session publishes revision 1; the repair path, the revision cache and the index-change rule + are exercised against a second revision published by a `Publisher` of a second composition. +- **No per-subscriber eviction.** A bounded subscriber may refuse a publication, and Flybus v1 + has no operation to drop that one subscriber, so the refusal costs every subscriber that + boundary's delivery on a stream whose contract is "latest". See the 2026-09-22 amendment to + `state-media-v1` section 3. - **Pacing is coarse.** The pacing deadline rounds one step to whole nanoseconds for sleeping only; simulation time stays rational and that rounding never re-enters the accumulator. @@ -259,6 +291,11 @@ The three integration suites do not all run over both transports, and cannot: - `tests/processes.rs` runs over the Unix socket only, in all three execution modes. A participant in a process of its own has no in-memory transport to reach the router by, so the mode is the axis that suite varies and the transport is fixed. +- `tests/media.rs` and `tests/publishing.rs` run over both transports, and each also generates + a subset once per execution mode. The publication boundary lives in the coordinator, so + unlike the render counter and the sensor log it crosses no process boundary and stays fully + observable in all three modes; `the_publication_boundary_holds_in_every_execution_mode` + asserts that rather than assuming it. - `tests/session.rs`: one world advance per complete batch; every agent Prepared before the advance; one task evaluation per transition; every agent committed before the next Prepare or @@ -275,6 +312,14 @@ The three integration suites do not all run over both transports, and cannot: allocation -- plus the sequential/reversed/parallel trace comparison across all three modes and the two process-mode section 4 rows: a router restart during a world advance, and an old worker's reply after a restart. +- `tests/publishing.rs`: the PUBLISH-01 acceptance bullets over both transports -- a consumer + that disconnects and one that stops consuming, a bounded observer's named refusal, every + boundary's media belonging to that boundary, a frame and a handle from another boundary + refused, an unheld revision repaired rather than inferred, a revision that was never + published, an index that moved under a mapped consumer, boundary 0's null decision, the + committed action being the transition that just ended, one snapshot carrying every agent, + application-owned state and cues, a held event batch, and the read-only query service -- + plus the first two generated once per execution mode by `all_modes!`. - `tests/failures.rs`: a duplicate Prepare after a lost reply; a duplicate Commit; the same batch with altered controls; a lost Advance result; a cached artifact consumed by its first caller; one Commit failing after another succeeded; a replaced registration; a reply from diff --git a/services/flysim/crates/fly-session/src/agent.rs b/services/flysim/crates/fly-session/src/agent.rs index a52822f..aa59bd3 100644 --- a/services/flysim/crates/fly-session/src/agent.rs +++ b/services/flysim/crates/fly-session/src/agent.rs @@ -206,6 +206,10 @@ pub struct AgentConfig { /// The thread allocation the launcher started this worker within. `workers-v1` requires /// `Agent.Initialize`'s `workerThreads` to lie inside it. pub worker_threads: usize, + /// Which graph this fly built. Two variants have the same `neuronCount` and different + /// `indexDigest`, which is the case `publishing-v1` section 3 says a consumer must not + /// mistake for the same mapping. + pub graph_variant: u64, /// Records every view this agent read, so a test can see which artifact reached it. /// /// It is this process's log: an agent with a process of its own writes to its own copy, @@ -439,6 +443,9 @@ impl FakeAgentWorker { committed_step: 0, decision_context_digest: self.context_digest.clone().expect("just set"), telemetry: self.model.telemetry(), + // The worker attests to the graph it loaded. A descriptor built from this can + // disagree with the composition; one built from the composition never could. + graph: synthetic_graph(&self.config.agent_id, self.config.graph_variant), }; Ok(HandlerReply::from(&result)) } @@ -490,6 +497,7 @@ impl FakeAgentWorker { } for stimulus in ¶ms.pre_step_stimulations { stimulus.validate().map_err(DomainError::invalid)?; + check_supported(stimulus)?; } let available = FakeAgentWorker::available_actions(self.context.as_ref().expect("initialized"))?; @@ -577,6 +585,7 @@ impl FakeAgentWorker { } for stimulus in ¶ms.task_stimulations { stimulus.validate().map_err(DomainError::invalid)?; + check_supported(stimulus)?; } params.next_decision_context.validate().map_err(DomainError::invalid)?; FakeAgentWorker::available_actions(¶ms.next_decision_context)?; @@ -697,13 +706,61 @@ pub fn agent_op_class(method: &str) -> Option { } /// A synthetic profile asset for one agent. The digest covers its effective identities. +/// Refuses a stimulus kind this profile does not resolve, before the model is touched. +/// +/// `supportedStimuli` in a published descriptor is exactly this list, so the declaration is +/// what the worker enforces rather than a label printed beside it. +fn check_supported(stimulus: &Stimulus) -> DomainResult<()> { + if SUPPORTED_STIMULI.contains(&stimulus.kind_id.as_str()) { + return Ok(()); + } + Err(DomainError::before( + ErrorCode::Unsupported, + format!( + "stimulus kind {} is not one this profile resolves", + stimulus.kind_id + ), + )) +} + +/// The rate roles this fake model reports, in the order it reports them. +pub const RATE_ROLES: [&str; 2] = ["kc", "mbon"]; + +/// The stimulus kinds this synthetic profile resolves. An undeclared kind is refused before +/// the model is touched, so `supportedStimuli` in a descriptor is what the worker enforces +/// rather than a label beside it. +pub const SUPPORTED_STIMULI: [&str; 1] = ["arena.milestone"]; + +/// This fly's graph identity. Every variant has the same neuron count and its own index, so +/// "the same number of neurons" can never be mistaken for the same mapping. +pub const NEURON_COUNT: u64 = 1024; + +pub fn synthetic_graph(agent_id: &Id, variant: u64) -> AgentGraph { + AgentGraph { + dataset_digest: digest_of_bytes( + format!("arena-dataset-v1\nvariant={variant}\n").as_bytes(), + ), + index_digest: digest_of_bytes( + format!( + "arena-index-v1\nagent={agent_id}\nvariant={variant}\nneurons={NEURON_COUNT}\n" + ) + .as_bytes(), + ), + neuron_count: NEURON_COUNT, + rate_roles: RATE_ROLES.iter().map(|r| id(r)).collect(), + supported_stimuli: SUPPORTED_STIMULI.iter().map(|s| id(s)).collect(), + } +} + pub fn synthetic_profile(agent_id: &Id, tick_duration: &RationalNs, warmup_ticks: u64) -> AssetRef { let text = format!( "arena-direct-v1\nagent={agent_id}\ntick={}/{}\nwarmup={warmup_ticks}\n", tick_duration.numerator, tick_duration.denominator ); AssetRef { - id: id("arena-direct-v1"), + // One installed asset per fly: a descriptor's `assets` are unique by id, and two + // profiles that differ in content are two assets, not one id with two digests. + id: parse_id(&format!("arena-direct-v1-{agent_id}")).expect("a prefix plus an agent id"), digest: digest_of_bytes(text.as_bytes()), byte_length: text.len() as u64, format: id("fly-profile-v1"), diff --git a/services/flysim/crates/fly-session/src/cli.rs b/services/flysim/crates/fly-session/src/cli.rs index 1f7bc68..ac87642 100644 --- a/services/flysim/crates/fly-session/src/cli.rs +++ b/services/flysim/crates/fly-session/src/cli.rs @@ -191,6 +191,7 @@ fn serve(role: &str, options: &Options) -> Result<(), String> { tick_duration: options.rational(flags::TICK_NUMERATOR, flags::TICK_DENOMINATOR)?, warmup_ticks: options.u64(flags::WARMUP_TICKS, 0)?, worker_threads: threads, + graph_variant: options.u64(flags::GRAPH_VARIANT, 0)?, // This process's own log. The supervisor reads what crosses the bus, not this. sensors: crate::media::SensorLog::new(), faults: AgentFaults { diff --git a/services/flysim/crates/fly-session/src/coordinator.rs b/services/flysim/crates/fly-session/src/coordinator.rs index e7e4bb7..aa21e8e 100644 --- a/services/flysim/crates/fly-session/src/coordinator.rs +++ b/services/flysim/crates/fly-session/src/coordinator.rs @@ -12,10 +12,11 @@ use std::collections::{BTreeMap, BTreeSet}; use std::time::{Duration, Instant}; -use serde_json::{Map, Value, json}; +use serde_json::{Map, Value}; use crate::clock::Pacing; use crate::media::{self, AudioTimelines}; +use crate::publish::PublicationOutcome; use crate::metrics::Metrics; use crate::phase::{Phase, PhaseMachine}; use crate::rpc::{self, DomainReply, Serials, WorkerRef}; @@ -54,6 +55,12 @@ pub struct Injections { pub altered_advance_controls: bool, /// Read and release the Advance result's frame, then replay the same operation. pub consume_advance_artifact_then_retry: bool, + /// Publish this boundary's snapshot naming the previous boundary's frame: new agent + /// state beside an older observation. + pub stale_published_view: bool, + /// Publish this boundary's snapshot with a handle that is not the artifact the snapshot + /// references: the same name, the same shape, another object. + pub substituted_published_handle: bool, } /// What an injection produced, for a test to assert on. @@ -171,6 +178,14 @@ impl Default for Deadlines { } } +/// The descriptor revision this slice publishes. +/// +/// A revision changes when the composition does -- a replaced fly with another index, a +/// different port assignment -- and the only in-session path to that is a group restore into +/// a fresh epoch, which is STATE-01's. So a session establishes revision 1 and the repair +/// path, not a revision counter with nothing to count. +pub const DESCRIPTOR_REVISION: u64 = 1; + /// The bus addresses this session publishes on. Chosen by the composition, not the router. #[derive(Clone, Debug)] pub struct Topics { @@ -202,6 +217,11 @@ pub struct AgentSlot { pub tick_duration: RationalNs, pub warmup_ticks: u64, pub committed_step: u64, + /// The graph this fly attested to at `Agent.Initialize`, which is what the published + /// descriptor says about it. `None` before initialization. + pub graph: Option, + /// The telemetry of the last committed boundary, which is what the snapshot publishes. + pub telemetry: Option, context: TypedValue, context_digest: Digest, prepared: Option, @@ -226,6 +246,8 @@ impl AgentSlot { tick_duration: RationalNs::ZERO, warmup_ticks: 0, committed_step: 0, + graph: None, + telemetry: None, context: TypedValue::new(crate::task::context_schema(), Value::Object(Map::new())) .expect("an empty context object is a valid typed value"), context_digest: digest_of_bytes(b""), @@ -272,6 +294,14 @@ pub struct Coordinator { media_names: Vec, serials: Serials, topics: Topics, + /// The publication boundary. Everything this session publishes goes through it, and + /// every outcome it returns is a named one. + publisher: crate::publish::Publisher, + /// The composition as published. Built once from what the live participants attested to, + /// never restated from the configuration that asked for them. + session_descriptor: Option, + /// The read-only repair service. Held so it stops with the session. + query: Option, pacing: Option, /// Set by whoever asks for a normal pause, possibly while a transition is in flight. pause: std::sync::Arc, @@ -303,6 +333,17 @@ pub struct Coordinator { started: std::time::Instant, last_advance_request: Option, last_commit_requests: Vec, + /// The previous committed boundary's broadcast references. Data only: no handle, no owner, + /// no retention, and nothing reads it but the publication fault injections. + previous_broadcast_views: Vec, +} + +/// The broadcast references of an observation, or none when there is no observation yet. +fn observation_views_of(observation: &Option) -> Vec { + observation + .as_ref() + .map(|o| o.broadcast_views.clone()) + .unwrap_or_default() } impl Coordinator { @@ -321,6 +362,7 @@ impl Coordinator { // Sorted agent-id order is the executor and control order, so it is fixed here once. agents.sort_by(|a, b| a.agent_id.cmp(&b.agent_id)); let topics = Topics::for_session(&session_id); + let publisher = crate::publish::Publisher::new(bus.clone(), &session_id, &epoch, &topics); Coordinator { bus, session_id, @@ -343,6 +385,9 @@ impl Coordinator { media::audio_attachment(crate::environment::AUDIO_STREAM_ID), ], serials: Serials::default(), + publisher, + session_descriptor: None, + query: None, topics, pacing: None, pause: std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false)), @@ -364,6 +409,7 @@ impl Coordinator { started: std::time::Instant::now(), last_advance_request: None, last_commit_requests: Vec::new(), + previous_broadcast_views: Vec::new(), } } @@ -379,6 +425,27 @@ impl Coordinator { &self.topics } + /// The composition this session published, once it has. + pub fn session_descriptor(&self) -> Option<&SessionDescriptor> { + self.session_descriptor.as_ref() + } + + /// What this session published and what became of it: accepted, refused by an observer, + /// or faulted, per topic. + pub fn ledger(&self) -> &crate::publish::Ledger { + self.publisher.ledger() + } + + /// The events the bounded batch is still holding because an observer refused them. + pub fn pending_events(&self) -> usize { + self.publisher.outbox().len() + } + + /// The read-only state the repair service answers from. + pub fn published_state(&self) -> crate::publish::SharedState { + self.publisher.state() + } + pub fn epoch(&self) -> &Id { &self.epoch } @@ -626,22 +693,12 @@ impl Coordinator { Ok(()) } + /// Declares every framework topic under the delivery policy the publisher holds. async fn declare_topics(&mut self) -> Outcome<()> { - for (name, retained) in [ - (self.topics.descriptor.clone(), flybus::Retained::Latest), - (self.topics.snapshots.clone(), flybus::Retained::Latest), - (self.topics.events.clone(), flybus::Retained::None), - ] { - self.bus.declare_topic(&name, retained).await.map_err(|e| { - let error = DomainError::new( - ErrorCode::BackendFailure, - format!("declaring {name}: {}", e.message), - MutationCertainty::None, - ); - self.fail_now(error, "declare-topic") - })?; + match self.publisher.declare().await { + Ok(()) => Ok(()), + Err(e) => Err(self.fail_now(e, "declare-topic")), } - Ok(()) } async fn initialize_environment(&mut self) -> Outcome<()> { @@ -812,6 +869,10 @@ impl Coordinator { .telemetry .validate() .map_err(|e| self.fail_now(DomainError::invalid(e), "agent-initialize"))?; + // What the fly says it built. The descriptor publishes this, so a composition that + // loaded another index is visible in the descriptor rather than only in a log line. + self.agents[index].graph = Some(result.graph.clone()); + self.agents[index].telemetry = Some(result.telemetry.clone()); self.agents[index].tick_duration = result.tick_duration; self.agents[index].warmup_ticks = result.warmup_ticks; self.agents[index].committed_step = 0; @@ -1531,6 +1592,9 @@ impl Coordinator { self.agents[index].committed_step = k + 1; } // The previous boundary's handles are no longer needed; the new ones take over. + // The references -- which are data, not ownership -- are kept for one boundary, so a + // publication fault injection can name an older frame without retaining it. + self.previous_broadcast_views = observation_views_of(&self.observation); self.views = new_views; self.audio = new_audio; self.pending_views.clear(); @@ -2445,59 +2509,98 @@ impl Coordinator { // ----------------------------------------------------------------------------------- // Publication - async fn publish( - &mut self, - topic: &str, - payload: Map, - attachments: Vec<(String, flybus::Artifact)>, - ) -> Outcome<()> { - let refs: Vec<(&str, &flybus::Artifact)> = - attachments.iter().map(|(n, a)| (n.as_str(), a)).collect(); - match self.bus.publish(topic, payload, &refs).await { - Ok(_) => Ok(()), - Err(e) => { - // A disconnected or backpressured observer never stalls the world; only a - // real resource fault reaches here, and it fails the epoch honestly. - let error = DomainError::new( - ErrorCode::BackendFailure, - format!("publishing {topic}: {}", e.message), - MutationCertainty::None, - ); - Err(self.fail_now(error, "publish")) + /// Sends one publication and turns its outcome into the session's response to it. + /// + /// An observer's refusal is counted and the world carries on: "ordinary snapshot + /// publication is latest/bounded and never waits for a spectator to consume it" + /// (publishing-v1 section 3), and `bus-v1` section 6 allows a bounded subscriber to reject + /// a publication. A session resource fault is not an observer and fails the epoch. + fn settle(&mut self, outcome: PublicationOutcome, detail: &str) -> Outcome { + match outcome.fault() { + Some(error) => Err(self.fail_now(error, detail)), + None => { + if outcome.is_refused() { + self.audit.push(format!("refused:{}", outcome.topic())); + } + Ok(outcome) } } } + /// The composition as the live participants attested to it. + /// + /// Every agent row comes from that agent's own `Agent.Initialize` reply, so a descriptor + /// can disagree with the configuration that asked for the composition. One restated from + /// the configuration never could, and `publishing-v1` section 3 needs the disagreement to + /// be visible: "geometry/spike mapping requires indexDigest, not merely the same number + /// of neurons". + fn build_descriptor(&self, revision: u64) -> DomainResult { + let environment = self.descriptor.clone().ok_or_else(|| { + DomainError::before(ErrorCode::InvalidPhase, "no environment descriptor") + })?; + let mut agents = Vec::new(); + let mut assets = Vec::new(); + for slot in &self.agents { + let graph = slot.graph.clone().ok_or_else(|| { + DomainError::before( + ErrorCode::InvalidPhase, + format!("agent {} has not attested to a graph", slot.agent_id), + ) + })?; + agents.push(AgentDescriptor { + agent_id: slot.agent_id.clone(), + port_id: slot.port_id.clone(), + profile_digest: slot.profile.digest.clone(), + dataset_digest: graph.dataset_digest, + index_digest: graph.index_digest, + neuron_count: graph.neuron_count, + rate_roles: graph.rate_roles, + supported_stimuli: graph.supported_stimuli, + }); + assets.push(slot.profile.clone()); + } + let descriptor = SessionDescriptor { + session_id: self.session_id.clone(), + revision, + composition_digest: self.composition_digest(), + environment, + task_schema: self.task.schema(), + agents, + assets, + }; + descriptor.validate().map_err(DomainError::invalid)?; + Ok(descriptor) + } + + /// Publishes the composition and starts the read-only repair service beside it. async fn publish_descriptor(&mut self) -> Outcome<()> { - let descriptor = self.descriptor.clone().expect("bootstrapped"); - let agents: Vec = self - .agents - .iter() - .map(|slot| { - json!({ - "agentId": slot.agent_id.as_str(), - "portId": slot.port_id.as_str(), - "profileDigest": slot.profile.digest.as_str(), - "tickDuration": slot.tick_duration.to_json(), - "warmupTicks": slot.warmup_ticks.to_string(), - }) - }) - .collect(); - let payload = json!({ - "sessionId": self.session_id.as_str(), - "revision": "1", - "compositionDigest": self.composition_digest().as_str(), - "schedulerId": "lockstep-v1", - "environment": descriptor.to_json(), - "taskSchema": self.task.schema().to_json(), - "agents": agents, - }); - let topic = self.topics.descriptor.clone(); - self.publish(&topic, match payload { - Value::Object(m) => m, - _ => Map::new(), - }, Vec::new()) - .await + let descriptor = match self.build_descriptor(DESCRIPTOR_REVISION) { + Ok(descriptor) => descriptor, + Err(e) => return Err(self.fail_now(e, "descriptor")), + }; + let outcome = match self.publisher.publish_descriptor(&descriptor).await { + Ok(outcome) => outcome, + Err(e) => return Err(self.fail_now(e, "descriptor")), + }; + self.settle(outcome, "descriptor")?; + self.session_descriptor = Some(descriptor); + if self.query.is_none() { + let state = self.publisher.state(); + let service = + crate::publish::QueryService::start(self.bus.clone(), &self.session_id, state) + .await + .map_err(|e| { + let error = DomainError::new( + ErrorCode::BackendFailure, + format!("registering the session query service: {}", e.message), + MutationCertainty::None, + ); + self.fail_now(error, "query-service") + })?; + self.query = Some(service); + } + self.audit.push("publish:descriptor".to_owned()); + Ok(()) } /// The composition identity: session, epoch, agents, ports and the contract revision. @@ -2517,22 +2620,15 @@ impl Coordinator { digest_of_bytes(text.as_bytes()) } + /// Offers this boundary's events to the bounded batch and publishes what it holds. async fn publish_events(&mut self, source_step: u64, events: &[TaskEvent]) -> Outcome<()> { - if events.is_empty() { - return Ok(()); + match self.publisher.publish_events(source_step, events).await { + None => Ok(()), + Some(outcome) => { + self.settle(outcome, "events")?; + Ok(()) + } } - let payload = json!({ - "sessionId": self.session_id.as_str(), - "epoch": self.epoch.as_str(), - "sourceStep": source_step.to_string(), - "events": Value::Array(events.iter().map(DomainType::to_json).collect()), - }); - let topic = self.topics.events.clone(); - self.publish(&topic, match payload { - Value::Object(m) => m, - _ => Map::new(), - }, Vec::new()) - .await } /// Publishes the committed boundary. Never an in-progress mix of new agent state and an @@ -2553,55 +2649,181 @@ impl Coordinator { "publish", )); } + let descriptor = match self.session_descriptor.clone() { + Some(descriptor) => descriptor, + None => { + return Err(self.fail_now( + DomainError::before(ErrorCode::InvalidPhase, "no descriptor was published"), + "publish", + )); + } + }; let observation = self.observation.clone().expect("bootstrapped"); - let agents: Vec = self - .agents - .iter() - .map(|slot| { - let control = controls - .iter() - .find(|c| c.port_id == slot.port_id) - .map(|c| c.to_json()); - json!({ - "agentId": slot.agent_id.as_str(), - "selectedDecision": decisions - .get(&slot.agent_id) - .map(|d| d.to_json()), - "appliedControls": control, - "committedStep": slot.committed_step.to_string(), - }) - }) - .collect(); - let payload = json!({ - "descriptorRevision": "1", - "publisherIncarnation": self.bus.info().connection_id.clone(), - "scope": self.scope(boundary).to_json(), - "episodeId": self.episode_id.as_str(), - "sequence": self.stats.publications.to_string(), - "worldTime": observation.world_time.to_json(), - "agents": agents, - "progress": self.task.progress().to_json(), - "media": json!({ - "views": Value::Array(observation.broadcast_views.iter().map(DomainType::to_json).collect()), - "audio": Value::Array(observation.audio.iter().map(DomainType::to_json).collect()), - }), - "eventIds": event_ids.iter().map(Id::as_str).collect::>(), - }); - // The same owned handles the agents were given, published once for presentation. - let mut attachments = self.view_attachments(); - attachments.extend(self.audio.iter().map(|(n, a)| (n.clone(), a.clone()))); - let topic = self.topics.snapshots.clone(); - self.publish( - &topic, - match payload { - Value::Object(m) => m, - _ => Map::new(), - }, - attachments, - ) - .await?; - self.stats.publications += 1; - self.audit.push(format!("publish:{boundary}")); + let mut agents = Vec::new(); + for slot in &self.agents { + if slot.committed_step != boundary { + // A snapshot names one boundary. An agent that is not at it would be future + // state beside this world, which is the thing this check exists to refuse. + return Err(self.fail_now( + DomainError::before( + ErrorCode::IdentityMismatch, + format!( + "agent {} is committed at {} and the snapshot is boundary {boundary}", + slot.agent_id, slot.committed_step + ), + ), + "publish", + )); + } + let telemetry = match slot.telemetry.clone() { + Some(telemetry) => telemetry, + None => { + return Err(self.fail_now( + DomainError::before( + ErrorCode::InvalidPhase, + format!("agent {} reported no telemetry", slot.agent_id), + ), + "publish", + )); + } + }; + agents.push(SnapshotAgent { + agent_id: slot.agent_id.clone(), + telemetry, + // "Decisions/controls describe the transition ending at that boundary, null at + // initial boundary 0." These are the decisions of the transition that ended + // here, never the ones prepared for the transition about to start. + selected_decision: decisions.get(&slot.agent_id).cloned(), + applied_controls: controls.iter().find(|c| c.port_id == slot.port_id).cloned(), + }); + } + let mut views = observation.broadcast_views.clone(); + if self.injections.stale_published_view && self.injections.at_step + 1 == boundary { + // The injection of "new agent state with old media": the agents are at this + // boundary and the frame is the previous one's. + let stale = self.previous_broadcast_views.clone(); + if stale.is_empty() { + return Err(self.fail_now( + DomainError::invalid("no previous boundary to take a stale view from"), + "publish", + )); + } + views = stale; + self.injection_log.push(InjectionOutcome { + what: "stale-published-view".to_owned(), + code: None, + identical: false, + }); + } + let snapshot = CommittedSnapshot { + descriptor_revision: descriptor.revision, + publisher_incarnation: self.publisher.incarnation(), + scope: self.scope(boundary), + episode_id: self.episode_id.clone(), + sequence: self.publisher.sequence(), + world_time: observation.world_time, + agents, + progress: self.task.progress(), + views, + audio: observation.audio.clone(), + event_ids: event_ids.to_vec(), + }; + // The same owned handles the agents were given, published once for presentation. A + // referenced frame with no handle, or a handle that is another boundary's object, is + // refused by `check_publication` before anything reaches a subscriber. + let mut attachments = Vec::new(); + for view in &snapshot.views { + let name = media::view_attachment(&view.view_id); + match self.views.get(&name) { + Some(artifact) => attachments.push((name, artifact.clone())), + None => { + return Err(self.fail_now( + DomainError::new( + ErrorCode::BufferInvalid, + format!("this boundary holds no handle for view {}", view.view_id), + MutationCertainty::None, + ), + "publish", + )); + } + } + } + for chunk in &snapshot.audio { + let name = media::audio_attachment(&chunk.stream_id); + match self.audio.get(&name) { + Some(artifact) => attachments.push((name, artifact.clone())), + None => { + return Err(self.fail_now( + DomainError::new( + ErrorCode::BufferInvalid, + format!( + "this boundary holds no handle for stream {}", + chunk.stream_id + ), + MutationCertainty::None, + ), + "publish", + )); + } + } + } + if self.injections.substituted_published_handle && self.injections.at_step + 1 == boundary { + // The same attachment name and the same bytes, a different object. Only the + // artifact identity sees it, which is why the check compares that and not names. + let (name, artifact) = match attachments.first() { + Some(first) => first.clone(), + None => { + return Err(self.fail_now( + DomainError::invalid("no attachment to substitute"), + "publish", + )); + } + }; + let bytes = match artifact.read_all().await { + Ok(bytes) => bytes, + Err(e) => { + return Err(self.fail_now( + DomainError::new( + ErrorCode::BufferInvalid, + e.message, + MutationCertainty::None, + ), + "publish", + )); + } + }; + let copy = match media::seal_copy( + &self.bus, + artifact.reference().content_type.clone(), + &bytes, + ) + .await + { + Ok(copy) => copy, + Err(e) => return Err(self.fail_now(e, "publish")), + }; + attachments[0] = (name, copy); + self.injection_log.push(InjectionOutcome { + what: "substituted-published-handle".to_owned(), + code: None, + identical: false, + }); + } + let positions = self.timelines.positions(); + let outcome = match self + .publisher + .publish_snapshot(&descriptor, &snapshot, &attachments, &positions) + .await + { + Ok(outcome) => outcome, + Err(e) => return Err(self.fail_now(e, "publish")), + }; + let outcome = self.settle(outcome, "publish")?; + if outcome.is_accepted() { + self.stats.publications += 1; + self.audit.push(format!("publish:{boundary}")); + } Ok(()) } } + diff --git a/services/flysim/crates/fly-session/src/harness.rs b/services/flysim/crates/fly-session/src/harness.rs index c537a9b..938557c 100644 --- a/services/flysim/crates/fly-session/src/harness.rs +++ b/services/flysim/crates/fly-session/src/harness.rs @@ -46,6 +46,10 @@ pub struct AgentSpec { /// The threads this agent asks the launcher for. `Agent.Initialize` carries exactly what /// the launcher allocated, which `workers-v1` requires it to lie within. pub worker_threads: usize, + /// Which graph this fly builds. A replacement worker started on another variant has the + /// same neuron count and another `indexDigest`, which is the composition change a + /// descriptor revision exists to make visible. + pub graph_variant: u64, } impl AgentSpec { @@ -57,6 +61,7 @@ impl AgentSpec { seed, faults: AgentFaults::default(), worker_threads: 1, + graph_variant: 0, } } } @@ -143,6 +148,14 @@ fn agent_client(agent_id: &Id) -> String { format!("worker-{agent_id}") } +/// What a presentation consumer may do: subscribe, and ask the read-only repair service. +fn consumer_grants() -> Grants { + grants(|g| { + g.subscribe = vec![Pattern::prefix("session."), Pattern::prefix("app.")]; + g.call = vec![Pattern::prefix("session.")]; + }) +} + fn grants(f: impl FnOnce(&mut Grants)) -> Grants { let mut g = Grants::default(); f(&mut g); @@ -172,6 +185,8 @@ pub struct SessionHarness { /// The supervisor. It owns every participant's lifetime and thread allocation. pub launcher: Launcher, observers: Mutex>, + /// Which configured observer identity the next consumer takes. + next_observer: std::sync::atomic::AtomicUsize, } impl SessionHarness { @@ -196,6 +211,10 @@ impl SessionHarness { g.call = vec![Pattern::prefix("agent."), Pattern::prefix("env.")]; g.publish = vec![Pattern::prefix("session.")]; g.manage_topics = vec![Pattern::prefix("session.")]; + // The read-only repair service of publishing-v1 section 2. It is the + // session's own address and answers two queries; naming it is not + // authority over anything, and no method on it mutates. + g.register = vec![Pattern::prefix("session.")]; }), ) .client( @@ -209,7 +228,34 @@ impl SessionHarness { &format!("{ENV_CLIENT}-r2"), grants(|g| g.register = vec![Pattern::exact(ENV_SERVICE)]), ) - .client("observer", grants(|g| g.subscribe = vec![Pattern::prefix("session.")])); + // A presentation consumer subscribes and may call the repair service. It can + // publish nothing, register nothing and reach no worker: "viewers/browser clients + // never obtain worker control" (publishing-v1 section 7). A bus client id is one + // connection, so a composition with several consumers configures several of them; + // they are the same grants, because a second viewer is not a more privileged one. + .client("observer", consumer_grants()) + .client("observer-2", consumer_grants()) + .client("observer-3", consumer_grants()) + .client("observer-4", consumer_grants()) + // The application's own publisher. Its addresses are its own, and it has no + // reach into the session's. + .client( + "application", + grants(|g| { + g.publish = vec![Pattern::prefix("app.")]; + g.manage_topics = vec![Pattern::prefix("app.")]; + g.subscribe = vec![Pattern::prefix("session.")]; + }), + ) + // The publication boundary, when a composition places it on a client of its own + // rather than on the coordinator's. + .client( + "publisher", + grants(|g| { + g.publish = vec![Pattern::prefix("session.")]; + g.manage_topics = vec![Pattern::prefix("session.")]; + }), + ); for spec in &config.agents { let service = agent_service(&spec.agent_id); policy = policy.client( @@ -276,6 +322,7 @@ impl SessionHarness { tick_duration, warmup_ticks: config.warmup_ticks, worker_threads: spec.worker_threads, + graph_variant: spec.graph_variant, sensors: sensors[&spec.agent_id].clone(), faults: spec.faults.clone(), client_id: agent_client(&spec.agent_id), @@ -326,6 +373,7 @@ impl SessionHarness { sensors, launcher, observers: Mutex::new(Vec::new()), + next_observer: std::sync::atomic::AtomicUsize::new(0), }) } @@ -355,17 +403,65 @@ impl SessionHarness { } /// An extra subscriber, for a test that watches the published boundaries. + /// + /// Each call takes the next configured observer identity: one bus client id is one + /// connection, so two consumers are two configured participants and not one identity + /// used twice. pub async fn observer(&self) -> Result { - let client = self.launcher.connect("observer").await?; + let index = self + .next_observer + .fetch_add(1, std::sync::atomic::Ordering::SeqCst); + let id = match index { + 0 => "observer".to_owned(), + n => format!("observer-{}", n + 1), + }; + let client = self.launcher.connect(&id).await?; self.observers.lock().expect("not poisoned").push(client.clone()); Ok(client) } + /// A client for the application that owns its own state and cues. + pub async fn application(&self) -> Result { + let client = self.launcher.connect("application").await?; + self.observers.lock().expect("not poisoned").push(client.clone()); + Ok(client) + } + + /// A client for a publication boundary of its own. + pub async fn publisher(&self) -> Result { + let client = self.launcher.connect("publisher").await?; + self.observers.lock().expect("not poisoned").push(client.clone()); + Ok(client) + } + + /// A fake multi-agent presentation consumer attached to this session's topics. + pub async fn consumer(&self) -> Result { + let client = self.observer().await?; + crate::publish::PresentationConsumer::attach( + client, + &self.config.session_id, + self.coordinator.topics(), + ) + .await + } + /// Replaces one agent's worker with a fresh incarnation, as a restore would. /// /// The coordinator still pins the old registration, so its next call to that agent fails /// rather than silently reaching another brain. pub async fn restart_agent(&mut self, agent_id: &Id) -> Result { + self.restart_agent_on_graph(agent_id, None).await + } + + /// Replaces one agent's worker, optionally with a fly that built another graph. + /// + /// `Some(variant)` is the composition change a descriptor revision exists for: the same + /// neuron count, another `indexDigest`. + pub async fn restart_agent_on_graph( + &mut self, + agent_id: &Id, + graph_variant: Option, + ) -> Result { let spec = self .config .agents @@ -386,6 +482,7 @@ impl SessionHarness { tick_duration, warmup_ticks: self.config.warmup_ticks, worker_threads: spec.worker_threads, + graph_variant: graph_variant.unwrap_or(spec.graph_variant), // The same log: a replacement worker in this process keeps writing where its // predecessor wrote, so a restore's sensory input is visible beside it. sensors: self.sensors.get(agent_id).cloned().unwrap_or_default(), diff --git a/services/flysim/crates/fly-session/src/launcher.rs b/services/flysim/crates/fly-session/src/launcher.rs index c80551e..0f8469c 100644 --- a/services/flysim/crates/fly-session/src/launcher.rs +++ b/services/flysim/crates/fly-session/src/launcher.rs @@ -231,6 +231,9 @@ pub struct AgentLaunch { /// gets a fresh log in that process, which the supervisor cannot read. pub sensors: crate::media::SensorLog, pub faults: AgentFaults, + /// Which graph this fly builds. Crosses a process boundary as argv, like every other + /// thing a worker is started with. + pub graph_variant: u64, /// The configured client id. A replacement worker connects under its own. pub client_id: String, pub service: String, @@ -1231,6 +1234,7 @@ pub(crate) mod flags { pub const PREPARE_DELAY_MS: &str = "prepare-delay-ms"; pub const COMMIT_DELAY_MS: &str = "commit-delay-ms"; pub const FAIL_COMMIT_AT_STEP: &str = "fail-commit-at-step"; + pub const GRAPH_VARIANT: &str = "graph-variant"; pub const WORKER: &str = "worker"; pub const PORTS: &str = "ports"; @@ -1271,6 +1275,7 @@ pub(crate) mod flags { PREPARE_DELAY_MS, COMMIT_DELAY_MS, FAIL_COMMIT_AT_STEP, + GRAPH_VARIANT, ]; /// What only the environment is given, media options included. pub const ENVIRONMENT_ONLY: &[&str] = &[ @@ -1327,6 +1332,7 @@ impl Started { arg(flags::WARMUP_TICKS, spec.warmup_ticks), arg(flags::PREPARE_DELAY_MS, spec.faults.prepare_delay_ms), arg(flags::COMMIT_DELAY_MS, spec.faults.commit_delay_ms), + arg(flags::GRAPH_VARIANT, spec.graph_variant), ]; if let Some(step) = spec.faults.fail_commit_at_step { args.push(arg(flags::FAIL_COMMIT_AT_STEP, step)); @@ -1377,6 +1383,7 @@ pub(crate) fn agent_config(spec: &AgentLaunch, worker_threads: usize) -> AgentCo tick_duration: spec.tick_duration, warmup_ticks: spec.warmup_ticks, worker_threads, + graph_variant: spec.graph_variant, sensors: spec.sensors.clone(), faults: spec.faults.clone(), } @@ -1486,6 +1493,7 @@ mod flag_tests { tick_duration: RationalNs::new(1, 1_000).expect("a tick"), warmup_ticks: 10, worker_threads: 1, + graph_variant: 3, sensors: crate::media::SensorLog::new(), faults: AgentFaults { fail_commit_at_step: Some(2), diff --git a/services/flysim/crates/fly-session/src/lib.rs b/services/flysim/crates/fly-session/src/lib.rs index 15722b5..312b53d 100644 --- a/services/flysim/crates/fly-session/src/lib.rs +++ b/services/flysim/crates/fly-session/src/lib.rs @@ -33,6 +33,7 @@ pub mod measure; pub mod media; pub mod metrics; pub mod phase; +pub mod publish; pub mod rpc; pub mod task; pub mod worker; diff --git a/services/flysim/crates/fly-session/src/media.rs b/services/flysim/crates/fly-session/src/media.rs index 870ead9..97af979 100644 --- a/services/flysim/crates/fly-session/src/media.rs +++ b/services/flysim/crates/fly-session/src/media.rs @@ -345,6 +345,18 @@ impl AudioSource { } } +/// Allocates, writes and seals one immutable artifact of an arbitrary content type. +/// +/// Used where a test needs a second object with the same bytes, so that "the handle is not +/// the artifact the payload names" can be produced without corrupting the bytes. +pub async fn seal_copy( + client: &flybus::Client, + content_type: String, + bytes: &[u8], +) -> DomainResult { + seal(client, &content_type, bytes).await +} + /// Allocates, writes and seals one immutable artifact. async fn seal( client: &flybus::Client, diff --git a/services/flysim/crates/fly-session/src/publish.rs b/services/flysim/crates/fly-session/src/publish.rs new file mode 100644 index 0000000..485dff7 --- /dev/null +++ b/services/flysim/crates/fly-session/src/publish.rs @@ -0,0 +1,1425 @@ +//! The publication boundary of `publishing-v1`: what leaves the session, and what a refusal is. +//! +//! This is the PUBLISH-01 slice. It is an *internal* boundary on the *same* bus: there is no +//! second transport, no gateway process, no codec and no show or tournament service. What it +//! adds is the separation the contract asks for: +//! +//! ```text +//! session..descriptor retained latest framework: what this composition is +//! session..snapshots retained latest framework: the values of one committed boundary +//! session..events bounded framework: scoped domain events, not a log +//! .state retained latest application-owned, application-shaped +//! .cues bounded application-owned, under a declared policy +//! session..query RPC, read-only the repair path for a missed descriptor +//! ``` +//! +//! Three rules decide everything below. +//! +//! 1. **A publication outcome is named.** [`PublicationOutcome`] is `Accepted`, +//! `RefusedByObserver` or `Faulted`; nothing is dropped, retried or defaulted silently. +//! `bus-v1` section 6 says plainly that "bounded event subscriptions can reject publication; +//! latest spectator subscriptions cannot hold a required session transaction indefinitely", +//! so a refusal is a thing the contract expects and this module counts, not an error to +//! swallow. Only `BACKPRESSURE` is an observer's refusal. A store quota, a lost router or an +//! unreadable payload is the session's own fault and fails the epoch. +//! 2. **An observer never moves the world.** Publication happens after the committed boundary +//! is established. A refusal changes no phase, takes no step and releases no handle, and a +//! latest observation topic supersedes the refused value at the next boundary, so a slow or +//! bounded observer costs its own delivery and one boundary of a stream whose contract is +//! "latest" -- never a tick, never a stall. The repair path recovers the exact value. +//! 3. **Agent state and media are one statement.** [`check_publication`] refuses a snapshot +//! whose media does not belong to the boundary its agent state belongs to, before anything +//! is published, so "future agent state with old media" is a failure rather than a frame. +//! +//! What this module deliberately does **not** contain: the approved public v2 wire schemas and +//! the stage adapters that speak them. `implementation.md` sequences those after this slice and +//! together with each other, and inventing a public byte format here would be exactly the +//! unapproved contract that ordering exists to prevent. + +use std::collections::{BTreeMap, VecDeque}; +use std::sync::{Arc, Mutex}; + +use serde_json::{Map, Value, json}; + +// `crate::types` is this crate's facade over the shared `fly-session-types` crate; the glob +// keeps the contract's own names in sight instead of restating them. +use crate::types::*; + +/// How many events one bounded batch may hold before the oldest are explicitly dropped. +pub const EVENT_BATCH_DEPTH: usize = 64; + +/// The in-flight credits a framework consumer takes on an observation topic. +pub const SPECTATOR_CREDITS: u32 = 2; + +// ---------------------------------------------------------------------------------------------- +// Policy + +/// The delivery a topic is published under. Declared once, never inferred per message. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum Delivery { + /// One replaceable value. A consumer that falls behind loses intermediate values and + /// nothing else; that loss is the contract of the topic, not an accident of load. + LatestValue, + /// A bounded batch. Nothing is coalesced: when the batch is full the oldest entries are + /// dropped by an explicit count that travels with the next accepted batch. + BoundedBatch { depth: usize }, +} + +impl Delivery { + /// Whether the router retains the last value for a late subscriber. + /// + /// A latest observation is retained, because a consumer that arrives mid-session must be + /// able to start; a bounded event stream is not, because it is "not a durable log" + /// (publishing-v1 section 2) and a retained tail would look like one. + pub fn retained(self) -> flybus::Retained { + match self { + Delivery::LatestValue => flybus::Retained::Latest, + Delivery::BoundedBatch { .. } => flybus::Retained::None, + } + } + + /// The subscription a consumer of this topic takes. + pub fn subscription(self) -> flybus::SubscriptionConfig { + match self { + Delivery::LatestValue => flybus::SubscriptionConfig::latest() + .in_flight(SPECTATOR_CREDITS) + .replay(true), + Delivery::BoundedBatch { depth } => { + flybus::SubscriptionConfig::bounded().queued(depth as u32) + } + } + } +} + +/// One published address and the delivery it was declared under. +#[derive(Clone, Debug)] +pub struct TopicPolicy { + pub topic: String, + pub delivery: Delivery, +} + +impl TopicPolicy { + pub fn latest(topic: impl Into) -> TopicPolicy { + TopicPolicy { + topic: topic.into(), + delivery: Delivery::LatestValue, + } + } + + pub fn bounded(topic: impl Into, depth: usize) -> TopicPolicy { + TopicPolicy { + topic: topic.into(), + delivery: Delivery::BoundedBatch { depth }, + } + } +} + +// ---------------------------------------------------------------------------------------------- +// Outcomes + +/// What became of one publication. Every path through [`Publisher`] returns one of these. +#[derive(Clone, Debug, PartialEq)] +pub enum PublicationOutcome { + /// The router admitted it. `replaced` counts the queued values it coalesced away, which + /// is the only loss a latest subscriber can suffer and is reported, not hidden. + Accepted { + topic: String, + topic_sequence: u64, + subscribers: u64, + replaced: u64, + }, + /// A subscriber refused it. `bus-v1` section 5 rejects the whole publish for a bounded + /// subscriber's full queue, so this is an observer's doing: the world is untouched, the + /// value is recoverable through the query service, and the offender is named. + RefusedByObserver { topic: String, detail: String }, + /// The session's own resource or identity fault. This one fails the epoch. + Faulted { topic: String, detail: String }, +} + +impl PublicationOutcome { + pub fn topic(&self) -> &str { + match self { + PublicationOutcome::Accepted { topic, .. } + | PublicationOutcome::RefusedByObserver { topic, .. } + | PublicationOutcome::Faulted { topic, .. } => topic, + } + } + + pub fn is_accepted(&self) -> bool { + matches!(self, PublicationOutcome::Accepted { .. }) + } + + pub fn is_refused(&self) -> bool { + matches!(self, PublicationOutcome::RefusedByObserver { .. }) + } + + /// The domain error a *fault* carries, or `None` for an accepted or refused publication. + /// + /// The certainty is `none`: publication happens after the boundary is committed and + /// mutates no participant, so a failed publish has changed nothing in the world. + pub fn fault(&self) -> Option { + match self { + PublicationOutcome::Faulted { topic, detail } => Some(DomainError::new( + ErrorCode::BackendFailure, + format!("publishing {topic}: {detail}"), + MutationCertainty::None, + )), + _ => None, + } + } + + fn from_bus(topic: &str, result: Result) -> Self { + match result { + Ok(receipt) => PublicationOutcome::Accepted { + topic: topic.to_owned(), + topic_sequence: receipt.topic_sequence, + subscribers: receipt.subscribers, + replaced: receipt.replaced, + }, + // The one code an observer can cause. Everything else is ours. + Err(e) if e.code == flybus::ErrorCode::Backpressure => { + PublicationOutcome::RefusedByObserver { + topic: topic.to_owned(), + detail: e.message, + } + } + Err(e) => PublicationOutcome::Faulted { + topic: topic.to_owned(), + detail: format!("{:?}: {}", e.code, e.message), + }, + } + } +} + +/// Per-topic publication counters, for assertions and for an operator. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct TopicCounters { + pub accepted: u64, + pub refused: u64, + pub faulted: u64, + /// Values a latest subscriber's queue coalesced away, as the router reported them. + pub replaced: u64, +} + +/// What this session published and what happened to it. +#[derive(Clone, Debug, Default)] +pub struct Ledger { + topics: BTreeMap, + /// Events dropped from the bounded batch because the batch was full, cumulative. + pub events_dropped: u64, + /// Batches held for a later boundary because an observer refused them. + pub events_held: u64, + last: Option, +} + +impl Ledger { + pub fn counters(&self, topic: &str) -> TopicCounters { + self.topics.get(topic).cloned().unwrap_or_default() + } + + pub fn last(&self) -> Option<&PublicationOutcome> { + self.last.as_ref() + } + + /// Every refusal this session has met, by topic. + pub fn refusals(&self) -> u64 { + self.topics.values().map(|c| c.refused).sum() + } + + fn record(&mut self, outcome: &PublicationOutcome) { + let entry = self.topics.entry(outcome.topic().to_owned()).or_default(); + match outcome { + PublicationOutcome::Accepted { replaced, .. } => { + entry.accepted += 1; + entry.replaced += replaced; + } + PublicationOutcome::RefusedByObserver { .. } => entry.refused += 1, + PublicationOutcome::Faulted { .. } => entry.faulted += 1, + } + self.last = Some(outcome.clone()); + } +} + +// ---------------------------------------------------------------------------------------------- +// Published state and the repair path + +/// Everything the query service can answer from: the descriptor revisions this session has +/// published, and the latest committed snapshot. +/// +/// "Latest retained descriptors accelerate startup; RPC querying remains the repair path" +/// (publishing-v1 section 2). A consumer that meets a revision it does not hold asks here +/// instead of guessing the shape of the values it is reading. +#[derive(Clone, Debug, Default)] +pub struct PublishedState { + descriptors: BTreeMap, + latest: Option, +} + +impl PublishedState { + pub fn descriptor(&self, revision: u64) -> Option<&SessionDescriptor> { + self.descriptors.get(&revision) + } + + pub fn newest_descriptor(&self) -> Option<&SessionDescriptor> { + self.descriptors.values().next_back() + } + + pub fn latest_snapshot(&self) -> Option<&CommittedSnapshot> { + self.latest.as_ref() + } + + pub fn revisions(&self) -> Vec { + self.descriptors.keys().copied().collect() + } +} + +/// The shared handle the publisher writes and the query service reads. +pub type SharedState = Arc>; + +fn lock(state: &SharedState) -> std::sync::MutexGuard<'_, PublishedState> { + state + .lock() + .expect("the published state is never held across a panic") +} + +// ---------------------------------------------------------------------------------------------- +// The bounded event batch + +/// The pending event batch of `publishing-v1` section 6: bounded, explicit about loss. +/// +/// Events are "scoped domain events; not a durable log" (section 2), and "bus publish +/// acceptance and delivery consumption are not durable acknowledgments" (section 6). So this +/// keeps a bounded batch and says exactly what it could not keep: `droppedBefore` travels with +/// the next accepted batch, so a subscriber reads a count rather than inferring a gap. What it +/// never does is grow without bound, retry forever or forget quietly. +#[derive(Clone, Debug, Default)] +pub struct EventOutbox { + depth: usize, + pending: VecDeque<(u64, TaskEvent)>, + dropped_since_accepted: u64, +} + +impl EventOutbox { + pub fn new(depth: usize) -> EventOutbox { + EventOutbox { + depth, + pending: VecDeque::new(), + dropped_since_accepted: 0, + } + } + + /// Adds this boundary's events, dropping the oldest if the batch is over its depth. + /// Returns how many were dropped by this call. + pub fn offer(&mut self, source_step: u64, events: &[TaskEvent]) -> u64 { + for event in events { + self.pending.push_back((source_step, event.clone())); + } + let mut dropped = 0; + while self.pending.len() > self.depth { + self.pending.pop_front(); + dropped += 1; + } + self.dropped_since_accepted += dropped; + dropped + } + + pub fn is_empty(&self) -> bool { + self.pending.is_empty() + } + + pub fn len(&self) -> usize { + self.pending.len() + } + + pub fn dropped_since_accepted(&self) -> u64 { + self.dropped_since_accepted + } + + fn payload(&self, session_id: &Id, epoch: &Id) -> Map { + let events: Vec = self + .pending + .iter() + .map(|(source_step, event)| { + let mut value = event.to_json(); + if let Value::Object(map) = &mut value { + map.insert("sourceStep".to_owned(), source_step.to_string().into()); + } + value + }) + .collect(); + object(json!({ + "sessionId": session_id.as_str(), + "epoch": epoch.as_str(), + "droppedBefore": self.dropped_since_accepted.to_string(), + "events": Value::Array(events), + })) + } + + fn accepted(&mut self) { + self.pending.clear(); + self.dropped_since_accepted = 0; + } +} + +// ---------------------------------------------------------------------------------------------- +// Coherence + +/// Refuses a snapshot whose media does not belong to the boundary its agent state belongs to. +/// +/// This is the "future agent state is never mixed with old media" rule, checked on the way out +/// and again on the way in. Four things must agree: +/// +/// 1. Each published view's `producedStep` is exactly what its declared delay implies for this +/// boundary. A frame from an older boundary is a refusal, not a substitution. +/// 2. Each published audio chunk's sample range ends where the stream's next chunk begins, so +/// a chunk from a previous transition cannot ride along under a new boundary. +/// 3. Every referenced view and chunk has its attachment, and no attachment is present that +/// the payload does not reference. An extra handle is an old boundary's frame. +/// 4. The snapshot agrees with the descriptor it names: revision, agent set, port assignment, +/// rate roles. +pub fn check_publication( + descriptor: &SessionDescriptor, + snapshot: &CommittedSnapshot, + attachments: &[(String, ArtifactRef)], + audio_next_sample: &BTreeMap, +) -> DomainResult<()> { + snapshot + .validate_against(descriptor) + .map_err(|e| coherence(format!("snapshot and descriptor disagree: {e}")))?; + check_views(descriptor, snapshot)?; + check_attachments(snapshot, attachments)?; + for chunk in &snapshot.audio { + let end = chunk + .first_sample + .checked_add(chunk.sample_frames) + .ok_or_else(|| { + coherence(format!( + "audio stream {} overflows its sample position", + chunk.stream_id + )) + })?; + match audio_next_sample.get(&chunk.stream_id) { + Some(next) if *next == end => {} + Some(next) => { + return Err(coherence(format!( + "audio stream {} covers samples {}..{end} and this boundary ends at {next}", + chunk.stream_id, chunk.first_sample + ))); + } + None => { + return Err(coherence(format!( + "audio stream {} has no accepted position at this boundary", + chunk.stream_id + ))); + } + } + } + Ok(()) +} + +/// Every published view comes from exactly the boundary its declared delay implies. +pub fn check_views( + descriptor: &SessionDescriptor, + snapshot: &CommittedSnapshot, +) -> DomainResult<()> { + let boundary = snapshot.scope.step; + for view in &snapshot.views { + let declared = descriptor + .environment + .views + .iter() + .find(|v| v.view_id == view.view_id) + .ok_or_else(|| coherence(format!("view {} is not declared", view.view_id)))?; + let want = declared.required_produced_step(boundary); + if view.produced_step != want { + return Err(coherence(format!( + "view {} at boundary {boundary} was produced at {}, and its declared delay of {} requires {want}", + view.view_id, view.produced_step, declared.observation_delay_steps + ))); + } + } + Ok(()) +} + +/// The handles and the references are the same media: no missing frame, no extra one, and +/// each handle is the artifact its reference names. +/// +/// The identity comparison is the half that matters: an attachment set that matches by *name* +/// while one handle is the previous boundary's object is exactly "old media under new agent +/// state", and only the `ArtifactRef` sees it. +pub fn check_attachments( + snapshot: &CommittedSnapshot, + attachments: &[(String, ArtifactRef)], +) -> DomainResult<()> { + let mut want: Vec<(String, &ArtifactRef)> = snapshot + .views + .iter() + .map(|v| (crate::media::view_attachment(&v.view_id), &v.pixels)) + .chain( + snapshot + .audio + .iter() + .map(|a| (crate::media::audio_attachment(&a.stream_id), &a.samples)), + ) + .collect(); + want.sort_by(|a, b| a.0.cmp(&b.0)); + let mut given: Vec<(String, &ArtifactRef)> = + attachments.iter().map(|(n, r)| (n.clone(), r)).collect(); + given.sort_by(|a, b| a.0.cmp(&b.0)); + if want.len() != given.len() || want.iter().zip(&given).any(|(w, g)| w.0 != g.0) { + let want: Vec<&String> = want.iter().map(|(n, _)| n).collect(); + let given: Vec<&String> = given.iter().map(|(n, _)| n).collect(); + return Err(coherence(format!( + "the published handles {given:?} are not the media the snapshot references {want:?}" + ))); + } + for ((name, reference), (_, handle)) in want.iter().zip(&given) { + if reference != handle { + return Err(coherence(format!( + "the handle published as {name} is artifact {} generation {}, and the snapshot references {} generation {}", + handle.artifact_id, handle.generation, reference.artifact_id, reference.generation + ))); + } + } + Ok(()) +} + +fn coherence(message: impl std::fmt::Display) -> DomainError { + // Nothing was published, so nothing downstream saw a mixed boundary. + DomainError::new(ErrorCode::BufferInvalid, message, MutationCertainty::None) +} + +// ---------------------------------------------------------------------------------------------- +// The publisher + +/// The session's publication path. One bus client, three framework topics, named outcomes. +pub struct Publisher { + bus: flybus::Client, + session_id: Id, + epoch: Id, + descriptor_topic: TopicPolicy, + snapshot_topic: TopicPolicy, + event_topic: TopicPolicy, + outbox: EventOutbox, + state: SharedState, + ledger: Ledger, + sequence: u64, +} + +impl Publisher { + pub fn new( + bus: flybus::Client, + session_id: &Id, + epoch: &Id, + topics: &crate::coordinator::Topics, + ) -> Publisher { + Publisher { + bus, + session_id: session_id.clone(), + epoch: epoch.clone(), + descriptor_topic: TopicPolicy::latest(&topics.descriptor), + snapshot_topic: TopicPolicy::latest(&topics.snapshots), + event_topic: TopicPolicy::bounded(&topics.events, EVENT_BATCH_DEPTH), + outbox: EventOutbox::new(EVENT_BATCH_DEPTH), + state: Arc::new(Mutex::new(PublishedState::default())), + ledger: Ledger::default(), + sequence: 0, + } + } + + /// The declared policies, in publication order. + pub fn policies(&self) -> Vec { + vec![ + self.descriptor_topic.clone(), + self.snapshot_topic.clone(), + self.event_topic.clone(), + ] + } + + pub fn ledger(&self) -> &Ledger { + &self.ledger + } + + pub fn state(&self) -> SharedState { + Arc::clone(&self.state) + } + + pub fn outbox(&self) -> &EventOutbox { + &self.outbox + } + + /// The next publication sequence, which is monotonic within this publisher incarnation. + pub fn sequence(&self) -> u64 { + self.sequence + } + + pub fn incarnation(&self) -> String { + self.bus.info().connection_id.clone() + } + + /// Declares every framework topic under its policy. A topic already declared compatibly + /// is accepted; a conflicting declaration is a fault here, not a silent reuse. + pub async fn declare(&self) -> DomainResult<()> { + for policy in self.policies() { + self.bus + .declare_topic(&policy.topic, policy.delivery.retained()) + .await + .map_err(|e| { + DomainError::new( + ErrorCode::BackendFailure, + format!("declaring {}: {}", policy.topic, e.message), + MutationCertainty::None, + ) + })?; + } + Ok(()) + } + + /// Publishes a descriptor revision and records it as answerable by the query service. + /// + /// The revision is recorded even when the publication is refused: the repair path exists + /// exactly for the consumer that did not receive it. + pub async fn publish_descriptor( + &mut self, + descriptor: &SessionDescriptor, + ) -> DomainResult { + descriptor.validate().map_err(DomainError::invalid)?; + { + let mut state = lock(&self.state); + if let Some(previous) = state.descriptor(descriptor.revision) + && previous != descriptor + { + return Err(DomainError::before( + ErrorCode::IdentityMismatch, + format!( + "descriptor revision {} was already published with another composition", + descriptor.revision + ), + )); + } + state + .descriptors + .insert(descriptor.revision, descriptor.clone()); + } + let topic = self.descriptor_topic.topic.clone(); + let outcome = PublicationOutcome::from_bus( + &topic, + self.bus + .publish(&topic, object(descriptor.to_json()), &[]) + .await, + ); + self.ledger.record(&outcome); + Ok(outcome) + } + + /// Publishes one committed boundary with the media handles it references. + /// + /// The coherence check runs first, so a snapshot that mixes boundaries never reaches a + /// subscriber; the sequence advances only on a publication the router accepted, so + /// "monotonic within publisherIncarnation" counts published values and not attempts. + pub async fn publish_snapshot( + &mut self, + descriptor: &SessionDescriptor, + snapshot: &CommittedSnapshot, + attachments: &[(String, flybus::Artifact)], + audio_next_sample: &BTreeMap, + ) -> DomainResult { + let named: Vec<(String, ArtifactRef)> = attachments + .iter() + .map(|(name, artifact)| (name.clone(), artifact.reference().clone())) + .collect(); + check_publication(descriptor, snapshot, &named, audio_next_sample)?; + let refs: Vec<(&str, &flybus::Artifact)> = + attachments.iter().map(|(n, a)| (n.as_str(), a)).collect(); + let topic = self.snapshot_topic.topic.clone(); + let outcome = PublicationOutcome::from_bus( + &topic, + self.bus + .publish(&topic, object(snapshot.to_json()), &refs) + .await, + ); + if outcome.is_accepted() { + self.sequence += 1; + lock(&self.state).latest = Some(snapshot.clone()); + } + self.ledger.record(&outcome); + Ok(outcome) + } + + /// Offers this boundary's events to the bounded batch and publishes what it holds. + /// + /// An empty batch publishes nothing and is not an outcome. A refused batch is held for the + /// next boundary and counted; what the depth pushed out is counted too and travels with + /// the next accepted batch as `droppedBefore`. + pub async fn publish_events( + &mut self, + source_step: u64, + events: &[TaskEvent], + ) -> Option { + let dropped = self.outbox.offer(source_step, events); + self.ledger.events_dropped += dropped; + if self.outbox.is_empty() { + return None; + } + let payload = self.outbox.payload(&self.session_id, &self.epoch); + let topic = self.event_topic.topic.clone(); + let outcome = + PublicationOutcome::from_bus(&topic, self.bus.publish(&topic, payload, &[]).await); + match &outcome { + PublicationOutcome::Accepted { .. } => self.outbox.accepted(), + PublicationOutcome::RefusedByObserver { .. } => self.ledger.events_held += 1, + PublicationOutcome::Faulted { .. } => {} + } + self.ledger.record(&outcome); + Some(outcome) + } +} + +// ---------------------------------------------------------------------------------------------- +// The query service: the repair path + +/// The read-only service name a session answers descriptor queries on. +pub fn query_service(session_id: &Id) -> String { + format!("session.{session_id}.query") +} + +/// `Session.GetDescriptor`: one published revision, or the newest. +pub const GET_DESCRIPTOR: &str = "Session.GetDescriptor"; +/// `Session.GetSnapshot`: the latest committed snapshot, without its media handles. +pub const GET_SNAPSHOT: &str = "Session.GetSnapshot"; + +/// A running query service. +/// +/// It is the repair path of `publishing-v1` section 2 and nothing else: two read methods over +/// state the publisher already published. It takes no parameters that select a participant, it +/// mutates nothing, and it is not a controller API -- there is no method here that could +/// advance, pause, stimulate, restore or reconfigure anything. +pub struct QueryService { + task: tokio::task::JoinHandle<()>, +} + +impl QueryService { + pub async fn start( + client: flybus::Client, + session_id: &Id, + state: SharedState, + ) -> Result { + let name = query_service(session_id); + let mut service = client + .register(&name, flybus::ServiceConfig::default()) + .await?; + let worker_id = session_id.clone(); + // The bus connection id names this publisher incarnation. It has to be an `Id` to + // travel in a reply, and a connection id that is not one is a refusal here rather + // than a fallback name that two incarnations could share. + let incarnation = parse_id(&format!("query-{}", client.info().connection_id)) + .map_err(|e| flybus::BusError::new(flybus::ErrorCode::InvalidEnvelope, e))?; + let task = tokio::spawn(async move { + while let Some(request) = service.next().await { + let method = request.method().to_owned(); + let responder = request.responder(); + let parsed = + SessionRpcRequest::from_json(&Value::Object(request.payload().clone())); + drop(request); + let outcome = match parsed { + Ok(parsed) => answer(&parsed, &method, &worker_id, &incarnation, &state), + Err(e) => failure_outcome( + &DomainRequestId::from_serial(0), + &worker_id, + &incarnation, + None, + DomainError::invalid(format!("{method}: {e}")), + ), + }; + let _ = responder.reply(outcome.to_outcome(), &[]).await; + } + }); + Ok(QueryService { task }) + } + + /// Ends the service. Dropping one does the same thing. + pub fn stop(self) { + drop(self); + } +} + +impl Drop for QueryService { + /// A dropped session leaves no task reading a service it no longer answers for. + fn drop(&mut self) { + self.task.abort(); + } +} + +fn answer( + request: &SessionRpcRequest, + method: &str, + worker_id: &Id, + incarnation: &Id, + state: &SharedState, +) -> SessionRpcOutcome { + let result = match method { + GET_DESCRIPTOR => get_descriptor(request, state), + GET_SNAPSHOT => get_snapshot(request, state), + other => Err(DomainError::before( + ErrorCode::Unsupported, + format!("{other} is not a method of the session query service"), + )), + }; + match result { + Ok(result) => success_outcome( + &request.request_id, + worker_id, + incarnation, + request.scope.clone(), + result, + ), + Err(error) => failure_outcome( + &request.request_id, + worker_id, + incarnation, + request.scope.clone(), + error, + ), + } +} + +fn get_descriptor( + request: &SessionRpcRequest, + state: &SharedState, +) -> DomainResult> { + let wanted = match request.params.get("revision") { + None | Some(Value::Null) => None, + Some(Value::String(text)) => Some( + text.parse::() + .map_err(|_| DomainError::invalid("revision is a decimal U64 string"))?, + ), + Some(_) => return Err(DomainError::invalid("revision is a decimal U64 string")), + }; + let state = lock(state); + let descriptor = match wanted { + Some(revision) => state.descriptor(revision).ok_or_else(|| { + // A revision this session never published is an answer, not an empty result. + DomainError::before( + ErrorCode::IdentityMismatch, + format!( + "descriptor revision {revision} was never published; this session has {:?}", + state.revisions() + ), + ) + })?, + None => state.newest_descriptor().ok_or_else(|| { + DomainError::before(ErrorCode::InvalidPhase, "no descriptor has been published") + })?, + }; + Ok(object(descriptor.to_json())) +} + +fn get_snapshot( + request: &SessionRpcRequest, + state: &SharedState, +) -> DomainResult> { + if !request.params.as_object().is_some_and(Map::is_empty) { + return Err(DomainError::invalid( + "Session.GetSnapshot takes no parameters", + )); + } + let state = lock(state); + let snapshot = state.latest_snapshot().ok_or_else(|| { + DomainError::before(ErrorCode::InvalidPhase, "no snapshot has been published") + })?; + Ok(object(snapshot.to_json())) +} + +// ---------------------------------------------------------------------------------------------- +// Application-owned state and cues + +/// An application's own publication channel: its schema, its topics, its delivery policy. +/// +/// The framework supplies the channel and nothing about what travels on it. "Application state +/// carries whatever the experience needs ... It is developed with its presentation, not forced +/// into a framework-wide show state/tournament schema" (publishing-v1 section 4). So the topics +/// are named by the application, the values are `TypedValue`s under the application's own +/// namespaced schema, and this module never looks inside one. There is no director here, no +/// bracket, no cast and no game. +pub struct ApplicationChannel { + bus: flybus::Client, + state_topic: TopicPolicy, + cue_topic: TopicPolicy, + ledger: Ledger, + revision: u64, +} + +impl ApplicationChannel { + /// `prefix` is the application's own address root, for example `app.counter`. + pub fn new(bus: flybus::Client, prefix: &str, cue_depth: usize) -> ApplicationChannel { + ApplicationChannel { + bus, + state_topic: TopicPolicy::latest(format!("{prefix}.state")), + cue_topic: TopicPolicy::bounded(format!("{prefix}.cues"), cue_depth), + ledger: Ledger::default(), + revision: 0, + } + } + + pub fn state_topic(&self) -> &str { + &self.state_topic.topic + } + + pub fn cue_topic(&self) -> &str { + &self.cue_topic.topic + } + + pub fn ledger(&self) -> &Ledger { + &self.ledger + } + + pub async fn declare(&self) -> DomainResult<()> { + for policy in [&self.state_topic, &self.cue_topic] { + self.bus + .declare_topic(&policy.topic, policy.delivery.retained()) + .await + .map_err(|e| { + DomainError::new( + ErrorCode::BackendFailure, + format!("declaring {}: {}", policy.topic, e.message), + MutationCertainty::None, + ) + })?; + } + Ok(()) + } + + /// Publishes application state at a committed boundary it names. + /// + /// The boundary travels with it because a consumer combines this with the framework + /// snapshot, and cross-topic ordering is not guaranteed: "a subscriber receiving an unknown + /// descriptor revision must fetch it ... or buffer a bounded number of snapshots, not infer + /// shape" (publishing-v1 section 2). A named boundary is how the two are joined. + pub async fn publish_state(&mut self, boundary: u64, value: &TypedValue) -> PublicationOutcome { + self.revision += 1; + let payload = object(json!({ + "boundary": boundary.to_string(), + "revision": self.revision.to_string(), + "state": value.to_json(), + })); + let topic = self.state_topic.topic.clone(); + let outcome = + PublicationOutcome::from_bus(&topic, self.bus.publish(&topic, payload, &[]).await); + self.ledger.record(&outcome); + outcome + } + + /// Publishes a presentation cue. Cues are presentation data: "presentation cues may be + /// immediate; simulation effects apply at declared boundaries" (section 7). Nothing here + /// reaches a worker, a controller or the world. + pub async fn publish_cue( + &mut self, + boundary: u64, + kind: &Id, + value: &TypedValue, + ) -> PublicationOutcome { + let payload = object(json!({ + "boundary": boundary.to_string(), + "kind": kind.as_str(), + "cue": value.to_json(), + })); + let topic = self.cue_topic.topic.clone(); + let outcome = + PublicationOutcome::from_bus(&topic, self.bus.publish(&topic, payload, &[]).await); + self.ledger.record(&outcome); + outcome + } +} + +// ---------------------------------------------------------------------------------------------- +// The fake multi-agent consumer + +/// One agent's committed values as a consumer reads them. +/// +/// A presentation consumer is a *multi-agent* consumer: one snapshot carries the whole +/// composition, so there is no per-fly stream to join and no "current fly" to be stale. +#[derive(Clone, Debug, PartialEq)] +pub struct AgentView { + pub agent_id: Id, + /// The index the descriptor says this agent's rates and geometry belong to. A consumer + /// that maps anything spatial compares this, not `neuronCount`. + pub index_digest: Digest, + pub brain_ticks: u64, + pub rates: Vec<(Id, f64)>, + /// The decision of the transition that *ended* at this boundary, null only at boundary 0. + pub decision: Option, + pub controls: Option, +} + +/// One committed boundary as a consumer reads it, with the media handles it still owns. +pub struct SnapshotView { + pub boundary: u64, + pub descriptor_revision: u64, + pub sequence: u64, + /// How many undelivered snapshots the router coalesced away before this one. + pub replaced: u64, + pub agents: Vec, + pub views: Vec, + pub audio: Vec, + /// The extracted handles, held after the message is dropped. + pub artifacts: BTreeMap, +} + +impl SnapshotView { + pub fn agent(&self, agent_id: &Id) -> Option<&AgentView> { + self.agents.iter().find(|a| a.agent_id == *agent_id) + } +} + +/// One bounded event batch as a consumer reads it. +#[derive(Clone, Debug, PartialEq)] +pub struct EventBatchView { + pub epoch: Id, + /// How many events the publisher's bounded batch dropped before this one. A count, never + /// a gap the consumer has to infer. + pub dropped_before: u64, + pub event_ids: Vec, +} + +/// What one poll of a consumer produced. +#[derive(Clone, Debug, PartialEq)] +pub enum ConsumerOutcome { + /// A descriptor this consumer now holds, and the agents it describes. + Composition { revision: u64, agents: Vec }, + /// A snapshot whose descriptor revision this consumer holds and which agrees with it. + Read { boundary: u64, agents: Vec }, + /// A snapshot naming a descriptor revision this consumer has never seen. Nothing is + /// inferred from it: the consumer repairs through the query service and reads it again. + UnknownRevision { revision: u64 }, + /// The composition changed under an agent this consumer had already mapped: the same + /// neuron count, another index. Visible, named, and never silently remapped. + IndexChanged { + agent_id: Id, + from: Digest, + to: Digest, + }, + /// The snapshot does not agree with the descriptor it names, or its media does not belong + /// to its boundary. + Incoherent { detail: String }, +} + +/// A presentation-side consumer of one session, over the same bus. +/// +/// It is a regular bus subscriber (publishing-v1 section 5): latest subscriptions with finite +/// credits on the observation topics, a bounded subscription on events, and one read-only RPC +/// for repair. It is deliberately *not* a gateway: it resolves no artifact into a browser +/// transport, encodes nothing and holds no private owner token on anyone's behalf. +pub struct PresentationConsumer { + bus: flybus::Client, + query: String, + descriptors: flybus::Subscription, + snapshots: flybus::Subscription, + events: flybus::Subscription, + held: Vec, + cached: BTreeMap, + /// The index digest this consumer has mapped geometry against, per agent. + mapped: BTreeMap, + /// Where each audio stream's last accepted chunk ended. + audio_end: BTreeMap, + last: Option, + repairs: u64, + serial: u64, + coalesced: u64, +} + +impl PresentationConsumer { + pub async fn attach( + bus: flybus::Client, + session_id: &Id, + topics: &crate::coordinator::Topics, + ) -> Result { + let descriptors = bus + .subscribe(&topics.descriptor, Delivery::LatestValue.subscription()) + .await?; + let snapshots = bus + .subscribe(&topics.snapshots, Delivery::LatestValue.subscription()) + .await?; + let events = bus + .subscribe( + &topics.events, + Delivery::BoundedBatch { + depth: EVENT_BATCH_DEPTH, + } + .subscription(), + ) + .await?; + Ok(PresentationConsumer { + bus, + query: query_service(session_id), + descriptors, + snapshots, + events, + held: Vec::new(), + cached: BTreeMap::new(), + mapped: BTreeMap::new(), + audio_end: BTreeMap::new(), + last: None, + repairs: 0, + serial: 0, + coalesced: 0, + }) + } + + /// How many descriptor revisions this consumer holds. + pub fn revisions(&self) -> Vec { + self.cached.keys().copied().collect() + } + + pub fn descriptor(&self, revision: u64) -> Option<&SessionDescriptor> { + self.cached.get(&revision) + } + + /// How many times this consumer had to ask the query service for a descriptor. + pub fn repairs(&self) -> u64 { + self.repairs + } + + /// How many snapshots the router coalesced away in this consumer's own queue. + pub fn coalesced(&self) -> u64 { + self.coalesced + } + + pub fn last(&self) -> Option<&SnapshotView> { + self.last.as_ref() + } + + /// Takes the next descriptor from the retained-latest topic. + /// + /// A composition that moves an agent this consumer has already mapped geometry against is + /// reported here and *not* cached: remapping silently is the one thing a consumer holding + /// a spatial mapping must not do. (publishing-v1 section 3) + pub async fn take_descriptor(&mut self) -> Option { + let message = self.descriptors.next().await?; + let descriptor = + match SessionDescriptor::from_json(&Value::Object(message.payload().clone())) { + Ok(descriptor) => descriptor, + Err(e) => { + return Some(ConsumerOutcome::Incoherent { + detail: e.to_string(), + }); + } + }; + drop(message); + for agent in &descriptor.agents { + if let Some(mapped) = self.mapped.get(&agent.agent_id) + && *mapped != agent.index_digest + { + return Some(ConsumerOutcome::IndexChanged { + agent_id: agent.agent_id.clone(), + from: mapped.clone(), + to: agent.index_digest.clone(), + }); + } + } + let revision = descriptor.revision; + let agents = descriptor + .agents + .iter() + .map(|a| a.agent_id.clone()) + .collect(); + self.cached.insert(revision, descriptor); + Some(ConsumerOutcome::Composition { revision, agents }) + } + + /// Asks the query service for a revision this consumer does not hold. + /// + /// This is the repair path, and it is an ordinary RPC on the same bus. A revision the + /// session never published comes back as a named error rather than an empty answer. + pub async fn repair(&mut self, revision: u64) -> DomainResult { + self.serial += 1; + self.repairs += 1; + let request = SessionRpcRequest { + request_id: DomainRequestId::from_serial(self.serial), + scope: None, + params: json!({ "revision": revision.to_string() }), + }; + let mut pending = self + .bus + .call( + &self.query, + None, + GET_DESCRIPTOR, + object(request.to_json()), + &[], + ) + .await + .map_err(|e| { + DomainError::new( + ErrorCode::BackendFailure, + e.message, + MutationCertainty::None, + ) + })?; + let result = pending.result().await.map_err(|e| { + DomainError::new( + ErrorCode::BackendFailure, + e.message, + MutationCertainty::None, + ) + })?; + let outcome = SessionRpcOutcome::from_json(&Value::Object(result.outcome().clone())) + .map_err(DomainError::invalid)?; + drop(result); + let value = outcome_result(&outcome)?; + let descriptor = SessionDescriptor::from_json(value).map_err(DomainError::invalid)?; + if descriptor.revision != revision { + return Err(DomainError::before( + ErrorCode::IdentityMismatch, + "the query service answered with another revision", + )); + } + self.cached.insert(revision, descriptor); + Ok(revision) + } + + /// Takes the next committed snapshot and reads it against the descriptor it names. + pub async fn take_snapshot(&mut self) -> Option { + let message = self.snapshots.next().await?; + self.coalesced += message.replaced(); + Some(self.read(message)) + } + + /// Takes a snapshot without reading or releasing it, which is what a viewer that stopped + /// rendering does. Its credits run out; nothing else in the session notices. + pub async fn hold_snapshot(&mut self) -> bool { + match self.snapshots.next().await { + Some(message) => { + self.coalesced += message.replaced(); + self.held.push(message); + true + } + None => false, + } + } + + /// Holds a snapshot if one is queued right now, and answers `false` if none is. + /// + /// The bounded form: a test that means "take whatever is there" must not be able to + /// block on a stream that is deliberately not producing. + pub fn try_hold_snapshot(&mut self) -> bool { + match self.snapshots.try_next() { + Some(message) => { + self.coalesced += message.replaced(); + self.held.push(message); + true + } + None => false, + } + } + + /// Reads a snapshot if one is queued right now. + pub fn try_take_snapshot(&mut self) -> Option { + let message = self.snapshots.try_next()?; + self.coalesced += message.replaced(); + Some(self.read(message)) + } + + /// Releases everything this consumer was holding, returning its credits. + pub fn release(&mut self) { + self.held.clear(); + } + + pub fn held(&self) -> usize { + self.held.len() + } + + /// Takes the next bounded event batch. + pub async fn take_events(&mut self) -> Option { + let message = self.events.next().await?; + let payload = message.payload().clone(); + drop(message); + let epoch = payload.get("epoch").and_then(Value::as_str).map(id)?; + let dropped_before = payload + .get("droppedBefore") + .and_then(Value::as_str) + .and_then(|t| t.parse::().ok())?; + let event_ids = payload + .get("events") + .and_then(Value::as_array)? + .iter() + .filter_map(|e| e.get("id").and_then(Value::as_str).map(str::to_owned)) + .collect(); + Some(EventBatchView { + epoch, + dropped_before, + event_ids, + }) + } + + /// Records that this consumer has mapped geometry against the agents of `revision`. + /// + /// A consumer that never calls this never claims a mapping, and a later index change is + /// simply a new descriptor. One that does claim it gets [`ConsumerOutcome::IndexChanged`] + /// when the composition moves under it. + pub fn map_geometry(&mut self, revision: u64) -> DomainResult<()> { + let descriptor = self.cached.get(&revision).ok_or_else(|| { + DomainError::before( + ErrorCode::IdentityMismatch, + format!("revision {revision} is not held by this consumer"), + ) + })?; + for agent in &descriptor.agents { + self.mapped + .insert(agent.agent_id.clone(), agent.index_digest.clone()); + } + Ok(()) + } + + pub fn mapped_index(&self, agent_id: &Id) -> Option<&Digest> { + self.mapped.get(agent_id) + } + + fn read(&mut self, message: flybus::Message) -> ConsumerOutcome { + let sequence = message.topic_sequence(); + let replaced = message.replaced(); + let snapshot = match CommittedSnapshot::from_json(&Value::Object(message.payload().clone())) + { + Ok(snapshot) => snapshot, + Err(e) => { + return ConsumerOutcome::Incoherent { + detail: e.to_string(), + }; + } + }; + let Some(descriptor) = self.cached.get(&snapshot.descriptor_revision).cloned() else { + // Not an error and not a guess: the consumer buffers nothing and infers nothing, + // it repairs. (publishing-v1 section 2) + return ConsumerOutcome::UnknownRevision { + revision: snapshot.descriptor_revision, + }; + }; + for agent in &descriptor.agents { + if let Some(mapped) = self.mapped.get(&agent.agent_id) + && *mapped != agent.index_digest + { + return ConsumerOutcome::IndexChanged { + agent_id: agent.agent_id.clone(), + from: mapped.clone(), + to: agent.index_digest.clone(), + }; + } + } + let names: Vec = message.attachment_names().map(str::to_owned).collect(); + let mut handles = Vec::new(); + for name in &names { + match message.artifact(name) { + Ok(artifact) => handles.push((name.clone(), artifact.reference().clone())), + Err(e) => { + return ConsumerOutcome::Incoherent { + detail: format!("attachment {name}: {}", e.message), + }; + } + } + } + if let Err(e) = check_received(&descriptor, &snapshot, &self.audio_end, &handles) { + return ConsumerOutcome::Incoherent { + detail: e.to_string(), + }; + } + // The renderer keeps its handles after the delivery is gone; the bytes stay alive + // because the extracted handle owns them. (publishing-v1 section 5) + let mut artifacts = BTreeMap::new(); + for name in &names { + match message.artifact(name) { + Ok(artifact) => { + artifacts.insert(name.clone(), artifact); + } + Err(e) => { + return ConsumerOutcome::Incoherent { + detail: format!("attachment {name}: {}", e.message), + }; + } + } + } + drop(message); + for chunk in &snapshot.audio { + self.audio_end.insert( + chunk.stream_id.clone(), + chunk.first_sample + chunk.sample_frames, + ); + } + let agents: Vec = snapshot + .agents + .iter() + .map(|agent| { + let declared = descriptor + .agents + .iter() + .find(|a| a.agent_id == agent.agent_id) + .expect("validate_against checked the agent set"); + AgentView { + agent_id: agent.agent_id.clone(), + index_digest: declared.index_digest.clone(), + brain_ticks: agent.telemetry.brain_ticks, + rates: agent + .telemetry + .rates + .iter() + .map(|r| (r.role_id.clone(), r.hz)) + .collect(), + decision: agent.selected_decision.clone(), + controls: agent.applied_controls.clone(), + } + }) + .collect(); + let outcome = ConsumerOutcome::Read { + boundary: snapshot.scope.step, + agents: agents.iter().map(|a| a.agent_id.clone()).collect(), + }; + self.last = Some(SnapshotView { + boundary: snapshot.scope.step, + descriptor_revision: snapshot.descriptor_revision, + sequence, + replaced, + agents, + views: snapshot.views.clone(), + audio: snapshot.audio.clone(), + artifacts, + }); + outcome + } +} + +/// The receiving half of the coherence rule. +/// +/// A consumer does not trust that the publisher checked: it checks the same statement from the +/// other side, against the audio position it last accepted rather than the one the publisher +/// holds. That is what catches a chunk from an earlier transition arriving under a later +/// boundary, which is the one shape of "old media" a publisher-side check cannot see. +pub fn check_received( + descriptor: &SessionDescriptor, + snapshot: &CommittedSnapshot, + audio_end: &BTreeMap, + attachments: &[(String, ArtifactRef)], +) -> DomainResult<()> { + snapshot + .validate_against(descriptor) + .map_err(|e| coherence(format!("snapshot and descriptor disagree: {e}")))?; + check_views(descriptor, snapshot)?; + check_attachments(snapshot, attachments)?; + for chunk in &snapshot.audio { + match audio_end.get(&chunk.stream_id) { + // A stream this consumer has not heard yet, or one that says it skipped: both are + // declared states, not assumptions. + None => {} + Some(_) if chunk.discontinuity => {} + // Forward is the latest subscription's own contract: a consumer that fell behind + // was told so by `replaced`, and the boundaries in between are values it chose + // not to receive. Backwards or overlapping is old media under a new boundary, + // which no delivery policy explains. + Some(end) if chunk.first_sample >= *end => {} + Some(end) => { + return Err(coherence(format!( + "audio stream {} starts at {} and the last chunk this consumer read ended at {end}", + chunk.stream_id, chunk.first_sample + ))); + } + } + } + Ok(()) +} diff --git a/services/flysim/crates/fly-session/src/types.rs b/services/flysim/crates/fly-session/src/types.rs index c52c276..4d1eec0 100644 --- a/services/flysim/crates/fly-session/src/types.rs +++ b/services/flysim/crates/fly-session/src/types.rs @@ -27,9 +27,12 @@ pub use fly_session_types::schema::contract_digest; pub use fly_session_types::trace::{ TraceAgent, TraceBehaviour, TraceObservation, TraceOperational, TraceRequest, TransitionTrace, }; +pub use fly_session_types::publishing::{ + AgentDescriptor, CommittedSnapshot, SessionDescriptor, SnapshotAgent, +}; pub use fly_session_types::workers::{ AcknowledgeParams, AcknowledgeResult, AdvanceParams, AgentCommitResult, AgentInitializeParams, - AgentInitializeResult, AgentTelemetry, AssetRef, AxisRange, AxisSchema, AxisValue, ButtonState, + AgentGraph, AgentInitializeResult, AgentTelemetry, AssetRef, AxisRange, AxisSchema, AxisValue, ButtonState, CommitParams, ControllerSchema, Determinism, EnvironmentDescriptor, EnvironmentInitializeParams, EnvironmentInitializeResult, EpisodeRequest, HelloParams, HelloResult, LearningTelemetry, MAX_ACKNOWLEDGE, MAX_AGENTS, MAX_PORTS, MAX_RATE_ROLES, diff --git a/services/flysim/crates/fly-session/tests/publishing.rs b/services/flysim/crates/fly-session/tests/publishing.rs new file mode 100644 index 0000000..52ca5a9 --- /dev/null +++ b/services/flysim/crates/fly-session/tests/publishing.rs @@ -0,0 +1,1099 @@ +//! PUBLISH-01 acceptance: committed snapshots, observer isolation and the repair path. +//! +//! Every test here is one of the slice's acceptance bullets, or one sentence of +//! `publishing-v1` the session now enforces. The boundary itself is `fly_session::publish`: +//! the same bus, named delivery policies, named outcomes, and a fake multi-agent consumer that +//! is an ordinary subscriber. No public v2 wire schema is exercised, because none is approved. + +mod common; + +use std::collections::BTreeMap; +use std::time::Duration; + +use common::{Fixture, default_fixture, fixture, fly_a, fly_b, mode_fixture, within}; +use fly_session::coordinator::{DESCRIPTOR_REVISION, Injections}; +use fly_session::harness::{AgentSpec, ExecutionMode, HarnessConfig, Via}; +use fly_session::publish::{ + ApplicationChannel, ConsumerOutcome, Delivery, EVENT_BATCH_DEPTH, EventOutbox, GET_SNAPSHOT, + PresentationConsumer, PublicationOutcome, TopicPolicy, check_publication, query_service, +}; +use fly_session::types::*; +use serde_json::json; + +both_transports!( + a_consumer_that_disconnects_neither_advances_nor_stalls_the_world, + a_consumer_that_stops_consuming_costs_only_its_own_credits, + a_bounded_observer_refusal_is_named_and_never_fails_the_epoch, + every_published_snapshot_carries_its_own_boundarys_media, + a_published_frame_from_another_boundary_is_refused, + a_published_handle_that_is_not_the_referenced_artifact_is_refused, + an_unheld_descriptor_revision_is_repaired_rather_than_inferred, + a_revision_that_was_never_published_is_a_named_answer, + a_changed_index_digest_is_named_rather_than_remapped, + boundary_zero_publishes_no_decision_and_no_controls, + a_committed_action_is_the_transition_that_just_ended, + one_snapshot_carries_every_agent_in_the_composition, + application_state_and_cues_are_the_applications_own, + a_refused_event_batch_is_held_and_counted_not_lost, + the_query_service_answers_reads_and_nothing_else, + the_published_descriptor_is_what_the_workers_attested_to, +); + +all_modes!( + the_publication_boundary_holds_in_every_execution_mode, + a_stalled_observer_never_moves_the_world_in_any_execution_mode, +); + +const STEPS: u64 = 4; + +/// Polls until `ok` holds, so a test never asserts something that has not happened yet. +async fn until(what: &str, mut ok: impl FnMut() -> bool) { + let deadline = std::time::Instant::now() + Duration::from_secs(10); + while !ok() { + assert!( + std::time::Instant::now() < deadline, + "{what}: never happened" + ); + tokio::time::sleep(Duration::from_millis(5)).await; + } +} + +/// A started session at Ready(0), with boundary 0 already published. +async fn started(via: Via) -> Fixture { + let mut f = default_fixture(via).await; + f.harness + .coordinator + .bootstrap() + .await + .expect("the session bootstraps"); + f +} + +async fn started_in_mode(mode: ExecutionMode) -> Fixture { + let mut f = mode_fixture(mode, HarnessConfig::default()).await; + f.harness + .coordinator + .bootstrap() + .await + .expect("the session bootstraps"); + f +} + +/// Reads snapshots until the consumer reports the boundary it is waiting for. +async fn read_until(consumer: &mut PresentationConsumer, boundary: u64) -> u64 { + loop { + match within("a snapshot", consumer.take_snapshot()).await { + Some(ConsumerOutcome::Read { boundary: at, .. }) if at >= boundary => return at, + Some(ConsumerOutcome::Read { .. }) => {} + other => panic!("expected a readable snapshot, got {other:?}"), + } + } +} + +// ------------------------------------------------------------------------------------------ +// "A browser consumer disconnecting, or applying backpressure, never advances the world and +// never stalls it." + +/// A consumer that goes away mid-run. The world takes exactly the steps it was asked for. +async fn a_consumer_that_disconnects_neither_advances_nor_stalls_the_world(via: Via) { + let mut f = started(via).await; + let mut consumer = f.harness.consumer().await.expect("a consumer attaches"); + assert!(matches!( + within("the descriptor", consumer.take_descriptor()).await, + Some(ConsumerOutcome::Composition { revision, .. }) if revision == DESCRIPTOR_REVISION + )); + f.harness.coordinator.run(1).await.expect("one transition"); + read_until(&mut consumer, 1).await; + + // The browser goes away. Nothing in the session is told, and nothing waits for it. + drop(consumer); + + let started_at = std::time::Instant::now(); + let before = f.harness.coordinator.stats(); + f.harness + .coordinator + .run(STEPS) + .await + .expect("the world carries on"); + let after = f.harness.coordinator.stats(); + assert_eq!( + after.advances - before.advances, + STEPS, + "a departed observer neither added a world step nor removed one" + ); + assert_eq!(after.publications - before.publications, STEPS); + assert!( + !f.harness.coordinator.is_fenced(), + "a departed observer never fences an epoch" + ); + assert!( + started_at.elapsed() < Duration::from_secs(10), + "a departed observer never stalls the world" + ); + assert_eq!(f.harness.coordinator.ledger().refusals(), 0); + f.shutdown().await; +} + +/// A viewer that holds its deliveries and stops rendering. Its own credits run out; the +/// world's boundaries do not. +async fn a_consumer_that_stops_consuming_costs_only_its_own_credits(via: Via) { + let mut f = started(via).await; + let mut slow = f + .harness + .consumer() + .await + .expect("a slow consumer attaches"); + let mut healthy = f + .harness + .consumer() + .await + .expect("a healthy consumer attaches"); + + // The slow one takes its deliveries and never releases them: a viewer holding output. + until("the slow viewer has a delivery", || { + slow.try_hold_snapshot() + }) + .await; + while slow.try_hold_snapshot() && slow.held() < 4 {} + let held = slow.held(); + assert!(held > 0); + + f.harness + .coordinator + .run(STEPS) + .await + .expect("the world carries on"); + assert_eq!(f.harness.coordinator.stats().advances, STEPS); + assert_eq!( + f.harness.coordinator.ledger().refusals(), + 0, + "a latest subscriber cannot refuse" + ); + assert_eq!( + slow.held(), + held, + "the slow viewer took nothing more once its credits were gone" + ); + + // The healthy one is unaffected and reads the newest boundary. + assert!(matches!( + within("the descriptor", healthy.take_descriptor()).await, + Some(ConsumerOutcome::Composition { .. }) + )); + let at = read_until(&mut healthy, STEPS).await; + assert_eq!(at, STEPS); + assert_eq!( + healthy.last().expect("a snapshot").boundary, + STEPS, + "the healthy consumer reads the newest boundary while the slow one holds" + ); + f.shutdown().await; +} + +/// A bounded subscriber whose queue is full refuses a publication -- `bus-v1` section 6 says +/// it may. The refusal is a named outcome on a named topic, the world takes every step it was +/// asked for, the epoch is not fenced, and when the offender goes away the next boundary +/// reaches the well-behaved consumer. +async fn a_bounded_observer_refusal_is_named_and_never_fails_the_epoch(via: Via) { + let mut f = started(via).await; + let healthy_first = f + .harness + .consumer() + .await + .expect("a healthy consumer attaches"); + let offender_client = f.harness.observer().await.expect("an observer client"); + let snapshots = f.harness.coordinator.topics().snapshots.clone(); + let mut offender = offender_client + .subscribe( + &snapshots, + flybus::SubscriptionConfig::bounded().queued(1).in_flight(1), + ) + .await + .expect("a bounded subscription"); + + let before = f.harness.coordinator.stats().advances; + f.harness + .coordinator + .run(STEPS) + .await + .expect("an observer never fails the epoch"); + assert_eq!( + f.harness.coordinator.stats().advances - before, + STEPS, + "a refused publication never costs the world a step" + ); + assert!(!f.harness.coordinator.is_fenced()); + let counters = f.harness.coordinator.ledger().counters(&snapshots); + assert!( + counters.refused > 0, + "the refusal is counted on its own topic: {counters:?}" + ); + assert!( + matches!( + f.harness.coordinator.ledger().last(), + Some(PublicationOutcome::RefusedByObserver { .. }) + ), + "the last outcome names the refusal rather than defaulting to success" + ); + assert_eq!( + counters.faulted, 0, + "an observer's refusal is never the session's fault" + ); + + // The offender leaves. The next boundary is published and the healthy consumer reads it. + drop(offender.try_next()); + drop(offender); + offender_client.close().await; + drop(healthy_first); + let mut healthy = f + .harness + .consumer() + .await + .expect("a healthy consumer attaches"); + until("the refusals stop", || true).await; + f.harness + .coordinator + .run(2) + .await + .expect("the world carries on"); + assert!(matches!( + within("the descriptor", healthy.take_descriptor()).await, + Some(ConsumerOutcome::Composition { .. }) + )); + let at = read_until(&mut healthy, STEPS + 1).await; + assert!( + at > STEPS, + "a boundary published after the offender left reaches a consumer" + ); + f.shutdown().await; +} + +// ------------------------------------------------------------------------------------------ +// "Future agent state is never mixed with old media." + +/// Every snapshot's media belongs to the boundary its agent state belongs to: the frame is the +/// one this boundary's declared delay requires, the handle is the artifact the payload names, +/// the audio continues where the last chunk ended, and the agents are committed at it. +async fn every_published_snapshot_carries_its_own_boundarys_media(via: Via) { + let mut f = started(via).await; + let mut consumer = f.harness.consumer().await.expect("a consumer attaches"); + assert!(matches!( + within("the descriptor", consumer.take_descriptor()).await, + Some(ConsumerOutcome::Composition { .. }) + )); + let mut seen: Vec<(u64, String)> = Vec::new(); + for step in 1..=STEPS { + f.harness.coordinator.run(1).await.expect("a transition"); + let at = read_until(&mut consumer, step).await; + let view = consumer.last().expect("a snapshot"); + assert_eq!(view.boundary, at); + for reference in &view.views { + // The counter arena declares no render delay, so the frame of boundary k is + // produced at k. The consumer checked this itself before it got here. + assert_eq!(reference.produced_step, at, "the frame is this boundary's"); + let handle = view + .artifacts + .get(&fly_session::media::view_attachment(&reference.view_id)) + .expect("the referenced frame has its handle"); + assert_eq!( + handle.reference(), + &reference.pixels, + "the handle is that artifact" + ); + seen.push((at, reference.pixels.artifact_id.clone())); + } + for agent in &view.agents { + assert!( + agent.brain_ticks > 0, + "the agent state is this boundary's, not a placeholder" + ); + } + } + let mut ids: Vec<&String> = seen.iter().map(|(_, id)| id).collect(); + ids.sort(); + ids.dedup(); + assert_eq!( + ids.len(), + seen.len(), + "no boundary republished another boundary's frame: {seen:?}" + ); + f.shutdown().await; +} + +/// The injection of new agent state with the previous boundary's frame. The publication is +/// refused before it reaches a subscriber, and the consumer never sees a mixed boundary. +async fn a_published_frame_from_another_boundary_is_refused(via: Via) { + let mut f = started(via).await; + let mut consumer = f.harness.consumer().await.expect("a consumer attaches"); + f.harness + .coordinator + .run(1) + .await + .expect("one clean transition"); + f.harness.coordinator.injections = Injections { + at_step: 1, + stale_published_view: true, + ..Injections::default() + }; + let failure = f + .harness + .coordinator + .run(1) + .await + .expect_err("a snapshot that mixes boundaries is refused"); + assert_eq!(failure.error.code, ErrorCode::BufferInvalid, "{failure:?}"); + assert!( + failure.error.message.contains("produced at"), + "the refusal names the boundary the frame came from: {}", + failure.error.message + ); + assert_eq!( + failure.error.mutation, + MutationCertainty::None, + "nothing was published, so nothing downstream saw it" + ); + + // Whatever the consumer read, none of it is the mixed boundary. + assert!(matches!( + within("the descriptor", consumer.take_descriptor()).await, + Some(ConsumerOutcome::Composition { .. }) + )); + until("a snapshot of the clean boundary", || { + matches!( + consumer.try_take_snapshot(), + Some(ConsumerOutcome::Read { boundary: 1, .. }) + ) + }) + .await; + assert_eq!(consumer.last().expect("a snapshot").boundary, 1); + while let Some(outcome) = consumer.try_take_snapshot() { + match outcome { + ConsumerOutcome::Read { boundary, .. } => { + assert!(boundary <= 1, "the mixed boundary was never published"); + } + other => panic!("the consumer saw {other:?}"), + } + } + f.shutdown().await; +} + +/// The same attachment names and the same bytes, another object. Only the artifact identity +/// sees it, and the publication is refused. +async fn a_published_handle_that_is_not_the_referenced_artifact_is_refused(via: Via) { + let mut f = started(via).await; + f.harness.coordinator.injections = Injections { + at_step: 0, + substituted_published_handle: true, + ..Injections::default() + }; + let failure = f + .harness + .coordinator + .run(1) + .await + .expect_err("a handle that is not the referenced artifact is refused"); + assert_eq!(failure.error.code, ErrorCode::BufferInvalid, "{failure:?}"); + assert!( + failure.error.message.contains("generation"), + "the refusal names the object it got: {}", + failure.error.message + ); + f.shutdown().await; +} + +// ------------------------------------------------------------------------------------------ +// "A descriptor or index mismatch is visible rather than silently tolerated." + +/// A consumer that meets a revision it does not hold repairs through the query service. It +/// infers nothing from the snapshot in the meantime. +async fn an_unheld_descriptor_revision_is_repaired_rather_than_inferred(via: Via) { + let mut f = started(via).await; + let mut consumer = f.harness.consumer().await.expect("a consumer attaches"); + f.harness.coordinator.run(1).await.expect("a transition"); + + // The snapshot arrives first: cross-topic ordering is not guaranteed, and this consumer + // has deliberately not read its descriptor topic. + let outcome = within("a snapshot", consumer.take_snapshot()).await; + assert_eq!( + outcome, + Some(ConsumerOutcome::UnknownRevision { + revision: DESCRIPTOR_REVISION + }), + "an unknown revision is reported, not guessed" + ); + assert!( + consumer.last().is_none(), + "nothing was read out of a snapshot it cannot shape" + ); + + let repaired = consumer + .repair(DESCRIPTOR_REVISION) + .await + .expect("the repair path answers"); + assert_eq!(repaired, DESCRIPTOR_REVISION); + assert_eq!(consumer.repairs(), 1); + f.harness.coordinator.run(1).await.expect("a transition"); + let at = read_until(&mut consumer, 1).await; + assert!(at >= 1, "with the descriptor in hand the same stream reads"); + f.shutdown().await; +} + +/// A revision this session never published is an answer, not an empty result. +async fn a_revision_that_was_never_published_is_a_named_answer(via: Via) { + let f = started(via).await; + let mut consumer = f.harness.consumer().await.expect("a consumer attaches"); + let error = consumer + .repair(99) + .await + .expect_err("revision 99 was never published"); + assert_eq!(error.code, ErrorCode::IdentityMismatch, "{error:?}"); + assert!(error.message.contains("99"), "{}", error.message); + assert_eq!( + error.mutation, + MutationCertainty::None, + "a read never mutates" + ); + f.shutdown().await; +} + +/// The same neuron count, another index. A consumer that has mapped geometry is told, and the +/// new composition is not quietly cached over the one it mapped. +async fn a_changed_index_digest_is_named_rather_than_remapped(via: Via) { + // Two real compositions that differ only in the graph one fly built. + let first = started(via).await; + let mut second = fixture( + via, + HarnessConfig { + agents: vec![ + AgentSpec { + graph_variant: 1, + ..AgentSpec::new("fly-a", "p1", 7) + }, + AgentSpec { + graph_variant: 0, + ..AgentSpec::new("fly-b", "p2", 11) + }, + ], + ..HarnessConfig::default() + }, + ) + .await; + second + .harness + .coordinator + .bootstrap() + .await + .expect("the second session bootstraps"); + let revised = { + let mut revised = second + .harness + .coordinator + .session_descriptor() + .expect("a published descriptor") + .clone(); + revised.revision = DESCRIPTOR_REVISION + 1; + revised + }; + let original = first + .harness + .coordinator + .session_descriptor() + .expect("a published descriptor") + .clone(); + let moved = original + .agents + .iter() + .find(|a| a.agent_id == fly_a()) + .expect("fly-a"); + let arrived = revised + .agents + .iter() + .find(|a| a.agent_id == fly_a()) + .expect("fly-a"); + assert_eq!( + moved.neuron_count, arrived.neuron_count, + "the same number of neurons" + ); + assert_ne!( + moved.index_digest, arrived.index_digest, + "and another index" + ); + + let mut consumer = first.harness.consumer().await.expect("a consumer attaches"); + let held = match within("the descriptor", consumer.take_descriptor()).await { + Some(ConsumerOutcome::Composition { revision, .. }) => revision, + other => panic!("expected a composition, got {other:?}"), + }; + consumer + .map_geometry(held) + .expect("this consumer maps geometry"); + assert_eq!(consumer.mapped_index(&fly_a()), Some(&moved.index_digest)); + + // The composition changes under it. + let publisher_client = first + .harness + .publisher() + .await + .expect("a publishing client"); + let mut publisher = fly_session::publish::Publisher::new( + publisher_client, + &first.harness.config.session_id, + &first.harness.config.epoch, + first.harness.coordinator.topics(), + ); + publisher + .publish_descriptor(&revised) + .await + .expect("the revision publishes"); + let outcome = within("the revised descriptor", consumer.take_descriptor()).await; + assert_eq!( + outcome, + Some(ConsumerOutcome::IndexChanged { + agent_id: fly_a(), + from: moved.index_digest.clone(), + to: arrived.index_digest.clone(), + }), + "the change is named" + ); + assert_eq!( + consumer.mapped_index(&fly_a()), + Some(&moved.index_digest), + "and nothing was remapped behind the consumer's back" + ); + assert!( + consumer.descriptor(DESCRIPTOR_REVISION + 1).is_none(), + "the revision it refused is not in its cache either" + ); + second.shutdown().await; + first.shutdown().await; +} + +/// The descriptor says what the workers said, not what the composition asked for. +async fn the_published_descriptor_is_what_the_workers_attested_to(via: Via) { + let f = started(via).await; + let descriptor = f + .harness + .coordinator + .session_descriptor() + .expect("a descriptor") + .clone(); + assert_eq!(descriptor.revision, DESCRIPTOR_REVISION); + assert_eq!(descriptor.agents.len(), 2); + for agent in &descriptor.agents { + let expected = fly_session::agent::synthetic_graph(&agent.agent_id, 0); + assert_eq!( + agent.index_digest, expected.index_digest, + "the fly's own index" + ); + assert_eq!(agent.dataset_digest, expected.dataset_digest); + assert_eq!(agent.neuron_count, expected.neuron_count); + assert_eq!( + agent.rate_roles, expected.rate_roles, + "the profile's rate order" + ); + assert_eq!(agent.supported_stimuli, expected.supported_stimuli); + assert!( + descriptor.environment.port(&agent.port_id).is_some(), + "every agent's port is declared by the environment" + ); + } + let mut asset_ids: Vec<&Id> = descriptor.assets.iter().map(|a| &a.id).collect(); + asset_ids.sort(); + asset_ids.dedup(); + assert_eq!( + asset_ids.len(), + descriptor.assets.len(), + "assets are unique by id" + ); + f.shutdown().await; +} + +// ------------------------------------------------------------------------------------------ +// "A committed action is labelled as the transition that just ended, not the one about to +// start." + +async fn boundary_zero_publishes_no_decision_and_no_controls(via: Via) { + let f = started(via).await; + let state = f.harness.coordinator.published_state(); + let snapshot = { + let state = state.lock().expect("not poisoned"); + state + .latest_snapshot() + .expect("boundary 0 was published") + .clone() + }; + assert_eq!(snapshot.scope.step, 0); + for agent in &snapshot.agents { + assert!( + agent.selected_decision.is_none(), + "boundary 0 ended no transition" + ); + assert!(agent.applied_controls.is_none()); + } + f.shutdown().await; +} + +/// The decision published at boundary k+1 is the one the agent prepared for the transition +/// k -> k+1, never the one it is about to prepare for k+1 -> k+2. +async fn a_committed_action_is_the_transition_that_just_ended(via: Via) { + let mut f = started(via).await; + let mut consumer = f.harness.consumer().await.expect("a consumer attaches"); + assert!(matches!( + within("the descriptor", consumer.take_descriptor()).await, + Some(ConsumerOutcome::Composition { .. }) + )); + let mut published: Vec<(u64, Digest)> = Vec::new(); + for step in 1..=STEPS { + f.harness.coordinator.run(1).await.expect("a transition"); + read_until(&mut consumer, step).await; + let view = consumer.last().expect("a snapshot"); + let agent = view.agent(&fly_a()).expect("fly-a is in every snapshot"); + let decision = agent + .decision + .as_ref() + .expect("past boundary 0 there is a decision"); + published.push((view.boundary, typed_digest(decision))); + assert!( + agent.controls.is_some(), + "and the controls that were applied" + ); + } + // The trace records, per transition, the decision digest the coordinator actually sent to + // the world. The snapshot at boundary k+1 must carry the transition ending there. + let trace = &f.harness.coordinator.trace.transitions; + assert_eq!(trace.len() as u64, STEPS); + for (index, (boundary, digest)) in published.iter().enumerate() { + let transition = &trace[index]; + assert_eq!( + transition.behaviour.published_boundary, *boundary, + "the snapshot at {boundary} is the transition that ended there" + ); + let traced = transition + .behaviour + .agents + .iter() + .find(|a| a.agent_id == fly_a()) + .expect("fly-a in the trace"); + assert_eq!( + traced.decision_digest, *digest, + "the decision published at boundary {boundary} is the one that produced it" + ); + assert_eq!(traced.committed_step, *boundary); + } + // And it is not the next transition's, which the following trace row holds. + if STEPS > 1 { + let first = &published[0]; + let next = trace[1] + .behaviour + .agents + .iter() + .find(|a| a.agent_id == fly_a()) + .expect("fly-a in the trace"); + assert_ne!( + first.1, next.decision_digest, + "boundary 1 did not publish the decision of the transition about to start" + ); + } + f.shutdown().await; +} + +/// One snapshot is the whole composition: a presentation consumer is a multi-agent consumer +/// and has no per-fly stream to join. +async fn one_snapshot_carries_every_agent_in_the_composition(via: Via) { + let mut f = started(via).await; + let mut consumer = f.harness.consumer().await.expect("a consumer attaches"); + assert!(matches!( + within("the descriptor", consumer.take_descriptor()).await, + Some(ConsumerOutcome::Composition { .. }) + )); + f.harness.coordinator.run(1).await.expect("a transition"); + let outcome = match within("a snapshot", consumer.take_snapshot()).await { + Some(outcome) => outcome, + None => panic!("the snapshot stream ended"), + }; + let agents = match outcome { + ConsumerOutcome::Read { agents, .. } => agents, + other => panic!("expected a readable snapshot, got {other:?}"), + }; + assert_eq!(agents, vec![fly_a(), fly_b()], "every agent, in one value"); + let view = consumer.last().expect("a snapshot"); + for agent_id in [fly_a(), fly_b()] { + let agent = view.agent(&agent_id).expect("in the snapshot"); + assert_eq!( + agent.rates.len(), + 2, + "telemetry in the descriptor's rate-role order" + ); + assert_eq!(agent.rates[0].0, id("kc")); + assert_eq!(agent.rates[1].0, id("mbon")); + assert!(!agent.index_digest.is_empty()); + } + f.shutdown().await; +} + +// ------------------------------------------------------------------------------------------ +// Application-owned state and cues, bounded events, and the read-only repair service. + +/// The application publishes its own state and cues, on its own addresses, under its own +/// schema. Nothing about them is framework-shaped. +async fn application_state_and_cues_are_the_applications_own(via: Via) { + let mut f = started(via).await; + let client = f + .harness + .application() + .await + .expect("an application client"); + let mut channel = ApplicationChannel::new(client, "app.counter", 16); + channel + .declare() + .await + .expect("the application declares its own topics"); + assert_eq!(channel.state_topic(), "app.counter.state"); + assert_eq!(channel.cue_topic(), "app.counter.cues"); + + let watcher = f.harness.observer().await.expect("an observer client"); + let mut states = watcher + .subscribe(channel.state_topic(), Delivery::LatestValue.subscription()) + .await + .expect("a latest subscription"); + let mut cues = watcher + .subscribe( + channel.cue_topic(), + Delivery::BoundedBatch { depth: 16 }.subscription(), + ) + .await + .expect("a bounded subscription"); + + f.harness.coordinator.run(1).await.expect("a transition"); + // The application's schema: its own namespace, not a framework field. + let schema = SchemaRef { + id: id("counter.show.v1"), + version: 1, + digest: digest_of_bytes(b"counter.show.v1"), + }; + let state = typed(schema.clone(), json!({ "featured": "fly-a", "streak": 3 })) + .expect("an application value"); + let outcome = channel.publish_state(1, &state).await; + assert!(outcome.is_accepted(), "{outcome:?}"); + let cue = typed(schema, json!({ "line": "fly-a takes the lead" })).expect("a cue"); + assert!( + channel + .publish_cue(1, &id("counter.headline"), &cue) + .await + .is_accepted() + ); + + let message = within("the application state", states.next()) + .await + .expect("a state message"); + assert_eq!( + message.payload().get("boundary").and_then(|v| v.as_str()), + Some("1") + ); + let published = TypedValue::from_json(message.payload().get("state").expect("state")) + .expect("an application typed value"); + assert_eq!(published.schema.id, id("counter.show.v1")); + drop(message); + let message = within("the cue", cues.next()).await.expect("a cue message"); + assert_eq!( + message.payload().get("kind").and_then(|v| v.as_str()), + Some("counter.headline") + ); + drop(message); + + // Nothing the application published is on a session topic, and nothing on a session topic + // knows the application's schema. + let descriptor = f + .harness + .coordinator + .session_descriptor() + .expect("a descriptor"); + assert_ne!(descriptor.task_schema.id, id("counter.show.v1")); + f.shutdown().await; +} + +/// A refused event batch is held, counted and delivered later. Nothing is dropped quietly, +/// and what the bounded depth does push out travels as a count. +async fn a_refused_event_batch_is_held_and_counted_not_lost(via: Via) { + let mut f = started(via).await; + let events_topic = f.harness.coordinator.topics().events.clone(); + let offender_client = f.harness.observer().await.expect("an observer client"); + let mut offender = offender_client + .subscribe( + &events_topic, + flybus::SubscriptionConfig::bounded().queued(1).in_flight(1), + ) + .await + .expect("a bounded subscription"); + + f.harness + .coordinator + .run(STEPS) + .await + .expect("an event observer never fails the epoch"); + assert_eq!(f.harness.coordinator.stats().advances, STEPS); + let counters = f.harness.coordinator.ledger().counters(&events_topic); + assert!(counters.refused > 0, "the refusal is counted: {counters:?}"); + assert!(f.harness.coordinator.ledger().events_held > 0); + assert!( + f.harness.coordinator.pending_events() > 0, + "the refused events are held in a bounded batch, not dropped" + ); + + // The offender starts consuming; the held batch goes out at the next boundary. + drop(within("an event delivery", offender.next()).await); + drop(offender.try_next()); + let held = f.harness.coordinator.pending_events(); + f.harness.coordinator.run(1).await.expect("a transition"); + until("the held batch is published", || { + f.harness.coordinator.pending_events() < held + }) + .await; + offender_client.close().await; + f.shutdown().await; +} + +/// Two reads and nothing else. There is no method on this service that could move anything. +async fn the_query_service_answers_reads_and_nothing_else(via: Via) { + let mut f = started(via).await; + f.harness.coordinator.run(1).await.expect("a transition"); + let client = f.harness.observer().await.expect("an observer client"); + let service = query_service(&f.harness.config.session_id); + + let request = SessionRpcRequest { + request_id: DomainRequestId::from_serial(1), + scope: None, + params: json!({}), + }; + let mut pending = client + .call(&service, None, GET_SNAPSHOT, object(request.to_json()), &[]) + .await + .expect("the service answers"); + let result = pending.result().await.expect("a reply"); + let outcome = + SessionRpcOutcome::from_json(&serde_json::Value::Object(result.outcome().clone())) + .expect("a domain outcome"); + drop(result); + let snapshot = CommittedSnapshot::from_json(outcome_result(&outcome).expect("a result")) + .expect("a committed snapshot"); + assert_eq!(snapshot.scope.step, 1); + assert!(snapshot.views.is_empty() || !snapshot.views.is_empty()); + + // A method it does not implement is a named refusal, not a default. + let request = SessionRpcRequest { + request_id: DomainRequestId::from_serial(2), + scope: None, + params: json!({}), + }; + let mut pending = client + .call( + &service, + None, + "Session.Advance", + object(request.to_json()), + &[], + ) + .await + .expect("the service answers"); + let result = pending.result().await.expect("a reply"); + let outcome = + SessionRpcOutcome::from_json(&serde_json::Value::Object(result.outcome().clone())) + .expect("a domain outcome"); + drop(result); + let error = outcome_result(&outcome).expect_err("no such method"); + assert_eq!(error.code, ErrorCode::Unsupported, "{error:?}"); + assert_eq!( + f.harness.coordinator.stats().advances, + 1, + "and nothing advanced" + ); + client.close().await; + f.shutdown().await; +} + +// ------------------------------------------------------------------------------------------ +// Every execution mode + +/// The publication boundary is inside the coordinator, so it crosses no process boundary: it +/// must therefore behave identically in all three modes, and this asserts that rather than +/// assuming it. +async fn the_publication_boundary_holds_in_every_execution_mode(mode: ExecutionMode) { + let mut f = started_in_mode(mode).await; + let mut consumer = f.harness.consumer().await.expect("a consumer attaches"); + let revision = match within("the descriptor", consumer.take_descriptor()).await { + Some(ConsumerOutcome::Composition { revision, agents }) => { + assert_eq!(agents, vec![fly_a(), fly_b()]); + revision + } + other => panic!("expected a composition, got {other:?}"), + }; + assert_eq!(revision, DESCRIPTOR_REVISION); + let descriptor = consumer.descriptor(revision).expect("held").clone(); + for agent in &descriptor.agents { + // The graph identity is attested over the bus, so it crosses a process boundary like + // every other reply and is observable in every mode. + assert_eq!( + agent.index_digest, + fly_session::agent::synthetic_graph(&agent.agent_id, 0).index_digest + ); + } + f.harness + .coordinator + .run(STEPS) + .await + .expect("the world runs"); + let at = read_until(&mut consumer, STEPS).await; + assert_eq!(at, STEPS); + let view = consumer.last().expect("a snapshot"); + assert_eq!(view.agents.len(), 2); + assert!(view.agent(&fly_a()).expect("fly-a").decision.is_some()); + assert_eq!( + f.harness + .coordinator + .ledger() + .counters(&f.harness.coordinator.topics().snapshots) + .accepted, + STEPS + 1 + ); + f.shutdown().await; +} + +/// A stalled observer costs only itself, in every mode. +async fn a_stalled_observer_never_moves_the_world_in_any_execution_mode(mode: ExecutionMode) { + let mut f = started_in_mode(mode).await; + let mut slow = f + .harness + .consumer() + .await + .expect("a slow consumer attaches"); + until("the slow viewer has a delivery", || { + slow.try_hold_snapshot() + }) + .await; + while slow.try_hold_snapshot() && slow.held() < 4 {} + let before = f.harness.coordinator.stats(); + let started_at = std::time::Instant::now(); + f.harness + .coordinator + .run(STEPS) + .await + .expect("the world carries on"); + assert_eq!( + f.harness.coordinator.stats().advances - before.advances, + STEPS + ); + assert!(!f.harness.coordinator.is_fenced()); + assert_eq!(f.harness.coordinator.ledger().refusals(), 0); + assert!(started_at.elapsed() < Duration::from_secs(10)); + f.shutdown().await; +} + +// ------------------------------------------------------------------------------------------ +// The policy and the batch, without a session + +#[test] +fn a_topic_policy_declares_its_delivery_once() { + let latest = TopicPolicy::latest("session.demo.snapshots"); + assert_eq!(latest.delivery, Delivery::LatestValue); + assert_eq!(latest.delivery.retained(), flybus::Retained::Latest); + assert_eq!(latest.delivery.subscription().mode, flybus::Mode::Latest); + let bounded = TopicPolicy::bounded("session.demo.events", 8); + assert_eq!(bounded.delivery, Delivery::BoundedBatch { depth: 8 }); + // A bounded stream is never retained: it is "not a durable log", and a retained tail + // would be the beginning of one. + assert_eq!(bounded.delivery.retained(), flybus::Retained::None); + assert_eq!(bounded.delivery.subscription().max_queued, 8); +} + +#[test] +fn a_full_event_batch_drops_the_oldest_by_an_explicit_count() { + let mut outbox = EventOutbox::new(EVENT_BATCH_DEPTH); + let event = |n: u64| TaskEvent { + id: id(&format!("ev-{n}")), + kind_id: id("arena.counter-delta"), + source_step: 1, + agent_id: None, + payload: typed( + SchemaRef { + id: id("t.v1"), + version: 1, + digest: digest_of_bytes(b"t.v1"), + }, + json!({ "n": n }), + ) + .expect("a typed value"), + }; + for n in 0..EVENT_BATCH_DEPTH as u64 { + assert_eq!( + outbox.offer(1, &[event(n)]), + 0, + "nothing is dropped inside the depth" + ); + } + assert_eq!(outbox.len(), EVENT_BATCH_DEPTH); + assert_eq!( + outbox.offer(2, &[event(999)]), + 1, + "the oldest is dropped, and counted" + ); + assert_eq!(outbox.dropped_since_accepted(), 1); + assert_eq!( + outbox.len(), + EVENT_BATCH_DEPTH, + "and the batch stays bounded" + ); +} + +#[tokio::test] +async fn a_snapshot_that_disagrees_with_its_descriptor_is_refused() { + let f = started(Via::Memory).await; + let descriptor = f + .harness + .coordinator + .session_descriptor() + .expect("a descriptor") + .clone(); + let snapshot = { + let state = f.harness.coordinator.published_state(); + let state = state.lock().expect("not poisoned"); + state.latest_snapshot().expect("boundary 0").clone() + }; + let positions: BTreeMap = snapshot + .audio + .iter() + .map(|a| (a.stream_id.clone(), a.first_sample + a.sample_frames)) + .collect(); + let attachments: Vec<(String, ArtifactRef)> = snapshot + .views + .iter() + .map(|v| { + ( + fly_session::media::view_attachment(&v.view_id), + v.pixels.clone(), + ) + }) + .chain(snapshot.audio.iter().map(|a| { + ( + fly_session::media::audio_attachment(&a.stream_id), + a.samples.clone(), + ) + })) + .collect(); + + let mut wrong = snapshot.clone(); + wrong.descriptor_revision = DESCRIPTOR_REVISION + 1; + let error = check_publication(&descriptor, &wrong, &attachments, &positions) + .expect_err("another revision is a disagreement"); + assert_eq!(error.code, ErrorCode::BufferInvalid, "{error:?}"); + + let mut renamed = snapshot.clone(); + renamed.agents[0].agent_id = id("fly-z"); + let error = check_publication(&descriptor, &renamed, &attachments, &positions) + .expect_err("an agent the descriptor does not declare"); + assert!(error.message.contains("fly-z"), "{}", error.message); + + // And a snapshot the session actually published passes the same check. + check_publication(&descriptor, &snapshot, &attachments, &positions) + .expect("what the session published agrees with the descriptor it named"); + f.shutdown().await; +} From 2987b243f47843e5d6f1e8578f2e18bcc0780739 Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 22 Sep 2026 18:37:26 +0000 Subject: [PATCH 02/20] probe: press at every battle menu to see which reading says it is accepting input Row 50 asks which WRAM reading tells a battle menu that is accepting input from one that is only drawn, and the honest way to answer it is to press. FLY_PROBE_CATCH=accept drives real battles, and on every battle frame it exports the emulator state, issues one directional pulse, reads wCurrentMenuItem and puts the state straight back -- so the run is not perturbed by the measurement and every frame gets a ground truth. Beside that it asks every byte of WRAM and HRAM whether its values on accepting frames are disjoint from its values on refusing ones, so a reading is found rather than nominated. The pulse releases the buttons before it presses: JoypadLowSensitivity acts on a key's edge, so a direction the fly is already holding reads as refused for the measurement's reason and not the cartridge's. --- .../crates/flysim/examples/scene_probe.rs | 337 ++++++++++++++++++ 1 file changed, 337 insertions(+) diff --git a/services/flysim/crates/flysim/examples/scene_probe.rs b/services/flysim/crates/flysim/examples/scene_probe.rs index 7282754..dd7fb5c 100644 --- a/services/flysim/crates/flysim/examples/scene_probe.rs +++ b/services/flysim/crates/flysim/examples/scene_probe.rs @@ -610,6 +610,327 @@ fn step_survey(gb: &mut Emulator, adapter: &mut PokemonRedReward, ms: &mut f64) println!("```"); } +/// Ground truth for "this menu is accepting input", measured rather than read off a flag. +/// +/// The emulator exports its own state, one directional pulse is issued into it, and +/// `wCurrentMenuItem` is read: `HandleMenuInput` moves the cursor on UP and DOWN before it even +/// looks at `wMenuWatchedKeys`, so a cursor that moves is a menu that is running its input loop and +/// a cursor that does not is a menu nobody is reading. The state goes straight back afterwards, so +/// the run this is measured inside is not perturbed by the measurement. +fn press_honoured(gb: &mut Emulator) -> bool { + let save = gb.export_state().expect("the emulator should export its own state"); + let before = gb.read8(ram::wCurrentMenuItem); + let mask = if before < gb.read8(ram::wMaxMenuItem) { + flybrain_gb::buttons::DOWN + } else { + flybrain_gb::buttons::UP + }; + // Released first, and that is not cosmetic. `JoypadLowSensitivity` acts on a key's *edge*, so a + // direction the fly is already holding when this pulse begins produces no press at all and the + // frame reads as refused for a reason that is the measurement's and not the cartridge's. The + // first survey of row 50 measured 187 such frames before this line existed. + for phase in 0..ACCEPT_PULSE { + gb.set_buttons(if (8..22).contains(&phase) { mask } else { 0 }); + gb.run_frame().expect("a frame should complete"); + } + let moved = gb.read8(ram::wCurrentMenuItem) != before; + gb.import_state(&save).expect("the emulator should take its own state back"); + moved +} + +/// Frames of the rollback pulse [`press_honoured`] issues: released, held, released. +const ACCEPT_PULSE: usize = 30; + +/// Whether the cursor bytes say "the move list", which is all the seam read before row 50. +fn move_cursor_geometry(gb: &mut Emulator) -> bool { + gb.read8(ram::wTopMenuItemY) == 12 && gb.read8(ram::wTopMenuItemX) == 5 +} + +/// Every byte of WRAM and HRAM, as two sets of values per address. +/// +/// The question row 50 asks is "which byte flips exactly when a press is honoured", and the honest +/// way to answer it is not to nominate candidates but to let every address answer: an address whose +/// values on accepting frames never once overlap its values on refusing frames *is* the reading, +/// and one that overlaps is not, however plausible its name. +struct Separator { + seen: BTreeMap, + counts: [usize; 2], +} + +impl Separator { + fn new() -> Self { + Self { seen: BTreeMap::new(), counts: [0, 0] } + } + + fn observe(&mut self, gb: &mut Emulator, honoured: bool) { + let class = usize::from(honoured); + self.counts[class] += 1; + for address in (0xc000u16..0xe000).chain(0xff80u16..0xffff) { + let value = gb.read8(address); + let bits = self.seen.entry(address).or_insert([[0; 4]; 2]); + bits[class][usize::from(value) / 64] |= 1u64 << (u32::from(value) % 64); + } + } + + /// The addresses whose two value sets never overlap, smallest sets first. + fn disjoint(&self) -> Vec<(u16, Vec, Vec)> { + let mut out: Vec<(u16, Vec, Vec)> = self + .seen + .iter() + .filter(|(_, bits)| { + (0..4).all(|word| bits[0][word] & bits[1][word] == 0) + && bits[0].iter().any(|word| *word != 0) + && bits[1].iter().any(|word| *word != 0) + }) + .map(|(address, bits)| (*address, values(&bits[0]), values(&bits[1]))) + .collect(); + out.sort_by_key(|(address, refused, honoured)| { + (refused.len() + honoured.len(), *address) + }); + out + } +} + +/// A 256-bit set back as the byte values in it, capped so a report line stays a line. +fn values(bits: &[u64; 4]) -> Vec { + let mut out = Vec::new(); + for value in 0..=255u16 { + if bits[usize::from(value) / 64] & (1u64 << (u32::from(value) % 64)) != 0 { + out.push(value as u8); + } + if out.len() >= 9 { + break; + } + } + out +} + +/// What the seam makes of this battle frame, in the shape the pad is dealt from. +fn battle_reading(gb: &mut Emulator, adapter: &PokemonRedReward) -> Option<(String, bool, bool)> { + use flybrain_gb::pokemon_red::macros::state::BattleMenu; + + let ledger = AdapterLedger(adapter); + let mut poke = flybrain_gb::pokemon_red::state::PokeState::with_ledger(gb, &ledger); + let battle = flybrain_gb::pokemon_red::macros::state::GameState::battle(&mut poke)?; + let name = match battle.menu { + BattleMenu::None => "none".to_string(), + BattleMenu::Main { cursor } => format!("main[{cursor}]"), + BattleMenu::Moves { cursor: Some(slot), count } => format!("moves[{slot}/{count}]"), + BattleMenu::Moves { cursor: None, count } => format!("moves[?/{count}]"), + BattleMenu::Party { cursor } => format!("party[{cursor}]"), + BattleMenu::Bag { cursor, count } => format!("bag[{cursor}/{count}]"), + }; + Some((name, battle.own_turn, battle.forced_switch)) +} + +/// Whether the move list's own box is on screen, by the two tiles only it draws. +/// +/// `MoveSelectionMenu`'s regular menu is a `TextBoxBorder` at (4, 12) fourteen wide, with the +/// junction tile written over (10, 12) afterwards. A plain battle text box is the full width of the +/// screen, so (10, 12) is a horizontal run and (4, 13) is inside it; the top-level battle menu's +/// own box starts at column 8. Either mark alone is ambiguous; together they are the move list. +fn move_box_drawn(gb: &mut Emulator) -> bool { + let corner = gb.read8(ram::wTileMap + 12 * 20 + 10); + let wall = gb.read8(ram::wTileMap + 13 * 20 + 4); + matches!(corner, 0x79 | 0x7b | 0x7d | 0x7e) && wall == 0x7c +} + +/// The whole screen as border tiles, the menu cursor and "some text", one row per line. +fn screen_rows(gb: &mut Emulator) -> String { + (0..18u16) + .map(|y| { + let row: String = (0..20u16) + .map(|x| match gb.read8(ram::wTileMap + y * 20 + x) { + 0x7f => '.', + 0x79 | 0x7b | 0x7d | 0x7e => '+', + 0x7a => '-', + 0x7c => '|', + 0xed => '>', + _ => 'x', + }) + .collect(); + format!(" {y:>2} {row}") + }) + .collect::>() + .join("\n") +} + +/// The tiles of the six rows a battle's bottom boxes are drawn in, as one line. +fn box_rows(gb: &mut Emulator) -> String { + (12..18u16) + .map(|y| { + (0..20u16) + .map(|x| match gb.read8(ram::wTileMap + y * 20 + x) { + 0x7f => '.', + 0x79 | 0x7b | 0x7d | 0x7e => '+', + 0x7a => '-', + 0x7c => '|', + 0xed => '>', + _ => 'x', + }) + .collect::() + }) + .collect::>() + .join("/") +} + +/// Row 50's survey: which reading says a battle menu is accepting input, and which only says it is +/// drawn. +/// +/// `infra/docs/macros-traps.md` row 50: `MOVE n` reports `blocked` 890 times in 1,431 macros, every +/// one of them on a move list the seam could place a cursor in. Two readings fit that -- a list +/// that is up and busy, or cursor bytes that outlive the list they were written for -- and they are +/// told apart by pressing at it, so this presses at it: every battle frame is classified by whether +/// a real directional press moves the cursor, and every byte of WRAM and HRAM is asked whether it +/// separates the two classes. +fn accept_survey( + gb: &mut Emulator, + adapter: &mut PokemonRedReward, + ms: &mut f64, + layer: &mut flysim::macros::MacroLayer, + decoder: &mut PopulationDecoder, + channels: &[String], + hold_ms: f64, +) { + let budget = env_usize("FLY_PROBE_FRAMES", 200_000); + let samples = env_usize("FLY_PROBE_SAMPLES", 3_000); + let trace = env_usize("FLY_PROBE_TRACE", 160); + let mut next_burst = *ms; + let mut burst = 0usize; + let mut separators: BTreeMap<&'static str, Separator> = BTreeMap::new(); + let mut tally: BTreeMap<(String, bool), [usize; 2]> = BTreeMap::new(); + let mut shown: BTreeMap<(String, bool), String> = BTreeMap::new(); + let mut traced = 0usize; + let mut tested = 0usize; + // [box not drawn, box drawn] x [press refused, press honoured], over every frame whose cursor + // bytes say "the move list" -- which is the whole of what the seam read before row 50. + let mut readings = [[0usize; 2]; 2]; + + println!("\n## Row 50: every battle frame, pressed at\n"); + println!("```"); + println!( + "frame seam turn honoured ccyx/cur/max/keys d125 cf94 cd6c cfc4 boxes" + ); + for _ in 0..budget { + let bursting = *ms < next_burst + BURST_MS; + let hot = bursting.then(|| channels[(burst / HOLDS_PER_SLOT) % channels.len()].as_str()); + if *ms >= next_burst + hold_ms { + next_burst = *ms; + burst += 1; + } + let bound = layer.bound_channels(); + let active = decoder.decode_bound(&rates(hot), *ms, false, None, Some(&bound)); + let mask = { + let ledger = AdapterLedger(adapter); + layer.decide(&active, 0, *ms, gb, &ledger).mask + }; + gb.set_buttons(mask as u8); + gb.run_frame().expect("a frame should complete"); + *ms += MS_PER_FRAME; + adapter.sample(gb, *ms); + { + let ledger = AdapterLedger(adapter); + let _ = layer.observe(gb, &ledger, *ms); + } + + let Some((name, own_turn, forced)) = battle_reading(gb, adapter) else { continue }; + let geom = move_cursor_geometry(gb); + if (name == "none" && !geom) || forced { + continue; + } + if tested >= samples { + break; + } + tested += 1; + let honoured = press_honoured(gb); + let drawn = move_box_drawn(gb); + if geom { + readings[usize::from(drawn)][usize::from(honoured)] += 1; + } + let key = (format!("{name} drawn={drawn}"), own_turn); + tally.entry(key.clone()).or_insert([0, 0])[usize::from(honoured)] += 1; + let kind = if name.starts_with("moves") { + "the move list" + } else if name.starts_with("main") { + "the top-level menu" + } else if name.starts_with("bag") { + "the bag" + } else { + "the party list" + }; + separators.entry(kind).or_insert_with(Separator::new).observe(gb, honoured); + let boxes = box_rows(gb); + shown.entry((name.clone(), honoured)).or_insert_with(|| screen_rows(gb)); + if traced < trace { + traced += 1; + println!( + "{tested:>5} {name:<18} {:<4} {:<8} {:>2},{:>2},{:>2},{:>2},{:#04x} \ + {:02x} {:02x} {:02x} {:02x} {boxes}", + own_turn, + honoured, + gb.read8(ram::wTopMenuItemY), + gb.read8(ram::wTopMenuItemX), + gb.read8(ram::wCurrentMenuItem), + gb.read8(ram::wMaxMenuItem), + gb.read8(ram::wMenuWatchedKeys), + gb.read8(ram::wTextBoxID), + gb.read8(ram::wListMenuID), + gb.read8(ram::wNumMovesMinusOne), + gb.read8(ram::wFontLoaded), + ); + } + } + println!("```"); + + let (stale, live) = (readings[0], readings[1]); + println!("\n## The move list, by which reading says it is up\n"); + println!("| the reading | press refused | press honoured |"); + println!("| --- | ---: | ---: |"); + println!( + "| the cursor bytes alone (what the seam read before row 50) | {} | {} |", + stale[0] + live[0], + stale[1] + live[1], + ); + println!("| the cursor bytes **and** the box on screen | {} | {} |", live[0], live[1]); + println!("| the cursor bytes with no box drawn | {} | {} |", stale[0], stale[1]); + + println!("\n## What the seam reads against what the cartridge honours\n"); + println!("| the seam's menu | `own_turn` | press refused | press honoured |"); + println!("| --- | --- | ---: | ---: |"); + for ((name, own_turn), counts) in &tally { + println!("| `{name}` | {own_turn} | {} | {} |", counts[0], counts[1]); + } + + for (kind, separator) in &separators { + println!( + "\n## The bytes that separate a honoured press from a refused one, on {kind}\n\n\ + {} refusing frames, {} accepting.\n", + separator.counts[0], separator.counts[1] + ); + let disjoint = separator.disjoint(); + if disjoint.is_empty() { + println!("No single byte of WRAM or HRAM separates the two classes here."); + continue; + } + println!("| address | when refused | when honoured |"); + println!("| ---: | --- | --- |"); + for (address, refused, honoured) in disjoint.iter().take(40) { + println!( + "| `{address:#06x}` | {} | {} |", + refused.iter().map(|v| format!("{v:02x}")).collect::>().join(" "), + honoured.iter().map(|v| format!("{v:02x}")).collect::>().join(" "), + ); + } + println!("\n{} addresses separate in all.", disjoint.len()); + } + + println!("\n## One screen of each class\n\n```"); + for ((name, honoured), boxes) in &shown { + println!("{name} honoured={honoured}\n{boxes}"); + } + println!("```"); +} + fn main() { let Some(path) = std::env::var_os("FLY_ROM") else { println!("FLY_ROM is not set, so there is nothing to probe."); @@ -662,6 +983,22 @@ fn main() { return; } + // Row 50's survey: drive real battles and press at every battle menu the seam reads, to tell a + // list that is accepting input from cursor bytes that outlived their list. + if std::env::var("FLY_PROBE_CATCH").is_ok_and(|value| value == "accept") { + let channels: Vec = channels.iter().map(|name| (*name).to_string()).collect(); + accept_survey( + &mut gb, + &mut adapter, + &mut ms, + &mut layer, + &mut decoder, + &channels, + hold_ms, + ); + return; + } + let budget = env_usize("FLY_PROBE_FRAMES", 200_000); let stuck_after = env_usize("FLY_PROBE_STUCK", 600); let mut next_burst = ms; From e2bf4c030588fe74e8525ffe576c3ffa694c77ca Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 22 Sep 2026 18:37:36 +0000 Subject: [PATCH 03/20] macros: the move list is the box on screen, not the cursor bytes it left behind Row 50: MOVE n reported blocked 890 times in 1,431 macros on the cartridge, every one of them on a frame the seam read as an open move list with a placeable cursor. MoveSelectionMenu writes wTopMenuItemY 12 and wTopMenuItemX 5 and nothing in the game clears them, exactly as the two-option box's geometry outlives its box. SelectMenuItem then decrements wCurrentMenuItem back to the 0-based slot on its way out, which lands straight back inside the one-based range the accessor reads. So the whole of a turn -- the text, the animation, the enemy's reply -- read as the fly's own turn on an open list, the pad dealt the four move buttons on it, and the cursor step pressed at a list nobody was reading until its budget ran out. The reading is the figure the menu draws, the same construction text_box's waiting and yes_no_prompt already make: a box at (4, 12) fourteen wide with a horizontal run over its top-left corner and the junction tile at (10, 12). Surveyed with one rollback pulse per battle frame over 3,102 frames at the rung-9 forest checkpoint: by the cursor bytes alone a real directional press was honoured on 264 of them, and by the cursor bytes and the box on 231 of 231. A frame whose list is not on screen is between turns, whose pad is the one NEXT that advances text. --- .../flybrain-gb/src/pokemon_red/fake_wram.rs | 31 ++++++- .../flybrain-gb/src/pokemon_red/state.rs | 74 ++++++++++++++- .../src/pokemon_red/state/tests.rs | 89 +++++++++++++++++++ 3 files changed, 190 insertions(+), 4 deletions(-) diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/fake_wram.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/fake_wram.rs index 2bf4751..316cbb0 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/fake_wram.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/fake_wram.rs @@ -261,17 +261,42 @@ impl Wram { self.set(ram::wTextBoxID, poke::BATTLE_MENU_TEMPLATE).cursor(14, x, current, 1, keys) } - /// The move list, `MoveSelectionMenu`'s regular menu. `slot` is the 0-based move. + /// The move list, `MoveSelectionMenu`'s regular menu, open and accepting input. `slot` is the + /// 0-based move. + /// + /// Both halves, because since row 50 the seam reads both: the cursor bytes *and* the box the + /// menu draws. Use [`Self::move_menu_stale`] for the state a turn spends its text and animation + /// in, which is these bytes with no box on screen. pub fn move_menu(&mut self, slot: u8, moves: u8) -> &mut Self { + self.move_menu_stale(slot, moves).draw_move_list() + } + + /// The bytes `MoveSelectionMenu` wrote, with its box no longer on screen. + /// + /// Nothing in the game clears `wTopMenuItemY` / `wTopMenuItemX` / `wCurrentMenuItem`, so this + /// is what every frame of a turn's text, animation and reply reads back once a move has been + /// chosen (`infra/docs/macros-traps.md` row 50). Note that `SelectMenuItem` decrements + /// `wCurrentMenuItem` back to the 0-based slot on its way out, so `slot` here is one lower than + /// the slot the fly chose. + pub fn move_menu_stale(&mut self, slot: u8, moves: u8) -> &mut Self { self.set(ram::wNumMovesMinusOne, moves.saturating_sub(1)).cursor( - 12, - 5, + poke::MOVE_LIST_CURSOR_Y, + poke::MOVE_LIST_CURSOR_X, slot + 1, moves + 1, poke::pad::UP | poke::pad::DOWN | poke::pad::A, ) } + /// The figure `MoveSelectionMenu` draws: a box at (4, 12) with a horizontal run over its + /// top-left corner and the `┘` junction at (10, 12). + pub fn draw_move_list(&mut self) -> &mut Self { + let (left, top, right, bottom) = poke::MOVE_LIST_BOX; + self.draw_box(left, top, right, bottom) + .screen_tile(left, top, poke::frame::HORIZONTAL) + .screen_tile(poke::MOVE_LIST_JOIN, top, poke::frame::BOTTOM_RIGHT) + } + /// The party list. `forced` is the state `ChooseNextMon` leaves: A only, no way out. pub fn party_list(&mut self, current: u8, forced: bool) -> &mut Self { let count = self.peek(ram::wPartyCount).max(1); diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs index 0bd3e43..bdbbc6e 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs @@ -87,6 +87,18 @@ pub mod poke { pub const YES_NO_CURSOR_Y: u8 = 8; pub const YES_NO_CURSOR_X: u8 = 12; + /// The move list's own box, and the junction tile in its top edge + /// (`infra/docs/macros-traps.md`, row 50). + /// + /// `MoveSelectionMenu`'s regular menu draws a `TextBoxBorder` at (4, 12) fourteen wide and + /// four tall, then writes a horizontal run over its top-left corner and a `┘` over (10, 12). + /// Values rather than symbols, like `YES_NO_BOX`: this is a figure on screen, not a byte. + pub const MOVE_LIST_BOX: (u16, u16, u16, u16) = (4, 12, 19, 17); + pub const MOVE_LIST_JOIN: u16 = 10; + /// Where `MoveSelectionMenu` parks the shared cursor: row 12, column 5. + pub const MOVE_LIST_CURSOR_Y: u8 = 12; + pub const MOVE_LIST_CURSOR_X: u8 = 5; + /// `constants/ram_constants.asm`: `wMiscFlags` bit 3. pub const BIT_USING_GENERIC_PC: u8 = 1 << 3; /// `wFontLoaded` bit 0. @@ -428,9 +440,22 @@ pub fn battle(memory: &mut dyn MemoryReader) -> Option { // round, so `ITEM` and `THROW BALL` opened the party list and `SWITCH` opened the bag. let column = if right { 2 } else { 0 }; BattleMenu::Main { cursor: column + cursor.current.min(1) } - } else if cursor.top_y == 12 && cursor.top_x == 5 { + } else if cursor.top_y == poke::MOVE_LIST_CURSOR_Y + && cursor.top_x == poke::MOVE_LIST_CURSOR_X + && move_list_drawn(memory) + { // MoveSelectionMenu's regular menu. Its list is one-based: `wCurrentMenuItem` is // `wPlayerMoveListIndex + 1` and `wMaxMenuItem` is the move count plus one. + // + // **Both halves are load-bearing** (row 50, 2026-09-22). The cursor bytes are written once + // and never cleared, so the geometry alone is true for the whole turn -- the text, the + // animation, the enemy's reply -- and `SelectMenuItem` decrements `wCurrentMenuItem` back + // to a 0-based slot as it leaves, which lands right back inside this accessor's one-based + // range. So a frame of battle text read as an open move list with a placeable cursor, the + // pad dealt `MOVE 1..4` on it, and the cursor step pressed at a list nobody was reading: + // `MOVE n` reported `blocked` 890 times in 1,431 macros. The box on screen is what says the + // list is up, and it is the same construction `text_box`'s `waiting` and `yes_no_prompt` + // already make. let count = read(memory, ram::wNumMovesMinusOne).saturating_add(1).min(4); let slot = cursor.current.checked_sub(1).filter(|slot| *slot < count); BattleMenu::Moves { cursor: slot, count } @@ -476,6 +501,10 @@ pub fn battle(memory: &mut dyn MemoryReader) -> Option { // still 0 rather than the one-based slot the menu keeps. Measured on the cartridge: a wild // Weedle's opening frame reads `Moves { cursor: None, count: 2 }` with `own: None`. That // frame is between turns, which is what it was before this change. + // + // A move list that is *not on screen* never reaches this arm at all since row 50: the menu + // above reads `None` for it, so the frame is between turns and its pad is the one `NEXT` + // that advances text (section 12.10). BattleMenu::Moves { cursor, .. } => cursor.is_some(), BattleMenu::Party { .. } => !forced_switch, // The bag is a list the fly opened *during* its turn, and it is a menu cursor accepting @@ -576,6 +605,49 @@ pub fn yes_no_prompt(memory: &mut dyn MemoryReader) -> bool { border_drawn(memory, left, top, right, bottom) } +/// Whether `MoveSelectionMenu`'s own box is the figure on screen (`infra/docs/macros-traps.md`, +/// row 50). +/// +/// The cursor bytes alone are not the move list. `wTopMenuItemY` 12 and `wTopMenuItemX` 5 are +/// written by `MoveSelectionMenu` and **nothing clears them**, exactly as the two-option box's +/// geometry outlives its box (`yes_no_prompt` above): the whole rest of the turn -- the text, the +/// animation, the damage, the enemy's reply -- reads back the same five bytes. Surveyed on the +/// cartridge over 1,878 battle frames at the rung-9 forest checkpoint, one rollback pulse per frame +/// (`examples/scene_probe.rs`, `FLY_PROBE_CATCH=accept`): with this box **not** drawn a real +/// directional press moved `wCurrentMenuItem` on 15 frames of 1,731, and with it drawn on 140 of +/// 147. The cursor geometry on its own is honoured on 155 of 1,878. +/// +/// The figure is `MoveSelectionMenu`'s regular menu and only it: a `TextBoxBorder` at (4, 12) +/// fourteen wide and four tall, with two tiles written over it afterwards -- the top-left corner +/// becomes a horizontal run and (10, 12) becomes the `┘` junction with the PP box above. The +/// mimic and relearn menus draw at row 7 and never reach a battle's own turn. Read whole, like +/// every other box in this module, because a single tile id is an ordinary character. +fn move_list_drawn(memory: &mut dyn MemoryReader) -> bool { + let (left, top, right, bottom) = poke::MOVE_LIST_BOX; + if screen_tile(memory, left, top) != poke::frame::HORIZONTAL + || screen_tile(memory, poke::MOVE_LIST_JOIN, top) != poke::frame::BOTTOM_RIGHT + || screen_tile(memory, right, top) != poke::frame::TOP_RIGHT + || screen_tile(memory, left, bottom) != poke::frame::BOTTOM_LEFT + || screen_tile(memory, right, bottom) != poke::frame::BOTTOM_RIGHT + { + return false; + } + for x in (poke::MOVE_LIST_JOIN + 1)..right { + if screen_tile(memory, x, top) != poke::frame::HORIZONTAL { + return false; + } + } + for x in (left + 1)..right { + if screen_tile(memory, x, bottom) != poke::frame::HORIZONTAL { + return false; + } + } + (top + 1..bottom).all(|y| { + screen_tile(memory, left, y) == poke::frame::VERTICAL + && screen_tile(memory, right, y) == poke::frame::VERTICAL + }) +} + /// The four screen tiles the dialogue box's `waiting` test reads, in the order `box_drawn` reads /// them: top-left, top-right, bottom-left, bottom-right of a box at (0, 12)-(19, 17). /// diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/state/tests.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/state/tests.rs index 463b488..61e5981 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/state/tests.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/state/tests.rs @@ -227,6 +227,95 @@ fn the_move_list_is_reported_zero_based() { assert_eq!(battle(&mut wram).unwrap().menu, BattleMenu::Moves { cursor: None, count: 3 }); } +/// Row 50: the move list is the box on screen, not the cursor bytes it left behind. +/// +/// `MoveSelectionMenu` writes `wTopMenuItemY` 12 and `wTopMenuItemX` 5 and **nothing in the game +/// clears them**, exactly as the two-option box's geometry outlives its box (`yes_no_prompt`). +/// `SelectMenuItem` then decrements `wCurrentMenuItem` back to the 0-based slot on its way out, +/// which lands straight back inside the one-based range this accessor reads -- so a frame of battle +/// text read as an open move list with a placeable cursor, the pad dealt `MOVE 1..4` on it, and the +/// cursor step pressed at a list nobody was reading until its budget ran out: `MOVE n` reported +/// `blocked` 890 times in 1,431 macros on the cartridge. +/// +/// Surveyed with one rollback pulse per battle frame (`examples/scene_probe.rs`, +/// `FLY_PROBE_CATCH=accept`, 3,102 frames at the rung-9 forest checkpoint): by the cursor bytes +/// alone a real directional press moved the cursor on 264 frames, and by the cursor bytes **and** +/// the box on screen on 231 of 231. +#[test] +fn a_move_list_is_the_box_on_screen_and_not_the_cursor_bytes_it_left_behind() { + let battler = |wram: &mut Wram| { + wram.party_mon(0, 4, 7, 14, 22, 0, &[(10, 35)]); + wram.battle_mon(0, 4, 7, 14, 22, 0, &[(10, 35), (45, 40), (33, 30)]) + .enemy_mon(19, 3, 5, 11) + .battle(1); + }; + + // The list drawn: the fly's own turn, on the slot the cursor is on. + let mut wram = Wram::overworld(); + battler(&mut wram); + wram.move_menu(2, 3); + let fight = battle(&mut wram).unwrap(); + assert_eq!(fight.menu, BattleMenu::Moves { cursor: Some(2), count: 3 }); + assert!(fight.own_turn, "a move list with its box on screen is the fly's turn"); + + // The same bytes with the box gone -- every frame of the turn's text, animation and reply. + // Not a move list at all, so not the own turn, so the pad is the between-turns `NEXT`. + let mut wram = Wram::overworld(); + battler(&mut wram); + wram.move_menu_stale(2, 3); + let fight = battle(&mut wram).unwrap(); + assert_eq!(fight.menu, BattleMenu::None, "the cursor bytes alone are not a move list"); + assert!(!fight.own_turn, "cursor bytes with no box are between turns"); + + // The exact shape row 50 was measured in: `SelectMenuItem` decrements on its way out, so the + // slot the fly chose reads back one lower and stays inside the one-based range for ever. + let mut wram = Wram::overworld(); + battler(&mut wram); + wram.move_menu(3, 3).set(ram::wCurrentMenuItem, 3); + assert!(battle(&mut wram).unwrap().own_turn, "with the box drawn this is a real slot"); + let mut wram = Wram::overworld(); + battler(&mut wram); + wram.move_menu_stale(3, 3).set(ram::wCurrentMenuItem, 3); + assert!(!battle(&mut wram).unwrap().own_turn, "the same byte, no box, no turn"); + + // Half a figure is not a box. The junction tile at (10, 12) is the one `MoveSelectionMenu` + // writes over its own border, and a run of horizontals there is an ordinary text box. + let mut wram = Wram::overworld(); + battler(&mut wram); + wram.move_menu(1, 3).screen_tile(poke::MOVE_LIST_JOIN, 12, poke::frame::HORIZONTAL); + assert_eq!(battle(&mut wram).unwrap().menu, BattleMenu::None, "the junction tile is read"); + + // And the pads the two frames are dealt, which is what row 50 costs: the four move buttons and + // `BACK` where a list is open (section 13.1), and the one `NEXT` that advances text where the + // turn is resolving (section 12.10). + use crate::pokemon_red::macros::palette::{MacroKind, scene_set}; + let pad = |wram: &mut Wram| { + let scene = crate::pokemon_red::scene::detect(wram); + let mut poke = PokeState::new(wram); + scene_set(scene, &mut poke) + }; + + let mut wram = Wram::overworld(); + battler(&mut wram); + wram.move_menu(1, 3); + assert_eq!( + pad(&mut wram), + vec![ + MacroKind::Move1, + MacroKind::Move2, + MacroKind::Move3, + MacroKind::Move4, + MacroKind::Back + ], + "an open move list" + ); + + let mut wram = Wram::overworld(); + battler(&mut wram); + wram.move_menu_stale(1, 3); + assert_eq!(pad(&mut wram), vec![MacroKind::Next], "the same bytes with no box on screen"); +} + #[test] fn a_forced_switch_is_the_party_list_that_cannot_be_cancelled() { let mut wram = Wram::overworld(); From 6588897ab3312b9e972cd40cd61b59aa29632b98 Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 22 Sep 2026 18:43:05 +0000 Subject: [PATCH 04/20] docs: a menu is up while its box is on screen (section 12.18, row 50) macros.md gains 12.18 and macros-wram.md a section 10 for the accessor and the survey that found it: press at every battle frame with a rollback pulse, and ask every byte of WRAM and HRAM which of them separates a honoured press from a refused one. The half of row 50 that was wrong is where the fix is. The move list was not drawn: MoveSelectionMenu's cursor bytes are never cleared and SelectMenuItem decrements wCurrentMenuItem back into the one-based range on its way out, so a turn's whole text and animation read as an open list with a placeable cursor. The battle bag is the same trap on wListMenuID and is named rather than fixed, because the figure that tells its list from the frame after it closes has not been surveyed yet. --- docs/design/macros-wram.md | 71 +++++++++++++++++++++++++++++++++++++- docs/design/macros.md | 49 ++++++++++++++++++++++++-- infra/docs/macros-traps.md | 2 +- 3 files changed, 118 insertions(+), 4 deletions(-) diff --git a/docs/design/macros-wram.md b/docs/design/macros-wram.md index 3b55030..5cf5efb 100644 --- a/docs/design/macros-wram.md +++ b/docs/design/macros-wram.md @@ -128,7 +128,7 @@ parked its cursor. All five bytes are contiguous: `wTopMenuItemY` `$cc24`, `wTop | menu | signature | cursor | verified | | --- | --- | --- | --- | | the top-level battle menu | `wTextBoxID` = `$0b`, `wTopMenuItemY` = 14, `wTopMenuItemX` = 9 with watched keys `PAD_RIGHT\|PAD_A` (left column) or 15 with `PAD_LEFT\|PAD_A` (right), `wMaxMenuItem` = 1 (`DisplayBattleMenu`, `engine/battle/core.asm:2081` and `:2114`) | reported 0 FIGHT, 1 PKMN, 2 ITEM, 3 RUN: the game keeps the index *within* the column and `.rightColumn` adds two on selection | ROM (a fresh menu is FIGHT; RIGHT is ITEM; DOWN from there is RUN), trace | -| the move list | `wTopMenuItemY` = 12, `wTopMenuItemX` = 5 (`MoveSelectionMenu`'s regular menu, `:2492`) | the game's list is **one-based** — `wCurrentMenuItem` is `wPlayerMoveListIndex + 1` and `wMaxMenuItem` is the move count plus one — so the accessor reports the 0-based slot, and `None` for an index that names no move | trace | +| the move list | `wTopMenuItemY` = 12, `wTopMenuItemX` = 5 (`MoveSelectionMenu`'s regular menu, `:2492`) **and the box it draws** — section 10, because nothing clears the cursor bytes and `SelectMenuItem` decrements `wCurrentMenuItem` back into range on its way out | the game's list is **one-based** — `wCurrentMenuItem` is `wPlayerMoveListIndex + 1` and `wMaxMenuItem` is the move count plus one — so the accessor reports the 0-based slot, and `None` for an index that names no move | trace, and the press survey of section 10 | | the party list | `wTopMenuItemY` = 1, `wTopMenuItemX` = 0, `wMaxMenuItem` = `wPartyCount - 1`, watched keys `PAD_A\|PAD_B` or `PAD_A` alone (`PartyMenuInit`, `home/pokemon.asm:201`) | 0-based party slot | trace | | **a forced switch** | the party list, in a battle, with `wPartyMenuTypeOrMessageID` = `BATTLE_PARTY_MENU` (`$02`) at `$d07d`. `ChooseNextMon` is the battle path that sets it (`engine/battle/core.asm:1088`, and `:1389` for the "use next mon?" branch); choosing PKMN from the menu sets `NORMAL_PARTY_MENU` (`$00`, `:2316`), which is why the two are distinguishable. `wForcePlayerToChooseMon` (`$d11f`) is the byte `PartyMenuInit` turns into "A only, no way out". | — | trace | @@ -714,3 +714,72 @@ answers that, and the executor's per-step moved check covers the rest), a warp t step onto it, and a script that pushes the fly off a tile (a session ledger answers that). The water half of the tile-pair lists is deliberately absent: it is the list `CheckForJumpingAndTilePairCollisions` uses while surfing, and the palette cannot surf. + +## 10. A menu that is accepting input, against one that is only remembered (2026-09-22, row 50) + +`HandleMenuInput` is shared by every menu in the game (section 2) and so are the five bytes it +parks a cursor in. Section 2's table reads those bytes to say *which* menu is up; it does not say +whether anybody is reading them. The difference is the whole of row 50: `MOVE n` reported `blocked` +**890 times in 1,431 macros** on the cartridge, every one of them on a frame the seam called an +open move list with a placeable cursor. + +**Nothing in the game clears the cursor bytes.** `MoveSelectionMenu` writes `wTopMenuItemY` 12 and +`wTopMenuItemX` 5 once, and the whole of the turn that follows — the text, the animation, the +damage, the enemy's reply — reads them back unchanged. It is the same fact section 7's YES/NO box +rests on ("the cursor bytes survive the box closing"), and the reason the battle's *top-level* menu +never had this problem is that it carries `wTextBoxID` = `$0b` beside its geometry. + +`SelectMenuItem` makes it worse rather than better: on its way out of `HandleMenuInput` it does +`ld a, [wCurrentMenuItem] / dec a / ld [wCurrentMenuItem], a`, turning the menu's one-based index +back into a 0-based move slot. That lands straight back inside the range the accessor reads as a +valid one-based slot, so a turn spent on move 2, 3 or 4 leaves a *placeable* cursor behind it. + +### The accessor + +| state | how | verified | +| --- | --- | --- | +| the move list is **accepting input** | the cursor at `wTopMenuItemY` 12 / `wTopMenuItemX` 5 **and** the figure `MoveSelectionMenu` draws: a `TextBoxBorder` at (4, 12) fourteen wide and four tall, with a horizontal run written over its top-left corner and the `┘` junction written over (10, 12) (`engine/battle/core.asm`, `.regularmenu`). Read whole — both verticals, both horizontal runs, all four corners — because a single frame tile id is an ordinary character. The mimic and relearn menus draw at row 7 and never reach a battle's own turn. | survey (below) | + +`Scene::Battle { own_turn }` follows it: a frame whose move list is not on screen reads +`BattleMenu::None`, which is nobody's turn, which is the between-turns row and its one `NEXT` +(`docs/design/macros.md` 12.10). Nothing else moves — the top-level menu, the party list and the +bag keep the readings they had. + +### The survey + +`examples/scene_probe.rs`, `FLY_PROBE_CATCH=accept`, from the rung-9 forest checkpoint. The +question "is this menu accepting input" is answered by **pressing at it**, not by nominating a +flag: on every battle frame the emulator exports its state, one directional pulse is issued, +`wCurrentMenuItem` is read, and the state goes straight back — `HandleMenuInput` moves the cursor +on UP and DOWN before it even looks at `wMenuWatchedKeys`, so a cursor that moves is a menu running +its input loop. The pulse *releases* the buttons first, because `JoypadLowSensitivity` acts on a +key's edge and a direction the fly is already holding would read as refused for the measurement's +reason rather than the cartridge's. + +| the reading | press refused | press honoured | +| --- | ---: | ---: | +| the cursor bytes alone (what the seam read before row 50) | 2,838 | 264 | +| the cursor bytes **and** the box on screen | **0** | **231** | +| the cursor bytes with no box drawn | 2,838 | 33 | + +So 91.5% of the frames the old reading called an open move list were frames no press reached, and +the reading that survives is exact on the 231 it keeps. (The 33 are frames where the pulse's own +thirty frames were long enough for the cartridge to open something by itself; the pulse is a +measurement and not a claim about one frame.) + +Beside the press, the probe asks **every byte of WRAM and HRAM** whether its values on accepting +frames are disjoint from its values on refusing ones, so a reading is found rather than guessed. +Over the move list, once the box is in the reading, no byte separates the two classes at all — +there is nothing left to separate. Over the whole class before the fix, the only separators were +the HRAM joypad bytes, which is the measurement seeing its own held button. + +### What the same survey found and this section did not fix + +- **The top-level battle menu is already exact**: 413 frames, 0 refused. `wTextBoxID` is why. +- **The bag is the same trap, unfixed and named.** `wListMenuID` = `ITEMLISTMENU` outlives the bag + exactly as the cursor bytes outlive the move list: 449 refused against 36 honoured over the + frames the seam calls an open battle bag. The bag list is drawn in the top half of the screen and + the survey has not yet found the figure that tells it from the frame after it closes, so it is + reported rather than guessed — `docs/design/ladder.md`'s rule. `ITEM` and `THROW BALL` are the + two macros it costs. +- **The party list, likewise**: `PartyMenuInit`'s geometry outlives its list. diff --git a/docs/design/macros.md b/docs/design/macros.md index 81535b7..3c2c559 100644 --- a/docs/design/macros.md +++ b/docs/design/macros.md @@ -1217,6 +1217,51 @@ walk. Both were measured from the same rung-10 checkpoint, with Nothing here changes which button the fly presses. The decoder, the reward catalog, the adapter version and the compatibility string are untouched. +### 12.18 A menu is up while its box is on screen, not while its cursor bytes say so (2026-09-22, row 50) + +The largest thing left inside a battle after 12.17: `MOVE n` reported `blocked` **890 times in +1,431 macros** from the rung-9 forest checkpoint, `MOVE 4` **222 of 224**, and 82% of a fixed run's +frames were battle time with one battle running 30,809 of them. Row 50 called it "the move list +drawn and its cursor placeable but not accepting input". Half of that turned out to be wrong, and +finding out which half is the whole fix. + +- **The cursor bytes outlive the list, so the list was not drawn at all.** `MoveSelectionMenu` + writes `wTopMenuItemY` 12 and `wTopMenuItemX` 5 and **nothing in the game clears them**. That is + the same fact 12.12 rested the YES/NO box on — "the cursor bytes survive the box closing" — and + the reason the *top-level* battle menu never had it is that `wTextBoxID` = `$0b` sits beside its + geometry and is written by somebody else. `SelectMenuItem` then decrements `wCurrentMenuItem` back + into a 0-based move slot on its way out, which lands inside the one-based range the accessor reads + as valid, so a turn spent on move 2, 3 or 4 leaves a *placeable* cursor behind it. Every frame of + the text, the animation, the damage and the enemy's reply read as the fly's own turn on an open + move list. The pad dealt `MOVE 1..4` and `BACK` on all of them, the roll landed on one, and the + cursor step pressed at a list nobody was reading until its budget ran out. That is section 12.2's + trap wearing 12.6's clothes: a macro whose precondition is satisfied where the fly stands. +- **So a menu is up while its box is on screen.** The reading is the figure `MoveSelectionMenu` + draws — a box at (4, 12) fourteen wide, with a horizontal run over its top-left corner and the + `┘` junction over (10, 12) — read whole, exactly as `text_box`'s `waiting` and `yes_no_prompt` + are. `docs/design/macros-wram.md` section 10 has the accessor. +- **It was surveyed by pressing, not by nominating a flag.** `examples/scene_probe.rs`, + `FLY_PROBE_CATCH=accept`: on every battle frame the emulator exports its state, one directional + pulse is issued, `wCurrentMenuItem` is read and the state goes straight back, so every frame has a + ground truth and the run is not perturbed by the measurement. By the cursor bytes alone a press + was honoured on **264 frames of 3,102**; by the cursor bytes and the box on **231 of 231**. Beside + it every byte of WRAM and HRAM was asked whether it separates the two classes, so a reading was + found rather than guessed — nothing separates once the box is in it, which is what "exact" means + here. +- **A frame whose list is not on screen is between turns**, whose pad is the one `NEXT` that + advances text (12.10). No pad gains or loses a button anywhere else: the top-level menu, the party + list, the bag and the forced switch keep exactly the rows 13.1 gives them, and which move the fly + uses is still the fly's. +- **The bag is the same trap and it is named rather than fixed.** `wListMenuID` = `ITEMLISTMENU` + outlives the bag as surely as the cursor bytes outlive the move list: over the frames the seam + calls an open battle bag, the same survey refused **449** presses against 36 honoured. The bag's + list is drawn in the top half of the screen and the survey has not yet found the figure that tells + it from the frame after it closes, so `ITEM` and `THROW BALL` still pay for it and that is + reported. A reading this crate cannot verify does not go in (`docs/design/ladder.md`). + +The decoder, the reward catalog, the adapter version, the roles and the compatibility string are +untouched. + ## 13. Shops and Pokémon Centers (the operator, 2026-09-17: "refactor the shop macros. make it a ## priority to visit the shop at least once per area; make shop macros item purchases. same ## for the Pokécenter. heal should be a macro.") @@ -1298,11 +1343,11 @@ observe is not a precondition, it is a guess. | Menu (the bag, an elevator, the party list outside a battle) | CLOSE, CONFIRM, BACK | unchanged | | Unknown (the Pokédex, the trainer card, OPTION, a naming screen, a mid-warp frame) | NEXT, **BACK** | **BACK added** (row 9): B is what leaves the first three, and A leaves none of them | | Battle, own turn, main menu | MOVE 1..4, SWITCH, ITEM, THROW BALL, RUN (whose cursor indices are FIGHT 0, **ITEM 1, PKMN 2**, RUN 3 -- two columns, 12.11) | four move buttons for `ATTACK` (section 14); THROW BALL added, and gated on the species since 12.9; **RUN gated**, below. No `BACK`: the four entries are the answers to this menu. **`NEXT` removed by 12.10** — an A press here confirms FIGHT and reopens the list the move list's `BACK` just closed, and `MOVE 1` is the backstop instead, bound here whatever the battler reads as | -| Battle, own turn, move list | MOVE 1..4, BACK -- or **MOVE 1 alone** | as above, plus **12.11**: `BACK` is dealt here only while `wBattleMon*` reads, because a list that binds no `MOVE n` has a pad whose one button closes the list `MOVE 1` underneath had just opened. With nothing readable the pad is `MOVE 1` and its script confirms where the cursor stands | +| Battle, own turn, move list (**the box on screen**, 12.18) | MOVE 1..4, BACK -- or **MOVE 1 alone** | as above, plus **12.11**: `BACK` is dealt here only while `wBattleMon*` reads, because a list that binds no `MOVE n` has a pad whose one button closes the list `MOVE 1` underneath had just opened. With nothing readable the pad is `MOVE 1` and its script confirms where the cursor stands | | Battle, own turn, party list | SWITCH, BACK | unchanged | | Battle, own turn, the bag | ITEM, THROW BALL, **BACK** | the bag reports a *cursor* (`macros-wram.md` 7.1), and since **12.10** it is the own turn, because a cursor accepting input is one. Its pad is the list's own answers; `NEXT` and `CONFIRM` are both off it, being the same blind A press that *uses* whatever the cursor holds | | Battle, forced switch | SWITCH, NEXT | unchanged (row 8). The one arm that keeps `NEXT` with a cursor up, because it cannot be cancelled and has no `BACK` to undo it | -| Battle, between turns | NEXT | `BACK` was added here for the bag and **taken back out by 12.9**: on a frame of battle text there is no list to leave, and a `BACK` that changes nothing is the trap of section 12.2. Since **12.10** the bag is not on this row at all, so `NEXT` here is only ever the A that advances text | +| Battle, between turns | NEXT | since **12.18** this row is most of a battle, and correctly so: a frame whose move list is remembered rather than drawn lands here. `BACK` was added here for the bag and **taken back out by 12.9**: on a frame of battle text there is no list to leave, and a `BACK` that changes nothing is the trap of section 12.2. Since **12.10** the bag is not on this row at all, so `NEXT` here is only ever the A that advances text | | Shop | BUY POTION, BUY BALL, BUY ANTIDOTE, BUY REPEL, CONFIRM, LEAVE | two purchases to four; CONFIRM added | | PC | **CONFIRM**, LEAVE | **CONFIRM added**: a list the fly opened is one it can answer rather than only close. Depositing and withdrawing are still not in the vocabulary (row 17) | | Title | nothing | unchanged, by contract: the readout's boot variant applies | diff --git a/infra/docs/macros-traps.md b/infra/docs/macros-traps.md index 8043bf7..90a2835 100644 --- a/infra/docs/macros-traps.md +++ b/infra/docs/macros-traps.md @@ -1863,7 +1863,7 @@ same question, and that was the second half of the trap. | 41 | the nurse's conversation is a ring of forty-six A presses that ends where it began, and the dialog pad deals two names for the A press that walks it | standing at a Pokémon Center's counter with a party that is already full -- which is every visit after a heal, and the state a `GO HEAL` errand leaves the fly in | `talk_is_off_the_pad_at_a_nurse_the_party_has_no_use_for`, `the_nurses_prompt_offers_only_the_answer_that_changes_something`, `a_completed_heal_writes_the_nurse_into_the_talked_ledger`, `a_declined_heal_writes_the_nurse_into_the_talked_ledger`, `the_fly_leaves_the_pokemon_center_from_the_rung_ten_checkpoint` (ROM-gated) | **fixed**: `TALK` is off the pad at a nurse the party has no use for; her prompt deals only the answer that changes something; `NEXT` is off any readable YES/NO pad, because an A press there *is* `YES`; and a completed heal or a declined prompt retires her | | 48 | `TALK` is bound by a reach that goes over a counter and recorded by one that does not, so a counter person is never retired | any mart clerk or centre nurse, since the counter reach was added | `a_completed_heal_writes_the_nurse_into_the_talked_ledger` (the ledger entry is the assertion) | **fixed**: the ledger entry comes from `palette::facing_target`, which is `TALK`'s own precondition | | 49 | a YES/NO answer that brings the same prompt straight back | any readable two-option box the answer does not settle | `a_yes_no_box_that_reopens_unchanged_takes_that_answer_off_the_pad`, `a_prompt_that_does_not_come_back_excludes_nothing` | **fixed**: `TargetKey::Answer { at, yes }` in the blocked ledger, same ten-minute window as a walk's target, armed for one hold after the answer. The exclusion narrows a pad and never empties one | -| 50 | `MOVE n` reports `blocked` with the move list drawn and its cursor placeable but not accepting input | every battle | -- | **unchanged from v0.4.3 and v0.4.4, named again**: 222 of 224 `MOVE 4` and 162 of 171 `MOVE 2` in the ROM run below. Row 30b's unplaceable cursor inverted; the honest fix is a WRAM reading of "this list is accepting input" rather than a pad change, and it is the next brief | +| 50 | `MOVE n` reports `blocked` with the move list drawn and its cursor placeable but not accepting input | every battle | `a_move_list_is_the_box_on_screen_and_not_the_cursor_bytes_it_left_behind`, and the `MOVE n` blocked share in `the_battles_turns_advance_from_the_rung_nine_forest_checkpoint` (ROM) | **fixed** (2026-09-22, `docs/design/macros.md` 12.18), and the half of the row that was wrong is where the fix is: the list was **not** drawn. `MoveSelectionMenu`'s cursor bytes are never cleared and `SelectMenuItem` decrements `wCurrentMenuItem` back into the one-based range on its way out, so every frame of a turn's text and animation read as an open list with a placeable cursor. A menu is up while its **box** is on screen -- surveyed by pressing at every battle frame with a rollback pulse, honoured on 264 frames of 3,102 by the cursor bytes alone and on **231 of 231** by the bytes and the box | ### The ROM-gated run, from the live checkpoint From f43fd4d9ffd8ede3d284e2bfeb34ea155a235e68 Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 22 Sep 2026 18:47:10 +0000 Subject: [PATCH 05/20] session: supportedStimuli is enforced, and the query method names are provisional An undeclared stimulus kind is refused before the model is touched, proved by an injection through Agent.Commit rather than by a unit call, so the declaration a descriptor publishes is the thing the worker enforces. The publishing-v1 section 2 amendment now says in its own words that Session.GetDescriptor and Session.GetSnapshot are internal and provisional names, which the later public v2 step may rename or supersede. --- .../design/session-framework/publishing-v1.md | 4 ++ .../crates/fly-session/src/coordinator.rs | 14 ++++- .../crates/fly-session/tests/publishing.rs | 56 +++++++++++++++++++ 3 files changed, 73 insertions(+), 1 deletion(-) diff --git a/docs/design/session-framework/publishing-v1.md b/docs/design/session-framework/publishing-v1.md index c1ebce2..c438ac4 100644 --- a/docs/design/session-framework/publishing-v1.md +++ b/docs/design/session-framework/publishing-v1.md @@ -51,6 +51,10 @@ or, with no revision, the newest; `Session.GetSnapshot` takes no parameters and latest `CommittedSnapshot`. A revision the session never published is `IDENTITY_MISMATCH`, not an empty answer. Nothing on this service mutates, selects a participant or reaches a worker, so it is not the controller API section 7 rules out; adding a third method that did would be. +These two names are **internal and provisional**: they are what the internal boundary needs in +order to be buildable now, and the later public v2 step is free to rename them, supersede them +or expose a different repair surface entirely. Nothing about them is browser-facing, and the +public step does not inherit them by default merely because they landed first. Descriptor revisions and scope link observations to schemas. Cross-topic ordering is not guaranteed; a subscriber receiving an unknown descriptor revision must fetch it through the diff --git a/services/flysim/crates/fly-session/src/coordinator.rs b/services/flysim/crates/fly-session/src/coordinator.rs index aa21e8e..fb5ddaa 100644 --- a/services/flysim/crates/fly-session/src/coordinator.rs +++ b/services/flysim/crates/fly-session/src/coordinator.rs @@ -61,6 +61,8 @@ pub struct Injections { /// Publish this boundary's snapshot with a handle that is not the artifact the snapshot /// references: the same name, the same shape, another object. pub substituted_published_handle: bool, + /// Ask an agent to apply a stimulus kind its published descriptor does not declare. + pub undeclared_stimulus: bool, } /// What an injection produced, for a test to assert on. @@ -2246,13 +2248,23 @@ impl Coordinator { .expect("every agent prepared"); let outcome = outcomes.get(&agent_id).cloned().unwrap_or_default(); let next_context = next_contexts.get(&agent_id).cloned().expect("checked"); + let mut task_stimulations = outcome.stimulations.clone(); + if self.injections.undeclared_stimulus && self.injections.at_step == k { + // A kind outside the agent's published `supportedStimuli`. The declaration is + // only worth publishing if the worker enforces it. + task_stimulations.push(Stimulus { + id: parse_id(&format!("stim-undeclared-{k}")).expect("a serial makes an Id"), + kind_id: id("arena.undeclared"), + duration_ms: 1.0, + }); + } let params = CommitParams { agent_id: agent_id.clone(), prepared_request_id: prepared_request.clone(), next_input: self.sensory_input(observation, k + 1), next_decision_context: next_context, rewards: outcome.rewards.clone(), - task_stimulations: outcome.stimulations.clone(), + task_stimulations, }; let params = match params.to_json() { Value::Object(m) => m, diff --git a/services/flysim/crates/fly-session/tests/publishing.rs b/services/flysim/crates/fly-session/tests/publishing.rs index 52ca5a9..a600717 100644 --- a/services/flysim/crates/fly-session/tests/publishing.rs +++ b/services/flysim/crates/fly-session/tests/publishing.rs @@ -37,6 +37,7 @@ both_transports!( a_refused_event_batch_is_held_and_counted_not_lost, the_query_service_answers_reads_and_nothing_else, the_published_descriptor_is_what_the_workers_attested_to, + a_stimulus_kind_the_descriptor_does_not_declare_is_refused, ); all_modes!( @@ -608,6 +609,61 @@ async fn the_published_descriptor_is_what_the_workers_attested_to(via: Via) { f.shutdown().await; } +/// `supportedStimuli` is enforced, not advertised: a kind outside the list the descriptor +/// publishes is refused before the model is touched, so the declaration is worth reading. +async fn a_stimulus_kind_the_descriptor_does_not_declare_is_refused(via: Via) { + let mut f = started(via).await; + let declared = f + .harness + .coordinator + .session_descriptor() + .expect("a descriptor") + .agents + .iter() + .find(|a| a.agent_id == fly_a()) + .expect("fly-a") + .supported_stimuli + .clone(); + assert!(!declared.contains(&id("arena.undeclared")), "{declared:?}"); + + // A clean transition first, so the refusal is the injection and not the composition. + f.harness.coordinator.run(1).await.expect("one clean transition"); + let before = f.harness.coordinator.stats().advances; + f.harness.coordinator.injections = Injections { + at_step: 1, + undeclared_stimulus: true, + ..Injections::default() + }; + let failure = f + .harness + .coordinator + .run(1) + .await + .expect_err("an undeclared stimulus kind is refused"); + assert_eq!(failure.error.code, ErrorCode::Unsupported, "{failure:?}"); + assert_eq!( + failure.error.mutation, + MutationCertainty::None, + "refused before the model is touched" + ); + assert!( + failure.error.message.contains("arena.undeclared"), + "the refusal names the kind: {}", + failure.error.message + ); + assert!(failure.participant.is_some(), "and the participant it came from"); + assert_eq!( + f.harness.coordinator.stats().advances, + before, + "the refused transition never completed, so no boundary was added" + ); + assert!( + f.harness.coordinator.is_fenced(), + "a commit that refused after the world moved fences the epoch" + ); + f.shutdown().await; +} + // ------------------------------------------------------------------------------------------ // "A committed action is labelled as the transition that just ended, not the one about to // start." From 6d67fa7ed293ca7c00a9019ab90ece740400ee39 Mon Sep 17 00:00:00 2001 From: acamilo Date: Tue, 22 Sep 2026 18:47:42 +0000 Subject: [PATCH 06/20] session: a retried Acknowledge is not a failed epoch The bound test failed two runs in thirty under load, and not on the bound it was testing. Both failures were bootstrap: "a worker did not acknowledge every lifecycle reply". ipc-v1 section 5 says already released or unknown ids are ignored, and the contract type already holds the acknowledged list to a subset of the request. So the second Acknowledge of the same ids answers with an empty list by design -- and the section 6 resolution produces exactly that second Acknowledge whenever the first reply is slower than the probe. Demanding the whole list back turned a safe, contract-sanctioned retry into a failed epoch, which is a defect in the coordinator rather than in the test: a slow lifecycle reply would do it to a real session too. The test made itself easy to hit by installing a fifty-millisecond probe before bootstrap, so bootstrap's own lifecycle calls ran under a budget meant for the step under test. It now bootstraps at ordinary deadlines and tightens them afterwards. The two bounds are also separated by construction rather than by clock. Each half puts the bound it is not testing out of reach -- u32::MAX attempts against a fifth of a second, three attempts against an hour -- so no scheduling delay can flip which one fires, and the silent participant is ten minutes slow against a twenty-second test timeout, so returning at all proves a bound ended it. The wall-clock assertion is gone and the attempt count is asserted instead, which last_resolution_attempts now records. One agent per composition, so the participant the failure names is not a race either. --- .../crates/fly-session/src/coordinator.rs | 21 +++-- .../crates/fly-session/tests/processes.rs | 85 ++++++++++++------- 2 files changed, 68 insertions(+), 38 deletions(-) diff --git a/services/flysim/crates/fly-session/src/coordinator.rs b/services/flysim/crates/fly-session/src/coordinator.rs index e7e4bb7..825a366 100644 --- a/services/flysim/crates/fly-session/src/coordinator.rs +++ b/services/flysim/crates/fly-session/src/coordinator.rs @@ -290,6 +290,8 @@ pub struct Coordinator { pub resolutions: u64, /// How the last resolution ended, so a test or a supervisor can tell which bound fired. pub last_resolution: Option, + /// How many attempts the last resolution spent. Counted, not inferred from the clock. + pub last_resolution_attempts: u32, /// The caller-side failure-detection budgets of `ipc-v1` section 6. pub deadlines: Deadlines, /// Per-method and critical-path latency samples. Local synthetic timings, never a @@ -357,6 +359,7 @@ impl Coordinator { in_progress_replies: 0, resolutions: 0, last_resolution: None, + last_resolution_attempts: 0, deadlines: Deadlines::default(), metrics: Metrics::default(), blame: None, @@ -840,14 +843,15 @@ impl Coordinator { let reply = self .call(&worker, "Worker.Acknowledge", None, object(params.to_json()), &[], &[]) .await?; - let result: AcknowledgeResult = + // The reply lists what this call released, which is not always everything it + // asked about: `ipc-v1` section 5 says "Already released/unknown IDs are + // ignored", and the contract type already holds the list to a subset of the + // request. A second Acknowledge therefore answers with an empty list by design -- + // and the section 6 resolution produces exactly that second Acknowledge whenever + // the first one's reply was slow. Demanding the whole list back turned a safe, + // contract-sanctioned retry into a failed epoch. + let _: AcknowledgeResult = reply.parse().map_err(|e| self.fail_now(e, "acknowledge"))?; - if result.acknowledged.len() != ids.len() { - return Err(self.fail_now( - DomainError::invalid("a worker did not acknowledge every lifecycle reply"), - "acknowledge", - )); - } } self.audit.push("acknowledge.lifecycle".to_owned()); Ok(()) @@ -1147,11 +1151,14 @@ impl Coordinator { // The budget is the working limit and the attempt count is a guard; whichever runs // out is recorded, so "it gave up" is never an unexplained number. let mut end = ResolutionEnd::AttemptsExhausted; + let mut spent = 0u32; for _ in 0..attempts { if started.elapsed() >= budget { end = ResolutionEnd::BudgetExpired; break; } + spent += 1; + self.last_resolution_attempts = spent; let outcome = call_owned( self.bus.clone(), worker.clone(), diff --git a/services/flysim/crates/fly-session/tests/processes.rs b/services/flysim/crates/fly-session/tests/processes.rs index d2dcd53..77014c5 100644 --- a/services/flysim/crates/fly-session/tests/processes.rs +++ b/services/flysim/crates/fly-session/tests/processes.rs @@ -16,7 +16,7 @@ use common::{at, count, fly_a, fly_b, mode_fixture, within}; use fly_session::agent::AgentFaults; use fly_session::coordinator::{DispatchOrder, Injections}; use fly_session::environment::EnvironmentFaults; -use fly_session::harness::{ExecutionMode, HarnessConfig, Via}; +use fly_session::harness::{AgentSpec, ExecutionMode, HarnessConfig, Via}; use fly_session::launcher::{ReapOutcome, ThreadBudget}; use fly_session::ResolutionEnd; use fly_session::phase::Phase; @@ -114,15 +114,19 @@ async fn a_slow_participant_is_resolved_rather_than_failed(mode: ExecutionMode) config.environment_faults = EnvironmentFaults { advance_delay_ms: 500, ..EnvironmentFaults::default() }; let mut f = mode_fixture(mode, config).await; + // Bootstrap first, at ordinary deadlines: its lifecycle calls are not what this test is + // about, and squeezing them through the probe below only tests the machine's luck. + within("bootstrap", f.harness.coordinator.bootstrap()).await.unwrap(); // A probe well inside both delays, and a resolution budget well outside them: the point is - // a call that expires and an operation that is nevertheless fine. + // a call that expires and an operation that is nevertheless fine. The guard is out of + // reach so the budget is the only bound in play, and the budget is far above what the + // delays need, so neither ends this resolution -- the answer does. f.harness.coordinator.deadlines = fly_session::Deadlines { probe: Duration::from_millis(120), - resolve: Duration::from_secs(20), - resolve_attempts: 4096, + resolve: Duration::from_secs(15), + resolve_attempts: u32::MAX, boot: Duration::from_secs(30), }; - within("bootstrap", f.harness.coordinator.bootstrap()).await.unwrap(); let reports = within("run", f.harness.coordinator.run(2)) .await .expect("a slow participant is resolved, not failed"); @@ -174,26 +178,44 @@ async fn a_slow_participant_is_resolved_rather_than_failed(mode: ExecutionMode) f.shutdown().await; } +/// One agent, one port, and a participant that will not answer this side of the test's own +/// timeout. The composition for the bound tests: one participant means one possible name in +/// the failure, so which agent is blamed is not a race. +fn one_silent_agent(mode: ExecutionMode) -> HarnessConfig { + HarnessConfig { + agents: vec![AgentSpec { + // Ten minutes. The suite's own `within` gives up at twenty seconds, so if the step + // returns at all, a bound ended it and not the participant. That is a claim about + // the code rather than about how fast this machine happens to be. + faults: AgentFaults { prepare_delay_ms: 600_000, ..AgentFaults::default() }, + ..AgentSpec::new("fly-a", "p1", 7) + }], + mode, + ..HarnessConfig::default() + } +} + /// The resolution has two bounds, and which one ended it is never left to be guessed. /// -/// `resolve` is the working limit at the default values -- the attempt guard is over sixteen -/// seconds of pauses against an eight-second budget -- so an unresponsive participant runs the -/// budget out. Setting the guard low instead ends the same resolution the other way, and the -/// failure says so both in `last_resolution` and in its own message. +/// Both halves are arranged so the bound under test is the only one that *can* fire: the +/// other is set orders of magnitude out of reach, so no amount of scheduling delay flips them. +/// The claim is the contract's -- a resolution ends by budget or by guard, records which, and +/// names it in the failure -- and nothing here is timed. +/// +/// The deadlines are installed after `bootstrap`, deliberately. Bootstrap makes lifecycle +/// calls of its own, and squeezing them through a fifty-millisecond probe tests the harness's +/// luck rather than the resolution. async fn a_resolution_says_which_of_its_two_bounds_ended_it(mode: ExecutionMode) { - // The budget is what ends it at ordinary settings: a generous attempt guard, a short - // budget, and a participant far slower than either. - let mut config = two_agents(mode); - config.agents[1].faults = AgentFaults { prepare_delay_ms: 30_000, ..AgentFaults::default() }; - let mut f = mode_fixture(mode, config).await; + // Half one: the budget fires, because the guard cannot. `u32::MAX` attempts at the two + // millisecond pause is over ninety days; the budget is a fifth of a second. + let mut f = mode_fixture(mode, one_silent_agent(mode)).await; + within("bootstrap", f.harness.coordinator.bootstrap()).await.unwrap(); f.harness.coordinator.deadlines = fly_session::Deadlines { probe: Duration::from_millis(50), - resolve: Duration::from_millis(300), - resolve_attempts: 8192, + resolve: Duration::from_millis(200), + resolve_attempts: u32::MAX, boot: Duration::from_secs(30), }; - within("bootstrap", f.harness.coordinator.bootstrap()).await.unwrap(); - let started = Instant::now(); let failure = within("step", f.harness.coordinator.step()) .await .expect_err("a participant that never answers exhausts the resolution"); @@ -202,36 +224,37 @@ async fn a_resolution_says_which_of_its_two_bounds_ended_it(mode: ExecutionMode) failure.error.message.contains("resolution budget"), "the message names the bound that fired: {failure}" ); - assert_eq!(failure.participant.as_deref(), Some(fly_b().as_str())); - assert_eq!(failure.error.mutation, MutationCertainty::Unknown); assert!( - started.elapsed() < Duration::from_secs(20), - "the budget, not the 30-second participant, is what ended it" + f.harness.coordinator.last_resolution_attempts < u32::MAX, + "the budget ended it with attempts still in hand, which is what makes it the budget" ); + assert_eq!(failure.participant.as_deref(), Some(fly_a().as_str())); + assert_eq!(failure.error.mutation, MutationCertainty::Unknown); assert!(f.harness.coordinator.is_fenced()); f.shutdown().await; - // The guard is what ends it when it is set below the budget: three attempts against a - // budget the participant could never reach anyway. - let mut config = two_agents(mode); - config.agents[1].faults = AgentFaults { prepare_delay_ms: 30_000, ..AgentFaults::default() }; - let mut f = mode_fixture(mode, config).await; + // Half two: the guard fires, because the budget cannot. Three attempts against an hour. + let mut f = mode_fixture(mode, one_silent_agent(mode)).await; + within("bootstrap", f.harness.coordinator.bootstrap()).await.unwrap(); f.harness.coordinator.deadlines = fly_session::Deadlines { probe: Duration::from_millis(50), - resolve: Duration::from_secs(600), + resolve: Duration::from_secs(3_600), resolve_attempts: 3, boot: Duration::from_secs(30), }; - within("bootstrap", f.harness.coordinator.bootstrap()).await.unwrap(); let failure = within("step", f.harness.coordinator.step()) .await .expect_err("three attempts are not enough to resolve a silent participant"); assert_eq!(f.harness.coordinator.last_resolution, Some(ResolutionEnd::AttemptsExhausted)); assert!( - failure.error.message.contains("attempt guard") && failure.error.message.contains("3 attempts"), + failure.error.message.contains("attempt guard") + && failure.error.message.contains("3 attempts"), "the message names the bound that fired and its size: {failure}" ); - assert_eq!(failure.participant.as_deref(), Some(fly_b().as_str())); + // Counted, not timed: the guard was spent exactly, and the hour never came near. + assert_eq!(f.harness.coordinator.last_resolution_attempts, 3); + assert_eq!(failure.participant.as_deref(), Some(fly_a().as_str())); + assert_eq!(failure.error.mutation, MutationCertainty::Unknown); f.shutdown().await; } From c25c28efe37105e5ee6eb984eb4abd489589ddbe Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 22 Sep 2026 18:57:18 +0000 Subject: [PATCH 07/20] test: hold the rung-nine forest run to a MOVE n that finishes, and a median battle Row 50's two numbers, ROM-gated from the forest checkpoint. Before, on main: MOVE n 940 starts and 838 blocked, 11 battles entered and 10 ended, worst 503 macros, median 48. After: 51 starts and 0 blocked, 13 entered and 13 ended, worst 283, median 43, and the fly leaves the forest north through the gate. The blocked share is the assertion the row is about; the median is what it buys, and the worst battle is a tail rather than the run. --- .../crates/flysim/tests/rom_macros_mode.rs | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/services/flysim/crates/flysim/tests/rom_macros_mode.rs b/services/flysim/crates/flysim/tests/rom_macros_mode.rs index 4cfacff..a8022d5 100644 --- a/services/flysim/crates/flysim/tests/rom_macros_mode.rs +++ b/services/flysim/crates/flysim/tests/rom_macros_mode.rs @@ -219,6 +219,9 @@ struct Run { /// number of macros rather than on however many holds the 2-cycle takes to fall out of. macros_this_battle: u32, worst_battle_macros: u32, + /// What every battle that *ended* cost in macros, so the run can be asked for a median rather + /// than only for its worst (row 50). + battle_costs: Vec, battles_entered: u32, battles_ended: u32, was_in_battle: bool, @@ -396,6 +399,7 @@ impl Run { last_battle_start: None, macros_this_battle: 0, worst_battle_macros: 0, + battle_costs: Vec::new(), battles_entered: 0, battles_ended: 0, was_in_battle: false, @@ -507,6 +511,7 @@ impl Run { last_battle_start: None, macros_this_battle: 0, worst_battle_macros: 0, + battle_costs: Vec::new(), battles_entered: 0, battles_ended: 0, was_in_battle: false, @@ -904,6 +909,7 @@ impl Run { self.battles_ended += 1; self.worst_battle_macros = self.worst_battle_macros.max(self.macros_this_battle); + self.battle_costs.push(self.macros_this_battle); self.macros_this_battle = 0; } _ => {} @@ -1337,6 +1343,50 @@ fn the_battles_turns_advance_from_the_rung_nine_forest_checkpoint() { run.worst_battle_macros, run.longest_next_back_alternation ); + + // **Row 50**: a `MOVE n` that starts on an accepting move list finishes its cursor walk. + // + // What was measured on v0.4.6 from this checkpoint: `MOVE n` reported `blocked` **890 times in + // 1,431 macros**, and `MOVE 4` 222 of 224 -- every one of them on a frame whose cursor bytes + // said "the move list" while no list was on screen. `MoveSelectionMenu` writes + // `wTopMenuItemY` 12 and `wTopMenuItemX` 5 and nothing ever clears them, so the whole of a + // turn's text, animation and reply read back an open list with a placeable cursor; the pad + // dealt the four move buttons on it and the cursor step pressed at nothing until its budget + // ran out. Surveyed with one rollback pulse per battle frame + // (`examples/scene_probe.rs`, `FLY_PROBE_CATCH=accept`): by the cursor bytes alone a real + // directional press was honoured on 264 frames of 3,102, and by the cursor bytes *and* the box + // on screen on 231 of 231. + let move_starts: u32 = run + .started + .iter() + .filter(|(name, _)| name.starts_with("MOVE ")) + .map(|(_, n)| *n) + .sum(); + let move_blocked: u32 = run + .blocked + .iter() + .filter(|(name, _)| name.starts_with("MOVE ")) + .map(|(_, n)| *n) + .sum(); + eprintln!("`MOVE n`: {move_starts} starts, {move_blocked} blocked"); + assert!(move_starts > 0, "no move button ever started: {:?}", run.started); + assert!( + move_blocked * 20 < move_starts, + "`MOVE n` reported blocked on {move_blocked} of {move_starts} starts, which is row 50" + ); + + // And what that buys, which is the thing the audience sees: a battle that is over in a + // sensible number of presses rather than one that spends its turns pressing at text. The + // worst battle is a tail; the median is the run. + let mut costs = run.battle_costs.clone(); + costs.sort_unstable(); + let median = costs.get(costs.len() / 2).copied().unwrap_or(0); + eprintln!("battle cost in macros: {costs:?}, median {median}"); + assert!( + median > 0 && median < 300, + "the median battle cost {median} macros over {} that ended", + run.battles_ended + ); // `BACK` is still pressed, and that is the contract rather than a residual: over the move list // and over a one-Pokemon party list it is one of the two answers a list has, and where it // leads is a menu with the move buttons on it (row 34). Its share is *reported* -- under this From f6baeb5cfa13479a79a6772eb2d4570496f64bcf Mon Sep 17 00:00:00 2001 From: acamilo Date: Tue, 22 Sep 2026 19:03:18 +0000 Subject: [PATCH 08/20] session: a test for the acknowledge rule, not only for the flake The short-list acknowledgment is a contract rule, so it gets a test that says so rather than one that depends on a worker being slow. acknowledge_replies carries the ipc-v1 section 5 sentence in its doc comment and returns what the worker actually released; acknowledge_lifecycle calls it, so bootstrap and the test exercise the same path. an_acknowledge_that_releases_nothing_is_not_a_failure drives the case directly, once per execution mode: bootstrap releases every lifecycle reply, the test asks for those ids again, the worker ignores them and releases nothing, and the coordinator must accept the empty list, stay unfenced, stay at its boundary and still play the next transition. It fails if the equality check returns. --- .../crates/fly-session/src/coordinator.rs | 43 ++++++++++++----- .../crates/fly-session/tests/processes.rs | 47 +++++++++++++++++++ 2 files changed, 77 insertions(+), 13 deletions(-) diff --git a/services/flysim/crates/fly-session/src/coordinator.rs b/services/flysim/crates/fly-session/src/coordinator.rs index 825a366..f9d74c5 100644 --- a/services/flysim/crates/fly-session/src/coordinator.rs +++ b/services/flysim/crates/fly-session/src/coordinator.rs @@ -839,24 +839,41 @@ impl Coordinator { .push(request_id); } for (worker, ids) in by_worker.into_values() { - let params = AcknowledgeParams { request_ids: ids.clone() }; - let reply = self - .call(&worker, "Worker.Acknowledge", None, object(params.to_json()), &[], &[]) - .await?; - // The reply lists what this call released, which is not always everything it - // asked about: `ipc-v1` section 5 says "Already released/unknown IDs are - // ignored", and the contract type already holds the list to a subset of the - // request. A second Acknowledge therefore answers with an empty list by design -- - // and the section 6 resolution produces exactly that second Acknowledge whenever - // the first one's reply was slow. Demanding the whole list back turned a safe, - // contract-sanctioned retry into a failed epoch. - let _: AcknowledgeResult = - reply.parse().map_err(|e| self.fail_now(e, "acknowledge"))?; + self.acknowledge_replies(&worker, &ids).await?; } self.audit.push("acknowledge.lifecycle".to_owned()); Ok(()) } + /// Releases a worker's retained lifecycle replies, and accepts a short answer. + /// + /// **`ipc-v1` section 5: "Already released/unknown IDs are ignored."** The reply lists what + /// *this* call released, which is not always everything it asked about, and the contract + /// type already holds that list to a subset of the request. So a second Acknowledge of the + /// same ids answers with an empty list by design, and an empty list is success. + /// + /// This matters beyond tidiness. The `ipc-v1` section 6 resolution turns any Acknowledge + /// whose reply is slower than the probe into a second Acknowledge of the same ids, so the + /// short answer is not an edge case -- it is what the contract produces on an ordinarily + /// slow worker. Requiring the whole list back made the contract's own idempotence a failed + /// epoch, which is what + /// `an_acknowledge_that_releases_nothing_is_not_a_failure` guards against. + /// + /// Returns the ids the worker actually released. + pub async fn acknowledge_replies( + &mut self, + worker: &WorkerRef, + request_ids: &[DomainRequestId], + ) -> Outcome> { + let params = AcknowledgeParams { request_ids: request_ids.to_vec() }; + let reply = self + .call(worker, "Worker.Acknowledge", None, object(params.to_json()), &[], &[]) + .await?; + let result: AcknowledgeResult = + reply.parse().map_err(|e| self.fail_now(e, "acknowledge"))?; + Ok(result.acknowledged) + } + /// Queries one worker's status without waiting for its current mutation. pub async fn status(&mut self, worker: &WorkerRef) -> Outcome { let reply = self diff --git a/services/flysim/crates/fly-session/tests/processes.rs b/services/flysim/crates/fly-session/tests/processes.rs index 77014c5..6cdd3b5 100644 --- a/services/flysim/crates/fly-session/tests/processes.rs +++ b/services/flysim/crates/fly-session/tests/processes.rs @@ -23,6 +23,7 @@ use fly_session::phase::Phase; use fly_session::types::*; all_modes!( + an_acknowledge_that_releases_nothing_is_not_a_failure, a_slow_participant_is_resolved_rather_than_failed, a_resolution_says_which_of_its_two_bounds_ended_it, a_delayed_one_agent_result_holds_the_world, @@ -84,6 +85,52 @@ async fn sequential_reversed_and_parallel_completion_agree() { } } +// ------------------------------------------------------------------------------------------- +// ipc-v1 section 5: an Acknowledge that releases nothing is success + +/// `ipc-v1` section 5: "Already released/unknown IDs are ignored." +/// +/// A second `Worker.Acknowledge` of ids the worker has already released answers with an empty +/// list. That is the contract working, not a worker misbehaving, and the coordinator must +/// accept it and carry on. The session's own bootstrap releases every lifecycle reply, so +/// asking again for the same ids is exactly that case -- driven directly here rather than by +/// making something slow, because it is a rule about the reply and not about timing. +/// +/// The rule has teeth because of section 6: any Acknowledge whose reply outruns the probe is +/// resolved, and the resolution *is* a second Acknowledge of the same ids. A coordinator that +/// demands the whole list back therefore fences a healthy session the first time a worker is +/// slow to answer. It did, on this branch's parent; this test fails if that check returns. +async fn an_acknowledge_that_releases_nothing_is_not_a_failure(mode: ExecutionMode) { + let mut f = mode_fixture(mode, two_agents(mode)).await; + // Bootstrap acknowledges every lifecycle reply, so afterwards the worker holds none. + within("bootstrap", f.harness.coordinator.bootstrap()).await.unwrap(); + let worker = f.harness.coordinator.agent_ref(&fly_a()).cloned().unwrap(); + + // The ids bootstrap already released. The worker ignores them and releases nothing. + let already: Vec = + (1..=3).map(DomainRequestId::from_serial).collect(); + let released = within( + "acknowledge", + f.harness.coordinator.acknowledge_replies(&worker, &already), + ) + .await + .expect("a second Acknowledge of released ids is success, not a failed epoch"); + assert!( + released.is_empty(), + "already released ids are ignored, so this call released nothing: {released:?}" + ); + + // The session is untouched by it: not fenced, still at its boundary, and still plays. + assert!(!f.harness.coordinator.is_fenced(), "an empty acknowledgment is not a fault"); + assert_eq!(f.harness.coordinator.phase(), Phase::Ready(0)); + let report = within("step", f.harness.coordinator.step()) + .await + .expect("the session continues after an Acknowledge that released nothing"); + assert_eq!(report.boundary, 1); + assert_eq!(f.harness.coordinator.stats().advances, 1); + f.shutdown().await; +} + // ------------------------------------------------------------------------------------------- // ipc-v1 section 6: an uncertain call is resolved, not failed From ef82a05a3a745a4ef89f4c3b3287abcae4b8e91a Mon Sep 17 00:00:00 2001 From: acamilo Date: Tue, 22 Sep 2026 19:05:58 +0000 Subject: [PATCH 09/20] docs: which replies permit a subset, and which do not The sweep behind this branch found one check demanding an exact match where the contract permits a short answer, and four that were right to demand one. The question that separates them belongs where the next check gets written, not only in a run report: is the far side reporting what it did, or being held to a requirement? Worker.Acknowledge is the only reply of the first kind here, because ipc-v1 section 5 makes it idempotent. The commit and batch checks are the second kind and are named so nobody loosens them later in the name of tolerance; they are what make a partial commit and an incomplete batch fail. --- services/flysim/crates/fly-session/README.md | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/services/flysim/crates/fly-session/README.md b/services/flysim/crates/fly-session/README.md index 8e114e2..1d3b0ae 100644 --- a/services/flysim/crates/fly-session/README.md +++ b/services/flysim/crates/fly-session/README.md @@ -178,6 +178,22 @@ harness.shutdown().await; event ids derived from epoch, source step, rule and ordinal. - **Executors.** The stateless identity executor only, as v1 specifies. +## Before you add a check to a reply + +Ask which kind of reply it is. Is the far side **reporting what it did**, in which case a +subset or an empty answer is permitted and must be accepted? Or is it **being held to a +requirement**, in which case exactness is the rule and must be enforced? `Worker.Acknowledge` +is the only reply of the first kind in this crate, because `ipc-v1` section 5 explicitly makes +it idempotent -- "Already released/unknown IDs are ignored" -- so a second one legitimately +releases nothing, and the section 6 resolution turns any slow Acknowledge into exactly that +second one. Demanding the whole list back there fenced healthy sessions until +`an_acknowledge_that_releases_nothing_is_not_a_failure` was written. + +The commit and batch checks are the second kind and must stay exact: `commit_all` requires +every agent (`step-v1` section 3 phase D, section 7) and `check_batch` requires every declared +port (`workers-v1` section 3). Loosening those in the name of tolerance is the same mistake +pointing the other way -- they are what make a partial commit and an incomplete batch fail. + ## Where this crate narrows or adds to the contract crate - **Required views.** `WorldObservation::validate_against` checks the views a result carries From 9501a5a17c533a16ca2197433272b4c9e8285e39 Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 22 Sep 2026 19:09:42 +0000 Subject: [PATCH 10/20] macros: the survey numbers in the accessor are the corrected pulse's The first pass of the press survey counted 187 refusals that were its own held button: JoypadLowSensitivity acts on a key's edge, so a direction the fly was already holding produced no press. With the pulse releasing first, the reading is 264 honoured of 3,102 by the cursor bytes alone and 231 of 231 by the bytes and the box. --- .../flysim/crates/flybrain-gb/src/pokemon_red/state.rs | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs b/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs index bdbbc6e..007b87a 100644 --- a/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs +++ b/services/flysim/crates/flybrain-gb/src/pokemon_red/state.rs @@ -612,10 +612,11 @@ pub fn yes_no_prompt(memory: &mut dyn MemoryReader) -> bool { /// written by `MoveSelectionMenu` and **nothing clears them**, exactly as the two-option box's /// geometry outlives its box (`yes_no_prompt` above): the whole rest of the turn -- the text, the /// animation, the damage, the enemy's reply -- reads back the same five bytes. Surveyed on the -/// cartridge over 1,878 battle frames at the rung-9 forest checkpoint, one rollback pulse per frame -/// (`examples/scene_probe.rs`, `FLY_PROBE_CATCH=accept`): with this box **not** drawn a real -/// directional press moved `wCurrentMenuItem` on 15 frames of 1,731, and with it drawn on 140 of -/// 147. The cursor geometry on its own is honoured on 155 of 1,878. +/// cartridge over 3,102 battle frames at the rung-9 forest checkpoint, one rollback pulse per frame +/// (`examples/scene_probe.rs`, `FLY_PROBE_CATCH=accept`): by the cursor geometry alone a real +/// directional press moved `wCurrentMenuItem` on **264** of them, and by the geometry **and** this +/// box on **231 of 231**. With the box not drawn, 33 of 2,871 -- and those thirty-three are frames +/// where the pulse's own thirty were long enough for the cartridge to open something by itself. /// /// The figure is `MoveSelectionMenu`'s regular menu and only it: a `TextBoxBorder` at (4, 12) /// fourteen wide and four tall, with two tiles written over it afterwards -- the top-left corner From 7ce645f1dcf2670efc4a3b87aa60fd96bf8e4c0e Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 22 Sep 2026 19:24:44 +0000 Subject: [PATCH 11/20] session: a restored boundary is an installed one, and the revision follows the composition A group restore re-establishes a committed boundary this epoch did not run a transition into, so its snapshot carries no decision and no controls for any agent, and a fresh epoch is a new compositionDigest, so the descriptor takes the next revision rather than republishing revision 1 with different contents. CommittedSnapshot's rule becomes: null at boundary 0 and at an installed boundary, always together, and for every agent or none -- a snapshot where one fly acted and another did not would be two boundaries in one value. Dated amendment to publishing-v1 section 3, with the schema set, the fixtures and the TypeScript package moved together and the digest regenerated. --- .../design/session-framework/publishing-v1.md | 10 + packages/session-types/src/publishing.ts | 14 +- .../fixtures/contract-digest.json | 4 +- .../fly-session-types/fixtures/invalid.json | 174 +++++++++++++++++- .../fixtures/schema-set.json | 2 +- .../fly-session-types/fixtures/valid.json | 94 ++++++++++ .../fly-session-types/src/publishing.rs | 21 ++- .../crates/fly-session-types/src/schema.rs | 4 +- .../crates/fly-session/src/coordinator.rs | 29 ++- .../crates/fly-session/tests/publishing.rs | 57 ++++++ 10 files changed, 393 insertions(+), 16 deletions(-) diff --git a/docs/design/session-framework/publishing-v1.md b/docs/design/session-framework/publishing-v1.md index c438ac4..7258845 100644 --- a/docs/design/session-framework/publishing-v1.md +++ b/docs/design/session-framework/publishing-v1.md @@ -91,6 +91,16 @@ interface CommittedSnapshot { } ``` +**Amendment, 2026-09-22 (PUBLISH-01).** "Null at initial boundary 0" is the rule for a +boundary this epoch *produced*. A group restore ([state/media](state-media-v1.md) section 5) +re-establishes a committed boundary `k > 0` that this epoch did not run a transition into, and +the abandoned epoch's decisions are not this session's to republish under a new epoch. So the +rule is: `selectedDecision` and `appliedControls` are null at boundary 0 and at a boundary +*installed* by a restore, present otherwise, and always **together** and for **every agent or +none**. A snapshot where one fly carries an action and another does not would be two different +boundaries in one value, and is refused. Without this, the section 6 requirement to publish the +recovery could not be met at all: the restored boundary's snapshot would be unrepresentable. + Publish only after all agent commits establish Ready(k). Decisions/controls describe the transition ending at that boundary, null at initial boundary 0. Health updates are separate and never claim an uncommitted future boundary. Every transient media reference is a declared diff --git a/packages/session-types/src/publishing.ts b/packages/session-types/src/publishing.ts index a117650..104e777 100644 --- a/packages/session-types/src/publishing.ts +++ b/packages/session-types/src/publishing.ts @@ -168,16 +168,24 @@ export function readCommittedSnapshot(value: unknown): CommittedSnapshot { const atBoundaryZero = u64(scope.step) === 0n; for (const agent of agents) { // "Decisions/controls describe the transition ending at that boundary, null at initial - // boundary 0." (publishing-v1 section 3) + // boundary 0." (publishing-v1 section 3, and its 2026-09-22 amendment for a boundary that + // was installed rather than produced.) if (atBoundaryZero && (agent.selectedDecision !== null || agent.appliedControls !== null)) { fail('CommittedSnapshot: at boundary 0 selectedDecision and appliedControls are null'); } - if (!atBoundaryZero && (agent.selectedDecision === null || agent.appliedControls === null)) { + if ((agent.selectedDecision === null) !== (agent.appliedControls === null)) { fail( - 'CommittedSnapshot: past boundary 0 every agent has a decision and applied controls', + 'CommittedSnapshot: selectedDecision and appliedControls are null together or present together', ); } } + // A boundary is produced by a transition or installed by one, and the whole snapshot says + // which: every agent carries the transition that ended here, or none does. + if (agents.some((a) => (a.selectedDecision === null) !== (agents[0].selectedDecision === null))) { + fail( + 'CommittedSnapshot: either every agent carries the transition that ended here, or none does', + ); + } return { descriptorRevision, publisherIncarnation, diff --git a/services/flysim/crates/fly-session-types/fixtures/contract-digest.json b/services/flysim/crates/fly-session-types/fixtures/contract-digest.json index 36c1f10..90e4419 100644 --- a/services/flysim/crates/fly-session-types/fixtures/contract-digest.json +++ b/services/flysim/crates/fly-session-types/fixtures/contract-digest.json @@ -1,8 +1,8 @@ { "description": "contractDigest is the SHA-256 of the canonical schema set in schema-set.json.", - "contractDigest": "fa1f9671c098e0e5fe2d2ad9642d10eae39c6e751760eb1db7b51d5575898509", + "contractDigest": "7f4b11d6737e5097c6889657479527174ddf496e50b60322b281e6c7490bcb4a", "schemaSetVersion": 1, - "schemaSetBytes": 27407, + "schemaSetBytes": 27470, "types": 54, "enums": 11, "limits": 26 diff --git a/services/flysim/crates/fly-session-types/fixtures/invalid.json b/services/flysim/crates/fly-session-types/fixtures/invalid.json index 0b6bcff..b43edec 100644 --- a/services/flysim/crates/fly-session-types/fixtures/invalid.json +++ b/services/flysim/crates/fly-session-types/fixtures/invalid.json @@ -4168,7 +4168,179 @@ }, "eventIds": [] }, - "reason": "a committed transition has applied controls" + "reason": "selectedDecision and appliedControls are null together or present together" + }, + { + "name": "snapshot where one agent carries the transition and another does not", + "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 + } + ] + } + }, + { + "agentId": "fly-b", + "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": null, + "appliedControls": null + } + ], + "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" + ] + }, + "reason": "a boundary is produced or installed for the whole composition, never per agent" }, { "name": "trace whose commit acknowledgment is the old boundary", diff --git a/services/flysim/crates/fly-session-types/fixtures/schema-set.json b/services/flysim/crates/fly-session-types/fixtures/schema-set.json index 6477910..7078001 100644 --- a/services/flysim/crates/fly-session-types/fixtures/schema-set.json +++ b/services/flysim/crates/fly-session-types/fixtures/schema-set.json @@ -1 +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},{"name":"maxWorkerThreads","source":"workers-v1 2","value":4096}],"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":"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":"<= 64, unique","kind":"array","name":"supportedStimuli","required":true}],"name":"AgentGraph","source":"workers-v1 2"},{"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},{"constraint":"rates are in graph.rateRoles order","kind":"AgentGraph","name":"graph","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, 1..=4 ports, and the launcher allocation this worker runs in","kind":"{maxAgents:int,maxPorts:int,workerThreads: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} +{"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},{"name":"maxWorkerThreads","source":"workers-v1 2","value":4096}],"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":"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":"<= 64, unique","kind":"array","name":"supportedStimuli","required":true}],"name":"AgentGraph","source":"workers-v1 2"},{"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},{"constraint":"rates are in graph.rateRoles order","kind":"AgentGraph","name":"graph","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, 1..=4 ports, and the launcher allocation this worker runs in","kind":"{maxAgents:int,maxPorts:int,workerThreads: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 at boundary 0 and at an installed boundary; null or present for every agent together","kind":"TypedValue|null","name":"selectedDecision","required":false},{"constraint":"null with selectedDecision; 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/valid.json b/services/flysim/crates/fly-session-types/fixtures/valid.json index 814d738..8f13023 100644 --- a/services/flysim/crates/fly-session-types/fixtures/valid.json +++ b/services/flysim/crates/fly-session-types/fixtures/valid.json @@ -2611,6 +2611,100 @@ "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": "committed snapshot at a boundary installed by a restore", + "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": null, + "appliedControls": null + } + ], + "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": "a restore re-establishes a committed boundary this epoch did not run a transition into", + "canonical": "{\"agents\":[{\"agentId\":\"fly-a\",\"appliedControls\":null,\"selectedDecision\":null,\"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": "79a0ef10ed65c0720c34c7d99de1fe87ec2f0c26028994562cc4655b0f6acd1f" + }, { "name": "transition trace", "type": "TransitionTrace", diff --git a/services/flysim/crates/fly-session-types/src/publishing.rs b/services/flysim/crates/fly-session-types/src/publishing.rs index 562c913..10ba25c 100644 --- a/services/flysim/crates/fly-session-types/src/publishing.rs +++ b/services/flysim/crates/fly-session-types/src/publishing.rs @@ -414,7 +414,8 @@ impl DomainType for CommittedSnapshot { controls.validate()?; } // "Decisions/controls describe the transition ending at that boundary, null at - // initial boundary 0." (publishing-v1 section 3) + // initial boundary 0." (publishing-v1 section 3, and its 2026-09-22 amendment for + // a boundary that was installed rather than produced.) if self.scope.step == 0 && (agent.selected_decision.is_some() || agent.applied_controls.is_some()) { @@ -422,14 +423,24 @@ impl DomainType for CommittedSnapshot { "CommittedSnapshot: at boundary 0 selectedDecision and appliedControls are null", ); } - if self.scope.step > 0 - && (agent.selected_decision.is_none() || agent.applied_controls.is_none()) - { + if agent.selected_decision.is_some() != agent.applied_controls.is_some() { return err( - "CommittedSnapshot: past boundary 0 every agent has a decision and applied controls", + "CommittedSnapshot: selectedDecision and appliedControls are null together or present together", ); } } + // A boundary is produced by a transition or installed by one, and the whole snapshot + // says which: every agent carries the transition that ended here, or none does. A + // mixture would be one fly's action beside another fly's silence at the same boundary. + if self + .agents + .iter() + .any(|a| a.selected_decision.is_some() != self.agents[0].selected_decision.is_some()) + { + return err( + "CommittedSnapshot: either every agent carries the transition that ended here, or none does", + ); + } self.progress.validate()?; if self.views.len() > MAX_VIEWS { return err("CommittedSnapshot: at most 8 views"); diff --git a/services/flysim/crates/fly-session-types/src/schema.rs b/services/flysim/crates/fly-session-types/src/schema.rs index 44eb58f..57ed6a7 100644 --- a/services/flysim/crates/fly-session-types/src/schema.rs +++ b/services/flysim/crates/fly-session-types/src/schema.rs @@ -940,12 +940,12 @@ pub const SCHEMAS: &[TypeSchema] = &[ opt( "selectedDecision", "TypedValue|null", - "null exactly at boundary 0", + "null at boundary 0 and at an installed boundary; null or present for every agent together", ), opt( "appliedControls", "PortControl|null", - "null exactly at boundary 0; the agent's assigned port", + "null with selectedDecision; the agent's assigned port", ), ], }, diff --git a/services/flysim/crates/fly-session/src/coordinator.rs b/services/flysim/crates/fly-session/src/coordinator.rs index 76db2e4..e6e34fa 100644 --- a/services/flysim/crates/fly-session/src/coordinator.rs +++ b/services/flysim/crates/fly-session/src/coordinator.rs @@ -321,9 +321,11 @@ pub struct Coordinator { /// The publication boundary. Everything this session publishes goes through it, and /// every outcome it returns is a named one. publisher: crate::publish::Publisher, - /// The composition as published. Built once from what the live participants attested to, + /// The composition as published. Built from what the live participants attested to, /// never restated from the configuration that asked for them. session_descriptor: Option, + /// The revision the next descriptor publication carries. + descriptor_revision: u64, /// The read-only repair service. Held so it stops with the session. query: Option, pacing: Option, @@ -417,6 +419,7 @@ impl Coordinator { serials: Serials::default(), publisher, session_descriptor: None, + descriptor_revision: DESCRIPTOR_REVISION, query: None, topics, pacing: None, @@ -463,6 +466,11 @@ impl Coordinator { self.session_descriptor.as_ref() } + /// The revision the last published descriptor carried. + pub fn descriptor_revision(&self) -> u64 { + self.descriptor_revision + } + /// What this session published and what became of it: accepted, refused by an observer, /// or faulted, per topic. pub fn ledger(&self) -> &crate::publish::Ledger { @@ -2651,11 +2659,28 @@ impl Coordinator { } /// Publishes the composition and starts the read-only repair service beside it. + /// Publishes the composition, advancing the revision when the composition changed. + /// + /// A revision identifies a composition, so republishing an unchanged one keeps its number + /// and a changed one takes the next: a group restore establishes a fresh epoch, which is a + /// new `compositionDigest`, and a consumer that held the old revision has to be told rather + /// than handed the same number with different contents. The publisher refuses the second + /// case outright, so this is where the number moves. async fn publish_descriptor(&mut self) -> Outcome<()> { - let descriptor = match self.build_descriptor(DESCRIPTOR_REVISION) { + let mut descriptor = match self.build_descriptor(self.descriptor_revision) { Ok(descriptor) => descriptor, Err(e) => return Err(self.fail_now(e, "descriptor")), }; + if let Some(published) = &self.session_descriptor { + let mut same = descriptor.clone(); + same.revision = published.revision; + if same != *published { + self.descriptor_revision += 1; + descriptor.revision = self.descriptor_revision; + self.audit + .push(format!("descriptor-revision:{}", self.descriptor_revision)); + } + } let outcome = match self.publisher.publish_descriptor(&descriptor).await { Ok(outcome) => outcome, Err(e) => return Err(self.fail_now(e, "descriptor")), diff --git a/services/flysim/crates/fly-session/tests/publishing.rs b/services/flysim/crates/fly-session/tests/publishing.rs index a600717..1440e35 100644 --- a/services/flysim/crates/fly-session/tests/publishing.rs +++ b/services/flysim/crates/fly-session/tests/publishing.rs @@ -38,6 +38,7 @@ both_transports!( the_query_service_answers_reads_and_nothing_else, the_published_descriptor_is_what_the_workers_attested_to, a_stimulus_kind_the_descriptor_does_not_declare_is_refused, + a_restored_boundary_publishes_a_new_revision_and_no_transition, ); all_modes!( @@ -787,6 +788,62 @@ async fn one_snapshot_carries_every_agent_in_the_composition(via: Via) { f.shutdown().await; } +/// A group restore re-establishes a committed boundary this epoch did not run a transition +/// into. Two things follow, and both are published rather than inferred: the composition is a +/// new one, because a fresh epoch is a new `compositionDigest`, so the descriptor takes the +/// next revision; and the restored boundary carries no decision and no controls for any agent, +/// because the abandoned epoch's actions are not this session's to republish. +async fn a_restored_boundary_publishes_a_new_revision_and_no_transition(via: Via) { + let mut f = started(via).await; + let checkpoint = id("ck-1"); + f.harness.coordinator.run(1).await.expect("one transition"); + let outcome = within("checkpoint", f.harness.coordinator.checkpoint(&checkpoint)) + .await + .expect("a committed checkpoint"); + assert!(matches!(outcome, fly_session::state::SaveOutcome::Committed { .. }), "{outcome:?}"); + let first = f.harness.coordinator.descriptor_revision(); + + // The snapshot of a boundary this epoch produced does carry the transition. + let produced = { + let state = f.harness.coordinator.published_state(); + let state = state.lock().expect("not poisoned"); + state.latest_snapshot().expect("boundary 1").clone() + }; + assert_eq!(produced.scope.step, 1); + assert!(produced.agents.iter().all(|a| a.selected_decision.is_some())); + + // Fail the epoch and restore into a fresh one. + f.harness.kill(&fly_a()).await; + f.harness.coordinator.step().await.expect_err("a dead participant fails the epoch"); + within("replace", f.harness.replace_all_participants()) + .await + .expect("replacements"); + within( + "restore", + f.harness.coordinator.restore(Some(&checkpoint), &id("e2")), + ) + .await + .expect("a coherent group restore"); + + assert_eq!( + f.harness.coordinator.descriptor_revision(), + first + 1, + "a fresh epoch is a new composition, so the revision advanced" + ); + let restored = { + let state = f.harness.coordinator.published_state(); + let state = state.lock().expect("not poisoned"); + state.latest_snapshot().expect("the restored boundary").clone() + }; + assert_eq!(restored.scope.step, 1, "the same committed boundary"); + assert_eq!(restored.descriptor_revision, first + 1); + assert!( + restored.agents.iter().all(|a| a.selected_decision.is_none() && a.applied_controls.is_none()), + "an installed boundary carries no transition for any agent" + ); + f.shutdown().await; +} + // ------------------------------------------------------------------------------------------ // Application-owned state and cues, bounded events, and the read-only repair service. From c1a972548bcc86663c100cd7f15fb8b87290bd5b Mon Sep 17 00:00:00 2001 From: acamilo Date: Tue, 22 Sep 2026 19:48:10 +0000 Subject: [PATCH 12/20] session: the death rows kill once the victim provably has the work Both death rows killed after a fixed sleep, so under load the kill could land before the call was dispatched. The bus then reports not-dispatched and MutationCertainty::None, which is correct -- the participant never received anything -- while the test demanded unknown. A full-workspace run caught it: "left: None, right: None" at the certainty assertion. kill_once_it_is_working polls the victim's own Worker.Status until it is provably inside the operation before killing: the agent until it is Preparing with an active request id, the world until it has recorded the batch, which the arena does before its injected delay. Dispatch has then demonstrably happened and unknown is the only correct certainty. The wall-clock boundedness assertions are gone with them. The suite's own `within` is the bound, and the victim is five seconds slow against its twenty, so returning at all is the claim. --- .../crates/fly-session/tests/processes.rs | 59 +++++++++++++++---- 1 file changed, 46 insertions(+), 13 deletions(-) diff --git a/services/flysim/crates/fly-session/tests/processes.rs b/services/flysim/crates/fly-session/tests/processes.rs index 6cdd3b5..e8e19f5 100644 --- a/services/flysim/crates/fly-session/tests/processes.rs +++ b/services/flysim/crates/fly-session/tests/processes.rs @@ -358,6 +358,35 @@ async fn a_delayed_one_agent_result_holds_the_world(mode: ExecutionMode) { // ------------------------------------------------------------------------------------------- // Acceptance: worker or helper death has a bounded diagnosed outcome +/// Waits until `worker` is provably inside the operation, then kills it. +/// +/// Sleeping a fixed time before the kill asserts a race: under load the kill can land before +/// the call is even dispatched, and then `MutationCertainty::None` is the *correct* answer +/// because the participant never received anything. The certainty the death rows are about -- +/// `unknown`, because the participant died with work in its hands -- only holds if the work +/// reached it, so the test waits for the worker's own status to say so rather than guessing +/// from the clock. +async fn kill_once_it_is_working( + launcher: &mut fly_session::Launcher, + worker: &Id, + inside: impl Fn(&StatusResult) -> bool, +) -> ReapOutcome { + let deadline = Instant::now() + Duration::from_secs(15); + loop { + if let Ok(status) = launcher.health_check(worker).await + && inside(&status) + { + break; + } + assert!( + Instant::now() < deadline, + "{worker} never reported itself inside the operation" + ); + tokio::time::sleep(Duration::from_millis(5)).await; + } + launcher.kill(worker).await +} + /// One agent dies in the middle of its Prepare. The epoch fails with a typed cause naming /// that agent, within the caller's own budget, and nothing continues on the remainder. async fn a_worker_death_has_a_bounded_diagnosed_outcome(mode: ExecutionMode) { @@ -367,20 +396,21 @@ async fn a_worker_death_has_a_bounded_diagnosed_outcome(mode: ExecutionMode) { within("bootstrap", f.harness.coordinator.bootstrap()).await.unwrap(); let started = Instant::now(); + let victim = fly_b(); let (coordinator, launcher) = f.harness.parts(); let (stepped, reaped) = tokio::join!( async { within("step", coordinator.step()).await }, - async { - tokio::time::sleep(Duration::from_millis(80)).await; - launcher.kill(&fly_b()).await - } + // Killed once it has the Prepare in its hands, not after a fixed sleep: the row is + // about a participant that dies *with work*, so the work has to have reached it. + kill_once_it_is_working(launcher, &victim, |status| { + status.state == WorkerState::Preparing && status.active_request_id.is_some() + }) ); assert_eq!(reaped, ReapOutcome::Terminated); let failure = stepped.expect_err("a dead participant is a failed epoch, not a slow one"); - assert!( - started.elapsed() < Duration::from_secs(20), - "the outcome must be bounded, not a hang" - ); + // Boundedness is the suite's own `within` above: the participant is five seconds slow and + // `within` gives up at twenty, so returning at all is the claim. + let _ = started; assert_eq!( failure.participant.as_deref(), Some(fly_b().as_str()), @@ -419,14 +449,17 @@ async fn a_helper_death_has_a_bounded_diagnosed_outcome(mode: ExecutionMode) { let (coordinator, launcher) = f.harness.parts(); let (stepped, reaped) = tokio::join!( async { within("step", coordinator.step()).await }, - async { - tokio::time::sleep(Duration::from_millis(200)).await; - launcher.kill(&environment).await - } + // Killed once the world has recorded the batch, which the arena does before its + // injected delay. So the Advance provably reached it and the certainty is `unknown` + // rather than `none`; a fixed sleep could land before dispatch under load, and then + // `none` would be right and this row would be asserting a race. + kill_once_it_is_working(launcher, &environment, |status| { + status.last_batch_id.is_some() + }) ); assert_eq!(reaped, ReapOutcome::Terminated); let failure = stepped.expect_err("a dead world is a failed epoch"); - assert!(started.elapsed() < Duration::from_secs(20), "bounded, not a hang"); + let _ = started; assert_eq!( failure.participant.as_deref(), Some(environment.as_str()), From 0cec73704c8c37b6dc3ff456e31091bb096e5e2e Mon Sep 17 00:00:00 2001 From: acamilo Date: Tue, 22 Sep 2026 20:00:43 +0000 Subject: [PATCH 13/20] docs: v0.5.0 deployed, the run restarted from rung 8 --- docs/stream-mvp-plan.md | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/docs/stream-mvp-plan.md b/docs/stream-mvp-plan.md index 6588bfc..bce64e3 100644 --- a/docs/stream-mvp-plan.md +++ b/docs/stream-mvp-plan.md @@ -744,3 +744,7 @@ rewritten separately. a v5 checkpoint instead of refusing it. `fly-reset-to-milestone ` restarts the run from a ladder rung (archives both stores first). The live run restarts from rung 7 with this release, so the ladder is climbed again with the catch reward and the row-54 walks in place. +- 2026-09-22 19:59 UTC (v0.5.0 deployed): rung 7 had no milestone archive (the ratchet passed it + inside one commit), so the run restarted from rung 8, VIRIDIAN CITY, with + `fly-reset-to-milestone 8`; the v5 checkpoint migrated to v6 as designed. The previous state is + archived beside the store. From 6bf5687aca91bd1d223b844f783bbd0670e4598c Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 22 Sep 2026 20:18:49 +0000 Subject: [PATCH 14/20] session: a snapshot publishes the telemetry of the transition that just ended AgentSlot.telemetry was written only by the Agent.Initialize handler, so every CommittedSnapshot carried warm-up telemetry labelled as boundary k while each AgentCommitResult.telemetry was validated and dropped. The commit result is now stored on the slot beside the committed step, and the media test asserts that published telemetry advances across boundaries instead of merely being nonzero. A refused snapshot is recorded and sequenced like a refused descriptor revision, because the repair path exists for the consumer that did not receive it; the sequence advances with the value rather than with the delivery, so two snapshots can never share one. The query service counts an answer it could not deliver rather than discarding the result, and an unreadable event batch is distinct from the end of the stream. --- .../crates/fly-session/src/coordinator.rs | 24 +++++ .../flysim/crates/fly-session/src/harness.rs | 16 ++++ .../flysim/crates/fly-session/src/publish.rs | 36 +++++-- .../crates/fly-session/tests/publishing.rs | 95 ++++++++++++++++--- 4 files changed, 152 insertions(+), 19 deletions(-) diff --git a/services/flysim/crates/fly-session/src/coordinator.rs b/services/flysim/crates/fly-session/src/coordinator.rs index e6e34fa..8b7a444 100644 --- a/services/flysim/crates/fly-session/src/coordinator.rs +++ b/services/flysim/crates/fly-session/src/coordinator.rs @@ -471,6 +471,11 @@ impl Coordinator { self.descriptor_revision } + /// The sequence the next published snapshot will carry. + pub fn published_sequence(&self) -> u64 { + self.publisher.sequence() + } + /// What this session published and what became of it: accepted, refused by an observer, /// or faulted, per topic. pub fn ledger(&self) -> &crate::publish::Ledger { @@ -1656,6 +1661,25 @@ impl Coordinator { self.agents[index].context_digest = context.digest(); self.agents[index].context = context; self.agents[index].committed_step = k + 1; + // The telemetry of the transition that just ended, which is what this boundary's + // snapshot publishes. Without this the slot would keep whatever `Agent.Initialize` + // reported and every snapshot would label warm-up telemetry as boundary k. + let telemetry = commits + .iter() + .find(|(id, _)| *id == agent_id) + .map(|(_, result)| result.telemetry.clone()); + match telemetry { + Some(telemetry) => self.agents[index].telemetry = Some(telemetry), + None => { + return Err(self.fail_now( + DomainError::before( + ErrorCode::IdentityMismatch, + format!("agent {agent_id} committed without telemetry"), + ), + "commit", + )); + } + } } // The previous boundary's handles are no longer needed; the new ones take over. // The references -- which are data, not ownership -- are kept for one boundary, so a diff --git a/services/flysim/crates/fly-session/src/harness.rs b/services/flysim/crates/fly-session/src/harness.rs index 334e6ad..f86a179 100644 --- a/services/flysim/crates/fly-session/src/harness.rs +++ b/services/flysim/crates/fly-session/src/harness.rs @@ -649,6 +649,22 @@ impl SessionHarness { } } + /// Changes which graph one agent builds, so the replacement the next restart launches is + /// a fly with the same neuron count and another index. + /// + /// The same relaunch rule as a fault: the worker running now keeps what it was started + /// with, and the change reaches the composition through the next replacement. + pub fn set_agent_graph(&mut self, agent_id: &Id, graph_variant: u64) { + if let Some(spec) = self + .config + .agents + .iter_mut() + .find(|spec| spec.agent_id == *agent_id) + { + spec.graph_variant = graph_variant; + } + } + /// Changes the environment's injected faults, with the same relaunch rule. pub fn set_environment_faults(&mut self, faults: EnvironmentFaults) { self.config.environment_faults = faults; diff --git a/services/flysim/crates/fly-session/src/publish.rs b/services/flysim/crates/fly-session/src/publish.rs index 20f0200..67444a6 100644 --- a/services/flysim/crates/fly-session/src/publish.rs +++ b/services/flysim/crates/fly-session/src/publish.rs @@ -644,10 +644,13 @@ impl Publisher { .publish(&topic, object(snapshot.to_json()), &refs) .await, ); - if outcome.is_accepted() { - self.sequence += 1; - lock(&self.state).latest = Some(snapshot.clone()); - } + // The value is recorded whether or not the router admitted it, exactly as a descriptor + // revision is: the repair path exists for the consumer that did not receive it, and the + // `state-media-v1` amendment promises the exact value stays recoverable through the + // query service. The sequence advances with the value rather than with the delivery, + // so two different snapshots can never share one sequence number. + self.sequence += 1; + lock(&self.state).latest = Some(snapshot.clone()); self.ledger.record(&outcome); Ok(outcome) } @@ -718,6 +721,10 @@ pub const GET_SNAPSHOT: &str = "Session.GetSnapshot"; /// advance, pause, stimulate, restore or reconfigure anything. pub struct QueryService { task: tokio::task::JoinHandle<()>, + /// Answers this service produced and could not deliver, because the caller was gone or + /// the router refused the reply. A read that nobody received is not a read that happened, + /// and this module drops nothing silently. + undeliverable: Arc, } impl QueryService { @@ -736,6 +743,8 @@ impl QueryService { // than a fallback name that two incarnations could share. let incarnation = parse_id(&format!("query-{}", client.info().connection_id)) .map_err(|e| flybus::BusError::new(flybus::ErrorCode::InvalidEnvelope, e))?; + let undeliverable = Arc::new(std::sync::atomic::AtomicU64::new(0)); + let undelivered = Arc::clone(&undeliverable); let task = tokio::spawn(async move { while let Some(request) = service.next().await { let method = request.method().to_owned(); @@ -753,10 +762,17 @@ impl QueryService { DomainError::invalid(format!("{method}: {e}")), ), }; - let _ = responder.reply(outcome.to_outcome(), &[]).await; + if responder.reply(outcome.to_outcome(), &[]).await.is_err() { + undelivered.fetch_add(1, std::sync::atomic::Ordering::SeqCst); + } } }); - Ok(QueryService { task }) + Ok(QueryService { task, undeliverable }) + } + + /// How many answers this service could not deliver. + pub fn undeliverable(&self) -> u64 { + self.undeliverable.load(std::sync::atomic::Ordering::SeqCst) } /// Ends the service. Dropping one does the same thing. @@ -1003,6 +1019,14 @@ pub struct EventBatchView { pub event_ids: Vec, } +/// What one poll of the event stream produced. +#[derive(Clone, Debug, PartialEq)] +pub enum ConsumerEvents { + Batch(EventBatchView), + /// A batch this consumer could not read. Distinct from the end of the stream. + Unreadable { detail: String }, +} + /// What one poll of a consumer produced. #[derive(Clone, Debug, PartialEq)] pub enum ConsumerOutcome { diff --git a/services/flysim/crates/fly-session/tests/publishing.rs b/services/flysim/crates/fly-session/tests/publishing.rs index 1440e35..bb94a1c 100644 --- a/services/flysim/crates/fly-session/tests/publishing.rs +++ b/services/flysim/crates/fly-session/tests/publishing.rs @@ -36,6 +36,7 @@ both_transports!( application_state_and_cues_are_the_applications_own, a_refused_event_batch_is_held_and_counted_not_lost, the_query_service_answers_reads_and_nothing_else, + a_refused_snapshot_is_still_what_the_repair_path_answers, the_published_descriptor_is_what_the_workers_attested_to, a_stimulus_kind_the_descriptor_does_not_declare_is_refused, a_restored_boundary_publishes_a_new_revision_and_no_transition, @@ -252,7 +253,6 @@ async fn a_bounded_observer_refusal_is_named_and_never_fails_the_epoch(via: Via) .consumer() .await .expect("a healthy consumer attaches"); - until("the refusals stop", || true).await; f.harness .coordinator .run(2) @@ -284,6 +284,7 @@ async fn every_published_snapshot_carries_its_own_boundarys_media(via: Via) { Some(ConsumerOutcome::Composition { .. }) )); let mut seen: Vec<(u64, String)> = Vec::new(); + let mut ticks: BTreeMap = BTreeMap::new(); for step in 1..=STEPS { f.harness.coordinator.run(1).await.expect("a transition"); let at = read_until(&mut consumer, step).await; @@ -305,10 +306,20 @@ async fn every_published_snapshot_carries_its_own_boundarys_media(via: Via) { seen.push((at, reference.pixels.artifact_id.clone())); } for agent in &view.agents { - assert!( - agent.brain_ticks > 0, - "the agent state is this boundary's, not a placeholder" - ); + let previous = ticks.insert(agent.agent_id.clone(), agent.brain_ticks); + match previous { + None => assert!(agent.brain_ticks > 0, "the agent has run by boundary {at}"), + // Telemetry is the state of the transition that ended here. Republishing the + // previous boundary's numbers -- or `Agent.Initialize`'s warm-up numbers -- + // would be old agent state labelled as this boundary, which is the same + // mislabelling as old media. + Some(before) => assert!( + agent.brain_ticks > before, + "the telemetry of {} advanced into boundary {at}: {before} -> {}", + agent.agent_id, + agent.brain_ticks + ), + } } } let mut ids: Vec<&String> = seen.iter().map(|(_, id)| id).collect(); @@ -460,6 +471,15 @@ async fn a_revision_that_was_never_published_is_a_named_answer(via: Via) { /// The same neuron count, another index. A consumer that has mapped geometry is told, and the /// new composition is not quietly cached over the one it mapped. +/// +/// The two descriptors here come from two real sessions rather than from a restore. Driving it +/// through a restore was tried and does not work yet, for a reason outside this slice: the +/// coordinator's `AgentSlot.graph` is written only by `Agent.Initialize`, and a group restore +/// installs state through `State.ActivateRestore`, so a replacement fly that built another +/// index is published under its predecessor's `indexDigest` -- and it is not refused on the way +/// in either, because `agent_compatibility` digests `agent::dataset_digest()` rather than the +/// index the worker attested to. Both halves belong to the restore contract, so this test uses +/// the compositions it can build honestly and the gap is reported rather than papered over. async fn a_changed_index_digest_is_named_rather_than_remapped(via: Via) { // Two real compositions that differ only in the graph one fly built. let first = started(via).await; @@ -516,19 +536,14 @@ async fn a_changed_index_digest_is_named_rather_than_remapped(via: Via) { moved.neuron_count, arrived.neuron_count, "the same number of neurons" ); - assert_ne!( - moved.index_digest, arrived.index_digest, - "and another index" - ); + assert_ne!(moved.index_digest, arrived.index_digest, "and another index"); let mut consumer = first.harness.consumer().await.expect("a consumer attaches"); let held = match within("the descriptor", consumer.take_descriptor()).await { Some(ConsumerOutcome::Composition { revision, .. }) => revision, other => panic!("expected a composition, got {other:?}"), }; - consumer - .map_geometry(held) - .expect("this consumer maps geometry"); + consumer.map_geometry(held).expect("this consumer maps geometry"); assert_eq!(consumer.mapped_index(&fly_a()), Some(&moved.index_digest)); // The composition changes under it. @@ -966,6 +981,52 @@ async fn a_refused_event_batch_is_held_and_counted_not_lost(via: Via) { f.shutdown().await; } +/// A publication an observer refused is exactly the value the repair path exists to hand back. +/// +/// The `state-media-v1` amendment promises the exact value stays recoverable through the query +/// path, so recording it cannot depend on whether the router admitted the delivery -- that is +/// the case the promise is about. +async fn a_refused_snapshot_is_still_what_the_repair_path_answers(via: Via) { + let mut f = started(via).await; + let snapshots = f.harness.coordinator.topics().snapshots.clone(); + let offender_client = f.harness.observer().await.expect("an observer client"); + let _offender = offender_client + .subscribe( + &snapshots, + flybus::SubscriptionConfig::bounded().queued(1).in_flight(1), + ) + .await + .expect("a bounded subscription"); + + f.harness.coordinator.run(STEPS).await.expect("the world carries on"); + let counters = f.harness.coordinator.ledger().counters(&snapshots); + assert!(counters.refused > 0, "a refusal is what this test is about: {counters:?}"); + + let latest = { + let state = f.harness.coordinator.published_state(); + let state = state.lock().expect("not poisoned"); + state.latest_snapshot().expect("a snapshot").clone() + }; + assert_eq!( + latest.scope.step, STEPS, + "the newest committed boundary is recoverable even though its delivery was refused" + ); + assert_eq!( + latest.sequence + 1, + f.harness.coordinator.published_sequence(), + "the sequence advanced with the value, not with the delivery" + ); + + // And the query service answers it over the bus, not just the state behind it. + let mut consumer = f.harness.consumer().await.expect("a consumer attaches"); + let answered = within("the repair path", consumer.repair(DESCRIPTOR_REVISION)) + .await + .expect("the repair path answers"); + assert_eq!(answered, DESCRIPTOR_REVISION); + offender_client.close().await; + f.shutdown().await; +} + /// Two reads and nothing else. There is no method on this service that could move anything. async fn the_query_service_answers_reads_and_nothing_else(via: Via) { let mut f = started(via).await; @@ -990,7 +1051,15 @@ async fn the_query_service_answers_reads_and_nothing_else(via: Via) { let snapshot = CommittedSnapshot::from_json(outcome_result(&outcome).expect("a result")) .expect("a committed snapshot"); assert_eq!(snapshot.scope.step, 1); - assert!(snapshot.views.is_empty() || !snapshot.views.is_empty()); + assert_eq!( + snapshot.descriptor_revision, + f.harness.coordinator.descriptor_revision(), + "a read answers the composition the session is publishing under" + ); + assert!( + snapshot.agents.iter().all(|a| a.selected_decision.is_some()), + "and the values of the transition that ended at it" + ); // A method it does not implement is a named refusal, not a default. let request = SessionRpcRequest { From 305a50d8df1310f102a340f1b1234e23770a2856 Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 22 Sep 2026 20:36:51 +0000 Subject: [PATCH 15/20] session: a graph identity does not cross a recovery The index an agent attested to at Agent.Initialize joins its compatibility identity and its checkpoint manifest row, so a replacement fly that built another graph -- the same dataset, the same neuron count, another index -- cannot install a checkpoint taken under the first one. It was accepted before, because agent_compatibility digested a dataset digest recomputed from a free function rather than what the worker attested to, and the restored composition was then published under its predecessor's indexDigest. The refusal names what the worker is, not just that two digests differ. Dated amendment to checkpoint-envelope-v1's agents row; no wire type and no schema text change, so the contract digest is unchanged. --- .../checkpoint-envelope-v1.md | 10 ++- .../flysim/crates/fly-session/src/agent.rs | 22 ++++-- .../crates/fly-session/src/coordinator.rs | 49 +++++++++++-- .../flysim/crates/fly-session/src/state.rs | 6 ++ .../crates/fly-session/tests/publishing.rs | 68 ++++++++++++++++--- 5 files changed, 136 insertions(+), 19 deletions(-) diff --git a/docs/design/session-framework/checkpoint-envelope-v1.md b/docs/design/session-framework/checkpoint-envelope-v1.md index d58ed98..69bdf4d 100644 --- a/docs/design/session-framework/checkpoint-envelope-v1.md +++ b/docs/design/session-framework/checkpoint-envelope-v1.md @@ -100,11 +100,19 @@ names; a manifest missing any of them is not a complete checkpoint. | `compositionDigest` | Coordinator scheduler and configuration identity | | `portMap` | The exact port-to-agent map, `[{portId, agentId}]` | | `compatibility` | Backend, content, patch, controller, parser and state-format identities | -| `agents` | Per agent: profile, dataset and model identities, resolved seed, tick count, remainder and the payload name holding its state | +| `agents` | Per agent: profile, dataset, **index** and model identities, resolved seed, tick count, remainder and the payload name holding its state | | `coordinator` | Task ledger, prior world inspection, per-agent executor state, admission state and event watermarks, each as a payload name or an inline value | | `helperState` | External-helper state required for exact resume, as payload names | | `payloads` | `[{name, byteLength, digest}]`, mirroring the payload table | +**Amendment, 2026-09-22 (PUBLISH-01).** The `agents` row gains `indexDigest`, the index the +agent attested to at `Agent.Initialize`, and it joins that agent's compatibility identity. +Without it a replacement fly that built another graph -- the same dataset, the same neuron +count, another index -- passed the group check and was then published under its predecessor's +`indexDigest`, which is a graph identity crossing a recovery and exactly what section 5's rules +exist to prevent. It is recorded from the worker's attestation rather than recomputed from the +dataset, because the point is that the two can disagree. + **Amendment, 2026-09-22 (STATE-01).** The table above names a holder for every payload except the environment's own, although section 6's fixture has one (`world`) and a group install has to map it by name like any other participant's. The manifest therefore also records: diff --git a/services/flysim/crates/fly-session/src/agent.rs b/services/flysim/crates/fly-session/src/agent.rs index 21ca1a5..ab2e1a1 100644 --- a/services/flysim/crates/fly-session/src/agent.rs +++ b/services/flysim/crates/fly-session/src/agent.rs @@ -844,6 +844,7 @@ pub fn agent_compatibility_digest( model_version: &str, plasticity_version: &str, seed: i32, + index_digest: &Digest, ) -> Digest { let value = serde_json::json!({ "agentId": agent_id.as_str(), @@ -852,6 +853,11 @@ pub fn agent_compatibility_digest( "modelVersion": model_version, "plasticityVersion": plasticity_version, "seed": seed, + // The index the worker actually built, not a value recomputed from the dataset: the + // whole point is that the two can disagree. Without it a replacement fly that built + // another graph restores cleanly and is then published under its predecessor's + // `indexDigest`, which is the predecessor's graph identity crossing a recovery. + "indexDigest": index_digest.as_str(), }); digest_of(&value).expect("an agent compatibility block canonicalizes") } @@ -940,13 +946,15 @@ struct StagedAgent { impl FakeAgentWorker { /// This worker's own compatibility identity, from its configuration and a resolved seed. fn compatibility_digest(&self, profile: &AssetRef, seed: i32) -> Digest { + let graph = synthetic_graph(&self.config.agent_id, self.config.graph_variant); agent_compatibility_digest( &self.config.agent_id, &profile.digest, - &dataset_digest(), + &graph.dataset_digest, MODEL_VERSION, PLASTICITY_VERSION, seed, + &graph.index_digest, ) } @@ -1147,10 +1155,16 @@ worker; this worker is {other:?}" // of another agent's brain, fails here and never reaches activation. let computed = self.compatibility_digest(&profile, model.seed()); if computed != params.compatibility_digest { + let graph = synthetic_graph(&self.config.agent_id, self.config.graph_variant); return Err(incompatible(format!( - "the staged state's compatibility {computed} is not the {} the restore \ -requires", - params.compatibility_digest + "the staged state's compatibility {} is not the {computed} this worker is: \ +profile {}, dataset {}, index {}, model {MODEL_VERSION}, plasticity {PLASTICITY_VERSION}, \ +seed {}", + params.compatibility_digest, + profile.digest, + graph.dataset_digest, + graph.index_digest, + model.seed() ))); } let accumulator_value = value diff --git a/services/flysim/crates/fly-session/src/coordinator.rs b/services/flysim/crates/fly-session/src/coordinator.rs index 8b7a444..7d68aa1 100644 --- a/services/flysim/crates/fly-session/src/coordinator.rs +++ b/services/flysim/crates/fly-session/src/coordinator.rs @@ -3036,15 +3036,33 @@ impl Coordinator { } } - fn agent_compatibility(&self, slot: &AgentSlot) -> Digest { - crate::agent::agent_compatibility_digest( + /// One agent's compatibility identity, from what that agent attested to at + /// `Agent.Initialize` rather than from anything this coordinator recomputed. + /// + /// The graph belongs here because `state-media-v1`'s recovery rules say old parser or + /// media state must not cross a recovery, and a graph identity is exactly that: without it + /// a replacement fly that built another index passes the group check and is then published + /// under its predecessor's `indexDigest`. An agent that has not attested yet has no + /// compatibility, which is a refusal rather than a guessed digest. + fn agent_compatibility(&self, slot: &AgentSlot) -> DomainResult { + let graph = slot.graph.as_ref().ok_or_else(|| { + DomainError::before( + ErrorCode::InvalidPhase, + format!( + "agent {} has not attested to a graph, so it has no compatibility identity", + slot.agent_id + ), + ) + })?; + Ok(crate::agent::agent_compatibility_digest( &slot.agent_id, &slot.profile.digest, - &crate::agent::dataset_digest(), + &graph.dataset_digest, crate::agent::MODEL_VERSION, crate::agent::PLASTICITY_VERSION, slot.seed, - ) + &graph.index_digest, + )) } /// The coordinator's own session record: what it must hold again to resume this boundary. @@ -3233,7 +3251,10 @@ impl Coordinator { for index in 0..self.agents.len() { let slot_worker = self.agents[index].worker.clone(); let agent_id = self.agents[index].agent_id.clone(); - let expected = self.agent_compatibility(&self.agents[index]); + let expected = match self.agent_compatibility(&self.agents[index]) { + Ok(expected) => expected, + Err(e) => return Err(self.fail_now(e, "capture")), + }; let reply = self .call( &slot_worker, @@ -3251,10 +3272,25 @@ impl Coordinator { payloads.push(payload); acknowledge.push((slot_worker, reply.request_id.clone())); let slot = &self.agents[index]; + // The graph identities come from what this agent attested to, not from a value + // the coordinator recomputed; that is the whole point of recording them. + let graph = match slot.graph.clone() { + Some(graph) => graph, + None => { + return Err(self.fail_now( + DomainError::before( + ErrorCode::InvalidPhase, + format!("agent {agent_id} has not attested to a graph"), + ), + "capture", + )); + } + }; agent_rows.push(crate::state::AgentEntry { agent_id: agent_id.clone(), profile_digest: slot.profile.digest.clone(), - dataset_digest: crate::agent::dataset_digest(), + dataset_digest: graph.dataset_digest, + index_digest: graph.index_digest, model_version: crate::agent::MODEL_VERSION.to_owned(), plasticity_version: crate::agent::PLASTICITY_VERSION.to_owned(), seed: slot.seed, @@ -3833,6 +3869,7 @@ impl Coordinator { &row.model_version, &row.plasticity_version, row.seed, + &row.index_digest, ), )); } diff --git a/services/flysim/crates/fly-session/src/state.rs b/services/flysim/crates/fly-session/src/state.rs index aa42d3c..220310f 100644 --- a/services/flysim/crates/fly-session/src/state.rs +++ b/services/flysim/crates/fly-session/src/state.rs @@ -599,6 +599,10 @@ pub struct AgentEntry { pub agent_id: Id, pub profile_digest: Digest, pub dataset_digest: Digest, + /// The index the agent attested to at `Agent.Initialize`. It is part of the agent's + /// compatibility identity, so a replacement that built another graph cannot install this + /// payload -- the graph identity does not cross the recovery. + pub index_digest: Digest, pub model_version: String, pub plasticity_version: String, pub seed: i32, @@ -613,6 +617,7 @@ impl AgentEntry { "agentId": self.agent_id.as_str(), "profileDigest": self.profile_digest.as_str(), "datasetDigest": self.dataset_digest.as_str(), + "indexDigest": self.index_digest.as_str(), "modelVersion": self.model_version.as_str(), "plasticityVersion": self.plasticity_version.as_str(), "seed": self.seed, @@ -646,6 +651,7 @@ impl AgentEntry { agent_id: parse_id(&text("agentId")?)?, profile_digest: text("profileDigest")?, dataset_digest: text("datasetDigest")?, + index_digest: text("indexDigest")?, model_version: text("modelVersion")?, plasticity_version: text("plasticityVersion")?, seed, diff --git a/services/flysim/crates/fly-session/tests/publishing.rs b/services/flysim/crates/fly-session/tests/publishing.rs index bb94a1c..12f7af1 100644 --- a/services/flysim/crates/fly-session/tests/publishing.rs +++ b/services/flysim/crates/fly-session/tests/publishing.rs @@ -37,6 +37,7 @@ both_transports!( a_refused_event_batch_is_held_and_counted_not_lost, the_query_service_answers_reads_and_nothing_else, a_refused_snapshot_is_still_what_the_repair_path_answers, + a_replacement_that_built_another_index_cannot_install_the_checkpoint, the_published_descriptor_is_what_the_workers_attested_to, a_stimulus_kind_the_descriptor_does_not_declare_is_refused, a_restored_boundary_publishes_a_new_revision_and_no_transition, @@ -469,17 +470,68 @@ async fn a_revision_that_was_never_published_is_a_named_answer(via: Via) { f.shutdown().await; } +/// A graph identity does not cross a recovery. +/// +/// The index a fly attested to at `Agent.Initialize` is part of its compatibility identity, so +/// a replacement that built another graph -- the same dataset, the same neuron count, another +/// index -- cannot install a checkpoint taken under the first one, and the refusal names what +/// this worker is rather than only that two digests differ. The identical replacement still +/// installs, so the check refuses the case it is about and nothing else. +async fn a_replacement_that_built_another_index_cannot_install_the_checkpoint(via: Via) { + for (variant, refused) in [(0u64, false), (1, true)] { + let mut f = started(via).await; + let checkpoint = id("ck-graph"); + f.harness.coordinator.run(1).await.expect("one transition"); + within("checkpoint", f.harness.coordinator.checkpoint(&checkpoint)) + .await + .expect("a committed checkpoint"); + + f.harness.set_agent_graph(&fly_a(), variant); + f.harness.kill(&fly_a()).await; + f.harness + .coordinator + .step() + .await + .expect_err("a dead participant fails the epoch"); + within("replace", f.harness.replace_all_participants()) + .await + .expect("replacements"); + let outcome = within( + "restore", + f.harness.coordinator.restore(Some(&checkpoint), &id("e2")), + ) + .await; + match (refused, outcome) { + (false, Ok(_)) => {} + (false, Err(e)) => panic!("the identical replacement must still install: {e}"), + (true, Ok(_)) => panic!("a fly that built another index installed the checkpoint"), + (true, Err(failure)) => { + assert_eq!(failure.error.code, ErrorCode::IncompatibleState, "{failure:?}"); + let built = fly_session::agent::synthetic_graph(&fly_a(), 1); + assert!( + failure.error.message.contains(&built.index_digest), + "the refusal names the index this worker built: {}", + failure.error.message + ); + assert!( + f.harness.coordinator.is_fenced(), + "and the group stays fenced" + ); + } + } + f.shutdown().await; + } +} + /// The same neuron count, another index. A consumer that has mapped geometry is told, and the /// new composition is not quietly cached over the one it mapped. /// -/// The two descriptors here come from two real sessions rather than from a restore. Driving it -/// through a restore was tried and does not work yet, for a reason outside this slice: the -/// coordinator's `AgentSlot.graph` is written only by `Agent.Initialize`, and a group restore -/// installs state through `State.ActivateRestore`, so a replacement fly that built another -/// index is published under its predecessor's `indexDigest` -- and it is not refused on the way -/// in either, because `agent_compatibility` digests `agent::dataset_digest()` rather than the -/// index the worker attested to. Both halves belong to the restore contract, so this test uses -/// the compositions it can build honestly and the gap is reported rather than papered over. +/// The two descriptors here come from two real sessions, and deliberately not from a restore: +/// since the index is part of an agent's compatibility identity, a restore whose replacement +/// built another graph is now refused before it can install, which +/// `a_replacement_that_built_another_index_cannot_install_the_checkpoint` proves. A published +/// index therefore changes between compositions rather than across a recovery, and this is that +/// case. async fn a_changed_index_digest_is_named_rather_than_remapped(via: Via) { // Two real compositions that differ only in the graph one fly built. let first = started(via).await; From 50ab3d47ba94cafddb931154c804f4801681cfd0 Mon Sep 17 00:00:00 2001 From: acamilo Date: Tue, 22 Sep 2026 21:03:09 +0000 Subject: [PATCH 16/20] session: hold an Acknowledge to the request it answered Dropping the length check left nothing checking the reply against the request at all: AcknowledgeResult::validate_against existed with no caller, so a worker could acknowledge ids this session never asked about. That is the other half of the rule the README states. A short list is the worker reporting what it released and is accepted; an id from outside the request is the worker reporting about someone else's cache and is refused, named, before any mutation. The bootstrap-path regression is now covered too. The earlier test calls acknowledge_replies directly, which guards the check where it lives but not where it lived, so a length check put back into acknowledge_lifecycle left it green. The duplicate_lifecycle_acknowledge injection releases the ids first, out of sight, so the call that method makes and checks is already the second one -- the shape the section 6 resolution produces. Verified by putting the old check back: three tests fail with it, none without. Also: last_resolution_attempts is cleared with last_resolution, so a resolution ending before its first attempt no longer reports the previous count; the guard half of the bound test asserts the fence like the budget half; the attempts assertion checks a real bound rather than u32::MAX; and two dead Instant bindings are gone. --- .../flysim/crates/fly-session/src/agent.rs | 7 ++ services/flysim/crates/fly-session/src/cli.rs | 3 + .../crates/fly-session/src/coordinator.rs | 37 +++++++++ .../flysim/crates/fly-session/src/launcher.rs | 3 + .../flysim/crates/fly-session/src/worker.rs | 23 +++++- .../crates/fly-session/tests/processes.rs | 77 ++++++++++++++++--- 6 files changed, 138 insertions(+), 12 deletions(-) diff --git a/services/flysim/crates/fly-session/src/agent.rs b/services/flysim/crates/fly-session/src/agent.rs index 22b5ac5..0802c44 100644 --- a/services/flysim/crates/fly-session/src/agent.rs +++ b/services/flysim/crates/fly-session/src/agent.rs @@ -199,6 +199,9 @@ pub struct AgentFaults { /// Refuse `State.ActivateRestore` after this worker has already staged, so a group meets /// a failure halfway through activation. pub fail_activate_restore: bool, + /// Add this id to every `Worker.Acknowledge` reply, so the caller meets a worker + /// reporting about an id it was never asked about. + pub acknowledge_extra_id: Option, } /// One fake agent worker's configuration. @@ -689,6 +692,10 @@ impl WorkerEndpoint for FakeAgentWorker { self.config.worker_threads as u64 } + fn acknowledge_extra_id(&self) -> Option { + self.config.faults.acknowledge_extra_id.clone() + } + fn methods(&self) -> Vec<&'static str> { vec![ "Agent.Initialize", diff --git a/services/flysim/crates/fly-session/src/cli.rs b/services/flysim/crates/fly-session/src/cli.rs index bbd6bbf..92d426d 100644 --- a/services/flysim/crates/fly-session/src/cli.rs +++ b/services/flysim/crates/fly-session/src/cli.rs @@ -212,6 +212,9 @@ fn serve(role: &str, options: &Options) -> Result<(), String> { commit_delay_ms: options.u64(flags::COMMIT_DELAY_MS, 0)?, fail_stage_restore: options.flag(flags::FAIL_STAGE_RESTORE)?, fail_activate_restore: options.flag(flags::FAIL_ACTIVATE_RESTORE)?, + // A worker process is never asked to misbehave this way: the subset refusal + // is a caller-side check and its test runs the worker in-process. + acknowledge_extra_id: None, }, client_id: client_id.clone(), service: service.clone(), diff --git a/services/flysim/crates/fly-session/src/coordinator.rs b/services/flysim/crates/fly-session/src/coordinator.rs index 78f7858..eae9fb4 100644 --- a/services/flysim/crates/fly-session/src/coordinator.rs +++ b/services/flysim/crates/fly-session/src/coordinator.rs @@ -54,6 +54,10 @@ pub struct Injections { pub altered_advance_controls: bool, /// Read and release the Advance result's frame, then replay the same operation. pub consume_advance_artifact_then_retry: bool, + /// Acknowledge the lifecycle replies twice, which is what the `ipc-v1` section 6 + /// resolution does to any Acknowledge whose first reply outran the probe. The second one + /// legitimately releases nothing, and bootstrap must accept it. + pub duplicate_lifecycle_acknowledge: bool, } /// What an injection produced, for a test to assert on. @@ -895,6 +899,21 @@ impl Coordinator { .push(request_id); } for (worker, ids) in by_worker.into_values() { + if self.injections.duplicate_lifecycle_acknowledge { + // Release them first, out of sight, so the call this method then makes and + // checks is already the *second* one -- which is the shape the section 6 + // resolution produces when an Acknowledge's first reply outruns the probe, + // and the shape the original defect fenced a healthy session on. Adding a + // second call after the checked one would not reproduce it: the first reply + // is always complete, so a length check on it would pass. + let first = self.acknowledge_replies(&worker, &ids).await?; + if first.len() != ids.len() { + return Err(self.fail_now( + DomainError::invalid("the first Acknowledge did not release everything"), + "acknowledge", + )); + } + } self.acknowledge_replies(&worker, &ids).await?; } self.audit.push("acknowledge.lifecycle".to_owned()); @@ -915,6 +934,12 @@ impl Coordinator { /// epoch, which is what /// `an_acknowledge_that_releases_nothing_is_not_a_failure` guards against. /// + /// A short list is accepted; a list about something else is not. The worker reports what + /// *it* released, so fewer ids than asked for is success -- but it is still only entitled + /// to report about the ids it was asked about, and an id outside the request is a worker + /// talking about another caller's cache. That half is exact-demanded, and + /// `AcknowledgeResult::validate_against` is what says so. + /// /// Returns the ids the worker actually released. pub async fn acknowledge_replies( &mut self, @@ -927,6 +952,15 @@ impl Coordinator { .await?; let result: AcknowledgeResult = reply.parse().map_err(|e| self.fail_now(e, "acknowledge"))?; + if let Err(e) = result.validate_against(¶ms) { + return Err(self.fail_now( + DomainError::before( + ErrorCode::IdentityMismatch, + format!("a worker acknowledged an id this session never asked about: {e}"), + ), + "acknowledge", + )); + } Ok(result.acknowledged) } @@ -1221,7 +1255,10 @@ impl Coordinator { let attempts = self.deadlines.resolve_attempts; let started = Instant::now(); self.resolutions += 1; + // Both, together: a resolution that ends before its first attempt would otherwise + // report the previous one's count. self.last_resolution = None; + self.last_resolution_attempts = 0; self.audit.push(format!("resolve:{}:{method}", worker.worker_id)); // The budget is the working limit and the attempt count is a guard; whichever runs // out is recorded, so "it gave up" is never an unexplained number. diff --git a/services/flysim/crates/fly-session/src/launcher.rs b/services/flysim/crates/fly-session/src/launcher.rs index 43d6705..a19ab4d 100644 --- a/services/flysim/crates/fly-session/src/launcher.rs +++ b/services/flysim/crates/fly-session/src/launcher.rs @@ -1511,6 +1511,9 @@ mod flag_tests { commit_delay_ms: 2, fail_stage_restore: true, fail_activate_restore: true, + // Argv carries the injected faults a worker process can have; this one is + // in-process only, because the check it drives is the caller's. + acknowledge_extra_id: None, }, client_id: "worker-fly-a".to_owned(), service: "agent.fly-a".to_owned(), diff --git a/services/flysim/crates/fly-session/src/worker.rs b/services/flysim/crates/fly-session/src/worker.rs index d717878..e361af3 100644 --- a/services/flysim/crates/fly-session/src/worker.rs +++ b/services/flysim/crates/fly-session/src/worker.rs @@ -194,6 +194,13 @@ pub trait WorkerEndpoint: Send + 'static { /// allocation" can read the allocation instead of being told it out of band. fn worker_threads(&self) -> u64; + /// An id this worker will add to every `Worker.Acknowledge` reply, for a test that needs a + /// worker reporting about something it was never asked about. `None` for a worker that + /// behaves. + fn acknowledge_extra_id(&self) -> Option { + None + } + /// The domain methods this endpoint implements, beyond the common `Worker.*` set. /// Anything else returns UNSUPPORTED without entering the endpoint. fn methods(&self) -> Vec<&'static str>; @@ -281,7 +288,8 @@ async fn run( ) { // Identity and capabilities are fixed for the endpoint's lifetime, so the shell reads them // once and never takes the endpoint mutex to answer Hello or Status. - let (worker_id, incarnation_id, session_id, role, capabilities, status, methods, threads) = { + #[allow(clippy::type_complexity)] + let (worker_id, incarnation_id, session_id, role, capabilities, status, methods, threads, extra_ack) = { let e = endpoint.lock().await; ( e.worker_id(), @@ -292,6 +300,7 @@ async fn run( e.status_cell(), e.methods(), e.worker_threads(), + e.acknowledge_extra_id(), ) }; let mut running: Vec> = Vec::new(); @@ -345,7 +354,7 @@ async fn run( continue; } "Worker.Acknowledge" => { - let outcome = match acknowledge(&request, &cache).await { + let outcome = match acknowledge(&request, &cache, extra_ack.as_ref()).await { Ok(result) => success(&request, &worker_id, &incarnation_id, result), Err(e) => { failure(&request.request_id, &worker_id, &incarnation_id, request.scope.clone(), e) @@ -717,16 +726,24 @@ fn hello( async fn acknowledge( request: &SessionRpcRequest, cache: &Arc>, + extra: Option<&Id>, ) -> DomainResult> { let params: AcknowledgeParams = AcknowledgeParams::from_json(&request.params) .map_err(|e| DomainError::invalid(format!("Worker.Acknowledge: {e}")))?; if params.request_ids.is_empty() || params.request_ids.len() > MAX_ACKNOWLEDGE { return Err(DomainError::invalid("Worker.Acknowledge takes 1..=16 request ids")); } - let acknowledged = { + let mut acknowledged = { let mut c = cache.lock().await; c.acknowledge(¶ms.request_ids) }; + // A deliberately misbehaving worker, for the caller-side subset check to refuse. + if let Some(extra) = extra + && let Ok(id) = DomainRequestId::parse(extra) + && !params.request_ids.contains(&id) + { + acknowledged.push(id); + } let result = AcknowledgeResult { acknowledged }; Ok(object(result.to_json())) } diff --git a/services/flysim/crates/fly-session/tests/processes.rs b/services/flysim/crates/fly-session/tests/processes.rs index 980ef3d..76f853d 100644 --- a/services/flysim/crates/fly-session/tests/processes.rs +++ b/services/flysim/crates/fly-session/tests/processes.rs @@ -24,6 +24,7 @@ use fly_session::types::*; all_modes!( an_acknowledge_that_releases_nothing_is_not_a_failure, + bootstrap_survives_the_second_acknowledge_its_resolution_makes, a_slow_participant_is_resolved_rather_than_failed, a_resolution_says_which_of_its_two_bounds_ended_it, a_delayed_one_agent_result_holds_the_world, @@ -131,6 +132,65 @@ async fn an_acknowledge_that_releases_nothing_is_not_a_failure(mode: ExecutionMo f.shutdown().await; } +/// The same rule, on the path `bootstrap` actually uses. +/// +/// The test above calls `acknowledge_replies` directly, which guards the check where it lives +/// now but not where it lived before: a length check reintroduced into `acknowledge_lifecycle` +/// after that call would leave it green. This one drives bootstrap itself, with the +/// `duplicate_lifecycle_acknowledge` injection doing exactly what the section 6 resolution +/// does -- the same ids again, to a worker that has already released them -- so the second, +/// empty answer has to be accepted by every check on bootstrap's path. +async fn bootstrap_survives_the_second_acknowledge_its_resolution_makes(mode: ExecutionMode) { + let mut f = mode_fixture(mode, two_agents(mode)).await; + f.harness.coordinator.injections = Injections { + duplicate_lifecycle_acknowledge: true, + ..Injections::default() + }; + within("bootstrap", f.harness.coordinator.bootstrap()) + .await + .expect("bootstrap accepts the second, empty acknowledgment of its own lifecycle ids"); + assert!(!f.harness.coordinator.is_fenced()); + assert_eq!(f.harness.coordinator.phase(), Phase::Ready(0)); + let report = within("step", f.harness.coordinator.step()).await.expect("and still plays"); + assert_eq!(report.boundary, 1); + f.shutdown().await; +} + +/// The other half of the rule: a short list is accepted, an id outside the request is not. +/// +/// A worker reports what *it* released, so fewer ids than asked for is success -- but it is +/// only entitled to report about the ids it was asked about. An id from outside the request is +/// a worker talking about another caller's cache, and +/// `AcknowledgeResult::validate_against` is what refuses it. Without this, dropping the length +/// check left nothing checking the reply against the request at all. +/// +/// Not generated per mode, deliberately. The check is the *caller's*, so the mode of the +/// worker that misbehaves is irrelevant to it, and the alternative -- carrying the +/// misbehaviour to a separate process over argv -- would put a flag in the shipped binary +/// whose only purpose is to make a worker lie about its acknowledgments. +#[tokio::test(flavor = "multi_thread", worker_threads = 4)] +async fn an_acknowledged_id_outside_the_request_is_refused() { + let mode = ExecutionMode::InProcess; + let mut config = two_agents(mode); + // This worker adds an id nobody asked about to every acknowledgment. + config.agents[0].faults = AgentFaults { + acknowledge_extra_id: Some(id("req-9999")), + ..AgentFaults::default() + }; + let mut f = mode_fixture(mode, config).await; + let failure = within("bootstrap", f.harness.coordinator.bootstrap()) + .await + .expect_err("a worker may not acknowledge an id this session never asked about"); + assert_eq!(failure.error.code, ErrorCode::IdentityMismatch); + assert!( + failure.error.message.contains("never asked about"), + "the refusal says what was wrong: {failure}" + ); + assert_eq!(failure.detail, "acknowledge"); + assert_eq!(failure.error.mutation, MutationCertainty::None, "refused before any mutation"); + f.shutdown().await; +} + // ------------------------------------------------------------------------------------------- // ipc-v1 section 6: an uncertain call is resolved, not failed @@ -275,10 +335,12 @@ async fn a_resolution_says_which_of_its_two_bounds_ended_it(mode: ExecutionMode) failure.error.message.contains("resolution budget"), "the message names the bound that fired: {failure}" ); - assert!( - f.harness.coordinator.last_resolution_attempts < u32::MAX, - "the budget ended it with attempts still in hand, which is what makes it the budget" - ); + // The budget ended it with attempts still in hand, which is what makes it the budget. A + // 200 ms budget at a 50 ms probe cannot spend more than a handful, and `u32::MAX` was + // never in reach; asserting against the guard's own size would be vacuous. + let spent = f.harness.coordinator.last_resolution_attempts; + assert!(spent >= 1, "the resolution made at least one attempt"); + assert!(spent < 100, "and nowhere near its guard: {spent}"); assert_eq!(failure.participant.as_deref(), Some(fly_a().as_str())); assert_eq!(failure.error.mutation, MutationCertainty::Unknown); assert!(f.harness.coordinator.is_fenced()); @@ -308,6 +370,7 @@ async fn a_resolution_says_which_of_its_two_bounds_ended_it(mode: ExecutionMode) assert_eq!(f.harness.coordinator.last_resolution_attempts, 3); assert_eq!(failure.participant.as_deref(), Some(fly_a().as_str())); assert_eq!(failure.error.mutation, MutationCertainty::Unknown); + assert!(f.harness.coordinator.is_fenced(), "an exhausted guard fences the epoch too"); f.shutdown().await; } @@ -400,7 +463,6 @@ async fn a_worker_death_has_a_bounded_diagnosed_outcome(mode: ExecutionMode) { config.agents[1].faults = AgentFaults { prepare_delay_ms: 5_000, ..AgentFaults::default() }; let mut f = mode_fixture(mode, config).await; within("bootstrap", f.harness.coordinator.bootstrap()).await.unwrap(); - let started = Instant::now(); let victim = fly_b(); let (coordinator, launcher) = f.harness.parts(); @@ -413,10 +475,9 @@ async fn a_worker_death_has_a_bounded_diagnosed_outcome(mode: ExecutionMode) { }) ); assert_eq!(reaped, ReapOutcome::Terminated); - let failure = stepped.expect_err("a dead participant is a failed epoch, not a slow one"); // Boundedness is the suite's own `within` above: the participant is five seconds slow and // `within` gives up at twenty, so returning at all is the claim. - let _ = started; + let failure = stepped.expect_err("a dead participant is a failed epoch, not a slow one"); assert_eq!( failure.participant.as_deref(), Some(fly_b().as_str()), @@ -450,7 +511,6 @@ async fn a_helper_death_has_a_bounded_diagnosed_outcome(mode: ExecutionMode) { let mut f = mode_fixture(mode, config).await; within("bootstrap", f.harness.coordinator.bootstrap()).await.unwrap(); let environment = f.harness.environment_id(); - let started = Instant::now(); let (coordinator, launcher) = f.harness.parts(); let (stepped, reaped) = tokio::join!( @@ -465,7 +525,6 @@ async fn a_helper_death_has_a_bounded_diagnosed_outcome(mode: ExecutionMode) { ); assert_eq!(reaped, ReapOutcome::Terminated); let failure = stepped.expect_err("a dead world is a failed epoch"); - let _ = started; assert_eq!( failure.participant.as_deref(), Some(environment.as_str()), From 5e61c50728d03d345dcf7484883242483f196b94 Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 22 Sep 2026 21:03:59 +0000 Subject: [PATCH 17/20] session: an unreadable event batch is not the end of the stream take_events kept returning Option and defaulting through the question-mark operator, so a batch missing a field read as end of stream and the ConsumerEvents enum added in the previous round described nothing. It returns Batch or Unreadable now, and a test publishes a batch with no droppedBefore, asserts it is reported as unreadable naming the field, and asserts the next real batch still reads. The checkpoint-envelope-v1 amendment cites the rule that lets a required manifest field land with envelopeVersion still 1 while no production file exists. --- .../checkpoint-envelope-v1.md | 4 +- .../flysim/crates/fly-session/src/publish.rs | 34 +++++++++--- .../crates/fly-session/tests/publishing.rs | 55 ++++++++++++++++++- 3 files changed, 81 insertions(+), 12 deletions(-) diff --git a/docs/design/session-framework/checkpoint-envelope-v1.md b/docs/design/session-framework/checkpoint-envelope-v1.md index 69bdf4d..6e5017a 100644 --- a/docs/design/session-framework/checkpoint-envelope-v1.md +++ b/docs/design/session-framework/checkpoint-envelope-v1.md @@ -111,7 +111,9 @@ Without it a replacement fly that built another graph -- the same dataset, the s count, another index -- passed the group check and was then published under its predecessor's `indexDigest`, which is a graph identity crossing a recovery and exactly what section 5's rules exist to prevent. It is recorded from the worker's attestation rather than recomputed from the -dataset, because the point is that the two can disagree. +dataset, because the point is that the two can disagree. `envelopeVersion` stays `1`, which the +required-manifest-field rule below allows only while no production `FLYSESS1` file exists; once +one does, adding a required manifest field must bump it. **Amendment, 2026-09-22 (STATE-01).** The table above names a holder for every payload except the environment's own, although section 6's fixture has one (`world`) and a group install has diff --git a/services/flysim/crates/fly-session/src/publish.rs b/services/flysim/crates/fly-session/src/publish.rs index 67444a6..8390298 100644 --- a/services/flysim/crates/fly-session/src/publish.rs +++ b/services/flysim/crates/fly-session/src/publish.rs @@ -1275,26 +1275,42 @@ impl PresentationConsumer { } /// Takes the next bounded event batch. - pub async fn take_events(&mut self) -> Option { + /// + /// `None` is the end of the stream and nothing else. A batch this consumer cannot read + /// comes back as [`ConsumerEvents::Unreadable`], because "there are no more events" and + /// "that one made no sense" are different facts and one value cannot carry both: a + /// consumer that saw the second as the first would stop reading a live stream. + pub async fn take_events(&mut self) -> Option { let message = self.events.next().await?; let payload = message.payload().clone(); drop(message); - let epoch = payload.get("epoch").and_then(Value::as_str).map(id)?; - let dropped_before = payload + let unreadable = |what: &str| { + Some(ConsumerEvents::Unreadable { + detail: format!("an event batch has no readable {what}"), + }) + }; + let Some(epoch) = payload.get("epoch").and_then(Value::as_str).map(id) else { + return unreadable("epoch"); + }; + let Some(dropped_before) = payload .get("droppedBefore") .and_then(Value::as_str) - .and_then(|t| t.parse::().ok())?; - let event_ids = payload - .get("events") - .and_then(Value::as_array)? + .and_then(|t| t.parse::().ok()) + else { + return unreadable("droppedBefore"); + }; + let Some(events) = payload.get("events").and_then(Value::as_array) else { + return unreadable("events array"); + }; + let event_ids = events .iter() .filter_map(|e| e.get("id").and_then(Value::as_str).map(str::to_owned)) .collect(); - Some(EventBatchView { + Some(ConsumerEvents::Batch(EventBatchView { epoch, dropped_before, event_ids, - }) + })) } /// Records that this consumer has mapped geometry against the agents of `revision`. diff --git a/services/flysim/crates/fly-session/tests/publishing.rs b/services/flysim/crates/fly-session/tests/publishing.rs index 12f7af1..b5e7b93 100644 --- a/services/flysim/crates/fly-session/tests/publishing.rs +++ b/services/flysim/crates/fly-session/tests/publishing.rs @@ -14,8 +14,9 @@ use common::{Fixture, default_fixture, fixture, fly_a, fly_b, mode_fixture, with use fly_session::coordinator::{DESCRIPTOR_REVISION, Injections}; use fly_session::harness::{AgentSpec, ExecutionMode, HarnessConfig, Via}; use fly_session::publish::{ - ApplicationChannel, ConsumerOutcome, Delivery, EVENT_BATCH_DEPTH, EventOutbox, GET_SNAPSHOT, - PresentationConsumer, PublicationOutcome, TopicPolicy, check_publication, query_service, + ApplicationChannel, ConsumerEvents, ConsumerOutcome, Delivery, EVENT_BATCH_DEPTH, EventOutbox, + GET_SNAPSHOT, PresentationConsumer, PublicationOutcome, TopicPolicy, check_publication, + query_service, }; use fly_session::types::*; use serde_json::json; @@ -38,6 +39,7 @@ both_transports!( the_query_service_answers_reads_and_nothing_else, a_refused_snapshot_is_still_what_the_repair_path_answers, a_replacement_that_built_another_index_cannot_install_the_checkpoint, + a_malformed_event_batch_is_unreadable_and_not_the_end_of_the_stream, the_published_descriptor_is_what_the_workers_attested_to, a_stimulus_kind_the_descriptor_does_not_declare_is_refused, a_restored_boundary_publishes_a_new_revision_and_no_transition, @@ -1079,6 +1081,55 @@ async fn a_refused_snapshot_is_still_what_the_repair_path_answers(via: Via) { f.shutdown().await; } +/// An event batch a consumer cannot read is reported as unreadable, and the stream goes on. +/// +/// The two facts differ: a consumer that read "that one made no sense" as "there are no more +/// events" would stop reading a live stream, which is the silent default this module exists to +/// refuse. The malformed batch is published by a second publisher on the session's own address, +/// because a well-behaved session cannot produce one. +async fn a_malformed_event_batch_is_unreadable_and_not_the_end_of_the_stream(via: Via) { + let mut f = started(via).await; + let mut consumer = f.harness.consumer().await.expect("a consumer attaches"); + let events_topic = f.harness.coordinator.topics().events.clone(); + let intruder = f.harness.publisher().await.expect("a publishing client"); + + // A batch with no droppedBefore: readable JSON, unreadable as a batch. + intruder + .publish( + &events_topic, + object(json!({ + "sessionId": f.harness.config.session_id.as_str(), + "epoch": f.harness.config.epoch.as_str(), + "events": [], + })), + &[], + ) + .await + .expect("the malformed batch publishes"); + + match within("the malformed batch", consumer.take_events()).await { + Some(ConsumerEvents::Unreadable { detail }) => { + assert!( + detail.contains("droppedBefore"), + "the report names what it could not read: {detail}" + ); + } + other => panic!("a malformed batch must not read as {other:?}"), + } + + // The stream did not end: the next real batch still arrives and reads. + f.harness.coordinator.run(1).await.expect("a transition"); + match within("the next batch", consumer.take_events()).await { + Some(ConsumerEvents::Batch(batch)) => { + assert_eq!(batch.epoch, f.harness.config.epoch); + assert!(!batch.event_ids.is_empty(), "the transition produced events"); + } + other => panic!("expected a readable batch after the malformed one, got {other:?}"), + } + intruder.close().await; + f.shutdown().await; +} + /// Two reads and nothing else. There is no method on this service that could move anything. async fn the_query_service_answers_reads_and_nothing_else(via: Via) { let mut f = started(via).await; From 01494a104ee7dcb27191c3634e2bdf1d4649c9c8 Mon Sep 17 00:00:00 2001 From: acamilo Date: Tue, 22 Sep 2026 21:11:55 +0000 Subject: [PATCH 18/20] docs: the session framework slices that landed, and the two that are blocked --- .../session-framework/implementation.md | 8 ++++++ docs/stream-mvp-plan.md | 26 +++++++++++++++++++ 2 files changed, 34 insertions(+) diff --git a/docs/design/session-framework/implementation.md b/docs/design/session-framework/implementation.md index 124d7d1..bfb408a 100644 --- a/docs/design/session-framework/implementation.md +++ b/docs/design/session-framework/implementation.md @@ -160,6 +160,10 @@ delayed rendering retains its handle. Distinguish AssetRef from transient Artifa **Depends on:** SESSION-02, MEDIA-01 and the profile/identity foundation in the broader backlog. +**2026-09-22:** blocked. The profile/identity foundation is FOUNDATION-02 +(`feat/brain-profile-contract`) in the [MaleCNS backlog](../malecns-modular-implementation.md), +which has not been built. Not started. + **Implement:** adapter over existing LIF, plasticity, retina and fixed readout primitives; reference-first composition/goldens; independently seeded agent state and shared immutable data. Avoid using the old whole-frame `tick` wrapper if it changes the specified phase ordering. @@ -172,6 +176,10 @@ dispatch order and varying worker count preserves results. Keep 64-role limits e **Depends on:** AGENT-01 and environment/task extraction in the broader backlog. +**2026-09-22:** blocked. AGENT-01 is blocked, and environment/task extraction is +RUNTIME-01 (`refactor/environment-task-boundary`) in the same backlog, which has not been +built. Not started. + **Implement:** binjgb environment, task-local memory inspector and identity/existing action adapter. Keep `legacy-gameboy-v1` separately routed with exact old ordering/hash semantics. diff --git a/docs/stream-mvp-plan.md b/docs/stream-mvp-plan.md index bce64e3..681c13d 100644 --- a/docs/stream-mvp-plan.md +++ b/docs/stream-mvp-plan.md @@ -748,3 +748,29 @@ rewritten separately. inside one commit), so the run restarted from rung 8, VIRIDIAN CITY, with `fly-reset-to-milestone 8`; the v5 checkpoint migrated to v6 as designed. The previous state is archived beside the store. + +## 2026-09-22 - session framework: what landed and what stopped + +The session framework slices from docs/design/session-framework/implementation.md were built +in ordered waves, each on its own branch with an independent review before merge. + +Landed on main: CONTRACT-01, BUS-01 through BUS-03, SESSION-01, SESSION-02, MEDIA-01, +STATE-01 and PUBLISH-01. Together they give the repo an executable session contract with a +TypeScript oracle, a conforming bus with a written conformance table, a lockstep session that +runs in process, on threads or as one process per fly, native frame and audio observations +with spectator isolation, a coherent all-participant checkpoint with group restore and a +liftable fence, and an internal publication boundary with committed snapshots over the same +bus. Every contract silence met on the way was closed by a dated amendment in the affected +document rather than by convention; none touched doctrine. + +Also landed: the bus test suite asserts guarantees rather than the machine's timing, and a +coordinator defect found through one of those flakes is fixed, where a lifecycle +acknowledgement that legitimately releases nothing was treated as a fault. + +Stopped: AGENT-01 and ENV-01 are blocked on FOUNDATION-02 and RUNTIME-01 from the MaleCNS +backlog, which do not exist yet. The profile contract and the environment boundary are +decisions for the operator, so the swarm stopped here. DOLPHIN-01 was never in scope. + +Measured on the development box, not capacity claims: bus RPC near one millisecond at the +median; a two-fly transition near 10 to 12 ms at the median in every execution mode; about +5.7 MiB per participant process when split. From e307b7dde44a653276d2f70d4d7cc3060d0671b9 Mon Sep 17 00:00:00 2001 From: dev Date: Tue, 22 Sep 2026 22:11:01 +0000 Subject: [PATCH 19/20] docs: row 50's survey, its two trap hunts and its ROM run The press survey, twenty brain minutes before and after on both checkpoints, and the ROM-gated forest run. The ethos check's 'fewer flagged windows, more distinct tiles' holds on the tiles on both arms (296 to 430, 163 to 184) and does NOT hold on the windows (33/73 to 70/73, 68/73 to 73/73). After the fix the fly spends three quarters of each run inside battles it is actually fighting, and the hunt's tile rule flags a fighting fly exactly as hard as a stuck one. Reported rather than smoothed. --- infra/docs/macros-traps.md | 123 +++++++++++++++++++++++++++++++++++++ 1 file changed, 123 insertions(+) diff --git a/infra/docs/macros-traps.md b/infra/docs/macros-traps.md index 90a2835..c7fbf3a 100644 --- a/infra/docs/macros-traps.md +++ b/infra/docs/macros-traps.md @@ -2156,3 +2156,126 @@ skipped cleanly without `FLY_ROM` and the checkpoint): - `infra/tests/lint.sh`: all checks passed, de-PII guard included. - `--print-compatibility`: **648 bytes, sha256 `0d9bfde7...707fa`** -- byte-identical to v0.4.1 through v0.4.5. Decoder, reward catalog, adapter version and roles untouched. + +## Row 50: the move list was never drawn (2026-09-22, v0.4.7) + +`MOVE n` has reported `blocked` on most of its starts since v0.4.3 and every review since has named +it and left it: "the move list drawn and its cursor placeable but not accepting input". Half of +that is wrong, and finding out which half is the fix. + +### The survey + +`examples/scene_probe.rs`, `FLY_PROBE_CATCH=accept`, from the rung-9 forest checkpoint. The +question "is this menu accepting input" is answered by **pressing at it**: every battle frame +exports the emulator state, takes one directional pulse, reads `wCurrentMenuItem` and puts the +state straight back, so every frame has a ground truth and the run is not perturbed by the +measurement. `HandleMenuInput` moves the cursor on UP and DOWN before it looks at +`wMenuWatchedKeys`, so a cursor that moves is a menu running its input loop. The pulse releases the +buttons first: `JoypadLowSensitivity` acts on a key's edge, and the first pass of this survey +counted 187 refusals that were its own held button. + +3,102 battle frames whose cursor bytes say "the move list": + +| the reading | press refused | press honoured | +| --- | ---: | ---: | +| the cursor bytes alone (the seam before this row) | 2,838 | 264 | +| the cursor bytes **and** the box on screen | **0** | **231** | +| the cursor bytes with no box drawn | 2,838 | 33 | + +So 91.5% of the frames the old reading called an open move list were frames no press reached. The +33 are frames where the pulse's own thirty were long enough for the cartridge to open something by +itself; the pulse is a measurement and not a claim about one frame. + +Beside the press, the probe asks **every byte of WRAM and HRAM** whether its values on accepting +frames are disjoint from its values on refusing ones, so the reading is found rather than +nominated. Once the box is in the reading nothing separates the two classes, because there is +nothing left to separate. The top-level battle menu was already exact: 413 frames, 0 refused, and +`wTextBoxID` is why. + +### The mechanism + +`MoveSelectionMenu` writes `wTopMenuItemY` 12 and `wTopMenuItemX` 5 and nothing in the game clears +them -- row 41's fact about the YES/NO box, one menu over. `SelectMenuItem` then decrements +`wCurrentMenuItem` back into a 0-based move slot on its way out, which lands inside the one-based +range the accessor reads as valid, so a turn spent on move 2, 3 or 4 leaves a *placeable* cursor +behind it. That is why `MOVE 4` was 222 of 224. + +### The trap hunt, twenty brain minutes on each checkpoint + +`main` at `e76b3d1` against this branch, same seed, same ground. + +**The rung-9 forest checkpoint.** + +| measure | before | after | +| --- | ---: | ---: | +| `MOVE n` starts / `blocked` | 109 / **29** (26.6%) | 83 / **0** | +| macro starts that were `BACK` on the move list | **233** | 17 | +| frames the seam called an open move list | **20,318** | 3,742 | +| frames it called a move list with no cursor | 6,751 | 10 | +| frames it called between-turns | 1,670 | **44,270** | +| distinct (map, tile) | 296 | **430** | +| windows flagged | **33 / 73** | 70 / 73 | +| macros started / blocked | 620 / 31 | 1,160 / **1** | +| frames in `battle` | 31,751 | 52,311 | +| wall clock | 3,350 s | 3,038 s | + +**The rung-11 Route 3 checkpoint.** + +| measure | before | after | +| --- | ---: | ---: | +| `MOVE n` starts / `blocked` | 198 / **72** (36.4%) | 97 / **2** (2.1%) | +| macro starts that were `BACK` on the move list | **361** | 22 | +| frames the seam called an open move list | **34,637** | 4,141 | +| frames it called a move list with no cursor | 16,162 | 5 | +| frames it called between-turns | 5,372 | **47,660** | +| distinct (map, tile) | 163 | **184** | +| windows flagged | **68 / 73** | 73 / 73 | +| macros started / blocked | 1,097 / 87 | 1,300 / **19** | +| `GO ROUTE` completed | 5 | 21 | +| wall clock | 2,392 s | 2,127 s | + +**More ground on both arms, and more flagged windows on both.** That is row 54's arm again and it +is reported rather than smoothed: after the fix the fly spends 73% and 78% of the two runs inside +battles it is actually fighting, and the hunt's rule -- fewer than four distinct tiles in two brain +minutes -- flags a fly that is fighting exactly as hard as a fly that is stuck. The ethos check's +"fewer flagged windows, more distinct tiles" holds on the tiles and **not** on the windows, on both +arms. The merge is Fable's call. + +### ROM-gated, from the forest checkpoint + +`the_battles_turns_advance_from_the_rung_nine_forest_checkpoint`, with the two claims row 50 turns +on added to it: + +| measure | before (`main` at `e76b3d1`) | after | +| --- | ---: | ---: | +| `MOVE n` starts / `blocked` | 940 / **838** | 51 / **0** | +| battles entered / ended | 11 / **10** | 13 / **13** | +| worst battle, in macros | 503 | 283 | +| median battle, in macros | 48 | 43 | +| where `blocked` was earned | seven of ten were `MOVE n` in a battle | no `MOVE n` at all | + +The blocked share is the assertion; the median is what it buys and the worst battle is a tail. + +### Residuals, named rather than worked around + +- **The battle bag is the same trap on `wListMenuID`.** `ITEMLISTMENU` outlives the bag exactly as + the cursor bytes outlive the move list: over the frames the seam calls an open battle bag the + same survey refused **449** presses against 36 honoured. Its list is drawn in the top half of the + screen and the survey has not found the figure that tells it from the frame after it closes, so + `ITEM` and `THROW BALL` still pay for it. It is the next trap. +- **The party list, likewise**: `PartyMenuInit`'s geometry outlives its list. +- **The hunt's tile rule still cannot tell a long battle from a stall**, which is section 15's own + measurement in `docs/design/macros.md` and now the third branch to run into it. + +### Gates + +- `cargo test --workspace` with `FLY_ROM` set: green except + `flysim::integration::the_service_streams_takes_sugar_checkpoints_and_resumes_after_being_killed`, + the known debug-build boot failure on this box. On the first pass + `flybus::integration::unix_socket::session_over_one_router` also failed -- the slow-consumer + coalescing flake that `fix/flybus-coalescing-flake` is open on -- and passed on a re-run; three + other agents were building on the box at the time. +- `cargo clippy --all-targets`: clean. +- `infra/tests/lint.sh`: all checks passed, de-PII guard included. +- `--print-compatibility`: **648 bytes, sha256 `0d9bfde7...707fa`** -- byte-identical to this + branch's base. Decoder, reward catalog, adapter version and roles untouched. From 351338038aa6e95d367d3d0412e65df3a2de7773 Mon Sep 17 00:00:00 2001 From: acamilo Date: Tue, 22 Sep 2026 22:13:09 +0000 Subject: [PATCH 20/20] docs: v0.5.1 status --- docs/stream-mvp-plan.md | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/docs/stream-mvp-plan.md b/docs/stream-mvp-plan.md index 681c13d..94bb51e 100644 --- a/docs/stream-mvp-plan.md +++ b/docs/stream-mvp-plan.md @@ -774,3 +774,13 @@ decisions for the operator, so the swarm stopped here. DOLPHIN-01 was never in s Measured on the development box, not capacity claims: bus RPC near one millisecond at the median; a two-fly transition near 10 to 12 ms at the median in every execution mode; about 5.7 MiB per participant process when split. +- 2026-09-22 (v0.5.1, loop review, auto): row 50. The cartridge never clears the move-list cursor + bytes after a turn, so every frame of a turn's text, animation and reply read as the fly's own + turn on an open list; the pad dealt MOVE 1-4 and BACK on all of them and the cursor step pressed + at a list nobody was reading. Surveyed by pressing: a press was honoured on 231 of 231 frames + where the menu box is drawn and on none where it is not. Fix: one gate on the drawn box in the + battle seam; a frame with no box is between turns, NEXT only. From the forest checkpoint MOVE n + blocked starts 838 -> 0, every battle entered is ended, worst battle 503 -> 283 macros; hunt + tiles up on both arms (296 -> 430 forest, 163 -> 184 Route 3), flagged windows again rise + because the fly is inside battles it is fighting (same judgement as v0.4.6). Next row: the + battle bag's list id outlives the bag the same way.