From efa7d5cff2669f65ef65d8866a03c035d5c8f508 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 02:34:20 +0200 Subject: [PATCH 01/62] World creation is one step: title, name, optional brief, Begin The no-world card asks for a title, a name and an optional brief and offers one Begin button. world.begin takes the world_create.v4 payload (the fields the form no longer asks for ride the button's action as authored constants) and submits create, approve and activate as three separate kernel commands, stopping at the first refusal. The create path is shared with world.create. Step ids are derived from the Begin's key so a replayed Begin replays through the kernel's ledger. A refusal never echoes a typed value, including the jurisdiction-handle refusal on world.create. Not yet built or run: the bridge fixture still carries the old create/approve/activate steps (re-derived from the real client in a later commit), and the ignored bridge tests have not run. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight-dungeon/src/eve.rs | 201 ++++---- crates/ghostlight-dungeon/src/runtime.rs | 565 ++++++++++++++++++----- 2 files changed, 535 insertions(+), 231 deletions(-) diff --git a/crates/ghostlight-dungeon/src/eve.rs b/crates/ghostlight-dungeon/src/eve.rs index 4249b93..8332e2b 100644 --- a/crates/ghostlight-dungeon/src/eve.rs +++ b/crates/ghostlight-dungeon/src/eve.rs @@ -51,18 +51,10 @@ pub(crate) struct EveRouteHint { const PLAY_FAULT_LINE: &str = "The storyteller lost the thread and your turn ended. Write again to start a new turn."; -/// The `world_create.v4` payload, in one place, so the button that captures it -/// and the descriptor that advertises it cannot drift. -const CREATE_BINDINGS: [&str; 8] = [ - "title", - "brief", - "subject_label", - "narrative_persona_label", - "operational_agent_label", - "targets", - "jurisdictions", - "lens_weights", -]; +/// What the Begin button captures from the owner: the three fields the form +/// asks for. The rest of `world_create.v4` rides the button's own action as +/// authored constants (`begin_action`). +const BEGIN_BINDINGS: [&str; 3] = ["title", "brief", "subject_label"]; const SEED_BINDINGS: [&str; 2] = ["vault_scope", "brief"]; @@ -209,6 +201,20 @@ fn jurisdiction_label(world: &WorldSnapshot, jurisdiction: JurisdictionKey) -> S } } +/// The `world_create.v4` fields the Begin form does not ask for, stated +/// explicitly: no scale targets, no jurisdiction roots, no persona or agent +/// label, and Dungeon's uniform lens policy (the library holds no default +/// weights). They travel as the button's own action, which the lowering +/// spreads into the payload beside the captured `bindings`. +fn begin_action() -> anyhow::Result { + Ok(json!({ + "targets":"{}", + "jurisdictions":"[]", + "lens_weights":serde_json::to_string(&crate::runtime::uniform_lens_weights()) + .context("the default lens weights encode")? + })) +} + /// `play` arrives beside the snapshot rather than inside it: `WorldSnapshot` /// carries no play-turn state of its own. The play card below reads `play` /// alone, never the operator's own unscoped log of every committed event: @@ -246,99 +252,43 @@ pub(crate) fn authenticated_surface( match snapshot { None => { - // Dungeon policy, editable before submission; the library holds no - // default weights. - let default_lens_weights = - serde_json::to_string(&crate::runtime::uniform_lens_weights()) - .context("the default lens weights encode")?; children.extend([ json!({ - "id":"world.create.title", + "id":"world.begin.title", "kind":"control.input.text", "props":{"label":"World title","placeholder":"A name for this world"}, "stateBindings":[local_draft("title", "string")], "children":[] }), - // Cut 10 (PA.f149): `CREATE_BINDINGS` has always advertised - // `brief` — `world_create.v4`'s `CreatePayload.brief` is - // required, not `#[serde(default)]` — but no control ever - // captured it. Driving `world.create` with the real - // vendored lowering surfaced this: nothing the lowering - // could ever submit through this form would satisfy - // `world_create.v4`, hand-built test payloads notwithstanding. json!({ - "id":"world.create.brief", - "kind":"control.input.textarea", - // PA.f164: `brief` is documented "required, may be empty" - // (`CreatePayload::brief`), the same deliberate-empty - // shape as `targets`/`jurisdictions` below — an authored - // `value` so a form left untouched here still submits. - "props":{"label":"Brief","rows":2,"value":"","placeholder":"One sentence of what this world is for"}, - "stateBindings":[local_draft("brief", "string")], - "children":[] - }), - json!({ - "id":"world.create.subject", + "id":"world.begin.subject", "kind":"control.input.text", "props":{"label":"Your name","placeholder":"The first person in the world"}, "stateBindings":[local_draft("subject_label", "string")], "children":[] }), + // `brief` is "required, may be empty" (`CreatePayload::brief`): + // an authored `value`, because the lowering captures only a + // `value`, so a form left untouched here still submits. json!({ - "id":"world.create.narrative_persona", - "kind":"control.input.text", - "props":{"label":"Narrative persona (optional)","placeholder":"A person who lives the story in prose"}, - "stateBindings":[local_draft("narrative_persona_label", "string")], - "children":[] - }), - json!({ - "id":"world.create.operational_agent", - "kind":"control.input.text", - "props":{"label":"Operational agent (optional)","placeholder":"An institution or operator-shaped mind"}, - "stateBindings":[local_draft("operational_agent_label", "string")], - "children":[] - }), - json!({ - "id":"world.create.targets", + "id":"world.begin.brief", "kind":"control.input.textarea", - // PA.f164: an authored `value`, not only a `placeholder` — - // `findAuthoredBindingValue` (the vendored lowering) only - // ever captures a control's `value`, never its - // `placeholder`, so a form submitted without editing this - // field would otherwise be refused as missing. `{}` is a - // legitimate `targets` (a world with no scale target is a - // deliberate choice, `CreatePayload`'s own doc comment). - "props":{"label":"Scale target","rows":2,"value":"{}","placeholder":"{\"person\": 12, \"institution\": 3}"}, - "stateBindings":[local_draft("targets", "string")], - "children":[] - }), - json!({ - "id":"world.create.jurisdictions", - "kind":"control.input.textarea", - // PA.f164: same gap, `[]` is a legitimate empty root list. - "props":{"label":"Jurisdiction roots","rows":3,"value":"[]","placeholder":"[{\"handle\":\"low_sere\",\"label\":\"The Low Sere\",\"permille\":700}]"}, - "stateBindings":[local_draft("jurisdictions", "string")], - "children":[] - }), - json!({ - "id":"world.create.lens_weights", - "kind":"control.input.textarea", - "props":{"label":"Lens weights","rows":2,"value":default_lens_weights,"placeholder":default_lens_weights}, - "stateBindings":[local_draft("lens_weights", "string")], + "props":{"label":"Brief (optional)","rows":2,"value":"","placeholder":"One sentence of what this world is for"}, + "stateBindings":[local_draft("brief", "string")], "children":[] }), command_button( - "world.create", - "Create world", - "world.create", - json!({}), - &CREATE_BINDINGS, + "world.begin", + "Begin", + "world.begin", + begin_action()?, + &BEGIN_BINDINGS, ), ]); commands.push(command_descriptor( - "world.create", + "world.begin", "ghostlight.world_create.v4", - &CREATE_BINDINGS, + &BEGIN_BINDINGS, "WorldMailbox", )); } @@ -711,6 +661,7 @@ pub(crate) fn operation_schema(operation: &str) -> Option<&'static str> { "heimdall.auth.complete" => "heimdall.auth_complete_command.v1", "app.auth.logout" => "ghostlight.app_logout.v2", "world.create" => "ghostlight.world_create.v4", + "world.begin" => "ghostlight.world_create.v4", "world.approve" => "ghostlight.world_approve.v0", "world.activate" => "ghostlight.world_activate.v0", "world.advance_time" => "ghostlight.world_advance_time.v0", @@ -922,34 +873,64 @@ mod tests { assert_eq!(surface["version"], 0); } - #[test] - fn empty_authenticated_surface_has_create_without_session_zero() { - let surface = authenticated_surface("sha256:owner", None, None, true).unwrap(); - let encoded = serde_json::to_string(&surface).unwrap(); - assert!(encoded.contains("world.create")); - assert!(encoded.contains("narrative_persona_label")); - assert!(encoded.contains("operational_agent_label")); - assert!(!encoded.contains("session_zero")); - assert!(!encoded.contains("campaign")); - assert_eq!(surface["version"], 0); - - // The lens control's default is Dungeon's uniform policy, every stock - // lens named, and it decodes as the weights the payload will carry. - fn find<'a>(node: &'a Value, id: &str) -> Option<&'a Value> { - if node["id"] == id { - return Some(node); - } - node.as_object()? - .values() - .flat_map(|value| match value { - Value::Array(items) => items.iter().collect::>(), - other => vec![other], - }) - .find_map(|child| find(child, id)) + fn find<'a>(node: &'a Value, id: &str) -> Option<&'a Value> { + if node["id"] == id { + return Some(node); } - let control = find(&surface, "world.create.lens_weights").expect("a lens weights control"); - let default = control["props"]["value"].as_str().expect("a default value"); - let decoded: ghostlight::LensWeights = serde_json::from_str(default).unwrap(); + node["children"].as_array()?.iter().find_map(|child| find(child, id)) + } + + /// R2 at the surface: the empty world asks for a title, a name and an + /// optional brief, and offers one command, Begin. The fields the form no + /// longer asks for are authored constants on the button, and the + /// descriptor captures only what the form asks. + #[test] + fn the_empty_world_surface_asks_for_a_title_a_name_and_offers_one_begin() { + let surface = authenticated_surface("sha256:owner", None, None, true).unwrap(); + assert_eq!(surface["version"], 0); + let encoded = serde_json::to_string(&surface).unwrap(); + for retired in [ + "narrative_persona", + "operational_agent", + "world.create", + "session_zero", + "campaign", + ] { + assert!( + !encoded.contains(retired), + "the empty-world surface still carries {retired}" + ); + } + let root = &surface["surface"]["root"]; + for id in ["world.begin.title", "world.begin.subject", "world.begin.brief"] { + assert!(find(root, id).is_some(), "{id}"); + } + for id in [ + "world.begin.narrative_persona", + "world.begin.operational_agent", + "world.begin.targets", + "world.begin.jurisdictions", + "world.begin.lens_weights", + ] { + assert!(find(root, id).is_none(), "{id}"); + } + assert_eq!(find(root, "world.begin.brief").unwrap()["props"]["value"], ""); + + let commands = surface["commands"].as_array().unwrap(); + assert_eq!(commands.len(), 1, "{commands:?}"); + assert_eq!(commands[0]["command"], "world.begin"); + assert_eq!(commands[0]["payloadSchema"], "ghostlight.world_create.v4"); + assert_eq!(commands[0]["captureBindings"], json!(["title", "brief", "subject_label"])); + + // The constants are explicit, and the lens weights are Dungeon's + // uniform policy, every stock lens named. + let button = find(root, "world.begin").expect("a Begin button"); + assert_eq!(button["props"]["label"], "Begin"); + let action = &button["props"]["action"]; + assert_eq!(action["targets"], "{}"); + assert_eq!(action["jurisdictions"], "[]"); + let decoded: ghostlight::LensWeights = + serde_json::from_str(action["lens_weights"].as_str().expect("lens weights text")).unwrap(); assert_eq!(decoded, crate::runtime::uniform_lens_weights()); assert_eq!(decoded.iter().count(), ghostlight::Lens::ALL.len()); assert!(decoded.iter().all(|(_, weight)| weight == 1)); diff --git a/crates/ghostlight-dungeon/src/runtime.rs b/crates/ghostlight-dungeon/src/runtime.rs index 807f6b5..96de692 100644 --- a/crates/ghostlight-dungeon/src/runtime.rs +++ b/crates/ghostlight-dungeon/src/runtime.rs @@ -14,7 +14,7 @@ use crate::{ use ghostlight::{ CONSUMER_BODY_LIMIT, CommandBody, CommandId, ConnectorBinding, ConsumerPort, ConsumerRegistry, ControllerError, ControllerModels, ControllerPort, ControllerRunner, ControllerWorkCustody, - CreateJurisdictionIntent, CreateWorldIntent, DEFAULT_LOCAL_MODEL_PREFIX, + CreateJurisdictionIntent, CreateWorldIntent, CreationReceipt, DEFAULT_LOCAL_MODEL_PREFIX, DEFAULT_SDK_MODEL_PREFIX, KernelError, Lens, LensWeights, LocalBinding, MailboxError, PersonaLane, PrincipalCommandIntent, PrincipalId, SdkBinding, SeedOutcome, SeedPort, SubjectKind, SubmitReceipt, TickMinutes, @@ -39,6 +39,7 @@ use cultnet_rs::{ decode_cultnet_message_from_slice, encode_cultnet_message_to_vec, }; use serde::Deserialize; +use sha2::{Digest, Sha256}; use serde_json::{Value, json}; use std::{ collections::{BTreeMap, HashMap}, @@ -1242,6 +1243,8 @@ enum RuntimeCommandError { Mailbox(#[from] MailboxError), #[error(transparent)] Kernel(#[from] KernelError), + #[error("the begin stopped at its {step} step: {cause}")] + BeginStopped { step: &'static str, cause: String }, } /// The one door an answer's own `answerToken` is judged by (PA.f179): admits @@ -1257,6 +1260,143 @@ fn answer_token_admits(given: Option<&str>, expected: Option<&str>) -> bool { matches!((given, expected), (Some(given), Some(expected)) if given == expected) } +/// `world.create`'s whole effect: genesis for the payload as stated. The one +/// create path, shared by the `world.create` command and the first step of +/// `world.begin`. +async fn create_world( + state: &AppState, + verified_principal: &VerifiedPrincipalEvidence, + command_id: CommandId, + payload: CreatePayload, +) -> Result { + if payload + .jurisdictions + .iter() + .any(|root| !is_handle_shape(&root.handle)) + { + return Err(RuntimeCommandError::Payload( + "a jurisdiction handle is not a draft handle".into(), + )); + } + state + .world + .create( + CreateWorldIntent { + id: command_id, + title: payload.title, + brief: payload.brief, + human_subject_label: payload.subject_label, + narrative_persona_label: payload.narrative_persona_label.filter(|label| !label.trim().is_empty()), + operational_agent_label: payload.operational_agent_label.filter(|label| !label.trim().is_empty()), + targets: payload.targets, + jurisdictions: payload + .jurisdictions + .into_iter() + .map(|root| CreateJurisdictionIntent { + handle: root.handle, + label: root.label, + permille: root.permille, + }) + .collect(), + lens_weights: payload.lens_weights, + }, + verified_principal, + ) + .await + .map_err(map_mailbox) +} + +fn created_receipt(receipt: &CreationReceipt) -> Value { + json!({ + "kind":"created", + "commandId":serde_json::to_value(receipt.command_id).unwrap_or(Value::Null), + "worldId":serde_json::to_value(receipt.world_id).unwrap_or(Value::Null), + "stateDigest":receipt.resulting_state_digest, + "commitDigest":receipt.commit_digest + }) +} + +/// The id of one of the kernel commands a Begin submits after its create: +/// derived from the Begin's own key and the step's name, so a replayed Begin +/// replays each of its commands through the kernel's own idempotency ledger +/// instead of submitting new ones. +fn begin_step_id(begin_key: &str, step: &str) -> Result { + let key = uuid::Uuid::parse_str(begin_key).map_err(|_| KernelError::InvalidCommandId)?; + let digest = Sha256::new() + .chain_update(b"ghostlight.world.begin.v1") + .chain_update(key.as_bytes()) + .chain_update(step.as_bytes()) + .finalize(); + let mut bytes = [0_u8; 16]; + bytes.copy_from_slice(&digest[..16]); + CommandId::parse_uuid(&uuid::Uuid::from_bytes(bytes).to_string()) +} + +/// `world.begin`: the owner's one click. The surface sequences, the kernel +/// decides: create, approve and activate reach it as three separate commands, +/// each admitted or refused on its own, and the sequence stops at the first +/// refusal. What the kernel committed before it stays committed, and the +/// card then offers the next step's own button. When the owner is not the +/// world's only required approver the kernel refuses the activate step. +async fn begin_world( + state: &AppState, + verified_principal: &VerifiedPrincipalEvidence, + begin_key: &str, + command_id: CommandId, + payload: CreatePayload, +) -> Result { + let created = create_world(state, verified_principal, command_id, payload) + .await + .map_err(|error| stopped_at("create", error))?; + let mut reached = "created"; + for (step, body, done) in [ + ("approve", CommandBody::ApproveDraft, "approved"), + ("activate", CommandBody::ActivateWorld, "activated"), + ] { + let snapshot = current_world(state) + .await + .ok() + .flatten() + .ok_or(RuntimeCommandError::BeginStopped { + step, + cause: "the world could not be read".into(), + })?; + state + .world + .submit_principal( + PrincipalCommandIntent { + id: begin_step_id(begin_key, step).map_err(|error| stopped_at(step, error.into()))?, + world_id: created.world_id, + expected_revision: snapshot.revision, + body, + }, + verified_principal, + ) + .await + .map_err(|error| stopped_at(step, map_mailbox(error)))?; + reached = done; + } + Ok(json!({ + "kind":"begun", + "reached":reached, + "worldId":serde_json::to_value(created.world_id).unwrap_or(Value::Null), + "commitDigest":created.commit_digest + })) +} + +/// A refusal at one step of a Begin: its own variant so the receipt names the +/// step, except an unknown outcome, which stays one (the caller must not read +/// it as a refusal). +fn stopped_at(step: &'static str, error: RuntimeCommandError) -> RuntimeCommandError { + match error { + RuntimeCommandError::OutcomeUnknown(detail) => RuntimeCommandError::OutcomeUnknown(format!("{step}: {detail}")), + other => RuntimeCommandError::BeginStopped { + step, + cause: other.to_string(), + }, + } +} + async fn execute_world( state: &AppState, verified_principal: &VerifiedPrincipalEvidence, @@ -1272,49 +1412,21 @@ async fn execute_world( if invocation.operation.operation_id == "world.create" { let payload: CreatePayload = serde_json::from_value(unwrap_bindings(&invocation.payload).map_err(RuntimeCommandError::Payload)?) .map_err(|error| RuntimeCommandError::Payload(error.to_string()))?; - if let Some(root) = payload - .jurisdictions - .iter() - .find(|root| !is_handle_shape(&root.handle)) - { - return Err(RuntimeCommandError::Payload(format!( - "jurisdiction handle {} is not a draft handle", - root.handle - ))); - } - let receipt = state - .world - .create( - CreateWorldIntent { - id: command_id, - title: payload.title, - brief: payload.brief, - human_subject_label: payload.subject_label, - narrative_persona_label: payload.narrative_persona_label.filter(|label| !label.trim().is_empty()), - operational_agent_label: payload.operational_agent_label.filter(|label| !label.trim().is_empty()), - targets: payload.targets, - jurisdictions: payload - .jurisdictions - .into_iter() - .map(|root| CreateJurisdictionIntent { - handle: root.handle, - label: root.label, - permille: root.permille, - }) - .collect(), - lens_weights: payload.lens_weights, - }, - verified_principal, - ) - .await - .map_err(map_mailbox)?; - return Ok(json!({ - "kind":"created", - "commandId":serde_json::to_value(receipt.command_id).unwrap_or(Value::Null), - "worldId":serde_json::to_value(receipt.world_id).unwrap_or(Value::Null), - "stateDigest":receipt.resulting_state_digest, - "commitDigest":receipt.commit_digest - })); + let receipt = create_world(state, verified_principal, command_id, payload).await?; + return Ok(created_receipt(&receipt)); + } + if invocation.operation.operation_id == "world.begin" { + // The same `world_create.v4` payload as `world.create`. The detail of a + // refused payload is not echoed: this route's fields are what the + // owner typed. + let payload: CreatePayload = unwrap_bindings(&invocation.payload) + .ok() + .and_then(|flat| serde_json::from_value(flat).ok()) + .ok_or_else(|| { + RuntimeCommandError::Payload("the begin payload does not satisfy world_create.v4".into()) + })?; + let key = invocation.operation.idempotency_key.as_deref().unwrap_or(""); + return begin_world(state, verified_principal, key, command_id, payload).await; } if invocation.operation.operation_id == "world.play" { // Cut 10 (PA.f152): owner-gated, like `world.advance_time` and @@ -3138,10 +3250,10 @@ mod tests { /// report, not a skip to swallow quietly. #[tokio::test] #[ignore = "requires node + a built vendor/eve/eve-browser-lowering + eve-contracts; run with `cargo test -- --ignored`"] - async fn world_create_seed_activate_and_play_round_trip_through_the_real_client() { + async fn world_begin_and_play_round_trip_through_the_real_client() { if !eve_client_bridge_is_available() { panic!( - "world_create_seed_activate_and_play_round_trip_through_the_real_client was run \ + "world_begin_and_play_round_trip_through_the_real_client was run \ (via --ignored) but the real Eve client bridge is not available: `node` is \ missing, or the vendored lowering's built `dist/index.js`, its `jsdom` \ devDependency, or the sibling `eve-contracts` package's own `ajv` dependency does \ @@ -3155,42 +3267,27 @@ mod tests { let provider = get(&fixture.state, &fixture.cookie, "/api/eve/provider").await; let empty_surface = get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; - let create_intents = client_intents( + let begin_intents = client_intents( &empty_surface, &provider, json!([ {"set": { - "world.create.title": "Bridge World", - "world.create.brief": "A world built by the real client bridge.", - "world.create.subject": "The Bridge Operator", - "world.create.targets": "{}", - "world.create.jurisdictions": "[]", - "world.create.lens_weights": "{\"patina\":1,\"charter\":1,\"ledger\":1,\"hearth\":1,\"tangle\":1,\"veil\":1,\"ember\":1,\"numen\":1}" + "world.begin.title": "Bridge World", + "world.begin.brief": "A world built by the real client bridge.", + "world.begin.subject": "The Bridge Operator" }}, - {"click": "world.create"} + {"click": "world.begin"} ]), ); - assert_eq!(create_intents.len(), 1); - let created = post(&fixture.state, &fixture.cookie, create_intents.into_iter().next().unwrap()).await; - assert_eq!(created["receipt"]["state"], "accepted", "world.create via the real client: {created}"); - - let draft_surface = get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; - let approve_intents = client_intents(&draft_surface, &provider, json!([{"click": "world.approve"}])); - assert_eq!(approve_intents.len(), 1); - let approved = post(&fixture.state, &fixture.cookie, approve_intents.into_iter().next().unwrap()).await; - assert_eq!(approved["receipt"]["state"], "accepted", "world.approve via the real client: {approved}"); - - // No fixture sets `AppState.seed_vault_root`, so the Draft surface - // offers no seeding to click at all (PA.f213). - let approved_surface = get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; + assert_eq!(begin_intents.len(), 1); + let begun = post(&fixture.state, &fixture.cookie, begin_intents.into_iter().next().unwrap()).await; + assert_eq!(begun["receipt"]["state"], "accepted", "world.begin via the real client: {begun}"); + assert_eq!(begun["receipt"]["reached"], "activated", "{begun}"); + let active_surface = get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; assert!( - !serde_json::to_string(&approved_surface).unwrap().contains("world.seed"), - "a server with no vault offered seeding to the real client" + serde_json::to_string(&active_surface).unwrap().contains("world.play.card"), + "one Begin through the real client did not reach the play card" ); - let activate_intents = client_intents(&approved_surface, &provider, json!([{"click": "world.activate"}])); - assert_eq!(activate_intents.len(), 1); - let activated = post(&fixture.state, &fixture.cookie, activate_intents.into_iter().next().unwrap()).await; - assert_eq!(activated["receipt"]["state"], "accepted", "world.activate via the real client: {activated}"); // A typed Play on a fresh Active world opens a turn. The first table's // inference port blocks until released, so the turn stays `Running` @@ -3344,7 +3441,7 @@ mod tests { /// PA.f162's own gate proof: replays the committed fixture's intents, /// in order, straight through `api_router` — no `node`, no bridge, no /// vendored `dist/index.js` anywhere in this call path. This is what - /// `world_create_seed_activate_and_play_round_trip_through_the_real_client` + /// `world_begin_and_play_round_trip_through_the_real_client` /// (which drives the real bridge, and so needs `node`) proved once, at /// capture time; this test proves it again on every run, on the door /// Idunn's own `cargo test --locked -p ghostlight-dungeon --bin @@ -3355,26 +3452,17 @@ mod tests { let fixture = fixture().await; let mut steps = doc.steps.into_iter(); - let create = steps.next().unwrap(); - assert_eq!(create.label, "world.create"); - let created = post(&fixture.state, &fixture.cookie, create.intent).await; - assert_eq!(created["receipt"]["state"], "accepted", "{created}"); - - let approve = steps.next().unwrap(); - assert_eq!(approve.label, "world.approve"); - let approved = post(&fixture.state, &fixture.cookie, approve.intent).await; - assert_eq!(approved["receipt"]["state"], "accepted", "{approved}"); - - let activate = steps.next().unwrap(); - assert_eq!(activate.label, "world.activate"); - let activated = post(&fixture.state, &fixture.cookie, activate.intent).await; - assert_eq!(activated["receipt"]["state"], "accepted", "{activated}"); + let begin = steps.next().unwrap(); + assert_eq!(begin.label, "world.begin"); + let begun = post(&fixture.state, &fixture.cookie, begin.intent).await; + assert_eq!(begun["receipt"]["state"], "accepted", "{begun}"); + assert_eq!(begun["receipt"]["reached"], "activated", "{begun}"); let play = steps.next().unwrap(); assert_eq!(play.label, "world.play"); let played = post(&fixture.state, &fixture.cookie, play.intent).await; assert_eq!(played["receipt"]["state"], "accepted", "{played}"); - assert!(steps.next().is_none(), "the fixture must carry exactly these four steps"); + assert!(steps.next().is_none(), "the fixture must carry exactly these two steps"); let table = fixture.state.play.clone().unwrap(); let observed = tokio::time::timeout(Duration::from_secs(5), async move { @@ -3826,7 +3914,7 @@ mod tests { /// client's past behavior pretending to still be the client's current /// one. Runs only when `node` and the built lowering are actually /// available (`eve_client_bridge_is_available`). `#[ignore]`d for the - /// same reason `world_create_seed_activate_and_play_round_trip_through_the_real_client` + /// same reason `world_begin_and_play_round_trip_through_the_real_client` /// is (Soul's owed item 3, above): a normal `cargo test` run must /// distinguish "not exercised" from "passed" in its own summary line /// without `--nocapture`, and only `--ignored` opts in. @@ -3868,39 +3956,22 @@ mod tests { let provider = get(&fixture.state, &fixture.cookie, "/api/eve/provider").await; let empty_surface = get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; - let create_intent = client_intents( + let begin_intent = client_intents( &empty_surface, &provider, json!([ {"set": { - "world.create.title": "Bridge Fixture World", - "world.create.brief": "A world captured for the committed Eve client bridge fixture.", - "world.create.subject": "The Fixture Operator", - "world.create.targets": "{}", - "world.create.jurisdictions": "[]", - "world.create.lens_weights": "{\"patina\":1,\"charter\":1,\"ledger\":1,\"hearth\":1,\"tangle\":1,\"veil\":1,\"ember\":1,\"numen\":1}" + "world.begin.title": "Bridge Fixture World", + "world.begin.brief": "A world captured for the committed Eve client bridge fixture.", + "world.begin.subject": "The Fixture Operator" }}, - {"click": "world.create"} + {"click": "world.begin"} ]), ) .into_iter() .next() .unwrap(); - post(&fixture.state, &fixture.cookie, create_intent.clone()).await; - - let draft_surface = get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; - let approve_intent = client_intents(&draft_surface, &provider, json!([{"click": "world.approve"}])) - .into_iter() - .next() - .unwrap(); - post(&fixture.state, &fixture.cookie, approve_intent.clone()).await; - - let approved_surface = get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; - let activate_intent = client_intents(&approved_surface, &provider, json!([{"click": "world.activate"}])) - .into_iter() - .next() - .unwrap(); - post(&fixture.state, &fixture.cookie, activate_intent.clone()).await; + post(&fixture.state, &fixture.cookie, begin_intent.clone()).await; let active_surface = get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await; let play_intent = client_intents( @@ -3916,7 +3987,7 @@ mod tests { .unwrap(); post(&fixture.state, &fixture.cookie, play_intent.clone()).await; - let regenerated = [create_intent, approve_intent, activate_intent, play_intent]; + let regenerated = [begin_intent, play_intent]; let committed = bridge_fixture().steps; assert_eq!(regenerated.len(), committed.len(), "step count drifted"); for (regenerated_intent, committed_step) in regenerated.into_iter().zip(committed) { @@ -4052,10 +4123,10 @@ mod tests { /// bridge-dependent tests (Soul's owed item 3, above). #[tokio::test] #[ignore = "requires node + a built vendor/eve/eve-browser-lowering + eve-contracts; run with `cargo test -- --ignored`"] - async fn a_create_form_filled_only_by_its_labelled_identity_fields_is_still_accepted() { + async fn a_begin_form_filled_only_by_its_labelled_fields_is_still_accepted() { if !eve_client_bridge_is_available() { panic!( - "a_create_form_filled_only_by_its_labelled_identity_fields_is_still_accepted was run \ + "a_begin_form_filled_only_by_its_labelled_fields_is_still_accepted was run \ (via --ignored) but the real Eve client bridge is not available: `node` is missing, \ or the vendored lowering's built `dist/index.js`, its `jsdom` devDependency, or the \ sibling `eve-contracts` package's own `ajv` dependency does not resolve in this \ @@ -4074,10 +4145,10 @@ mod tests { &provider, json!([ {"set": { - "world.create.title": "Untouched Fields World", - "world.create.subject": "The Owner" + "world.begin.title": "Untouched Fields World", + "world.begin.subject": "The Owner" }}, - {"click": "world.create"} + {"click": "world.begin"} ]), ); assert_eq!(create_intents.len(), 1); @@ -4087,8 +4158,11 @@ mod tests { // rather than omitting the fields outright. let bindings = &intent["payload"]["bindings"]; assert_eq!(bindings["brief"], ""); - assert_eq!(bindings["targets"], "{}"); - assert_eq!(bindings["jurisdictions"], "[]"); + // The fields the form does not ask for ride the button's own action, + // beside `bindings`, not through it. + assert_eq!(intent["payload"]["targets"], "{}"); + assert_eq!(intent["payload"]["jurisdictions"], "[]"); + assert!(intent["payload"]["lens_weights"].is_string()); let created = post(&fixture.state, &fixture.cookie, intent).await; assert_eq!( @@ -5645,6 +5719,255 @@ mod tests { ); } + // ---- World.begin ---------------------------------------------------- + + fn find_node<'a>(node: &'a Value, id: &str) -> Option<&'a Value> { + if node["id"] == id { + return Some(node); + } + node["children"] + .as_array()? + .iter() + .find_map(|child| find_node(child, id)) + } + + /// The payload the real client builds from the served Begin button: its + /// own action fields (minus the command name) beside the captured + /// `bindings`. Every authored constant comes from the surface this + /// process served, never from a copy in the test. + fn begin_payload_from(surface: &Value, title: &str, brief: &str, subject: &str) -> Value { + let button = find_node(&surface["surface"]["root"], "world.begin") + .expect("the empty-world surface offers Begin"); + let mut payload = button["props"]["action"].as_object().cloned().unwrap(); + payload.remove("command"); + payload.insert( + "bindings".into(), + json!({"title":title,"brief":brief,"subject_label":subject}), + ); + Value::Object(payload) + } + + fn begin_invocation(payload: Value, key: &str) -> Value { + invocation("world.begin", "ghostlight.world_create.v4", 0, payload, key) + } + + async fn served_surface(fixture: &Fixture) -> Value { + get(&fixture.state, &fixture.cookie, "/api/eve/surfaces/ghostlight.play").await + } + + async fn owner_principal(fixture: &Fixture) -> VerifiedPrincipalEvidence { + fixture + .state + .sessions + .lock() + .await + .account_for_cookie(&fixture.cookie, Utc::now()) + .unwrap() + .unwrap() + } + + /// R1 and R2: one Begin reaches an Active world, and the kernel received + /// three separate commands, each admitted on its own. The ledger is the + /// witness: each step's own id replays as already applied at its own + /// revision, so none of them was merged into another or skipped. + /// + /// Mutations: submit no approve (the activate is then refused); give the + /// approve and activate commands the create's id (the kernel's ledger + /// answers the replay of their own ids with a conflict, not a receipt). + #[tokio::test] + async fn begin_commits_create_approve_and_activate_as_three_separate_commands() { + let fixture = fixture().await; + let key = uuid::Uuid::new_v4().to_string(); + let payload = begin_payload_from(&served_surface(&fixture).await, "Begun World", "A premise.", "The Owner"); + let begun = post(&fixture.state, &fixture.cookie, begin_invocation(payload.clone(), &key)).await; + assert_eq!(begun["receipt"]["state"], "accepted", "{begun}"); + assert_eq!(begun["receipt"]["kind"], "begun"); + assert_eq!(begun["receipt"]["reached"], "activated"); + + let world = current_world(&fixture.state).await.unwrap().unwrap(); + assert_eq!(world.phase, WorldPhase::Active); + assert_eq!(world.revision, 2, "genesis, approval and activation are three commits"); + + let principal = owner_principal(&fixture).await; + for (step, body, revision) in [ + ("approve", CommandBody::ApproveDraft, 1), + ("activate", CommandBody::ActivateWorld, 2), + ] { + let replay = fixture + .state + .world + .submit_principal( + PrincipalCommandIntent { + id: begin_step_id(&key, step).unwrap(), + world_id: world.world_id, + expected_revision: world.revision, + body, + }, + &principal, + ) + .await + .unwrap(); + match replay { + SubmitReceipt::AlreadyApplied(receipt) => assert_eq!(receipt.resulting_revision, revision, "{step}"), + SubmitReceipt::Applied(_) => panic!("the {step} step was not already in the ledger"), + } + } + // The create is the command under the Begin's own key. + let create_replay = post( + &fixture.state, + &fixture.cookie, + invocation("world.create", "ghostlight.world_create.v4", 0, payload, &key), + ) + .await; + assert_eq!(create_replay["receipt"]["commitDigest"], begun["receipt"]["commitDigest"]); + } + + /// A replayed Begin (the same key) replays its commands through the + /// kernel's ledger and commits nothing new. + #[tokio::test] + async fn a_replayed_begin_commits_nothing_new() { + let fixture = fixture().await; + let key = uuid::Uuid::new_v4().to_string(); + let payload = begin_payload_from(&served_surface(&fixture).await, "Replayed World", "", "The Owner"); + let first = post(&fixture.state, &fixture.cookie, begin_invocation(payload.clone(), &key)).await; + assert_eq!(first["receipt"]["state"], "accepted", "{first}"); + let second = post(&fixture.state, &fixture.cookie, begin_invocation(payload, &key)).await; + assert_eq!(second["receipt"]["state"], "accepted", "{second}"); + assert_eq!(second["receipt"]["reached"], "activated"); + assert_eq!(current_world(&fixture.state).await.unwrap().unwrap().revision, 2); + } + + /// R3: the sequence stops at the first refusal, and the world stays in the + /// phase the kernel committed. Here the kernel refuses the approve (the + /// draft was already approved by hand), so the Begin is denied naming that + /// step, the world stays an approved Draft, and the card offers the next + /// step's own button, which continues to an Active world. + /// + /// Mutations: carry on to the activate after a refused approve; report a + /// refused step as accepted. + #[tokio::test] + async fn begin_stops_at_the_first_refusal_and_the_card_offers_the_next_step() { + let fixture = fixture().await; + let key = uuid::Uuid::new_v4().to_string(); + let payload = begin_payload_from(&served_surface(&fixture).await, "Stopped World", "", "The Owner"); + let created = post( + &fixture.state, + &fixture.cookie, + invocation("world.create", "ghostlight.world_create.v4", 0, payload.clone(), &key), + ) + .await; + assert_eq!(created["receipt"]["state"], "accepted", "{created}"); + let draft = current_world(&fixture.state).await.unwrap().unwrap(); + fixture + .state + .world + .submit_principal( + PrincipalCommandIntent { + id: CommandId::new(), + world_id: draft.world_id, + expected_revision: draft.revision, + body: CommandBody::ApproveDraft, + }, + &owner_principal(&fixture).await, + ) + .await + .unwrap(); + + let refused = post(&fixture.state, &fixture.cookie, begin_invocation(payload, &key)).await; + assert_eq!(refused["receipt"]["state"], "denied", "{refused}"); + let message = refused["receipt"]["message"].as_str().unwrap_or_default(); + assert!(message.contains("approve"), "the refusal did not name its step: {message}"); + let world = current_world(&fixture.state).await.unwrap().unwrap(); + assert_eq!(world.phase, WorldPhase::Draft, "a refused step must leave the draft as the kernel committed it"); + assert_eq!(world.revision, 1); + + let mut buttons = Vec::new(); + surface_buttons(&served_surface(&fixture).await["surface"]["root"], &mut buttons); + assert!(buttons.contains(&"world.activate".to_owned()), "{buttons:?}"); + assert!(!buttons.contains(&"world.begin".to_owned()), "a world exists; Begin must not be offered"); + + let activated = post( + &fixture.state, + &fixture.cookie, + invocation( + "world.activate", + "ghostlight.world_activate.v0", + world.revision + 1, + json!({}), + &uuid::Uuid::new_v4().to_string(), + ), + ) + .await; + assert_eq!(activated["receipt"]["state"], "accepted", "{activated}"); + assert_eq!(current_world(&fixture.state).await.unwrap().unwrap().phase, WorldPhase::Active); + } + + /// A Draft the kernel holds unapproved (a world created by `world.create` + /// alone) offers Approve, then Activate: Begin's refusal paths land on the + /// same buttons a hand-driven Draft always had. + #[tokio::test] + async fn an_unapproved_draft_offers_approve_and_no_begin() { + let fixture = fixture().await; + let payload = begin_payload_from(&served_surface(&fixture).await, "Plain Draft", "", "The Owner"); + let created = post( + &fixture.state, + &fixture.cookie, + invocation("world.create", "ghostlight.world_create.v4", 0, payload, &uuid::Uuid::new_v4().to_string()), + ) + .await; + assert_eq!(created["receipt"]["state"], "accepted", "{created}"); + let mut buttons = Vec::new(); + surface_buttons(&served_surface(&fixture).await["surface"]["root"], &mut buttons); + assert!(buttons.contains(&"world.approve".to_owned()), "{buttons:?}"); + assert!(!buttons.contains(&"world.begin".to_owned()), "{buttons:?}"); + } + + /// No refusal of a Begin echoes anything the owner typed or sent: not the + /// kernel's refusal of an empty title, not a payload the begin route cannot + /// read, not a jurisdiction handle (here and on `world.create`, which + /// shares the create path). + #[tokio::test] + async fn a_refused_begin_never_echoes_what_was_sent() { + let fixture = fixture().await; + let canary = "CANARY-7f3a91-ZZ"; + let surface = served_surface(&fixture).await; + + let mut empty_title = begin_payload_from(&surface, "", canary, canary); + empty_title["bindings"]["title"] = json!(""); + let mut unreadable = begin_payload_from(&surface, "A World", canary, canary); + unreadable[canary] = json!(canary); + let mut bad_handle = begin_payload_from(&surface, "A World", canary, canary); + bad_handle["jurisdictions"] = json!( + serde_json::to_string(&json!([{"handle":canary,"label":canary,"permille":10}])).unwrap() + ); + let mut missing_name = begin_payload_from(&surface, canary, canary, canary); + missing_name["bindings"].as_object_mut().unwrap().remove("subject_label"); + + for (what, operation, payload) in [ + ("empty title", "world.begin", empty_title), + ("unreadable payload", "world.begin", unreadable), + ("jurisdiction handle", "world.begin", bad_handle.clone()), + ("jurisdiction handle on create", "world.create", bad_handle), + ("missing name", "world.begin", missing_name), + ] { + let refused = post( + &fixture.state, + &fixture.cookie, + invocation(operation, "ghostlight.world_create.v4", 0, payload, &uuid::Uuid::new_v4().to_string()), + ) + .await; + assert_eq!(refused["receipt"]["state"], "denied", "{what}: {refused}"); + assert!( + !refused.to_string().contains(canary), + "{what}: the refusal echoed an input value: {refused}" + ); + assert!( + current_world(&fixture.state).await.unwrap().is_none(), + "{what}: a refused Begin left a world" + ); + } + } + #[tokio::test] async fn exact_stale_retry_returns_journal_receipt_without_app_cache() { let fixture = fixture().await; From f89be06a335ec9965026a5b5e6968b71cb036a2e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 02:35:48 +0200 Subject: [PATCH 02/62] Begin tests: reuse the untouched-form intent builder; the untouched-form walk begins instead of creating Does not build the fixture replay test: the bridge fixture still carries the old steps until it is re-captured from the real client. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight-dungeon/src/eve.rs | 9 +++++- crates/ghostlight-dungeon/src/runtime.rs | 38 +++++++----------------- 2 files changed, 19 insertions(+), 28 deletions(-) diff --git a/crates/ghostlight-dungeon/src/eve.rs b/crates/ghostlight-dungeon/src/eve.rs index 8332e2b..56c36f1 100644 --- a/crates/ghostlight-dungeon/src/eve.rs +++ b/crates/ghostlight-dungeon/src/eve.rs @@ -916,7 +916,14 @@ mod tests { } assert_eq!(find(root, "world.begin.brief").unwrap()["props"]["value"], ""); - let commands = surface["commands"].as_array().unwrap(); + // Beside the sign-out every signed-in surface carries, Begin is the + // only command. + let commands: Vec<&Value> = surface["commands"] + .as_array() + .unwrap() + .iter() + .filter(|descriptor| descriptor["command"] != "app.auth.logout") + .collect(); assert_eq!(commands.len(), 1, "{commands:?}"); assert_eq!(commands[0]["command"], "world.begin"); assert_eq!(commands[0]["payloadSchema"], "ghostlight.world_create.v4"); diff --git a/crates/ghostlight-dungeon/src/runtime.rs b/crates/ghostlight-dungeon/src/runtime.rs index 96de692..e69c400 100644 --- a/crates/ghostlight-dungeon/src/runtime.rs +++ b/crates/ghostlight-dungeon/src/runtime.rs @@ -5721,30 +5721,16 @@ mod tests { // ---- World.begin ---------------------------------------------------- - fn find_node<'a>(node: &'a Value, id: &str) -> Option<&'a Value> { - if node["id"] == id { - return Some(node); - } - node["children"] - .as_array()? - .iter() - .find_map(|child| find_node(child, id)) - } - - /// The payload the real client builds from the served Begin button: its - /// own action fields (minus the command name) beside the captured - /// `bindings`. Every authored constant comes from the surface this - /// process served, never from a copy in the test. + /// The payload the real client builds from the served Begin button, with + /// the three fields typed. Every authored constant comes from the surface + /// this process served, never from a copy in the test. fn begin_payload_from(surface: &Value, title: &str, brief: &str, subject: &str) -> Value { - let button = find_node(&surface["surface"]["root"], "world.begin") - .expect("the empty-world surface offers Begin"); - let mut payload = button["props"]["action"].as_object().cloned().unwrap(); - payload.remove("command"); - payload.insert( - "bindings".into(), - json!({"title":title,"brief":brief,"subject_label":subject}), - ); - Value::Object(payload) + untouched_intent( + surface, + "world.begin", + &[("title", json!(title)), ("brief", json!(brief)), ("subject_label", json!(subject))], + )["payload"] + .clone() } fn begin_invocation(payload: Value, key: &str) -> Value { @@ -6859,16 +6845,14 @@ mod tests { async fn every_bound_command_admits_the_payload_an_untouched_form_submits() { let fixture = fixture().await; let mut exercised = Vec::new(); - let steps: [(&str, Vec<(&str, Value)>); 5] = [ + let steps: [(&str, Vec<(&str, Value)>); 3] = [ ( - "world.create", + "world.begin", vec![ ("title", json!("Untouched Form World")), ("subject_label", json!("The Operator")), ], ), - ("world.approve", vec![]), - ("world.activate", vec![]), ("world.advance_time", vec![("minutes", json!(1))]), ("world.play", vec![]), ]; From 1645f4a97233a55ffb20a1f76958409b95dc0434 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 02:39:17 +0200 Subject: [PATCH 03/62] Bridge fixture: re-captured from the real pinned lowering for world.begin Captured by running the drift test's own walk (begin, then play) with a temporary, uncommitted patch that writes the regenerated intents instead of comparing them (vendor/eve at 672c0c1, built and run on Starfire). Co-Authored-By: Claude Opus 5.5 --- .../fixtures/eve_client_bridge_intents.json | 62 +++---------------- 1 file changed, 9 insertions(+), 53 deletions(-) diff --git a/crates/ghostlight-dungeon/src/fixtures/eve_client_bridge_intents.json b/crates/ghostlight-dungeon/src/fixtures/eve_client_bridge_intents.json index 3907bbc..76a1db4 100644 --- a/crates/ghostlight-dungeon/src/fixtures/eve_client_bridge_intents.json +++ b/crates/ghostlight-dungeon/src/fixtures/eve_client_bridge_intents.json @@ -5,10 +5,10 @@ "intent": { "clientId": "eve.bridge.test", "commandBoundary": "ghostlight.eve.commands", - "issuedAt": "2026-10-09T23:58:34.345Z", + "issuedAt": "2026-10-10T00:38:56.201Z", "operation": { - "idempotencyKey": "00e4b100-7626-4c62-8f45-5c2cfd5b5528", - "operationId": "world.create", + "idempotencyKey": "25d921aa-f228-425c-92e3-08c5dd9af508", + "operationId": "world.begin", "routeHint": { "sourceVersion": 0, "transport": "https-json" @@ -18,71 +18,27 @@ "payload": { "bindings": { "brief": "A world captured for the committed Eve client bridge fixture.", - "jurisdictions": "[]", - "lens_weights": "{\"patina\":1,\"charter\":1,\"ledger\":1,\"hearth\":1,\"tangle\":1,\"veil\":1,\"ember\":1,\"numen\":1}", "subject_label": "The Fixture Operator", - "targets": "{}", "title": "Bridge Fixture World" - } - }, - "providerId": "gamecult.ghostlight.dungeon", - "receiptSchema": "gamecult.eve.command_result.v1", - "schema": "gamecult.eve.command_invocation.v1", - "surfaceId": "ghostlight.play" - }, - "label": "world.create" - }, - { - "intent": { - "clientId": "eve.bridge.test", - "commandBoundary": "ghostlight.eve.commands", - "issuedAt": "2026-10-09T23:58:35.759Z", - "operation": { - "idempotencyKey": "10b5cf50-43c3-4735-b54c-3cec0c1e632b", - "operationId": "world.approve", - "routeHint": { - "sourceVersion": 1, - "transport": "https-json" }, - "schemaId": "ghostlight.world_approve.v0" + "jurisdictions": "[]", + "lens_weights": "{\"patina\":1,\"charter\":1,\"ledger\":1,\"hearth\":1,\"tangle\":1,\"veil\":1,\"ember\":1,\"numen\":1}", + "targets": "{}" }, - "payload": {}, "providerId": "gamecult.ghostlight.dungeon", "receiptSchema": "gamecult.eve.command_result.v1", "schema": "gamecult.eve.command_invocation.v1", "surfaceId": "ghostlight.play" }, - "label": "world.approve" + "label": "world.begin" }, { "intent": { "clientId": "eve.bridge.test", "commandBoundary": "ghostlight.eve.commands", - "issuedAt": "2026-10-09T23:58:37.240Z", + "issuedAt": "2026-10-10T00:38:57.625Z", "operation": { - "idempotencyKey": "d89dea99-cfeb-4992-9d5d-33b3f7a1cce2", - "operationId": "world.activate", - "routeHint": { - "sourceVersion": 2, - "transport": "https-json" - }, - "schemaId": "ghostlight.world_activate.v0" - }, - "payload": {}, - "providerId": "gamecult.ghostlight.dungeon", - "receiptSchema": "gamecult.eve.command_result.v1", - "schema": "gamecult.eve.command_invocation.v1", - "surfaceId": "ghostlight.play" - }, - "label": "world.activate" - }, - { - "intent": { - "clientId": "eve.bridge.test", - "commandBoundary": "ghostlight.eve.commands", - "issuedAt": "2026-10-09T23:58:38.611Z", - "operation": { - "idempotencyKey": "d654e813-be7d-4818-9872-9aecd86c4e39", + "idempotencyKey": "fc391d0d-d107-4a66-b5c5-b754ffa814fa", "operationId": "world.play", "routeHint": { "sourceVersion": 3, From 90c5e3b337dc7583f77d6e01a8b2060a6be49747 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 02:39:28 +0200 Subject: [PATCH 04/62] Docs: the no-world surface is Begin Co-Authored-By: Claude Opus 5.5 --- docs/architecture/ghostlight-dungeon-mvp.md | 5 +++-- docs/architecture/ghostlight-eve-native-interface.md | 2 +- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/docs/architecture/ghostlight-dungeon-mvp.md b/docs/architecture/ghostlight-dungeon-mvp.md index 0df4607..9b3ad24 100644 --- a/docs/architecture/ghostlight-dungeon-mvp.md +++ b/docs/architecture/ghostlight-dungeon-mvp.md @@ -625,8 +625,9 @@ runs `controllers::tests::real_local_model_cognition_modes_commit_speech` against a real model; exercising a whole play turn against one is not gated by this contract. `world.create`'s current payload is `world_create.v4`: title, brief, targets, -jurisdiction roots and lens weights are required, and Dungeon's create surface -offers uniform weights as its editable default. `world.seed` runs one +jurisdiction roots and lens weights are required. Dungeon's Begin button states +them as authored constants (no targets, no roots, uniform weights) beside the +three fields the form asks for. `world.seed` runs one `SeedRunner` session per invocation against a `VaultEvidenceSource`. Focused tests must prove: diff --git a/docs/architecture/ghostlight-eve-native-interface.md b/docs/architecture/ghostlight-eve-native-interface.md index a296f15..8b07c26 100644 --- a/docs/architecture/ghostlight-eve-native-interface.md +++ b/docs/architecture/ghostlight-eve-native-interface.md @@ -48,7 +48,7 @@ and world phase (`anonymous_surface`, `authenticated_surface` in |---|---|---| | Anonymous | `heimdall.access_gate` | `heimdall.auth.begin`, `heimdall.auth.complete` | | Authenticated, any world state | a `Sign out` button on every authenticated surface | `app.auth.logout` (`ghostlight.app_logout.v2`) | -| Authenticated, no world | the create form: title, brief, your name, optional Narrative persona and Operational agent labels, scale target, jurisdiction roots, lens weights | `world.create` (`ghostlight.world_create.v4`) | +| Authenticated, no world | title, your name, optional brief, and one Begin button | `world.begin` (`ghostlight.world_create.v4`): create, approve and activate as three kernel commands, stopping at the first refusal | | Draft world | world summary card; approve button for a required approver who has not approved; for the owner, the seed form and Seed card, and the activate button once every required approver has approved | `world.approve` (`ghostlight.world_approve.v0`), `world.seed` (`ghostlight.world_seed.v1`), `world.activate` (`ghostlight.world_activate.v0`) | | Active world, owner | advance-time control and the Play card: narration, the open question, the refusal of the player's own act, one free-text control | `world.advance_time` (`ghostlight.world_advance_time.v0`), `world.play` (`ghostlight.world_play.v0`) | From 8912dde49f5a886e0971698458912144c6a9db17 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 10 Oct 2026 02:47:01 +0200 Subject: [PATCH 05/62] Begin test: the refusal names its step in the begin's own words A mutant that dropped the step from the message survived because the kernel's own cause text also contains the word approve. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight-dungeon/src/runtime.rs | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/crates/ghostlight-dungeon/src/runtime.rs b/crates/ghostlight-dungeon/src/runtime.rs index e69c400..0af7364 100644 --- a/crates/ghostlight-dungeon/src/runtime.rs +++ b/crates/ghostlight-dungeon/src/runtime.rs @@ -5862,7 +5862,10 @@ mod tests { let refused = post(&fixture.state, &fixture.cookie, begin_invocation(payload, &key)).await; assert_eq!(refused["receipt"]["state"], "denied", "{refused}"); let message = refused["receipt"]["message"].as_str().unwrap_or_default(); - assert!(message.contains("approve"), "the refusal did not name its step: {message}"); + assert!( + message.starts_with("the begin stopped at its approve step"), + "the refusal did not name its step: {message}" + ); let world = current_world(&fixture.state).await.unwrap().unwrap(); assert_eq!(world.phase, WorldPhase::Draft, "a refused step must leave the draft as the kernel committed it"); assert_eq!(world.revision, 1); From 8f6b552e7a8b7fa751e7f0527141054bf2d47673 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 02:33:57 +0200 Subject: [PATCH 06/62] Verse genesis: the canonical Aetheria world is created from the catalog CreateVerseWorldIntent lowers to one CreateWorld bound to the catalog's grammar: a commons, one affordance per non-speak grammar verb (the kernel supplies speak), and per faction an import-keyed institution granted every verb plus a canonical fact citing aetheria::aetheria.faction/. WorldMailbox::create_verse sends it through create_authenticated. The role lowering moves into GrammarVerb::role_specs, which admits_roles now uses. Also pins the reader's non-string allegiance key and invalid-UTF-8 refusals. All commits build. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/aetheria/tests.rs | 63 ++++ crates/ghostlight/src/grammar.rs | 23 +- crates/ghostlight/src/lib.rs | 4 +- crates/ghostlight/src/mailbox.rs | 41 ++- crates/ghostlight/src/verse/genesis.rs | 143 ++++++++ crates/ghostlight/src/verse/genesis/tests.rs | 360 +++++++++++++++++++ crates/ghostlight/src/verse/mod.rs | 5 +- 7 files changed, 624 insertions(+), 15 deletions(-) create mode 100644 crates/ghostlight/src/verse/genesis.rs create mode 100644 crates/ghostlight/src/verse/genesis/tests.rs diff --git a/crates/ghostlight/src/aetheria/tests.rs b/crates/ghostlight/src/aetheria/tests.rs index 45f1702..c9333ec 100644 --- a/crates/ghostlight/src/aetheria/tests.rs +++ b/crates/ghostlight/src/aetheria/tests.rs @@ -524,3 +524,66 @@ fn bin_and_ext_fields_aetheria_adds_at_higher_keys_are_ignored() { read_aetheria_catalog(FIXTURE).unwrap() ); } + +/// A faction record re-encoded with `change` applied to its decoded cells, for +/// shapes `serde_json` cannot spell: a non-string map key, raw invalid bytes. +fn with_cells(key: &str, change: impl FnOnce(&mut Vec)) -> Vec { + let mut rows = fixture_rows(); + let row = row_of(&mut rows, key); + let rmpv::Value::Array(mut cells) = + rmpv::decode::read_value(&mut row.payload.as_slice()).unwrap() + else { + panic!("not positional"); + }; + change(&mut cells); + let mut payload = Vec::new(); + rmpv::encode::write_value(&mut payload, &rmpv::Value::Array(cells)).unwrap(); + row.payload = payload; + rows +} + +#[test] +fn an_allegiance_key_that_is_not_a_string_is_malformed_by_key_and_field() { + for key in [ + rmpv::Value::from(5), + rmpv::Value::Nil, + rmpv::Value::Binary(b"faction-adrasteia".to_vec()), + ] { + let rows = with_cells("faction-brannoch", |cells| { + cells[12] = rmpv::Value::Map(vec![(key, rmpv::Value::F64(0.5))]); + }); + assert_eq!( + read(&rows), + Err(CatalogReadError::MalformedRecord { + record: "faction-brannoch".into(), + field: 12 + }) + ); + } +} + +#[test] +fn text_that_is_not_utf8_is_malformed_by_key_and_field() { + // The marker is overwritten in the encoded bytes with an invalid sequence of + // the same length, since a decoded string cannot hold one. + const MARKER: &str = "ZZZZ"; + for field in [1usize, 3] { + let mut rows = with_cells("faction-brannoch", |cells| { + cells[field] = rmpv::Value::from(MARKER); + }); + let payload = &mut row_of(&mut rows, "faction-brannoch").payload; + let at = payload + .windows(MARKER.len()) + .position(|window| window == MARKER.as_bytes()) + .unwrap(); + payload[at..at + MARKER.len()].copy_from_slice(&[0xff, 0xfe, 0xfd, 0xfc]); + assert_eq!( + read(&rows), + Err(CatalogReadError::MalformedRecord { + record: "faction-brannoch".into(), + field: field as u8 + }), + "field {field}" + ); + } +} diff --git a/crates/ghostlight/src/grammar.rs b/crates/ghostlight/src/grammar.rs index b827461..a11cd69 100644 --- a/crates/ghostlight/src/grammar.rs +++ b/crates/ghostlight/src/grammar.rs @@ -8,7 +8,7 @@ //! digest its verbs do not have. use crate::SubjectKind; -use crate::patch::{AffordanceKindName, EntityKind, RefKind, RoleSpec, kernel_speak_entry}; +use crate::patch::{AffordanceKindName, EntityKind, RefKind, Role, RoleSpec, kernel_speak_entry}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use std::collections::BTreeMap; @@ -63,10 +63,20 @@ impl GrammarVerb { /// Whether a declared role list is this verb's signature: same names, same /// referent kinds, same order. pub(crate) fn admits_roles(&self, roles: &[RoleSpec]) -> bool { - self.roles.len() == roles.len() - && self.roles.iter().zip(roles).all(|(want, have)| { - want.role == have.role.0 && want.referent.ref_kind() == have.kind + self.role_specs() == roles + } + + /// This verb's signature as the kernel declares it: the one lowering of a + /// grammar role, so declaring a verb and admitting a declaration cannot + /// disagree. + pub(crate) fn role_specs(&self) -> Vec { + self.roles + .iter() + .map(|role| RoleSpec { + role: Role(role.role.clone()), + kind: role.referent.ref_kind(), }) + .collect() } } @@ -165,6 +175,11 @@ impl GrammarBinding { } } + /// Every verb, in kind order. + pub(crate) fn verbs(&self) -> impl Iterator { + self.verbs.iter().map(|(kind, verb)| (kind.as_str(), verb)) + } + pub(crate) fn verb(&self, kind: &str) -> Option<&GrammarVerb> { self.verbs.get(kind) } diff --git a/crates/ghostlight/src/lib.rs b/crates/ghostlight/src/lib.rs index 727733e..f373b42 100644 --- a/crates/ghostlight/src/lib.rs +++ b/crates/ghostlight/src/lib.rs @@ -144,8 +144,8 @@ pub use table::{ }; pub use vault::{VaultError, VaultEvidenceSource}; pub use verse::{ - CALL_RECORD_SCHEMA, CallRecordError, CallRecordStore, ProviderCallRecord, RecordingInferencePort, - ReplayInferencePort, RunField, RunId, RunStart, + CALL_RECORD_SCHEMA, CallRecordError, CallRecordStore, CreateVerseWorldIntent, ProviderCallRecord, + RecordingInferencePort, ReplayInferencePort, RunField, RunId, RunStart, VerseGenesisError, }; use patch::{EdgeRecord, EntityRecord, LedgerDelta, ResolvedOp, ResolvedPatch}; diff --git a/crates/ghostlight/src/mailbox.rs b/crates/ghostlight/src/mailbox.rs index be84e8f..76f76da 100644 --- a/crates/ghostlight/src/mailbox.rs +++ b/crates/ghostlight/src/mailbox.rs @@ -3,9 +3,10 @@ use super::{ ConsumerId, CreateWorld, CreateWorldIntent, CreationReceipt, DecisionInvocation, DecisionOpportunity, Declaration, DraftHandle, EntityDeclaration, EntityKind, JurisdictionKey, KernelError, NewController, OperatorEvent, PatchAnswer, PrincipalCommandIntent, PrincipalId, - Ref, SubjectDeclaration, SubjectKind, SubmitReceipt, SystemCapability, TickMinutes, WorldId, + EntityId, Ref, SubjectDeclaration, SubjectKind, SubmitReceipt, SystemCapability, TickMinutes, WorldId, WorldKernel, WorldPatch, WorldPhase, WorldScaleIntentRef, WorldSnapshot, journal, patch::kernel_speak_grant, prepare_creation, + verse::{CreateVerseWorldIntent, VerseGenesisError}, }; use crate::VerifiedPrincipalEvidence; use chrono::Utc; @@ -18,6 +19,21 @@ use tokio::task::JoinHandle; /// other. A world that wants more declares them through the patch lane. const GENESIS_PLACE: &str = "commons"; +/// The commons every genesis declares, the one room its first subjects share. +pub(crate) fn commons_declaration() -> Declaration { + Declaration::Entity(EntityDeclaration { + handle: DraftHandle::new(GENESIS_PLACE), + label: "The Commons".into(), + kind: EntityKind::Place, + container: None, + }) +} + +/// Where a genesis subject stands. +pub(crate) fn commons_ref() -> Ref { + Ref::Draft(DraftHandle::new(GENESIS_PLACE)) +} + const REQUEST_CAPACITY: usize = 32; #[derive(Clone, Debug)] @@ -120,7 +136,7 @@ impl WorldMailbox { kind, controller, affordances: kernel_speak_grant(), - position: Some(Ref::Draft(DraftHandle::new(GENESIS_PLACE))), + position: Some(commons_ref()), import_key: None, }) }; @@ -130,12 +146,7 @@ impl WorldMailbox { // jurisdiction's population target. The owner subject lands in // `Uncovered`, visible and reducing nothing. let mut declarations = vec![ - Declaration::Entity(EntityDeclaration { - handle: DraftHandle::new(GENESIS_PLACE), - label: "The Commons".into(), - kind: EntityKind::Place, - container: None, - }), + commons_declaration(), declare( "first-person", input.human_subject_label, @@ -219,6 +230,20 @@ impl WorldMailbox { .map_err(mailbox_outcome) } + /// Creates the canonical Aetheria verse world through the same + /// authenticated creation `create` uses. + pub async fn create_verse( + &self, + intent: CreateVerseWorldIntent, + principal: &VerifiedPrincipalEvidence, + ) -> Result { + let principal_id = PrincipalId::new(principal.account_subject_hash()); + let input = intent.lower(principal_id.clone())?; + Ok(self + .create_authenticated(input, AuthenticatedCaller::verified_principal(principal_id)) + .await?) + } + /// A wall-clock read at the boundary a request crosses into the mailbox is /// ingress, not reduce: the reducer stays deterministic over its journal, /// and this is the one place a caller's own claim of continued authority diff --git a/crates/ghostlight/src/verse/genesis.rs b/crates/ghostlight/src/verse/genesis.rs new file mode 100644 index 0000000..9641467 --- /dev/null +++ b/crates/ghostlight/src/verse/genesis.rs @@ -0,0 +1,143 @@ +//! The canonical Aetheria verse world's genesis. +//! +//! One intent lowers to one `CreateWorld`: a commons, every grammar verb +//! declared as an affordance, and each Aetheria faction as an institution +//! imported by its record key. The world is bound to the catalog's grammar, so +//! the kernel refuses any affordance the grammar does not carry. +//! +//! Phase A verbs record the act and change no component: an outcome band with +//! no effects, and the act carried as the utterance that makes an entry +//! admissible. Ghostlight owns each verb's lowering to kernel effects (ruling +//! `verb-effects-ghostlight`), and those arrive with phase B. + +use crate::aetheria::AetheriaCatalog; +use crate::mailbox::{MailboxError, commons_declaration, commons_ref}; +use crate::patch::{ + AffordanceDeclaration, AffordanceKindName, AudienceSpec, Declaration, DraftHandle, EvidenceRef, + FactDeclaration, FactStandingRef, OutcomeBand, Precondition, Ref, Statement, SubjectDeclaration, + kernel_speak_entry, kernel_speak_grant, +}; +use crate::{ + CommandId, CreateWorld, LensWeights, NewController, PrincipalId, SubjectKind, + WorldPatch, WorldScaleIntentRef, +}; +use std::collections::BTreeMap; + +const COMMIT_LEN: usize = 40; + +/// What a caller states to create the canonical verse world. Everything else a +/// world needs (the commons, the subjects, the affordances, their grants and +/// the provenance of what is known) is derived from the catalog. +#[derive(Clone, Debug)] +pub struct CreateVerseWorldIntent { + pub id: CommandId, + pub title: String, + pub brief: String, + pub catalog: AetheriaCatalog, + /// The Aetheria commit the catalog was read at: 40 lower-case hex digits. + /// It is the provenance of every fact the world holds about a faction. + pub aetheria_commit: String, + pub lens_weights: LensWeights, + pub targets: BTreeMap, +} + +#[derive(Debug, thiserror::Error)] +pub enum VerseGenesisError { + #[error("aetheria_commit is not 40 lower-case hex digits")] + NotACommit, + #[error(transparent)] + Mailbox(#[from] MailboxError), +} + +impl CreateVerseWorldIntent { + /// The one lowering. The kernel supplies the `speak` entry at genesis and + /// holds it to the grammar, so it is granted here and never declared. + pub(crate) fn lower(self, owner: PrincipalId) -> Result { + let commit = &self.aetheria_commit; + if commit.len() != COMMIT_LEN + || !commit + .bytes() + .all(|byte| byte.is_ascii_digit() || (b'a'..=b'f').contains(&byte)) + { + return Err(VerseGenesisError::NotACommit); + } + + let speak = kernel_speak_entry().kind; + let mut grants = kernel_speak_grant(); + let mut declarations = vec![commons_declaration()]; + for (kind, verb) in self.catalog.grammar.verbs() { + if kind == speak.0 { + continue; + } + let handle = DraftHandle::new(format!("verb-{kind}")); + grants.insert(Ref::Draft(handle.clone())); + declarations.push(Declaration::Affordance(AffordanceDeclaration { + handle, + kind: AffordanceKindName(kind.to_owned()), + roles: verb.role_specs(), + preconditions: vec![Precondition::CanBroadcast { + via: AudienceSpec::Colocated, + }], + effect_slots: Vec::new(), + outcome_bands: vec![OutcomeBand { + weight: 1, + effects: Vec::new(), + }], + // The kernel refuses an entry that can neither change a + // component nor speak, and a speaking entry needs an audience, + // so a phase A verb is its own utterance to the room: the act + // is recorded as speech and nothing else changes. + carries_speech: true, + })); + } + + let mut evidence = Vec::new(); + for (position, faction) in self.catalog.factions.iter().enumerate() { + declarations.push(Declaration::Subject(SubjectDeclaration { + handle: DraftHandle::new(format!("faction-{position}")), + label: faction.name.trim().to_owned(), + kind: SubjectKind::Institution, + controller: NewController::OperationalAgent, + affordances: grants.clone(), + position: Some(commons_ref()), + import_key: Some(faction.key.clone()), + })); + // A faction with no description has nothing to state. + if let Some(statement) = Statement::new(faction.description.trim()) { + let receipt = EvidenceRef::new(format!( + "aetheria:{commit}:{}/{}", + faction.key.schema(), + faction.key.record() + )); + evidence.push(receipt.clone()); + declarations.push(Declaration::Fact(FactDeclaration { + handle: DraftHandle::new(format!("fact-{position}")), + label: faction.name.trim().to_owned(), + statement, + standing: FactStandingRef::Canonical { evidence: receipt }, + })); + } + } + + Ok(CreateWorld { + id: self.id, + owner, + title: self.title, + brief: self.brief, + patch: WorldPatch { + declarations, + operations: Vec::new(), + evidence, + }, + scale_intent: WorldScaleIntentRef { + targets: self.targets, + jurisdictions: BTreeMap::new(), + }, + lens_weights: self.lens_weights, + grammar: Some(self.catalog.grammar), + }) + } +} + +#[cfg(test)] +mod tests; diff --git a/crates/ghostlight/src/verse/genesis/tests.rs b/crates/ghostlight/src/verse/genesis/tests.rs new file mode 100644 index 0000000..840c2d0 --- /dev/null +++ b/crates/ghostlight/src/verse/genesis/tests.rs @@ -0,0 +1,360 @@ +use super::*; +use crate::aetheria::read_aetheria_catalog; +use crate::patch::{FactStanding, Mismatch}; +use crate::tests::{auth_principal, owner, stock_weights}; +use crate::{ + CommandBody, ControllerMode, GrammarBinding, GrammarReferentKind, GrammarRole, GrammarVerb, + KernelError, PrincipalCommandIntent, RenderPath, VerifiedPrincipalEvidence, WorldKernel, + WorldMailbox, WorldPhase, +}; +use chrono::{Duration, Utc}; +use std::collections::BTreeSet; + +/// Aetheria master when the fixture catalog was written. +const COMMIT: &str = "92b82e9aa5bb422c568c4bfc85a8f641426a41fc"; +const OTHER_COMMIT: &str = "0123456789abcdef0123456789abcdef01234567"; + +const FIXTURE: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/aetheria-verse-catalog.cc" +); + +fn catalog() -> AetheriaCatalog { + read_aetheria_catalog(FIXTURE).unwrap() +} + +fn intent_of(catalog: AetheriaCatalog, commit: &str) -> CreateVerseWorldIntent { + CreateVerseWorldIntent { + id: CommandId::new(), + title: "The Verse".into(), + brief: "Aetheria.".into(), + catalog, + aetheria_commit: commit.into(), + lens_weights: stock_weights(), + targets: BTreeMap::new(), + } +} + +/// A world created through the kernel from the lowering, for what the +/// snapshot hides: the facts' evidence. +fn kernel_of(intent: CreateVerseWorldIntent) -> (tempfile::TempDir, Result) { + let dir = tempfile::tempdir().unwrap(); + let world = intent.lower(owner()).unwrap(); + let result = WorldKernel::create(dir.path().join("world.cc"), world, &auth_principal(owner())) + .map(|(kernel, _)| kernel); + (dir, result) +} + +struct Verse { + _dir: tempfile::TempDir, + _task: tokio::task::JoinHandle<()>, + mailbox: WorldMailbox, + principal: VerifiedPrincipalEvidence, + receipt: crate::CreationReceipt, +} + +async fn verse(intent: CreateVerseWorldIntent) -> Verse { + let dir = tempfile::tempdir().unwrap(); + let (mailbox, task) = WorldMailbox::open(dir.path().join("world.cc")).unwrap(); + let principal = + VerifiedPrincipalEvidence::fixture("verse-owner", Utc::now() + Duration::hours(1)); + let receipt = mailbox.create_verse(intent, &principal).await.unwrap(); + Verse { + _dir: dir, + _task: task, + mailbox, + principal, + receipt, + } +} + +#[tokio::test] +async fn verse_genesis_from_the_aetheria_fixture() { + let catalog = catalog(); + let verse = verse(intent_of(catalog.clone(), COMMIT)).await; + let snapshot = verse.mailbox.snapshot().await.unwrap(); + + // One institution per faction, keyed by its record, named by its Name. + let mut factions: Vec<_> = snapshot + .subjects + .iter() + .map(|subject| { + ( + subject.import_key.clone().unwrap(), + subject.label.clone(), + subject.kind, + subject.controller_mode, + ) + }) + .collect(); + factions.sort_by(|a, b| a.0.cmp(&b.0)); + let expected: Vec<_> = catalog + .factions + .iter() + .map(|faction| { + ( + faction.key.clone(), + faction.name.clone(), + SubjectKind::Institution, + Some(ControllerMode::OperationalAgent), + ) + }) + .collect(); + assert_eq!(factions.len(), 2); + assert_eq!(factions, expected); + + // One fact per faction, carrying its description. + let statements: BTreeSet<_> = snapshot + .facts + .iter() + .map(|fact| fact.statement.as_str().to_owned()) + .collect(); + assert_eq!( + statements, + catalog + .factions + .iter() + .map(|faction| faction.description.clone()) + .collect::>() + ); + + // The world is bound to the catalog's grammar. + assert_eq!(snapshot.grammar, Some(catalog.grammar.identity())); + assert_eq!(verse.receipt.world_id, snapshot.world_id); + + // Every faction stands in the one commons. + let commons: Vec<_> = snapshot + .places + .iter() + .filter(|place| place.label == "The Commons") + .collect(); + assert_eq!(commons.len(), 1); + assert!( + snapshot + .subjects + .iter() + .all(|subject| subject.position == Some(commons[0].id)) + ); +} + +#[test] +fn each_fact_cites_its_faction_record_and_the_commit_it_was_read_at() { + for commit in [COMMIT, OTHER_COMMIT] { + let (_dir, kernel) = kernel_of(intent_of(catalog(), commit)); + let kernel = kernel.unwrap(); + let evidence: BTreeSet = kernel + .state + .facts + .values() + .map(|fact| match &fact.standing { + FactStanding::Canonical { evidence } => evidence.text().to_owned(), + other => panic!("a verse fact is canonical, not {other:?}"), + }) + .collect(); + assert_eq!( + evidence, + catalog() + .factions + .iter() + .map(|faction| format!( + "aetheria:{commit}:aetheria.faction/{}", + faction.key.record() + )) + .collect::>() + ); + } +} + +#[test] +fn a_commit_is_forty_lower_case_hex_digits() { + for bad in [ + "", + &COMMIT[..39], + &format!("{COMMIT}0"), + &COMMIT.to_uppercase(), + &format!("{}g", &COMMIT[..39]), + ] { + assert!( + matches!( + intent_of(catalog(), bad).lower(owner()), + Err(VerseGenesisError::NotACommit) + ), + "{bad:?}" + ); + } + assert!(intent_of(catalog(), COMMIT).lower(owner()).is_ok()); + assert!(!VerseGenesisError::NotACommit.to_string().contains(COMMIT)); +} + +#[test] +fn verse_genesis_refuses_a_duplicate_faction_record() { + let mut twice = catalog(); + let first = twice.factions[0].clone(); + twice.factions.push(first); + // The lowering does not decide: it declares all three. + let world = intent_of(twice.clone(), COMMIT).lower(owner()).unwrap(); + let subjects = world + .patch + .declarations + .iter() + .filter(|declaration| matches!(declaration, Declaration::Subject(_))) + .count(); + assert_eq!(subjects, 3); + + let (_dir, kernel) = kernel_of(intent_of(twice, COMMIT)); + let Err(KernelError::PatchRejected(set)) = kernel else { + panic!("a second faction under one record key was admitted"); + }; + assert!( + set.iter() + .any(|mismatch| matches!(mismatch, Mismatch::DuplicateImportKey { .. })), + "{set:?}" + ); +} + +#[tokio::test] +async fn every_granted_affordance_is_a_grammar_verb() { + let catalog = catalog(); + let verse = verse(intent_of(catalog.clone(), COMMIT)).await; + let snapshot = verse.mailbox.snapshot().await.unwrap(); + + let mut kinds: Vec<_> = snapshot + .affordances + .iter() + .map(|affordance| affordance.entry.kind.0.clone()) + .collect(); + kinds.sort(); + // Each verb once: the kernel's speak is the grammar's speak. + assert_eq!( + kinds, + catalog + .grammar + .verbs() + .map(|(kind, _)| kind.to_owned()) + .collect::>() + ); + assert!(kinds.contains(&"speak".to_owned())); + + // Each has its render path from the grammar, and phase A changes nothing. + for affordance in &snapshot.affordances { + let verb = catalog.grammar.verb(&affordance.entry.kind.0).unwrap(); + assert_eq!(affordance.render_path, Some(verb.render_path)); + assert!(affordance.entry.effect_slots.is_empty()); + assert_eq!(affordance.entry.outcome_bands.len(), 1); + assert!(affordance.entry.outcome_bands[0].effects.is_empty()); + } + + // Every faction is granted every verb. + let all: BTreeSet<_> = snapshot.affordances.iter().map(|a| a.id).collect(); + for subject in &snapshot.subjects { + assert_eq!(subject.affordances, all); + } +} + +#[tokio::test] +async fn factions_get_opportunities() { + let verse = verse(intent_of(catalog(), COMMIT)).await; + for body in [CommandBody::ApproveDraft, CommandBody::ActivateWorld] { + let revision = verse.mailbox.snapshot().await.unwrap().revision; + verse + .mailbox + .submit_principal( + PrincipalCommandIntent { + id: CommandId::new(), + world_id: verse.receipt.world_id, + expected_revision: revision, + body, + }, + &verse.principal, + ) + .await + .unwrap(); + } + let snapshot = verse.mailbox.snapshot().await.unwrap(); + assert_eq!(snapshot.phase, WorldPhase::Active); + assert_eq!(snapshot.opportunities.len(), catalog().factions.len()); + let scopes: BTreeSet<_> = snapshot.opportunities.iter().map(|o| o.scope).collect(); + assert_eq!(scopes.len(), snapshot.opportunities.len()); + for opportunity in &snapshot.opportunities { + assert_eq!(opportunity.controller_mode, ControllerMode::OperationalAgent); + assert_eq!(opportunity.affordance_ids.len(), snapshot.affordances.len()); + } +} + +#[test] +fn a_faction_without_a_description_holds_no_fact() { + let mut bare = catalog(); + bare.factions[0].description = " ".into(); + bare.factions[1].description = " Second. ".into(); + let (_dir, kernel) = kernel_of(intent_of(bare, COMMIT)); + let facts: Vec<_> = kernel + .unwrap() + .state + .facts + .values() + .map(|fact| fact.statement.as_str().to_owned()) + .collect(); + assert_eq!(facts, vec!["Second.".to_owned()]); +} + +#[test] +fn a_grammar_the_kernel_speak_cannot_live_in_refuses_genesis() { + let mut mute = catalog(); + mute.grammar = GrammarBinding::new( + "aetheria.verse_grammar", + 1, + [( + "haul".to_owned(), + GrammarVerb { + render_path: RenderPath::ShipAction, + roles: vec![GrammarRole { + role: "cargo".into(), + referent: GrammarReferentKind::Cargo, + }], + }, + )], + ) + .unwrap(); + let (_dir, kernel) = kernel_of(intent_of(mute, COMMIT)); + let Err(KernelError::PatchRejected(set)) = kernel else { + panic!("a world whose grammar has no speak was admitted"); + }; + assert!( + set.iter() + .any(|mismatch| matches!(mismatch, Mismatch::AffordanceKindNotInGrammar { .. })), + "{set:?}" + ); +} + +#[tokio::test] +async fn the_dungeon_ingress_still_creates_an_unbound_world() { + let dir = tempfile::tempdir().unwrap(); + let (mailbox, _task) = WorldMailbox::open(dir.path().join("world.cc")).unwrap(); + let principal = + VerifiedPrincipalEvidence::fixture("dungeon-owner", Utc::now() + Duration::hours(1)); + mailbox + .create( + crate::CreateWorldIntent { + id: CommandId::new(), + title: "Dungeon".into(), + brief: String::new(), + human_subject_label: "Player".into(), + narrative_persona_label: None, + operational_agent_label: None, + targets: BTreeMap::new(), + jurisdictions: Vec::new(), + lens_weights: stock_weights(), + }, + &principal, + ) + .await + .unwrap(); + let snapshot = mailbox.snapshot().await.unwrap(); + assert_eq!(snapshot.grammar, None); + assert!( + snapshot + .places + .iter() + .any(|place| place.label == "The Commons") + ); +} diff --git a/crates/ghostlight/src/verse/mod.rs b/crates/ghostlight/src/verse/mod.rs index ba0cd08..2600127 100644 --- a/crates/ghostlight/src/verse/mod.rs +++ b/crates/ghostlight/src/verse/mod.rs @@ -1,9 +1,12 @@ //! The Aetheria verse run's own organs. The verse binary and its run record //! live above this module; here sit the parts of a run that belong to the -//! library: the record of every provider call and the spend it implies. +//! library: the record of every provider call and the spend it implies, and the +//! genesis of the canonical verse world. mod calls; +mod genesis; +pub use genesis::{CreateVerseWorldIntent, VerseGenesisError}; pub use calls::{ CALL_RECORD_SCHEMA, CallRecordError, CallRecordStore, ProviderCallRecord, RecordingInferencePort, ReplayInferencePort, RunField, RunId, RunStart, From e854cf3671228d1dd826d9e98dd128503954f14f Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 03:10:39 +0200 Subject: [PATCH 07/62] Genesis tests: pin the faction Name trim, double genesis, and a ship verb's speech Pins the trim of a faction Name into its institution label, genesis twice (same intent: same receipt, nothing changes; another command id: CreationTargetOccupied), and, as evidence for the open question ship-acts-are-not-speech, that a phase A ship verb commits only as an utterance. Builds. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/verse/genesis/tests.rs | 116 +++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/crates/ghostlight/src/verse/genesis/tests.rs b/crates/ghostlight/src/verse/genesis/tests.rs index 840c2d0..42b36da 100644 --- a/crates/ghostlight/src/verse/genesis/tests.rs +++ b/crates/ghostlight/src/verse/genesis/tests.rs @@ -358,3 +358,119 @@ async fn the_dungeon_ingress_still_creates_an_unbound_world() { .any(|place| place.label == "The Commons") ); } + +#[tokio::test] +async fn a_faction_name_is_trimmed_into_its_institution_label() { + let mut padded = catalog(); + let expected = padded.factions[0].name.trim().to_owned(); + padded.factions[0].name = format!(" {expected}\t "); + let key = padded.factions[0].key.clone(); + let verse = verse(intent_of(padded, COMMIT)).await; + let snapshot = verse.mailbox.snapshot().await.unwrap(); + let subject = snapshot + .subjects + .iter() + .find(|subject| subject.import_key.as_ref() == Some(&key)) + .expect("the padded faction was created"); + assert_eq!(subject.label, expected); +} + +#[tokio::test] +async fn genesis_twice_with_one_intent_is_one_creation() { + let dir = tempfile::tempdir().unwrap(); + let (mailbox, _task) = WorldMailbox::open(dir.path().join("world.cc")).unwrap(); + let principal = + VerifiedPrincipalEvidence::fixture("verse-owner", Utc::now() + Duration::hours(1)); + let intent = intent_of(catalog(), COMMIT); + let first = mailbox.create_verse(intent.clone(), &principal).await.unwrap(); + let before = mailbox.snapshot().await.unwrap(); + let again = mailbox.create_verse(intent, &principal).await.unwrap(); + let after = mailbox.snapshot().await.unwrap(); + assert_eq!(again, first); + assert_eq!(after.revision, before.revision); + assert_eq!(after.state_digest, before.state_digest); + assert_eq!(after.subjects.len(), before.subjects.len()); +} + +#[tokio::test] +async fn genesis_under_another_command_id_finds_the_world_occupied() { + let verse = verse(intent_of(catalog(), COMMIT)).await; + let before = verse.mailbox.snapshot().await.unwrap(); + let refused = verse + .mailbox + .create_verse(intent_of(catalog(), COMMIT), &verse.principal) + .await; + assert!( + matches!( + refused, + Err(VerseGenesisError::Mailbox(MailboxError::Kernel( + KernelError::CreationTargetOccupied + ))) + ), + "{refused:?}" + ); + let after = verse.mailbox.snapshot().await.unwrap(); + assert_eq!(after.revision, before.revision); + assert_eq!(after.state_digest, before.state_digest); +} + +/// Evidence for the open question `ship-acts-are-not-speech`, pinning what the +/// phase A lowering does today: a ship verb is only admissible as an utterance, +/// and committing it asserts a claim fact the room hears. +#[test] +fn phase_a_ship_verbs_are_spoken_acts_in_the_room() { + use crate::tests::{command, opportunity_for, spoken, submit_owner}; + use crate::{ + AuthenticatedCaller, CallerId, DecisionInvocation, Role, RoleBinding, SubmitReceipt, + Target, + }; + + let (_scratch, kernel) = kernel_of(intent_of(catalog(), COMMIT)); + let mut kernel = kernel.unwrap(); + for body in [CommandBody::ApproveDraft, CommandBody::ActivateWorld] { + let snapshot = kernel.snapshot().unwrap(); + submit_owner(&mut kernel, &snapshot, body); + } + let active = kernel.snapshot().unwrap(); + let attack = active + .affordances + .iter() + .find(|a| a.entry.kind.0 == "attack") + .unwrap() + .id; + let [first, second] = [&active.subjects[0], &active.subjects[1]]; + let opportunity = opportunity_for(&active, first.id); + let caller = CallerId::Controller(opportunity.controller_id); + let invocation = |speech: Option<&str>| DecisionInvocation { + affordance: attack, + bindings: vec![RoleBinding { + role: Role("target".into()), + target: Target::Subject(second.id), + }], + proposed: Vec::new(), + speech: speech.map(|text| crate::patch::Statement::new(text).unwrap()), + display: None, + }; + let try_it = |kernel: &mut WorldKernel, speech: Option<&str>| { + kernel.submit( + command( + &active, + CommandId::new(), + caller.clone(), + CommandBody::ExerciseDecision { + opportunity: opportunity.clone(), + invocation: invocation(speech), + }, + ), + &AuthenticatedCaller::fixture(caller.clone()), + ) + }; + + // Without an utterance the act is refused: the verb has no other way in. + assert!(try_it(&mut kernel, None).is_err()); + // With one, the act commits and its words are a claim fact. + let receipt = try_it(&mut kernel, Some("We strike.")).expect("the attack commits"); + assert!(matches!(receipt, SubmitReceipt::Applied(_))); + let event = kernel.state.events.last().unwrap(); + assert_eq!(spoken(&kernel.state, event), Some("We strike.")); +} From 2daa80e159ea19874918e4826fce1d9311131a0a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 08:58:20 +0200 Subject: [PATCH 08/62] Verse run: the manual tick driver, its run record and the ghostlight-verse command run_manual advances the verse world tick by tick under the recording port's token cap: snapshot, opportunities and agency graph from the mailbox, derive_cover, each cell on its members' current opportunities, one clock advance, a checkpoint in ghostlight.verse_run.v1. A tick that cannot finish leaves the clock where it was, so a later run re-derives the same cells and command ids and the kernel answers from its ledger. WorldMailbox::opportunities is the tick driver's own door beside agency_graph. OperatorEvent now names the verb, its render path and the bound roles. Not yet built or tested: this commit is the first write of the pass. Co-Authored-By: Claude Opus 5.5 --- Cargo.lock | 12 + Cargo.toml | 1 + crates/ghostlight-verse/Cargo.toml | 15 + crates/ghostlight-verse/src/main.rs | 294 ++++++ crates/ghostlight-verse/tests/verse_binary.rs | 283 +++++ crates/ghostlight/src/journal.rs | 5 + crates/ghostlight/src/lib.rs | 86 +- crates/ghostlight/src/mailbox.rs | 33 + crates/ghostlight/src/verse/calls.rs | 12 +- crates/ghostlight/src/verse/mod.rs | 14 +- crates/ghostlight/src/verse/run.rs | 627 +++++++++++ crates/ghostlight/src/verse/run/tests.rs | 998 ++++++++++++++++++ 12 files changed, 2371 insertions(+), 9 deletions(-) create mode 100644 crates/ghostlight-verse/Cargo.toml create mode 100644 crates/ghostlight-verse/src/main.rs create mode 100644 crates/ghostlight-verse/tests/verse_binary.rs create mode 100644 crates/ghostlight/src/verse/run.rs create mode 100644 crates/ghostlight/src/verse/run/tests.rs diff --git a/Cargo.lock b/Cargo.lock index dfffd46..251c9fe 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -730,6 +730,18 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "ghostlight-verse" +version = "0.1.0" +dependencies = [ + "anyhow", + "chrono", + "ghostlight", + "serde_json", + "tempfile", + "tokio", +] + [[package]] name = "hmac" version = "0.12.1" diff --git a/Cargo.toml b/Cargo.toml index 7bfeb7e..681ebc9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -3,6 +3,7 @@ members = [ "crates/ghostlight", "crates/ghostlight-dungeon", "crates/ghostlight-persona-projection", + "crates/ghostlight-verse", ] resolver = "2" diff --git a/crates/ghostlight-verse/Cargo.toml b/crates/ghostlight-verse/Cargo.toml new file mode 100644 index 0000000..89dd37f --- /dev/null +++ b/crates/ghostlight-verse/Cargo.toml @@ -0,0 +1,15 @@ +[package] +name = "ghostlight-verse" +version.workspace = true +edition.workspace = true +license.workspace = true + +[dependencies] +anyhow.workspace = true +chrono.workspace = true +ghostlight = { path = "../ghostlight" } +tokio.workspace = true + +[dev-dependencies] +serde_json.workspace = true +tempfile = "3" diff --git a/crates/ghostlight-verse/src/main.rs b/crates/ghostlight-verse/src/main.rs new file mode 100644 index 0000000..3bc026e --- /dev/null +++ b/crates/ghostlight-verse/src/main.rs @@ -0,0 +1,294 @@ +//! The verse runner's operator command line. +//! +//! `genesis` creates the canonical Aetheria verse world in a state root, and +//! `run` advances it a given number of ticks under a token cap, printing per +//! tick what each faction did and what it cost. A run starts only when this +//! command is invoked, and ends when it has done what it was asked. +//! +//! The state root holds `world.cc`, `controller-work.cc`, `runs.cc` and +//! `calls.cc`. The provider key is read from a file at run time and appears +//! nowhere else. + +use anyhow::{Context, Result, bail}; +use chrono::{Duration, Utc}; +use ghostlight::{ + CommandBody, CommandId, ControllerModels, ControllerRunner, DEFAULT_HOSTED_MODEL_PREFIX, + DEFAULT_LOCAL_MODEL_PREFIX, CallRecordStore, CoverBudget, CreateVerseWorldIntent, + HostedBinding, Lens, LensWeights, LocalBinding, PrincipalCommandIntent, RecordingInferencePort, + RunStart, RunStatus, TickMinutes, VerifiedPrincipalEvidence, VerseRunConfig, VerseRunStore, + WorldMailbox, call_run_id, id_text, open_controller_work, open_inference, + read_aetheria_catalog, run_manual, +}; +use std::collections::BTreeMap; +use std::net::SocketAddr; +use std::ops::ControlFlow; +use std::path::{Path, PathBuf}; +use std::process::ExitCode; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; + +/// What a manual run spends per tick on cognition cells. The operator's lever +/// on cost is the token cap; the partition always covers every faction. +const COVER: CoverBudget = CoverBudget { + cells: 8, + constituent_cap: 8, + urgency_slots: 2, +}; + +const RUNTIME_ID: &str = "ghostlight-verse"; + +#[tokio::main] +async fn main() -> ExitCode { + let arguments: Vec = std::env::args().skip(1).collect(); + match command(arguments).await { + Ok(code) => code, + Err(error) => { + eprintln!("ghostlight-verse: {error:#}"); + ExitCode::FAILURE + } + } +} + +async fn command(arguments: Vec) -> Result { + let Some((name, rest)) = arguments.split_first() else { + bail!("usage: ghostlight-verse genesis|run [flags]"); + }; + match name.as_str() { + "genesis" => genesis(Flags::parse(rest, &GENESIS_FLAGS)?).await, + "run" => run(Flags::parse(rest, &RUN_FLAGS)?).await, + _ => bail!("unknown command, expected genesis or run"), + } +} + +const GENESIS_FLAGS: [&str; 4] = ["catalog", "aetheria-commit", "state", "owner-hash"]; +const RUN_FLAGS: [&str; 7] = [ + "state", + "ticks", + "token-cap", + "tick-span-minutes", + "model", + "base-url", + "key-file", +]; +/// Flags a command may omit. +const OPTIONAL: [&str; 1] = ["key-file"]; + +/// `--name value` pairs, each named once and from the command's own list. +struct Flags(BTreeMap<&'static str, String>); + +impl Flags { + fn parse(arguments: &[String], allowed: &[&'static str]) -> Result { + let mut seen = BTreeMap::new(); + let mut rest = arguments.iter(); + while let Some(flag) = rest.next() { + let Some(name) = flag.strip_prefix("--") else { + bail!("expected a --flag, found a bare argument"); + }; + let Some(&known) = allowed.iter().find(|candidate| **candidate == name) else { + bail!("a flag this command does not take was given"); + }; + let Some(value) = rest.next() else { + bail!("--{known} needs a value"); + }; + if seen.insert(known, value.clone()).is_some() { + bail!("--{known} was given twice"); + } + } + for &required in allowed { + if !OPTIONAL.contains(&required) && !seen.contains_key(required) { + bail!("--{required} is required"); + } + } + Ok(Self(seen)) + } + + fn text(&self, name: &str) -> &str { + self.0.get(name).map_or("", String::as_str) + } + + fn path(&self, name: &str) -> PathBuf { + PathBuf::from(self.text(name)) + } + + fn number(&self, name: &str) -> Result { + match self.text(name).parse::() { + Ok(value) if value > T::default() => Ok(value), + _ => bail!("--{name} is not a positive integer"), + } + } +} + +async fn genesis(flags: Flags) -> Result { + let state = flags.path("state"); + std::fs::create_dir_all(&state).context("the state directory cannot be created")?; + let catalog = read_aetheria_catalog(flags.path("catalog")) + .context("the Aetheria catalog cannot be read")?; + let factions = catalog.factions.len(); + let principal = VerifiedPrincipalEvidence::new(flags.text("owner-hash"), Utc::now() + Duration::hours(1)); + + let (mailbox, task) = WorldMailbox::open(state.join("world.cc")) + .context("the world file cannot be opened")?; + let receipt = mailbox + .create_verse( + CreateVerseWorldIntent { + id: CommandId::new(), + title: "The Aetheria Verse".into(), + brief: "The Aetheria verse, simulated from its catalog.".into(), + catalog, + aetheria_commit: flags.text("aetheria-commit").into(), + lens_weights: LensWeights::new(Lens::ALL.into_iter().map(|lens| (lens, 1)).collect()), + targets: BTreeMap::new(), + }, + &principal, + ) + .await + .context("the verse world cannot be created")?; + // A created world is a draft: the owner approves and activates it, so the + // factions hold opportunities. + for body in [CommandBody::ApproveDraft, CommandBody::ActivateWorld] { + let revision = mailbox.snapshot().await.context("the world cannot be read")?.revision; + mailbox + .submit_principal( + PrincipalCommandIntent { + id: CommandId::new(), + world_id: receipt.world_id, + expected_revision: revision, + body, + }, + &principal, + ) + .await + .context("the world cannot be activated")?; + } + drop(mailbox); + task.await.context("the world file did not close cleanly")?; + println!("world {}", id_text(receipt.world_id)); + println!("factions {factions}"); + println!("phase Active"); + Ok(ExitCode::SUCCESS) +} + +async fn run(flags: Flags) -> Result { + let state = flags.path("state"); + let ticks: u32 = flags.number("ticks")?; + let cap: u64 = flags.number("token-cap")?; + let span: u32 = flags.number("tick-span-minutes")?; + let tick_span = TickMinutes::new(span).context("--tick-span-minutes is outside the clock's span")?; + if !state.join("world.cc").is_file() { + bail!("the state directory holds no world; run genesis first"); + } + + let model = flags.text("model").to_owned(); + let models = ControllerModels { + projector: model.clone(), + persona: model.clone(), + interpreter: model.clone(), + operational_agent: model.clone(), + elaborator: model.clone(), + }; + let inner = open_lane(&flags, &model, &models)?; + + let (mailbox, task) = WorldMailbox::open(state.join("world.cc")) + .context("the world file cannot be opened")?; + let world = mailbox.snapshot().await.context("the world cannot be read")?; + let calls = Arc::new(CallRecordStore::open(state.join("calls.cc")).context("the call records cannot be opened")?); + let runs = VerseRunStore::open(state.join("runs.cc")).context("the run records cannot be opened")?; + let work = open_controller_work(state.join("controller-work.cc")) + .context("the controller work store cannot be opened")?; + let sequence = runs.next_sequence(world.world_id).context("the run records cannot be read")?; + let port = Arc::new( + RecordingInferencePort::open(inner, calls, call_run_id(sequence), cap, &RunStart::of(&world)) + .context("the run's call records cannot be opened")?, + ); + let runner = ControllerRunner::open(mailbox.clone(), port.clone(), work, models) + .context("the controller organ cannot open")?; + + println!( + "run {sequence}: {ticks} ticks of {span} minutes, cap {cap} tokens, model {model}" + ); + let interrupted = Arc::new(AtomicBool::new(false)); + { + let interrupted = interrupted.clone(); + tokio::spawn(async move { + if tokio::signal::ctrl_c().await.is_ok() { + interrupted.store(true, Ordering::SeqCst); + } + }); + } + let outcome = run_manual( + &mailbox, + &runner, + &port, + &runs, + &VerseRunConfig { + ticks, + tick_span, + cover: COVER, + }, + sequence, + |report| { + print!("{report}"); + if interrupted.load(Ordering::SeqCst) { + ControlFlow::Break(()) + } else { + ControlFlow::Continue(()) + } + }, + ) + .await + .context("the run could not be recorded")?; + println!( + "status: {}; {} of {} ticks closed", + outcome.record.status, + outcome.record.checkpoints.len(), + outcome.record.ticks_requested + ); + drop(runner); + drop(port); + drop(mailbox); + task.await.context("the world file did not close cleanly")?; + Ok(match outcome.record.status { + RunStatus::Failed { .. } => ExitCode::FAILURE, + _ => ExitCode::SUCCESS, + }) +} + +/// The inference lane the model's prefix names. A hosted model needs an https +/// origin and a key file; a local one reaches a loopback server and needs +/// neither, so a key file beside it is refused. +fn open_lane( + flags: &Flags, + model: &str, + models: &ControllerModels, +) -> Result> { + let (mut hosted, mut local) = (None, None); + if model.starts_with(DEFAULT_HOSTED_MODEL_PREFIX) { + let Some(key) = flags.0.get("key-file") else { + bail!("a hosted model needs --key-file"); + }; + hosted = Some(HostedBinding { + base_url: flags.text("base-url").to_owned(), + key_path: Path::new(key).to_owned(), + model_prefix: DEFAULT_HOSTED_MODEL_PREFIX.into(), + caller_runtime_id: RUNTIME_ID.into(), + }); + } else if model.starts_with(DEFAULT_LOCAL_MODEL_PREFIX) { + if flags.0.contains_key("key-file") { + bail!("a local model takes no --key-file"); + } + let endpoint: SocketAddr = flags + .text("base-url") + .strip_prefix("http://") + .and_then(|address| address.trim_end_matches('/').parse().ok()) + .context("--base-url for a local model is not http://
:")?; + local = Some(LocalBinding { + endpoint, + model_prefix: DEFAULT_LOCAL_MODEL_PREFIX.into(), + caller_runtime_id: RUNTIME_ID.into(), + }); + } else { + bail!("--model names no inference lane (expected a hosted/ or local/ prefix)"); + } + open_inference(None, None, local, hosted, &models.each()) + .context("the inference lane cannot open") +} diff --git a/crates/ghostlight-verse/tests/verse_binary.rs b/crates/ghostlight-verse/tests/verse_binary.rs new file mode 100644 index 0000000..269d145 --- /dev/null +++ b/crates/ghostlight-verse/tests/verse_binary.rs @@ -0,0 +1,283 @@ +//! The `ghostlight-verse` command against a loopback scripted responder. No +//! real model is called and no key file is read: the responder answers the +//! OpenAI-compatible chat-completions request the local lane sends. + +use serde_json::{Value, json}; +use std::io::{Read, Write}; +use std::net::{SocketAddr, TcpListener, TcpStream}; +use std::path::Path; +use std::process::{Command, Output}; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; + +const BINARY: &str = env!("CARGO_BIN_EXE_ghostlight-verse"); +const CATALOG: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/../ghostlight/tests/fixtures/aetheria-verse-catalog.cc" +); +const COMMIT: &str = "92b82e9aa5bb422c568c4bfc85a8f641426a41fc"; +/// What the responder reports each call cost: 120 prompt and 30 completion. +const PER_CALL: u64 = 150; + +fn verse(arguments: &[&str]) -> Output { + Command::new(BINARY).args(arguments).output().unwrap() +} + +fn stdout(output: &Output) -> String { + String::from_utf8_lossy(&output.stdout).into_owned() +} + +fn stderr(output: &Output) -> String { + String::from_utf8_lossy(&output.stderr).into_owned() +} + +fn genesis(state: &Path) -> Output { + verse(&[ + "genesis", + "--catalog", + CATALOG, + "--aetheria-commit", + COMMIT, + "--state", + state.to_str().unwrap(), + "--owner-hash", + "verse-owner", + ]) +} + +/// Answers every chat-completions request with one tool call: the faction the +/// prompt names speaks its own line. Counts the requests it served. +struct Responder { + address: SocketAddr, + served: Arc, +} + +impl Responder { + fn start() -> Self { + let listener = TcpListener::bind("127.0.0.1:0").unwrap(); + let address = listener.local_addr().unwrap(); + let served = Arc::new(AtomicUsize::new(0)); + let counter = served.clone(); + std::thread::spawn(move || { + for stream in listener.incoming() { + let Ok(stream) = stream else { return }; + let counter = counter.clone(); + std::thread::spawn(move || serve(stream, &counter)); + } + }); + Self { address, served } + } + + fn url(&self) -> String { + format!("http://{}", self.address) + } +} + +fn serve(mut stream: TcpStream, served: &AtomicUsize) { + let mut raw = Vec::new(); + let mut chunk = [0u8; 4096]; + let (head_end, length) = loop { + let read = stream.read(&mut chunk).unwrap_or(0); + if read == 0 { + return; + } + raw.extend_from_slice(&chunk[..read]); + if let Some(end) = raw.windows(4).position(|window| window == b"\r\n\r\n") { + let head = String::from_utf8_lossy(&raw[..end]).to_ascii_lowercase(); + let length = head + .lines() + .find_map(|line| line.strip_prefix("content-length:")) + .and_then(|value| value.trim().parse::().ok()) + .unwrap_or(0); + break (end + 4, length); + } + }; + while raw.len() < head_end + length { + let read = stream.read(&mut chunk).unwrap_or(0); + if read == 0 { + return; + } + raw.extend_from_slice(&chunk[..read]); + } + let request: Value = serde_json::from_slice(&raw[head_end..head_end + length]).unwrap(); + let text = request.to_string(); + let faction = ["Adrasteia", "Brannoch"] + .into_iter() + .find(|name| text.contains(&format!("Decision owner:\\n{name}\\n"))) + .expect("a request names its faction"); + let sequence = served.fetch_add(1, Ordering::SeqCst); + let reply = json!({ + "id": format!("reply-{sequence}"), + "choices": [{ + "message": { + "role": "assistant", + "content": null, + "tool_calls": [{ + "id": format!("call-{sequence}"), + "type": "function", + "function": { + "name": "speak", + "arguments": json!({ "text": format!("{faction} holds the line.") }).to_string(), + }, + }], + }, + "finish_reason": "tool_calls", + }], + "usage": { "prompt_tokens": 120, "completion_tokens": 30 }, + }) + .to_string(); + let response = format!( + "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{reply}", + reply.len() + ); + let _ = stream.write_all(response.as_bytes()); +} + +fn run(state: &Path, responder: &Responder, ticks: &str, cap: &str) -> Output { + verse(&[ + "run", + "--state", + state.to_str().unwrap(), + "--ticks", + ticks, + "--token-cap", + cap, + "--tick-span-minutes", + "60", + "--model", + "local/scripted", + "--base-url", + &responder.url(), + ]) +} + +#[test] +fn binary_run_prints_acts_and_spend() { + let state = tempfile::tempdir().unwrap(); + let made = genesis(state.path()); + assert!(made.status.success(), "{}", stderr(&made)); + let made = stdout(&made); + assert!(made.contains("factions 2\n") && made.contains("phase Active\n"), "{made}"); + for file in ["world.cc"] { + assert!(state.path().join(file).is_file()); + } + + let responder = Responder::start(); + let output = run(state.path(), &responder, "2", "1000000"); + assert!(output.status.success(), "{}", stderr(&output)); + let text = stdout(&output); + + // Two ticks, each with both factions named by verb, and the spend as it + // accrues against the cap. + assert!(text.starts_with("run 1: 2 ticks of 60 minutes, cap 1000000 tokens"), "{text}"); + for tick in 0..2u64 { + let spent = PER_CALL * 2 * (tick + 1); + assert!( + text.contains(&format!("tick {tick} closed: 2 cells, 2 acts, 0 refused\n")), + "{text}" + ); + assert!( + text.contains(&format!( + " tokens: {} this tick, {spent} spent, {} remaining\n", + PER_CALL * 2, + 1_000_000 - spent + )), + "{text}" + ); + } + for faction in ["Adrasteia", "Brannoch"] { + assert_eq!( + text.matches(&format!(" {faction} speak [")).count(), + 2, + "{text}" + ); + assert!(text.contains(&format!("says \"{faction} holds the line.\"")), "{text}"); + } + assert!(text.ends_with("status: Completed; 2 of 2 ticks closed\n"), "{text}"); + assert_eq!(responder.served.load(Ordering::SeqCst), 4); + + // The state root holds each store the run wrote. + for file in ["world.cc", "controller-work.cc", "runs.cc", "calls.cc"] { + assert!(state.path().join(file).is_file(), "{file} was not written"); + } + + // A second run is the next in the world and carries on from its clock. + let again = run(state.path(), &responder, "1", "1000000"); + assert!(again.status.success(), "{}", stderr(&again)); + let text = stdout(&again); + assert!(text.starts_with("run 2: 1 ticks of 60 minutes"), "{text}"); + assert!(text.contains("tick 2 closed: 2 cells, 2 acts, 0 refused\n"), "{text}"); +} + +#[test] +fn a_cap_the_first_call_could_pass_ends_the_run_with_nothing_spent() { + let state = tempfile::tempdir().unwrap(); + assert!(genesis(state.path()).status.success()); + let responder = Responder::start(); + let output = run(state.path(), &responder, "3", "1000"); + assert!(output.status.success(), "{}", stderr(&output)); + let text = stdout(&output); + assert!(text.contains("tick 0 open: "), "{text}"); + assert!(text.contains("tokens: 0 this tick, 0 spent, 1000 remaining"), "{text}"); + assert!(text.ends_with("status: BudgetExhausted; 0 of 3 ticks closed\n"), "{text}"); + assert_eq!(responder.served.load(Ordering::SeqCst), 0, "no call was made"); +} + +#[test] +fn the_command_refuses_what_it_cannot_run_and_never_echoes_a_value() { + let state = tempfile::tempdir().unwrap(); + let responder = Responder::start(); + + // No world yet. + let output = run(state.path(), &responder, "1", "1000"); + assert!(!output.status.success()); + assert!(stderr(&output).contains("run genesis first"), "{}", stderr(&output)); + + assert!(genesis(state.path()).status.success()); + // A second genesis does not overwrite the first. + assert!(!genesis(state.path()).status.success()); + + let secret = "sk-not-a-real-key-0000"; + let key = state.path().join(secret); + let cases: [(Vec<&str>, &str); 6] = [ + (vec![], "usage"), + (vec!["fly"], "unknown command"), + ( + vec!["run", "--state", state.path().to_str().unwrap(), "--ticks", "0"], + "--token-cap is required", + ), + ( + vec![ + "run", "--state", state.path().to_str().unwrap(), "--ticks", "many", + "--token-cap", "1", "--tick-span-minutes", "60", "--model", "local/x", + "--base-url", "http://127.0.0.1:1", + ], + "--ticks is not a positive integer", + ), + ( + // A hosted model needs its key file; a missing one is named by flag. + vec![ + "run", "--state", state.path().to_str().unwrap(), "--ticks", "1", + "--token-cap", "1", "--tick-span-minutes", "60", "--model", "hosted/x", + "--base-url", "https://api.example.test", "--key-file", key.to_str().unwrap(), + ], + "the inference lane cannot open", + ), + ( + vec![ + "run", "--state", state.path().to_str().unwrap(), "--ticks", "1", + "--token-cap", "1", "--tick-span-minutes", "60", "--model", "local/x", + "--base-url", "http://127.0.0.1:1", "--key-file", key.to_str().unwrap(), + ], + "a local model takes no --key-file", + ), + ]; + for (arguments, expected) in cases { + let output = verse(&arguments); + assert!(!output.status.success(), "{arguments:?} succeeded"); + let error = stderr(&output); + assert!(error.contains(expected), "{arguments:?}: {error}"); + assert!(!error.contains(secret), "{arguments:?} echoed a path: {error}"); + assert!(!error.contains("many"), "{arguments:?} echoed a value: {error}"); + } +} diff --git a/crates/ghostlight/src/journal.rs b/crates/ghostlight/src/journal.rs index 8163d3a..a2d36fb 100644 --- a/crates/ghostlight/src/journal.rs +++ b/crates/ghostlight/src/journal.rs @@ -181,6 +181,11 @@ impl WorldJournal { self.commits.get(&command_id) } + /// Every commit the journal holds, in no particular order. + pub(super) fn commits(&self) -> impl Iterator { + self.commits.values() + } + #[cfg(test)] pub(super) fn commit_count(&self) -> usize { self.commits.len() diff --git a/crates/ghostlight/src/lib.rs b/crates/ghostlight/src/lib.rs index f373b42..ada2c6d 100644 --- a/crates/ghostlight/src/lib.rs +++ b/crates/ghostlight/src/lib.rs @@ -145,7 +145,10 @@ pub use table::{ pub use vault::{VaultError, VaultEvidenceSource}; pub use verse::{ CALL_RECORD_SCHEMA, CallRecordError, CallRecordStore, CreateVerseWorldIntent, ProviderCallRecord, - RecordingInferencePort, ReplayInferencePort, RunField, RunId, RunStart, VerseGenesisError, + RUN_RECORD_SCHEMA, RecordedCoverBudget, RecordingInferencePort, Refusal, RefusalReason, + ReplayInferencePort, RunField, RunId, RunOutcome, RunStart, RunStatus, TickCheckpoint, + TickReport, VerseGenesisError, VerseRunConfig, VerseRunError, VerseRunRecord, VerseRunStore, + call_run_id, run_manual, }; use patch::{EdgeRecord, EntityRecord, LedgerDelta, ResolvedOp, ResolvedPatch}; @@ -1275,6 +1278,21 @@ pub struct OperatorEvent { pub speaker_label: String, pub speech: Option, pub display: Option, + /// The affordance kind the act exercised. + pub verb: String, + /// How the world's grammar renders that verb; none in a world with no + /// grammar. + pub render_path: Option, + /// What the act bound each of the verb's roles to, in the invocation's + /// order. + pub roles: Vec, +} + +/// One role an act bound, with the label of what it was bound to. +#[derive(Clone, Debug, PartialEq, Eq)] +pub struct BoundRole { + pub role: String, + pub target: String, } #[derive(Clone, Debug, PartialEq, Eq)] @@ -1565,7 +1583,30 @@ impl WorldKernel { /// no consumer may read in full would be a loaded gun with a comment on it. fn operator_log(&self) -> Result, KernelError> { self.journal.ensure_healthy()?; - operator_log(&self.state) + // An event records what the world did; the invocation, which names the + // bound roles, lives on the commit that produced it. + let invocations: BTreeMap = self + .journal + .commits() + .filter_map(|commit| match &commit.effect { + WorldEffect::DecisionExercised { + invocation, event, .. + } => Some((event.id, invocation)), + _ => None, + }) + .collect(); + operator_log(&self.state, &invocations) + } + + /// The tick driver's opportunity list, in the same order a snapshot + /// carries it. Separate from `snapshot` for the reason `agency_graph` is: + /// only the tick driver reads it. + fn opportunities(&self) -> Result, KernelError> { + self.journal.ensure_healthy()?; + Ok(order_opportunities( + &self.state, + derive_opportunities(&self.state)?, + )) } /// The scheduler's adjacency projection. Separate from `snapshot` for the @@ -3740,7 +3781,10 @@ fn scope_components(state: &WorldState, subject_id: SubjectId) -> ScopeComponent /// The human operator's story feed, derived from the causal record. The events /// log is not a perception surface: `knowledge` is the sole answer to what a /// subject may act on and what its projection may contain. -fn operator_log(state: &WorldState) -> Result, KernelError> { +fn operator_log( + state: &WorldState, + invocations: &BTreeMap, +) -> Result, KernelError> { state .events .iter() @@ -3749,6 +3793,30 @@ fn operator_log(state: &WorldState) -> Result, KernelError> { .subjects .get(&event.scope.subject_id) .ok_or_else(|| KernelError::Invariant("event names no live subject".into()))?; + let invocation = invocations.get(&event.id).ok_or_else(|| { + KernelError::Invariant("event has no committed invocation".into()) + })?; + let entry = state + .affordance_catalog + .get(&event.affordance) + .ok_or_else(|| KernelError::Invariant("event names no catalog entry".into()))?; + let roles = invocation + .bindings + .iter() + .map(|binding| { + let label = match binding.target { + Target::Subject(id) => state.subjects.get(&id).map(|s| s.label.clone()), + Target::Entity(id) => state.entities.get(&id).map(|e| e.label.clone()), + Target::Edge(id) => state.edges.contains_key(&id).then(|| "edge".to_owned()), + }; + label + .map(|target| BoundRole { + role: binding.role.0.clone(), + target, + }) + .ok_or_else(|| KernelError::Invariant("event binds no live referent".into())) + }) + .collect::, _>>()?; Ok(OperatorEvent { revision: event.revision, speaker: event.scope.subject_id, @@ -3761,6 +3829,13 @@ fn operator_log(state: &WorldState) -> Result, KernelError> { .display .and_then(|fact| state.facts.get(&fact)) .map(|record| record.statement.clone()), + verb: entry.kind.0.clone(), + render_path: state + .grammar + .as_ref() + .and_then(|grammar| grammar.verb(&entry.kind.0)) + .map(|verb| verb.render_path), + roles, }) }) .collect() @@ -11533,7 +11608,7 @@ mod soul_knowledge_tests { // Nobody holds it. assert!(kernel.state.knowledge.is_empty()); // The operator reads it anyway, with the speaker resolved by label. - let log = operator_log(&kernel.state).unwrap(); + let log = kernel.operator_log().unwrap(); assert_eq!( log, vec![OperatorEvent { @@ -11542,6 +11617,9 @@ mod soul_knowledge_tests { speaker_label: "The Yard Bystander".into(), speech: Some(Statement::new(text).unwrap()), display: None, + verb: "speak".into(), + render_path: None, + roles: Vec::new(), }] ); // And no subject snapshot carries it. diff --git a/crates/ghostlight/src/mailbox.rs b/crates/ghostlight/src/mailbox.rs index 76f76da..4c162ce 100644 --- a/crates/ghostlight/src/mailbox.rs +++ b/crates/ghostlight/src/mailbox.rs @@ -82,6 +82,11 @@ enum Request { AgencyGraph { reply: oneshot::Sender>, }, + /// The tick driver's opportunity list. Separate from `Snapshot` so the + /// list a cover is derived from has one door, held by the driver. + Opportunities { + reply: oneshot::Sender, KernelError>>, + }, ControllerReceipt { command_id: CommandId, opportunity: super::DecisionOpportunity, @@ -486,6 +491,21 @@ impl WorldMailbox { .map_err(MailboxError::Kernel) } + /// The tick driver's opportunity list, in snapshot order. It is on + /// `WorldMailbox` and on no controller port, as `agency_graph` is. + pub async fn opportunities(&self) -> Result, MailboxError> { + let (reply, response) = oneshot::channel(); + self.sender + .send(Request::Opportunities { reply }) + .await + .map_err(|_| MailboxError::Unavailable)?; + + response + .await + .map_err(|_| MailboxError::Unavailable)? + .map_err(MailboxError::Kernel) + } + pub async fn operator_log(&self) -> Result, MailboxError> { let (reply, response) = oneshot::channel(); self.sender @@ -955,6 +975,19 @@ async fn run_owner(mut owned: OwnedWorld, mut receiver: mpsc::Receiver) return; } } + Request::Opportunities { reply } => { + let result = match &owned { + OwnedWorld::Empty(_) => Err(KernelError::WorldNotCreated), + OwnedWorld::Live(kernel) => kernel.opportunities(), + }; + let owner_must_stop = result + .as_ref() + .is_err_and(KernelError::requires_owner_restart); + let _ = reply.send(result); + if owner_must_stop { + return; + } + } Request::ControllerReceipt { command_id, opportunity, diff --git a/crates/ghostlight/src/verse/calls.rs b/crates/ghostlight/src/verse/calls.rs index d86a92d..079fa44 100644 --- a/crates/ghostlight/src/verse/calls.rs +++ b/crates/ghostlight/src/verse/calls.rs @@ -256,7 +256,7 @@ enum DecodedRow { Ledger(RunLedger), } -fn canonical Deserialize<'de>>( +pub(super) fn canonical Deserialize<'de>>( row: &CultCacheEnvelope, ) -> Result { let value: T = rmp_serde::from_slice(&row.payload).map_err(|_| CallRecordError::Corrupt)?; @@ -585,6 +585,16 @@ impl RecordingInferencePort { }) } + /// The run this port records. + pub fn run_id(&self) -> &RunId { + &self.run + } + + /// The token cap the run was opened under. + pub fn cap(&self) -> u64 { + self.cap + } + /// Tokens reserved or charged to the run so far. pub fn spent(&self) -> u64 { self.store.spent(&self.run).unwrap_or(u64::MAX) diff --git a/crates/ghostlight/src/verse/mod.rs b/crates/ghostlight/src/verse/mod.rs index 2600127..443b999 100644 --- a/crates/ghostlight/src/verse/mod.rs +++ b/crates/ghostlight/src/verse/mod.rs @@ -1,13 +1,19 @@ -//! The Aetheria verse run's own organs. The verse binary and its run record -//! live above this module; here sit the parts of a run that belong to the -//! library: the record of every provider call and the spend it implies, and the -//! genesis of the canonical verse world. +//! The Aetheria verse run's own organs. The verse binary sits above this +//! module; here sit the parts of a run that belong to the library: the record +//! of every provider call and the spend it implies, the genesis of the +//! canonical verse world, and the manual run that advances it with its record. mod calls; mod genesis; +mod run; pub use genesis::{CreateVerseWorldIntent, VerseGenesisError}; pub use calls::{ CALL_RECORD_SCHEMA, CallRecordError, CallRecordStore, ProviderCallRecord, RecordingInferencePort, ReplayInferencePort, RunField, RunId, RunStart, }; +pub use run::{ + RUN_RECORD_SCHEMA, RecordedCoverBudget, Refusal, RefusalReason, RunOutcome, RunStatus, + TickCheckpoint, TickReport, VerseRunConfig, VerseRunError, VerseRunRecord, VerseRunStore, + call_run_id, run_manual, +}; diff --git a/crates/ghostlight/src/verse/run.rs b/crates/ghostlight/src/verse/run.rs new file mode 100644 index 0000000..47c6dec --- /dev/null +++ b/crates/ghostlight/src/verse/run.rs @@ -0,0 +1,627 @@ +//! The manual verse run: the tick driver and its record. +//! +//! One invocation advances the world tick by tick. A tick reads the world, +//! derives the budgeted cover, runs each cell through the controller organ, +//! advances the clock once by the tick span, and checkpoints. The driver owns +//! the order of those steps and nothing else: cells decide what factions do, +//! the recording port owns spend, the kernel owns the world. +//! +//! The world's own clock is the resume point. A tick whose cells did not all +//! finish never advances the clock, so a later run derives the same tick index, +//! the same cells and the same command ids, the controller-work rows pick up +//! where they stopped, and the kernel answers every re-submission with the +//! original receipt. A run record therefore reports a run and is never read to +//! resume one. +//! +//! A run starts only when it is invoked: nothing here waits, schedules or +//! repeats. + +use super::calls::{RecordingInferencePort, canonical}; +use crate::controllers::{ + CellRun, ControllerError, ControllerPendingReason, ControllerRunner, InferenceFaultClass, + NarrativeRun, OperationalRun, SubmissionDisposition, +}; +use crate::cover::{Cell, Constituent, CoverBudget, CoverBudgetError, derive_cover}; +use crate::{ + CommandId, DecisionOpportunity, MailboxError, OperatorEvent, RenderPath, SubmitReceipt, + TickMinutes, WorldId, WorldMailbox, +}; +use chrono::Utc; +use cultcache_rs::{CacheBackingStore, CultCacheEnvelope, OwnedRedbMessagePackBackingStore}; +use serde::{Deserialize, Serialize}; +use std::{ops::ControlFlow, path::Path, sync::Mutex}; +use thiserror::Error; + +/// The run record document's schema. +pub const RUN_RECORD_SCHEMA: &str = "ghostlight.verse_run.v1"; +const RUN_ROW: &str = "verse_run.v1"; +const CLOCK_NAMESPACE: &str = "ghostlight.verse.clock"; + +/// Why a run could not be recorded or started. Every variant names a kind of +/// fault; none carries a value the run held. +#[derive(Debug, Error)] +pub enum VerseRunError { + #[error("the run record store could not be read or written")] + Store, + #[error("the run record store holds a row that is not a canonical run record")] + Corrupt, + #[error("a run record's sequence is not an existing run or the next in its world")] + OutOfSequence, + #[error("the recording port belongs to another run")] + PortNotThisRun, + #[error("the cover budget is not valid: {0}")] + Budget(#[from] CoverBudgetError), + #[error(transparent)] + Mailbox(#[from] MailboxError), +} + +/// Where a run stands. `Running` is only ever the record of a run in flight, +/// or of one whose process died before it could say how it ended. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RunStatus { + Running, + Completed, + /// A provider call could pass the token cap, so the run ended before the + /// open tick's clock advance. + BudgetExhausted, + /// The run ended without finishing its open tick. `reason` is a fixed code + /// naming the kind of fault, never a value. + Failed { + reason: String, + }, + /// The caller asked the run to end after a finished tick. + Stopped, +} + +/// Why a cell committed nothing and the run went on. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum RefusalReason { + /// A narrative turn the world moved under and could not lower again. + Interrupted, + /// The world refused the cell's submission. + WorldRejected, + /// The cell's subject no longer holds the opportunity it was derived for. + OpportunityGone, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct Refusal { + /// The cell's position in the tick's cover. + pub cell: u32, + pub reason: RefusalReason, +} + +/// One finished tick. The clock has advanced by the run's span. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct TickCheckpoint { + pub tick: u64, + pub cells: u32, + /// Every command the tick's cells hold a receipt for, in cell order. A + /// decline is a commit, so it is listed. + pub committed: Vec, + pub refusals: Vec, + /// Tokens the recording port charged during the tick. + pub tokens: u64, +} + +/// The cover budget as the record carries it. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct RecordedCoverBudget { + pub cells: u16, + pub constituent_cap: u16, + pub urgency_slots: u16, +} + +/// One invocation of the verse runner: what it was asked, what it did. +/// Document `ghostlight.verse_run.v1`, keyed (world, run sequence). +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct VerseRunRecord { + pub world_id: WorldId, + pub sequence: u64, + pub token_cap: u64, + pub ticks_requested: u32, + pub tick_span_minutes: u32, + pub cover_budget: RecordedCoverBudget, + /// The world's revision when the run began. + pub start_revision: u64, + pub checkpoints: Vec, + pub status: RunStatus, +} + +/// What a caller asks of one run. The token cap is not here: the recording +/// port owns it. +#[derive(Clone, Copy, Debug)] +pub struct VerseRunConfig { + pub ticks: u32, + pub tick_span: TickMinutes, + pub cover: CoverBudget, +} + +/// A tick as the operator reads it. `closed` is false for the tick a run ended +/// inside, whose cells may have committed before the run stopped. +#[derive(Clone, Debug)] +pub struct TickReport { + pub checkpoint: TickCheckpoint, + /// Every act the tick committed, from the operator log. + pub acts: Vec, + pub spent: u64, + pub remaining: u64, + pub closed: bool, +} + +impl std::fmt::Display for RunStatus { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Running => f.write_str("Running"), + Self::Completed => f.write_str("Completed"), + Self::BudgetExhausted => f.write_str("BudgetExhausted"), + Self::Failed { reason } => write!(f, "Failed ({reason})"), + Self::Stopped => f.write_str("Stopped"), + } + } +} + +/// The tick as the operator reads it: a header, one line per committed act +/// (who, which verb, how it renders, what its roles are bound to, what was +/// said or shown), one per refusal, and the spend. +impl std::fmt::Display for TickReport { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + let checkpoint = &self.checkpoint; + writeln!( + f, + "tick {} {}: {} cells, {} acts, {} refused", + checkpoint.tick, + if self.closed { "closed" } else { "open" }, + checkpoint.cells, + self.acts.len(), + checkpoint.refusals.len() + )?; + for act in &self.acts { + let path = match act.render_path { + Some(RenderPath::ShipAction) => "ship_action", + Some(RenderPath::Conversation) => "conversation", + None => "-", + }; + write!(f, " {} {} [{path}]", act.speaker_label, act.verb)?; + for role in &act.roles { + write!(f, " {}={}", role.role, role.target)?; + } + if let Some(speech) = &act.speech { + write!(f, " says {:?}", speech.as_str())?; + } + if let Some(display) = &act.display { + write!(f, " shows {:?}", display.as_str())?; + } + writeln!(f)?; + } + for refusal in &checkpoint.refusals { + let reason = match refusal.reason { + RefusalReason::Interrupted => "interrupted", + RefusalReason::WorldRejected => "world_rejected", + RefusalReason::OpportunityGone => "opportunity_gone", + }; + writeln!(f, " refused: cell {} {reason}", refusal.cell)?; + } + writeln!( + f, + " tokens: {} this tick, {} spent, {} remaining", + checkpoint.tokens, self.spent, self.remaining + ) + } +} + +#[derive(Clone, Debug)] +pub struct RunOutcome { + pub record: VerseRunRecord, +} + +fn row_key(world: WorldId, sequence: u64) -> String { + format!("{}/{sequence:020}", world.key()) +} + +/// Run records in one CultCache file. A run is saved when it begins and again +/// each time it moves, so a row is replaced by its own later state. +pub struct VerseRunStore { + store: Mutex, +} + +impl VerseRunStore { + /// Opens the file, creating it empty when absent. Every row must be a + /// canonical run record of this schema under its own key. + pub fn open(path: impl AsRef) -> Result { + let store = OwnedRedbMessagePackBackingStore::new(path.as_ref()) + .map_err(|_| VerseRunError::Store)?; + store + .validate_path_identity() + .map_err(|_| VerseRunError::Store)?; + let opened = Self { + store: Mutex::new(store), + }; + opened.records()?; + Ok(opened) + } + + fn records(&self) -> Result, VerseRunError> { + let rows = self + .store + .lock() + .map_err(|_| VerseRunError::Store)? + .pull_all() + .map_err(|_| VerseRunError::Store)?; + let mut records = Vec::new(); + for row in &rows { + if row.r#type != RUN_ROW || row.schema_id.as_deref() != Some(RUN_RECORD_SCHEMA) { + return Err(VerseRunError::Corrupt); + } + let record: VerseRunRecord = + canonical(row).map_err(|_| VerseRunError::Corrupt)?; + if row.key != row_key(record.world_id, record.sequence) || record.sequence == 0 { + return Err(VerseRunError::Corrupt); + } + records.push(record); + } + records.sort_by_key(|record| (record.world_id.key(), record.sequence)); + Ok(records) + } + + /// The sequence the next run of this world must carry. Sequences count from + /// one. + pub fn next_sequence(&self, world: WorldId) -> Result { + Ok(self + .records()? + .iter() + .filter(|record| record.world_id == world) + .map(|record| record.sequence) + .max() + .unwrap_or(0) + + 1) + } + + /// Every run of the world, in sequence. + pub fn runs(&self, world: WorldId) -> Result, VerseRunError> { + Ok(self + .records()? + .into_iter() + .filter(|record| record.world_id == world) + .collect()) + } + + /// Writes the record: the next run of its world, or a run already saved. + pub fn save(&self, record: &VerseRunRecord) -> Result<(), VerseRunError> { + if record.sequence == 0 || record.sequence > self.next_sequence(record.world_id)? { + return Err(VerseRunError::OutOfSequence); + } + let row = CultCacheEnvelope { + key: row_key(record.world_id, record.sequence), + r#type: RUN_ROW.into(), + payload: rmp_serde::to_vec_named(record).map_err(|_| VerseRunError::Store)?, + stored_at: Utc::now().to_rfc3339(), + schema_id: Some(RUN_RECORD_SCHEMA.into()), + }; + self.store + .lock() + .map_err(|_| VerseRunError::Store)? + .push(&row) + .map_err(|_| VerseRunError::Store) + } +} + +/// How an error ends a cell. +enum CellEnd { + Budget, + Fatal(&'static str), + Refused(RefusalReason), +} + +fn classify(error: &ControllerError) -> CellEnd { + match error { + ControllerError::Inference { source, .. } + if source.class() == InferenceFaultClass::BudgetExhausted => + { + CellEnd::Budget + } + _ if error.requires_quarantine() => CellEnd::Fatal("controller_quarantined"), + ControllerError::World(_) => CellEnd::Refused(RefusalReason::WorldRejected), + ControllerError::NoOpportunity { .. } + | ControllerError::AmbiguousOpportunity + | ControllerError::OpportunityMismatch => CellEnd::Refused(RefusalReason::OpportunityGone), + _ => CellEnd::Fatal("controller_error"), + } +} + +fn pending_code(reason: ControllerPendingReason) -> &'static str { + match reason { + ControllerPendingReason::InferenceRetryable => "inference_retryable", + ControllerPendingReason::InferenceRecoveryRequired => "inference_recovery_required", + ControllerPendingReason::WorldUnavailable => "world_unavailable", + ControllerPendingReason::WorldOutcomeUnknown => "world_outcome_unknown", + ControllerPendingReason::StoreReopenRequired => "store_reopen_required", + } +} + +fn command_of(submission: &SubmissionDisposition) -> CommandId { + match submission { + SubmissionDisposition::NoProposal(receipt) | SubmissionDisposition::Completed(receipt) => { + match receipt { + SubmitReceipt::Applied(commit) | SubmitReceipt::AlreadyApplied(commit) => { + commit.command_id + } + } + } + SubmissionDisposition::PreviouslyConfirmed(commit) => commit.command_id, + } +} + +/// Folds one finished cell into the tick. Returns the code of the pending state +/// that ends the run, when the cell did not finish. +fn absorb( + run: CellRun, + cell: u32, + committed: &mut Vec, + refusals: &mut Vec, +) -> Option<&'static str> { + match run { + CellRun::Narrative(NarrativeRun::Completed(decision)) => { + committed.push(command_of(decision.submission())); + None + } + CellRun::Narrative(NarrativeRun::Pending(pending)) => Some(pending_code(pending.reason())), + CellRun::Narrative(NarrativeRun::Interrupted(_)) => { + refusals.push(Refusal { + cell, + reason: RefusalReason::Interrupted, + }); + None + } + CellRun::Operational(OperationalRun::Completed(decision)) => { + committed.push(command_of(decision.submission())); + None + } + CellRun::Operational(OperationalRun::Pending(pending)) => { + Some(pending_code(pending.reason())) + } + CellRun::Grouped(run) => { + committed.extend(run.submissions.iter().map(|entry| command_of(&entry.submission))); + run.pending.map(pending_code) + } + } +} + +/// The cell with each member's opportunity read fresh. The cover is a +/// partition of subjects, fixed for the tick; an opportunity is the world's +/// current binding for a subject, and an earlier cell's act can move it (a +/// word spoken in a shared room changes what its listeners know). A subject +/// that no longer holds an opportunity leaves the cell, and an empty cell is +/// gone. +fn refreshed(cell: &Cell, fresh: &[DecisionOpportunity]) -> Option { + let current = |member: &Constituent| { + fresh + .iter() + .find(|opportunity| opportunity.scope == member.opportunity.scope) + .map(|opportunity| Constituent { + subject: member.subject, + opportunity: opportunity.clone(), + }) + }; + match cell { + Cell::Singleton { id, tick, member } => current(member).map(|member| Cell::Singleton { + id: *id, + tick: *tick, + member, + }), + Cell::Group { id, tick, members } => { + let members: Vec = members.iter().filter_map(current).collect(); + (!members.is_empty()).then(|| Cell::Group { + id: *id, + tick: *tick, + members, + }) + } + } +} + +enum TickEnd { + /// Every cell finished and the clock advanced. + Closed, + /// The run ends inside this tick with this status. + Open(RunStatus), +} + +fn failed(reason: &str) -> RunStatus { + RunStatus::Failed { + reason: reason.to_owned(), + } +} + +/// Runs `config.ticks` ticks from the world's present clock under the cap the +/// recording port was opened with. `observe` sees each tick as it ends, +/// including the one a run ends inside, and may stop the run after a finished +/// tick by breaking. `sequence` is the run's number from +/// [`VerseRunStore::next_sequence`]; the port must have been opened for it +/// ([`call_run_id`]). +pub async fn run_manual( + mailbox: &WorldMailbox, + runner: &ControllerRunner, + port: &RecordingInferencePort, + store: &VerseRunStore, + config: &VerseRunConfig, + sequence: u64, + mut observe: impl FnMut(&TickReport) -> ControlFlow<()>, +) -> Result { + if port.run_id() != &call_run_id(sequence) { + return Err(VerseRunError::PortNotThisRun); + } + let cover_budget = config.cover.validated()?; + let begun = mailbox.snapshot().await?; + let mut record = VerseRunRecord { + world_id: begun.world_id, + sequence, + token_cap: port.cap(), + ticks_requested: config.ticks, + tick_span_minutes: config.tick_span.minutes(), + cover_budget: RecordedCoverBudget { + cells: cover_budget.cells, + constituent_cap: cover_budget.constituent_cap, + urgency_slots: cover_budget.urgency_slots, + }, + start_revision: begun.revision, + checkpoints: Vec::new(), + status: RunStatus::Running, + }; + store.save(&record)?; + + let mut spent_before = port.spent(); + for completed in 0..config.ticks { + let (end, report) = run_tick( + mailbox, + runner, + port, + config, + cover_budget, + spent_before, + ) + .await; + spent_before = port.spent(); + match end { + TickEnd::Open(status) => { + record.status = status; + store.save(&record)?; + if let Some(report) = report { + let _ = observe(&report); + } + return Ok(RunOutcome { record }); + } + TickEnd::Closed => { + let report = report.expect("a closed tick has its report"); + record.checkpoints.push(report.checkpoint.clone()); + store.save(&record)?; + if observe(&report).is_break() && completed + 1 < config.ticks { + record.status = RunStatus::Stopped; + store.save(&record)?; + return Ok(RunOutcome { record }); + } + } + } + } + record.status = RunStatus::Completed; + store.save(&record)?; + Ok(RunOutcome { record }) +} + +/// The key a run's provider calls are recorded under: the run's sequence, so a +/// run record and its call records name each other. +pub fn call_run_id(sequence: u64) -> super::RunId { + super::RunId::new(&format!("run-{sequence}")) + .expect("a decimal sequence is a canonical run id") +} + +/// One tick. The report is absent only when the world could not be read at all. +async fn run_tick( + mailbox: &WorldMailbox, + runner: &ControllerRunner, + port: &RecordingInferencePort, + config: &VerseRunConfig, + cover_budget: CoverBudget, + spent_before: u64, +) -> (TickEnd, Option) { + let read = async { + let snapshot = mailbox.snapshot().await?; + let opportunities = mailbox.opportunities().await?; + let graph = mailbox.agency_graph().await?; + Ok::<_, MailboxError>((snapshot, opportunities, graph)) + } + .await; + let Ok((snapshot, opportunities, graph)) = read else { + return (TickEnd::Open(failed("world_unavailable")), None); + }; + let span = config.tick_span; + let cover = derive_cover( + snapshot.world_id, + snapshot.now, + span.minutes(), + &opportunities, + &graph, + cover_budget, + ); + + let mut committed = Vec::new(); + let mut refusals = Vec::new(); + let mut open: Option = None; + for (index, cell) in cover.cells.iter().enumerate() { + let index = index as u32; + let fresh = match mailbox.opportunities().await { + Ok(fresh) => fresh, + Err(_) => { + open = Some(failed("world_unavailable")); + break; + } + }; + let Some(cell) = refreshed(cell, &fresh) else { + refusals.push(Refusal { + cell: index, + reason: RefusalReason::OpportunityGone, + }); + continue; + }; + match runner.run_cell(&cell).await { + Ok(run) => { + if let Some(code) = absorb(run, index, &mut committed, &mut refusals) { + open = Some(failed(code)); + break; + } + } + Err(error) => match classify(&error) { + CellEnd::Budget => { + open = Some(RunStatus::BudgetExhausted); + break; + } + CellEnd::Fatal(code) => { + open = Some(failed(code)); + break; + } + CellEnd::Refused(reason) => refusals.push(Refusal { + cell: index, + reason, + }), + }, + } + } + + let closed = open.is_none(); + if closed { + let clock = CommandId::derived( + CLOCK_NAMESPACE, + &[&snapshot.world_id.text(), &cover.tick.0.to_string()], + ); + if mailbox.submit_clock(clock, span).await.is_err() { + open = Some(failed("clock_refused")); + } + } + + let spent = port.spent(); + let report = TickReport { + checkpoint: TickCheckpoint { + tick: cover.tick.0, + cells: cover.cells.len() as u32, + committed, + refusals, + tokens: spent.saturating_sub(spent_before), + }, + acts: mailbox + .operator_log() + .await + .map(|log| { + log.into_iter() + .filter(|event| event.revision > snapshot.revision) + .collect() + }) + .unwrap_or_default(), + spent, + remaining: port.cap().saturating_sub(spent), + closed: open.is_none(), + }; + (open.map_or(TickEnd::Closed, TickEnd::Open), Some(report)) +} + +#[cfg(test)] +mod tests; diff --git a/crates/ghostlight/src/verse/run/tests.rs b/crates/ghostlight/src/verse/run/tests.rs new file mode 100644 index 0000000..1ee04d4 --- /dev/null +++ b/crates/ghostlight/src/verse/run/tests.rs @@ -0,0 +1,998 @@ +use super::*; +use crate::aetheria::read_aetheria_catalog; +use crate::controllers::{ + ControllerModels, ControllerWorkStore, InferenceEvent, InferenceFault, InferenceOutput, + InferencePort, InferencePurpose, InferenceRequest, PreparedInference, TokenUsage, + open_controller_work, +}; +use crate::cover::derive_cover; +use crate::tests::stock_weights; +use crate::verse::{ + CallRecordStore, CreateVerseWorldIntent, RecordingInferencePort, ReplayInferencePort, + RunStart, +}; +use crate::{ + BoundRole, CommandBody, ControllerMode, FictionalMinutes, KernelError, + PrincipalCommandIntent, RenderPath, VerifiedPrincipalEvidence, id_text, +}; +use async_trait::async_trait; +use chrono::Duration; +use serde_json::json; +use std::collections::{BTreeMap, VecDeque}; +use std::path::Path; +use std::sync::Arc; +use std::sync::atomic::{AtomicUsize, Ordering}; +use tokio::task::JoinHandle; + +const COMMIT: &str = "92b82e9aa5bb422c568c4bfc85a8f641426a41fc"; +const FIXTURE: &str = concat!( + env!("CARGO_MANIFEST_DIR"), + "/tests/fixtures/aetheria-verse-catalog.cc" +); +const SPAN: u32 = 60; + +/// Creates the verse world at `dir/world.cc` from the fixture catalog and +/// activates it, then lets go of the file so a copy of it is a restored world. +async fn create_world(dir: &Path) { + let (mailbox, task) = WorldMailbox::open(dir.join("world.cc")).unwrap(); + let principal = + VerifiedPrincipalEvidence::fixture("verse-owner", Utc::now() + Duration::hours(1)); + let receipt = mailbox + .create_verse( + CreateVerseWorldIntent { + id: CommandId::new(), + title: "The Verse".into(), + brief: "Aetheria.".into(), + catalog: read_aetheria_catalog(FIXTURE).unwrap(), + aetheria_commit: COMMIT.into(), + lens_weights: stock_weights(), + targets: BTreeMap::new(), + }, + &principal, + ) + .await + .unwrap(); + for body in [CommandBody::ApproveDraft, CommandBody::ActivateWorld] { + let revision = mailbox.snapshot().await.unwrap().revision; + mailbox + .submit_principal( + PrincipalCommandIntent { + id: CommandId::new(), + world_id: receipt.world_id, + expected_revision: revision, + body, + }, + &principal, + ) + .await + .unwrap(); + } + drop(mailbox); + task.await.unwrap(); +} + +/// A scripted provider. `reply` sees the serialized request and answers with +/// the events a model would. It reports usage as a fixed share of the bound the +/// recording port holds the call to, so the charge is a function of the +/// request. +struct ScriptedPort { + reply: Box Vec + Send + Sync>, + faults: std::sync::Mutex>, + bounds: std::sync::Mutex>, + calls: AtomicUsize, +} + +impl ScriptedPort { + fn new(reply: impl Fn(&str) -> Vec + Send + Sync + 'static) -> Arc { + Arc::new(Self { + reply: Box::new(reply), + faults: std::sync::Mutex::new(VecDeque::new()), + bounds: std::sync::Mutex::new(Vec::new()), + calls: AtomicUsize::new(0), + }) + } + + /// Every faction speaks its own line. + fn speaking() -> Arc { + Self::new(|request| { + let name = who(request); + vec![speak_call(&format!("{name} holds the line."))] + }) + } + + fn fail_next(&self, fault: InferenceFault) { + self.faults.lock().unwrap().push_back(fault); + } + + fn calls(&self) -> usize { + self.calls.load(Ordering::SeqCst) + } + + fn bounds(&self) -> Vec { + self.bounds.lock().unwrap().clone() + } +} + +/// Which faction a request is for: the prompt names its decision owner. +fn who(request: &str) -> String { + for name in ["Adrasteia", "Brannoch"] { + if request.contains(&format!("Decision owner:\\n{name}\\n")) { + return name.to_owned(); + } + } + panic!("a request names no faction"); +} + +fn speak_call(text: &str) -> InferenceEvent { + InferenceEvent::ToolCall { + call_id: "act".into(), + name: "speak".into(), + arguments: json!({ "text": text }).to_string(), + } +} + +#[async_trait] +impl InferencePort for ScriptedPort { + fn prepare(&self, request: InferenceRequest) -> Result { + PreparedInference::prepare("verse-run-test", 4_102_444_800_000, request) + } + + async fn infer(&self, request: PreparedInference) -> Result { + let sequence = self.calls.fetch_add(1, Ordering::SeqCst); + let wire = &request.invocation.request; + let bound = serde_json::to_vec(wire).unwrap().len() as u64 + + u64::from(wire.max_output_tokens.unwrap()); + self.bounds.lock().unwrap().push(bound); + if let Some(fault) = self.faults.lock().unwrap().pop_front() { + return Err(fault); + } + let text = serde_json::to_string(wire).unwrap(); + Ok( + InferenceOutput::new((self.reply)(&text), format!("sha256:scripted-{sequence}")) + .with_usage(TokenUsage { + prompt: bound / 2, + completion: bound / 4, + cached_prompt: None, + }), + ) + } +} + +fn models() -> ControllerModels { + ControllerModels { + projector: "scripted".into(), + persona: "scripted".into(), + interpreter: "scripted".into(), + operational_agent: "scripted".into(), + elaborator: "scripted".into(), + } +} + +fn two_cells() -> CoverBudget { + CoverBudget { + cells: 2, + constituent_cap: 8, + urgency_slots: 0, + } +} + +fn config(ticks: u32) -> VerseRunConfig { + VerseRunConfig { + ticks, + tick_span: TickMinutes::new(SPAN).unwrap(), + cover: two_cells(), + } +} + +/// An opened verse state root: the world, its call records, its run records +/// and its controller work, as the binary holds them. +struct Harness { + mailbox: WorldMailbox, + _task: JoinHandle<()>, + calls: Arc, + runs: VerseRunStore, + work: Arc, +} + +impl Harness { + async fn open(dir: &Path) -> Self { + let (mailbox, task) = WorldMailbox::open(dir.join("world.cc")).unwrap(); + Self { + mailbox, + _task: task, + calls: Arc::new(CallRecordStore::open(dir.join("calls.cc")).unwrap()), + runs: VerseRunStore::open(dir.join("runs.cc")).unwrap(), + work: open_controller_work(dir.join("controller-work.cc")).unwrap(), + } + } + + async fn fresh() -> (tempfile::TempDir, Self) { + let dir = tempfile::tempdir().unwrap(); + create_world(dir.path()).await; + let harness = Self::open(dir.path()).await; + (dir, harness) + } + + /// One run over `inner`, recorded under the next sequence. + async fn run( + &self, + inner: Arc, + cap: u64, + ticks: u32, + observe: impl FnMut(&TickReport) -> ControlFlow<()>, + ) -> RunOutcome { + let world = self.mailbox.snapshot().await.unwrap(); + let sequence = self.runs.next_sequence(world.world_id).unwrap(); + let port = Arc::new( + RecordingInferencePort::open( + inner, + self.calls.clone(), + call_run_id(sequence), + cap, + &RunStart::of(&world), + ) + .unwrap(), + ); + let runner = + ControllerRunner::open(self.mailbox.clone(), port.clone(), self.work.clone(), models()) + .unwrap(); + run_manual( + &self.mailbox, + &runner, + &port, + &self.runs, + &config(ticks), + sequence, + observe, + ) + .await + .unwrap() + } + + async fn run_quietly(&self, inner: Arc, cap: u64, ticks: u32) -> RunOutcome { + self.run(inner, cap, ticks, |_| ControlFlow::Continue(())) + .await + } + + async fn now(&self) -> u64 { + self.mailbox.snapshot().await.unwrap().now.0 + } + + async fn acts(&self) -> usize { + self.mailbox.operator_log().await.unwrap().len() + } + + async fn subject(&self, label: &str) -> crate::SubjectId { + self.mailbox + .snapshot() + .await + .unwrap() + .subjects + .iter() + .find(|subject| subject.label == label) + .unwrap() + .id + } +} + +#[tokio::test] +async fn scripted_run_commits_and_checkpoints_every_tick() { + let (_dir, verse) = Harness::fresh().await; + let world = verse.mailbox.snapshot().await.unwrap(); + let opportunities = verse.mailbox.opportunities().await.unwrap(); + let graph = verse.mailbox.agency_graph().await.unwrap(); + let port = ScriptedPort::speaking(); + let mut reports = Vec::new(); + let outcome = verse + .run(port.clone(), 10_000_000, 3, |report| { + reports.push(report.clone()); + ControlFlow::Continue(()) + }) + .await; + + // One checkpoint per tick, tick indexes 0 to 2, the clock moved three spans. + assert_eq!(outcome.record.status, RunStatus::Completed); + let ticks: Vec = outcome.record.checkpoints.iter().map(|c| c.tick).collect(); + assert_eq!(ticks, vec![0, 1, 2]); + assert_eq!(verse.now().await, 3 * u64::from(SPAN)); + // Both factions acted every tick, the second on a scope the first moved. + assert_eq!(verse.acts().await, 6); + assert_eq!(port.calls(), 6); + + // Every committed command id is the one the cover derives for that cell, + // in cell order. + for checkpoint in &outcome.record.checkpoints { + let cover = derive_cover( + world.world_id, + FictionalMinutes(checkpoint.tick * u64::from(SPAN)), + SPAN, + &opportunities, + &graph, + two_cells(), + ); + let expected: Vec = cover + .cells + .iter() + .map(|cell| match cell { + Cell::Singleton { id, tick, member } => { + CommandId::for_cell_constituent(world.world_id, *id, member.subject, *tick) + } + Cell::Group { .. } => panic!("two factions under two cells are singletons"), + }) + .collect(); + assert_eq!(expected.len(), 2); + assert_eq!(checkpoint.committed, expected); + assert_eq!(checkpoint.cells, 2); + assert!(checkpoint.refusals.is_empty()); + } + + // The record in the store is the record the run returned, and it says what + // the run was asked. + assert_eq!( + verse.runs.runs(world.world_id).unwrap(), + vec![outcome.record.clone()] + ); + assert_eq!(outcome.record.world_id, world.world_id); + assert_eq!(outcome.record.sequence, 1); + assert_eq!(outcome.record.token_cap, 10_000_000); + assert_eq!(outcome.record.ticks_requested, 3); + assert_eq!(outcome.record.tick_span_minutes, SPAN); + assert_eq!(outcome.record.start_revision, world.revision); + assert_eq!( + outcome.record.cover_budget, + RecordedCoverBudget { + cells: 2, + constituent_cap: 8, + urgency_slots: 0 + } + ); + + // A tick's tokens are what the call store charged for its two calls. + let records = verse.calls.records(&call_run_id(1)).unwrap(); + assert_eq!(records.len(), 6); + for (tick, checkpoint) in outcome.record.checkpoints.iter().enumerate() { + let charged = records[2 * tick].charged() + records[2 * tick + 1].charged(); + assert!(charged > 0); + assert_eq!(checkpoint.tokens, charged); + } + let total: u64 = outcome.record.checkpoints.iter().map(|c| c.tokens).sum(); + assert_eq!(total, verse.calls.spent(&call_run_id(1)).unwrap()); + + // The operator sees each tick as it ends: its acts, and the spend so far. + assert_eq!(reports.len(), 3); + let mut spent = 0; + for (tick, report) in reports.iter().enumerate() { + spent += report.checkpoint.tokens; + assert!(report.closed); + assert_eq!(report.acts.len(), 2); + assert_eq!(report.spent, spent); + assert_eq!(report.remaining, 10_000_000 - spent); + assert_eq!(report.checkpoint, outcome.record.checkpoints[tick]); + for act in &report.acts { + assert_eq!(act.verb, "speak"); + assert_eq!( + act.speech.as_ref().unwrap().as_str(), + format!("{} holds the line.", act.speaker_label) + ); + } + } + // A tick reports only its own acts. + let revisions: Vec = reports + .iter() + .flat_map(|report| report.acts.iter().map(|act| act.revision)) + .collect(); + assert_eq!(revisions.len(), 6); + assert!(revisions.windows(2).all(|pair| pair[0] < pair[1])); +} + +#[tokio::test] +async fn budget_exhausted_stops_before_the_clock_and_resumes() { + // What the first call is held to, measured on a world of its own. + let (_probe_dir, probe) = Harness::fresh().await; + let measuring = ScriptedPort::speaking(); + probe.run_quietly(measuring.clone(), u64::MAX, 1).await; + let first_bound = measuring.bounds()[0]; + + let (_dir, verse) = Harness::fresh().await; + let world = verse.mailbox.snapshot().await.unwrap(); + // Room for the first call, and not for a second after the first's charge. + let first = ScriptedPort::speaking(); + let mut reports = Vec::new(); + let ended = verse + .run(first.clone(), first_bound + 64, 2, |report| { + reports.push(report.clone()); + ControlFlow::Continue(()) + }) + .await; + assert_eq!(ended.record.status, RunStatus::BudgetExhausted); + assert!(ended.record.checkpoints.is_empty()); + assert_eq!(verse.now().await, 0, "the open tick's clock did not advance"); + assert_eq!(first.calls(), 1, "the second call never reached the provider"); + assert_eq!(verse.acts().await, 1); + // The open tick is reported, unclosed, with what it committed. + assert_eq!(reports.len(), 1); + assert!(!reports[0].closed); + assert_eq!(reports[0].acts.len(), 1); + assert_eq!(reports[0].checkpoint.committed.len(), 1); + assert!(reports[0].remaining < first_bound); + assert_eq!( + verse.runs.runs(world.world_id).unwrap(), + vec![ended.record.clone()] + ); + + // A later run, under its own cap, finishes the tick: the first faction's + // act is answered from the kernel's ledger and costs no call. + let second = ScriptedPort::speaking(); + let resumed = verse.run_quietly(second.clone(), u64::MAX, 2).await; + assert_eq!(resumed.record.status, RunStatus::Completed); + assert_eq!(resumed.record.sequence, 2); + let ticks: Vec = resumed.record.checkpoints.iter().map(|c| c.tick).collect(); + assert_eq!(ticks, vec![0, 1], "the open tick is the first this run closes"); + assert_eq!(verse.now().await, 2 * u64::from(SPAN)); + assert_eq!(second.calls(), 3, "one call finished tick 0, two ran tick 1"); + assert_eq!(verse.acts().await, 4, "no act was committed twice"); + // The tick that was open committed both of its cells' commands. + assert_eq!(resumed.record.checkpoints[0].committed.len(), 2); + assert_eq!( + resumed.record.checkpoints[0].committed[0], + reports[0].checkpoint.committed[0] + ); +} + +#[tokio::test] +async fn recorded_run_replays_to_the_same_journal() { + let live_dir = tempfile::tempdir().unwrap(); + create_world(live_dir.path()).await; + let replay_dir = tempfile::tempdir().unwrap(); + std::fs::copy( + live_dir.path().join("world.cc"), + replay_dir.path().join("world.cc"), + ) + .unwrap(); + let live = Harness::open(live_dir.path()).await; + let replayed = Harness::open(replay_dir.path()).await; + + let provider = ScriptedPort::speaking(); + let recorded = live.run_quietly(provider.clone(), u64::MAX, 3).await; + assert_eq!(recorded.record.status, RunStatus::Completed); + + // The same run again, from the recorded replies and no provider. + let start = RunStart::of(&replayed.mailbox.snapshot().await.unwrap()); + let replay = Arc::new( + ReplayInferencePort::new("verse-replay", &live.calls, &call_run_id(1), &start).unwrap(), + ); + let again = replayed.run_quietly(replay, u64::MAX, 3).await; + assert_eq!(again.record.status, RunStatus::Completed); + assert_eq!(provider.calls(), 6, "replay made no provider call"); + + assert_eq!(again.record.checkpoints, recorded.record.checkpoints); + let live_world = live.mailbox.snapshot().await.unwrap(); + let replayed_world = replayed.mailbox.snapshot().await.unwrap(); + assert_eq!(replayed_world.revision, live_world.revision); + assert_eq!(replayed_world.state_digest, live_world.state_digest); + assert_eq!( + replayed.mailbox.operator_log().await.unwrap(), + live.mailbox.operator_log().await.unwrap() + ); + // Both stores hold the same calls, charged the same. + assert_eq!( + replayed.calls.records(&call_run_id(1)).unwrap(), + live.calls.records(&call_run_id(1)).unwrap() + ); +} + +#[tokio::test] +async fn a_run_the_caller_stops_ends_after_a_finished_tick() { + let (_dir, verse) = Harness::fresh().await; + let stopped = verse + .run(ScriptedPort::speaking(), u64::MAX, 3, |_| { + ControlFlow::Break(()) + }) + .await; + assert_eq!(stopped.record.status, RunStatus::Stopped); + assert_eq!(stopped.record.checkpoints.len(), 1); + assert_eq!(verse.now().await, u64::from(SPAN)); + + // A stop asked on the last tick is not a stop: the run was done. + let done = verse + .run(ScriptedPort::speaking(), u64::MAX, 1, |_| { + ControlFlow::Break(()) + }) + .await; + assert_eq!(done.record.status, RunStatus::Completed); +} + +#[tokio::test] +async fn a_pending_cell_ends_the_run_without_the_clock_and_a_later_run_finishes_it() { + let (_dir, verse) = Harness::fresh().await; + let flaky = ScriptedPort::speaking(); + flaky.fail_next(InferenceFault::retryable("the provider is busy")); + let ended = verse.run_quietly(flaky.clone(), u64::MAX, 2).await; + assert_eq!( + ended.record.status, + RunStatus::Failed { + reason: "inference_retryable".into() + } + ); + assert!(ended.record.checkpoints.is_empty()); + assert_eq!(verse.now().await, 0); + assert_eq!(verse.acts().await, 0); + + let finished = verse + .run_quietly(ScriptedPort::speaking(), u64::MAX, 1) + .await; + assert_eq!(finished.record.status, RunStatus::Completed); + assert_eq!(verse.now().await, u64::from(SPAN)); + assert_eq!(verse.acts().await, 2); +} + +#[tokio::test] +async fn a_provider_answer_against_the_tool_contract_ends_the_run_without_the_clock() { + let (_dir, verse) = Harness::fresh().await; + // The faction acts and then calls finish_without_proposal, which + // contradicts its own choice. + let port = ScriptedPort::new(|request| { + let name = who(request); + vec![ + speak_call(&format!("{name} speaks.")), + InferenceEvent::ToolCall { + call_id: "finish".into(), + name: "finish_without_proposal".into(), + arguments: "{}".into(), + }, + ] + }); + let outcome = verse.run_quietly(port, u64::MAX, 1).await; + assert_eq!( + outcome.record.status, + RunStatus::Failed { + reason: "inference_recovery_required".into() + } + ); + assert!(outcome.record.checkpoints.is_empty()); + assert_eq!(verse.now().await, 0); +} + +#[tokio::test] +async fn a_cell_the_world_refuses_is_a_refusal_and_the_tick_closes() { + let (_dir, verse) = Harness::fresh().await; + // Adrasteia attacks something that is not in the world; Brannoch speaks. + let nothing = id_text(CommandId::new()); + let port = ScriptedPort::new(move |request| { + let name = who(request); + if name == "Adrasteia" { + vec![InferenceEvent::ToolCall { + call_id: "act".into(), + name: "attack".into(), + arguments: json!({ "target": nothing, "text": "Adrasteia strikes." }).to_string(), + }] + } else { + vec![speak_call("Brannoch speaks.")] + } + }); + let outcome = verse.run_quietly(port, u64::MAX, 1).await; + assert_eq!(outcome.record.status, RunStatus::Completed); + let [checkpoint] = outcome.record.checkpoints.as_slice() else { + panic!("one tick, one checkpoint") + }; + assert_eq!(checkpoint.cells, 2); + assert_eq!(checkpoint.committed.len(), 1); + assert_eq!(checkpoint.refusals.len(), 1); + assert_eq!(checkpoint.refusals[0].reason, RefusalReason::WorldRejected); + assert!(checkpoint.refusals[0].cell < 2); + assert_eq!(verse.now().await, u64::from(SPAN)); + assert_eq!(verse.acts().await, 1); +} + +#[test] +fn the_operator_reads_refusals_and_how_a_run_ended() { + let report = TickReport { + checkpoint: TickCheckpoint { + tick: 4, + cells: 3, + committed: Vec::new(), + refusals: vec![ + Refusal { + cell: 1, + reason: RefusalReason::WorldRejected, + }, + Refusal { + cell: 2, + reason: RefusalReason::OpportunityGone, + }, + Refusal { + cell: 0, + reason: RefusalReason::Interrupted, + }, + ], + tokens: 7, + }, + acts: Vec::new(), + spent: 40, + remaining: 60, + closed: false, + }; + assert_eq!( + report.to_string(), + "tick 4 open: 3 cells, 0 acts, 3 refused + refused: cell 1 world_rejected + refused: cell 2 opportunity_gone + refused: cell 0 interrupted + tokens: 7 this tick, 40 spent, 60 remaining +" + ); + let ended: Vec = [ + RunStatus::Running, + RunStatus::Completed, + RunStatus::BudgetExhausted, + RunStatus::Failed { + reason: "clock_refused".into(), + }, + RunStatus::Stopped, + ] + .iter() + .map(ToString::to_string) + .collect(); + assert_eq!( + ended, + ["Running", "Completed", "BudgetExhausted", "Failed (clock_refused)", "Stopped"] + ); +} + +#[tokio::test] +async fn an_act_names_its_verb_render_path_and_bound_roles() { + let (_dir, verse) = Harness::fresh().await; + let target = id_text(verse.subject("Brannoch").await); + let port = ScriptedPort::new(move |request| { + if who(request) == "Adrasteia" { + vec![InferenceEvent::ToolCall { + call_id: "act".into(), + name: "attack".into(), + arguments: json!({ "target": target, "text": "Adrasteia strikes." }).to_string(), + }] + } else { + vec![speak_call("Brannoch answers.")] + } + }); + let mut reports = Vec::new(); + let outcome = verse + .run(port, u64::MAX, 1, |report| { + reports.push(report.clone()); + ControlFlow::Continue(()) + }) + .await; + assert_eq!(outcome.record.status, RunStatus::Completed); + let catalog = read_aetheria_catalog(FIXTURE).unwrap(); + let acts = &reports[0].acts; + let attack = acts.iter().find(|act| act.verb == "attack").unwrap(); + assert_eq!(attack.speaker_label, "Adrasteia"); + assert_eq!( + attack.render_path, + Some(catalog.grammar.verb("attack").unwrap().render_path) + ); + assert_eq!( + attack.roles, + vec![BoundRole { + role: "target".into(), + target: "Brannoch".into() + }] + ); + + // And the operator reads them so. + let name = |verb: &str| match catalog.grammar.verb(verb).unwrap().render_path { + RenderPath::ShipAction => "ship_action", + RenderPath::Conversation => "conversation", + }; + let text = reports[0].to_string(); + let checkpoint = &reports[0].checkpoint; + assert!(text.starts_with("tick 0 closed: 2 cells, 2 acts, 0 refused +"), "{text}"); + assert!(text.contains(&format!( + " + Adrasteia attack [{}] target=Brannoch says \"Adrasteia strikes.\" +", + name("attack") + )), "{text}"); + assert!(text.contains(&format!( + " + Brannoch speak [{}] says \"Brannoch answers.\" +", + name("speak") + )), "{text}"); + assert!(text.ends_with(&format!( + " tokens: {} this tick, {} spent, {} remaining +", + checkpoint.tokens, reports[0].spent, reports[0].remaining + )), "{text}"); + let speak = acts.iter().find(|act| act.verb == "speak").unwrap(); + assert_eq!(speak.speaker_label, "Brannoch"); + assert_eq!( + speak.render_path, + Some(catalog.grammar.verb("speak").unwrap().render_path) + ); + assert!(speak.roles.is_empty()); +} + +#[tokio::test] +async fn the_opportunity_door_answers_what_a_snapshot_carries() { + let (_dir, verse) = Harness::fresh().await; + let snapshot = verse.mailbox.snapshot().await.unwrap(); + assert_eq!(snapshot.opportunities.len(), 2); + assert_eq!( + verse.mailbox.opportunities().await.unwrap(), + snapshot.opportunities + ); +} + +#[tokio::test] +async fn a_cell_is_run_on_what_the_world_holds_for_its_members_now() { + let (_dir, verse) = Harness::fresh().await; + let opportunities = verse.mailbox.opportunities().await.unwrap(); + let graph = verse.mailbox.agency_graph().await.unwrap(); + let world = verse.mailbox.snapshot().await.unwrap().world_id; + let singles = derive_cover( + world, + FictionalMinutes(0), + SPAN, + &opportunities, + &graph, + two_cells(), + ); + let group = derive_cover( + world, + FictionalMinutes(0), + SPAN, + &opportunities, + &graph, + CoverBudget { + cells: 1, + constituent_cap: 8, + urgency_slots: 0, + }, + ); + let [grouped @ Cell::Group { members, .. }] = group.cells.as_slice() else { + panic!("one cell for two subjects is a group") + }; + assert_eq!(members.len(), 2); + + // Changed opportunities replace the ones the cover held. + let mut moved = opportunities.clone(); + for opportunity in &mut moved { + opportunity.revision += 1; + } + for cell in &singles.cells { + let Cell::Singleton { member: held, .. } = cell else { + panic!("two subjects under two cells are singletons") + }; + let Some(Cell::Singleton { member, .. }) = refreshed(cell, &moved) else { + panic!("a subject that still has an opportunity keeps its cell") + }; + assert_ne!(member.opportunity, held.opportunity); + assert!(moved.contains(&member.opportunity)); + } + let Some(Cell::Group { members: now, .. }) = refreshed(grouped, &moved) else { + panic!("a group keeps its members") + }; + assert_eq!(now.len(), 2); + assert!(now.iter().all(|member| moved.contains(&member.opportunity))); + + // A subject with no opportunity leaves its cell, and an empty cell is gone. + let one = &opportunities[..1]; + let Some(Cell::Group { members: left, .. }) = refreshed(grouped, one) else { + panic!("one member remains") + }; + assert_eq!(left.len(), 1); + assert_eq!(left[0].opportunity.scope, one[0].scope); + let gone = singles + .cells + .iter() + .filter(|cell| refreshed(cell, one).is_none()) + .count(); + assert_eq!(gone, 1); + assert!(refreshed(grouped, &[]).is_none()); +} + +#[test] +fn an_error_ends_a_cell_as_the_run_must_treat_it() { + let budget = ControllerError::Inference { + purpose: InferencePurpose::OperationalAgent, + source: InferenceFault::budget_exhausted(), + }; + assert!(matches!(classify(&budget), CellEnd::Budget)); + // Any other fault that stops the cognition organ stops the run. + let corrupted = ControllerError::Inference { + purpose: InferencePurpose::OperationalAgent, + source: InferenceFault::integrity_violation("the reply cannot be trusted"), + }; + assert!(matches!( + classify(&corrupted), + CellEnd::Fatal("controller_quarantined") + )); + assert!(matches!( + classify(&ControllerError::Serialization("x".into())), + CellEnd::Fatal("controller_quarantined") + )); + // What the world or the subject's standing refused stays with its cell. + assert!(matches!( + classify(&ControllerError::World(KernelError::AffordanceDenied)), + CellEnd::Refused(RefusalReason::WorldRejected) + )); + for gone in [ + ControllerError::NoOpportunity { + expected: ControllerMode::OperationalAgent, + }, + ControllerError::AmbiguousOpportunity, + ControllerError::OpportunityMismatch, + ] { + assert!(matches!( + classify(&gone), + CellEnd::Refused(RefusalReason::OpportunityGone) + )); + } + assert!(matches!( + classify(&ControllerError::CommandMismatch), + CellEnd::Fatal("controller_error") + )); +} + +#[tokio::test] +async fn a_port_opened_for_another_run_is_refused_before_anything_is_written() { + let (_dir, verse) = Harness::fresh().await; + let world = verse.mailbox.snapshot().await.unwrap(); + let port = Arc::new( + RecordingInferencePort::open( + ScriptedPort::speaking(), + verse.calls.clone(), + call_run_id(7), + 1_000, + &RunStart::of(&world), + ) + .unwrap(), + ); + let runner = ControllerRunner::open( + verse.mailbox.clone(), + port.clone(), + verse.work.clone(), + models(), + ) + .unwrap(); + let error = run_manual( + &verse.mailbox, + &runner, + &port, + &verse.runs, + &config(1), + 1, + |_| ControlFlow::Continue(()), + ) + .await + .unwrap_err(); + assert!(matches!(error, VerseRunError::PortNotThisRun)); + assert!(verse.runs.runs(world.world_id).unwrap().is_empty()); + + // A cover budget with no cells is refused before the record exists. + let mut bad = config(1); + bad.cover.cells = 0; + let own = Arc::new( + RecordingInferencePort::open( + ScriptedPort::speaking(), + verse.calls.clone(), + call_run_id(1), + 1_000, + &RunStart::of(&world), + ) + .unwrap(), + ); + let error = run_manual(&verse.mailbox, &runner, &own, &verse.runs, &bad, 1, |_| { + ControlFlow::Continue(()) + }) + .await + .unwrap_err(); + assert!(matches!( + error, + VerseRunError::Budget(CoverBudgetError::NoCells) + )); + assert!(verse.runs.runs(world.world_id).unwrap().is_empty()); +} + +fn record(world: WorldId, sequence: u64) -> VerseRunRecord { + VerseRunRecord { + world_id: world, + sequence, + token_cap: 10, + ticks_requested: 1, + tick_span_minutes: SPAN, + cover_budget: RecordedCoverBudget { + cells: 2, + constituent_cap: 8, + urgency_slots: 0, + }, + start_revision: 0, + checkpoints: Vec::new(), + status: RunStatus::Running, + } +} + +#[test] +fn the_run_store_numbers_runs_per_world_and_replaces_a_run_with_its_later_state() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("runs.cc"); + let store = VerseRunStore::open(&path).unwrap(); + let one = WorldId::nil_for_test(); + let other = WorldId::issue(); + assert_eq!(store.next_sequence(one).unwrap(), 1); + + // A run can only begin as the next in its world. + assert!(matches!( + store.save(&record(one, 2)), + Err(VerseRunError::OutOfSequence) + )); + assert!(matches!( + store.save(&record(one, 0)), + Err(VerseRunError::OutOfSequence) + )); + store.save(&record(one, 1)).unwrap(); + assert_eq!(store.next_sequence(one).unwrap(), 2); + assert_eq!( + store.next_sequence(other).unwrap(), + 1, + "sequences are per world" + ); + + // Saving a run again replaces it, and the file reopens to the same rows. + let mut finished = record(one, 1); + finished.status = RunStatus::Completed; + store.save(&finished).unwrap(); + store.save(&record(one, 2)).unwrap(); + store.save(&record(other, 1)).unwrap(); + drop(store); + let store = VerseRunStore::open(&path).unwrap(); + assert_eq!(store.runs(one).unwrap(), vec![finished, record(one, 2)]); + assert_eq!(store.runs(other).unwrap(), vec![record(other, 1)]); +} + +#[test] +fn the_run_store_refuses_rows_that_are_not_canonical_run_records() { + let dir = tempfile::tempdir().unwrap(); + let world = WorldId::nil_for_test(); + let good = record(world, 1); + let envelope = |key: String, schema: &str, payload: Vec| CultCacheEnvelope { + key, + r#type: RUN_ROW.into(), + payload, + stored_at: Utc::now().to_rfc3339(), + schema_id: Some(schema.into()), + }; + let named = rmp_serde::to_vec_named(&good).unwrap(); + let cases = [ + // Another schema version is not migrated. + envelope(row_key(world, 1), "ghostlight.verse_run.v2", named.clone()), + // A row under a key that is not its own. + envelope(row_key(world, 9), RUN_RECORD_SCHEMA, named.clone()), + // A payload that is not the record. + envelope(row_key(world, 1), RUN_RECORD_SCHEMA, vec![0xc0]), + // The record in a non-canonical encoding. + envelope( + row_key(world, 1), + RUN_RECORD_SCHEMA, + rmp_serde::to_vec(&good).unwrap(), + ), + // A sequence of zero. + envelope( + row_key(world, 0), + RUN_RECORD_SCHEMA, + rmp_serde::to_vec_named(&record(world, 0)).unwrap(), + ), + ]; + for (index, row) in cases.into_iter().enumerate() { + let file = dir.path().join(format!("case-{index}.cc")); + { + let mut raw = OwnedRedbMessagePackBackingStore::new(&file).unwrap(); + raw.push(&row).unwrap(); + } + assert!( + matches!(VerseRunStore::open(&file), Err(VerseRunError::Corrupt)), + "case {index} was accepted" + ); + } +} From cef64369711b003e1355421b1b064c8e81a20523 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 09:01:01 +0200 Subject: [PATCH 09/62] Verse run test: the resumed tick closes with the one cell the first run left The faction that acted holds no opportunity until the clock moves, so the open tick re-derives with one cell. Builds and passes except this assertion, which this commit corrects. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/verse/run/tests.rs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/crates/ghostlight/src/verse/run/tests.rs b/crates/ghostlight/src/verse/run/tests.rs index 1ee04d4..89da1a5 100644 --- a/crates/ghostlight/src/verse/run/tests.rs +++ b/crates/ghostlight/src/verse/run/tests.rs @@ -431,12 +431,14 @@ async fn budget_exhausted_stops_before_the_clock_and_resumes() { assert_eq!(verse.now().await, 2 * u64::from(SPAN)); assert_eq!(second.calls(), 3, "one call finished tick 0, two ran tick 1"); assert_eq!(verse.acts().await, 4, "no act was committed twice"); - // The tick that was open committed both of its cells' commands. - assert_eq!(resumed.record.checkpoints[0].committed.len(), 2); - assert_eq!( - resumed.record.checkpoints[0].committed[0], - reports[0].checkpoint.committed[0] - ); + // The faction that had acted holds no opportunity until the clock moves, so + // the tick that was open is closed by the one cell left, a command the + // first run did not commit. + let closing = &resumed.record.checkpoints[0]; + assert_eq!(closing.cells, 1); + assert_eq!(closing.committed.len(), 1); + assert_ne!(closing.committed[0], reports[0].checkpoint.committed[0]); + assert_eq!(resumed.record.checkpoints[1].committed.len(), 2); } #[tokio::test] From fc3f9fd668d0330ccb85abd4968667522144abce Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 09:04:52 +0200 Subject: [PATCH 10/62] diag (temporary) --- crates/ghostlight/src/verse/run/tests.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/crates/ghostlight/src/verse/run/tests.rs b/crates/ghostlight/src/verse/run/tests.rs index 89da1a5..cdddfcf 100644 --- a/crates/ghostlight/src/verse/run/tests.rs +++ b/crates/ghostlight/src/verse/run/tests.rs @@ -435,6 +435,7 @@ async fn budget_exhausted_stops_before_the_clock_and_resumes() { // the tick that was open is closed by the one cell left, a command the // first run did not commit. let closing = &resumed.record.checkpoints[0]; + eprintln!("DIAG first={:#?} resumed={:#?}", ended.record, resumed.record); assert_eq!(closing.cells, 1); assert_eq!(closing.committed.len(), 1); assert_ne!(closing.committed[0], reports[0].checkpoint.committed[0]); From 097bf02f1e9514aa40d47c7226014421f6a4ef9e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 09:08:02 +0200 Subject: [PATCH 11/62] Verse run: the open tick is recorded unclosed, and a resumed cell whose scope moved is its own refusal A run that ends inside a tick (budget, pending cell, failed clock) now keeps that tick's checkpoint with closed false, so what its cells committed is not lost. A cell that committed before an interruption meets a moved scope digest when the tick resumes and is refused as ScopeMoved; nothing commits twice. Removes the temporary diagnostic. Builds; not yet re-verified. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight-verse/src/main.rs | 2 +- crates/ghostlight/src/verse/run.rs | 42 +++++++--- crates/ghostlight/src/verse/run/tests.rs | 98 +++++++++++++++--------- 3 files changed, 94 insertions(+), 48 deletions(-) diff --git a/crates/ghostlight-verse/src/main.rs b/crates/ghostlight-verse/src/main.rs index 3bc026e..52f0c50 100644 --- a/crates/ghostlight-verse/src/main.rs +++ b/crates/ghostlight-verse/src/main.rs @@ -240,7 +240,7 @@ async fn run(flags: Flags) -> Result { println!( "status: {}; {} of {} ticks closed", outcome.record.status, - outcome.record.checkpoints.len(), + outcome.record.ticks_closed(), outcome.record.ticks_requested ); drop(runner); diff --git a/crates/ghostlight/src/verse/run.rs b/crates/ghostlight/src/verse/run.rs index 47c6dec..3dc83dd 100644 --- a/crates/ghostlight/src/verse/run.rs +++ b/crates/ghostlight/src/verse/run.rs @@ -80,8 +80,14 @@ pub enum RefusalReason { Interrupted, /// The world refused the cell's submission. WorldRejected, - /// The cell's subject no longer holds the opportunity it was derived for. + /// The cell's subject no longer holds an opportunity. OpportunityGone, + /// The cell's subject holds an opportunity whose scope moved since the + /// cell's work was bound. A cell that committed earlier in an interrupted + /// tick ends this way when a run resumes the tick: its own act, or a + /// neighbour's in the shared room, changed what it knows. Nothing is + /// committed twice. + ScopeMoved, } #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -91,10 +97,14 @@ pub struct Refusal { pub reason: RefusalReason, } -/// One finished tick. The clock has advanced by the run's span. +/// A tick as the run left it. A closed tick has all its cells run and the +/// clock advanced by the run's span. The tick a run ended inside is recorded +/// too, unclosed, so what its cells committed is not lost; a later run derives +/// that tick again from the world's clock. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct TickCheckpoint { pub tick: u64, + pub closed: bool, pub cells: u32, /// Every command the tick's cells hold a receipt for, in cell order. A /// decline is a commit, so it is listed. @@ -128,6 +138,13 @@ pub struct VerseRunRecord { pub status: RunStatus, } +impl VerseRunRecord { + /// The ticks this run finished: every cell run and the clock advanced. + pub fn ticks_closed(&self) -> usize { + self.checkpoints.iter().filter(|c| c.closed).count() + } +} + /// What a caller asks of one run. The token cap is not here: the recording /// port owns it. #[derive(Clone, Copy, Debug)] @@ -137,8 +154,7 @@ pub struct VerseRunConfig { pub cover: CoverBudget, } -/// A tick as the operator reads it. `closed` is false for the tick a run ended -/// inside, whose cells may have committed before the run stopped. +/// A tick as the operator reads it, including the one a run ended inside. #[derive(Clone, Debug)] pub struct TickReport { pub checkpoint: TickCheckpoint, @@ -146,7 +162,6 @@ pub struct TickReport { pub acts: Vec, pub spent: u64, pub remaining: u64, - pub closed: bool, } impl std::fmt::Display for RunStatus { @@ -171,7 +186,7 @@ impl std::fmt::Display for TickReport { f, "tick {} {}: {} cells, {} acts, {} refused", checkpoint.tick, - if self.closed { "closed" } else { "open" }, + if checkpoint.closed { "closed" } else { "open" }, checkpoint.cells, self.acts.len(), checkpoint.refusals.len() @@ -199,6 +214,7 @@ impl std::fmt::Display for TickReport { RefusalReason::Interrupted => "interrupted", RefusalReason::WorldRejected => "world_rejected", RefusalReason::OpportunityGone => "opportunity_gone", + RefusalReason::ScopeMoved => "scope_moved", }; writeln!(f, " refused: cell {} {reason}", refusal.cell)?; } @@ -322,9 +338,10 @@ fn classify(error: &ControllerError) -> CellEnd { } _ if error.requires_quarantine() => CellEnd::Fatal("controller_quarantined"), ControllerError::World(_) => CellEnd::Refused(RefusalReason::WorldRejected), - ControllerError::NoOpportunity { .. } - | ControllerError::AmbiguousOpportunity - | ControllerError::OpportunityMismatch => CellEnd::Refused(RefusalReason::OpportunityGone), + ControllerError::NoOpportunity { .. } | ControllerError::AmbiguousOpportunity => { + CellEnd::Refused(RefusalReason::OpportunityGone) + } + ControllerError::OpportunityMismatch => CellEnd::Refused(RefusalReason::ScopeMoved), _ => CellEnd::Fatal("controller_error"), } } @@ -485,9 +502,12 @@ pub async fn run_manual( match end { TickEnd::Open(status) => { record.status = status; - store.save(&record)?; if let Some(report) = report { + record.checkpoints.push(report.checkpoint.clone()); + store.save(&record)?; let _ = observe(&report); + } else { + store.save(&record)?; } return Ok(RunOutcome { record }); } @@ -602,6 +622,7 @@ async fn run_tick( let report = TickReport { checkpoint: TickCheckpoint { tick: cover.tick.0, + closed: open.is_none(), cells: cover.cells.len() as u32, committed, refusals, @@ -618,7 +639,6 @@ async fn run_tick( .unwrap_or_default(), spent, remaining: port.cap().saturating_sub(spent), - closed: open.is_none(), }; (open.map_or(TickEnd::Closed, TickEnd::Open), Some(report)) } diff --git a/crates/ghostlight/src/verse/run/tests.rs b/crates/ghostlight/src/verse/run/tests.rs index cdddfcf..8b98e88 100644 --- a/crates/ghostlight/src/verse/run/tests.rs +++ b/crates/ghostlight/src/verse/run/tests.rs @@ -294,6 +294,7 @@ async fn scripted_run_commits_and_checkpoints_every_tick() { assert_eq!(outcome.record.status, RunStatus::Completed); let ticks: Vec = outcome.record.checkpoints.iter().map(|c| c.tick).collect(); assert_eq!(ticks, vec![0, 1, 2]); + assert_eq!(outcome.record.ticks_closed(), 3); assert_eq!(verse.now().await, 3 * u64::from(SPAN)); // Both factions acted every tick, the second on a scope the first moved. assert_eq!(verse.acts().await, 6); @@ -323,6 +324,7 @@ async fn scripted_run_commits_and_checkpoints_every_tick() { assert_eq!(expected.len(), 2); assert_eq!(checkpoint.committed, expected); assert_eq!(checkpoint.cells, 2); + assert!(checkpoint.closed); assert!(checkpoint.refusals.is_empty()); } @@ -363,7 +365,7 @@ async fn scripted_run_commits_and_checkpoints_every_tick() { let mut spent = 0; for (tick, report) in reports.iter().enumerate() { spent += report.checkpoint.tokens; - assert!(report.closed); + assert!(report.checkpoint.closed); assert_eq!(report.acts.len(), 2); assert_eq!(report.spent, spent); assert_eq!(report.remaining, 10_000_000 - spent); @@ -405,15 +407,22 @@ async fn budget_exhausted_stops_before_the_clock_and_resumes() { }) .await; assert_eq!(ended.record.status, RunStatus::BudgetExhausted); - assert!(ended.record.checkpoints.is_empty()); + // The tick it ended inside is recorded, unclosed, with the act it committed. + let [open] = ended.record.checkpoints.as_slice() else { + panic!("the open tick is the run's one checkpoint") + }; + assert!(!open.closed); + assert_eq!(open.tick, 0); + assert_eq!(open.committed.len(), 1); + assert_eq!(ended.record.ticks_closed(), 0); assert_eq!(verse.now().await, 0, "the open tick's clock did not advance"); assert_eq!(first.calls(), 1, "the second call never reached the provider"); assert_eq!(verse.acts().await, 1); // The open tick is reported, unclosed, with what it committed. assert_eq!(reports.len(), 1); - assert!(!reports[0].closed); + assert!(!reports[0].checkpoint.closed); assert_eq!(reports[0].acts.len(), 1); - assert_eq!(reports[0].checkpoint.committed.len(), 1); + assert_eq!(&reports[0].checkpoint, open); assert!(reports[0].remaining < first_bound); assert_eq!( verse.runs.runs(world.world_id).unwrap(), @@ -431,14 +440,18 @@ async fn budget_exhausted_stops_before_the_clock_and_resumes() { assert_eq!(verse.now().await, 2 * u64::from(SPAN)); assert_eq!(second.calls(), 3, "one call finished tick 0, two ran tick 1"); assert_eq!(verse.acts().await, 4, "no act was committed twice"); - // The faction that had acted holds no opportunity until the clock moves, so - // the tick that was open is closed by the one cell left, a command the - // first run did not commit. + // Both factions still hold opportunities. The one the first run left in + // flight finishes; the one that already acted has had its scope moved by + // that act and by its neighbour's, so its cell is refused rather than run + // again. let closing = &resumed.record.checkpoints[0]; - eprintln!("DIAG first={:#?} resumed={:#?}", ended.record, resumed.record); - assert_eq!(closing.cells, 1); + assert!(closing.closed); + assert_eq!(closing.cells, 2); assert_eq!(closing.committed.len(), 1); - assert_ne!(closing.committed[0], reports[0].checkpoint.committed[0]); + assert_ne!(closing.committed[0], open.committed[0]); + assert_eq!(closing.refusals.len(), 1); + assert_eq!(closing.refusals[0].reason, RefusalReason::ScopeMoved); + assert_eq!(resumed.record.ticks_closed(), 2); assert_eq!(resumed.record.checkpoints[1].committed.len(), 2); } @@ -517,7 +530,11 @@ async fn a_pending_cell_ends_the_run_without_the_clock_and_a_later_run_finishes_ reason: "inference_retryable".into() } ); - assert!(ended.record.checkpoints.is_empty()); + assert_eq!(ended.record.ticks_closed(), 0); + let [open] = ended.record.checkpoints.as_slice() else { + panic!("the open tick is recorded") + }; + assert!(!open.closed && open.committed.is_empty()); assert_eq!(verse.now().await, 0); assert_eq!(verse.acts().await, 0); @@ -552,7 +569,7 @@ async fn a_provider_answer_against_the_tool_contract_ends_the_run_without_the_cl reason: "inference_recovery_required".into() } ); - assert!(outcome.record.checkpoints.is_empty()); + assert_eq!(outcome.record.ticks_closed(), 0); assert_eq!(verse.now().await, 0); } @@ -592,6 +609,7 @@ fn the_operator_reads_refusals_and_how_a_run_ended() { let report = TickReport { checkpoint: TickCheckpoint { tick: 4, + closed: false, cells: 3, committed: Vec::new(), refusals: vec![ @@ -607,22 +625,27 @@ fn the_operator_reads_refusals_and_how_a_run_ended() { cell: 0, reason: RefusalReason::Interrupted, }, + Refusal { + cell: 5, + reason: RefusalReason::ScopeMoved, + }, ], tokens: 7, }, acts: Vec::new(), spent: 40, remaining: 60, - closed: false, }; assert_eq!( report.to_string(), - "tick 4 open: 3 cells, 0 acts, 3 refused - refused: cell 1 world_rejected - refused: cell 2 opportunity_gone - refused: cell 0 interrupted - tokens: 7 this tick, 40 spent, 60 remaining -" + concat!( + "tick 4 open: 3 cells, 0 acts, 4 refused\n", + " refused: cell 1 world_rejected\n", + " refused: cell 2 opportunity_gone\n", + " refused: cell 0 interrupted\n", + " refused: cell 5 scope_moved\n", + " tokens: 7 this tick, 40 spent, 60 remaining\n", + ) ); let ended: Vec = [ RunStatus::Running, @@ -688,25 +711,25 @@ async fn an_act_names_its_verb_render_path_and_bound_roles() { }; let text = reports[0].to_string(); let checkpoint = &reports[0].checkpoint; - assert!(text.starts_with("tick 0 closed: 2 cells, 2 acts, 0 refused -"), "{text}"); - assert!(text.contains(&format!( - " - Adrasteia attack [{}] target=Brannoch says \"Adrasteia strikes.\" -", + assert!( + text.starts_with("tick 0 closed: 2 cells, 2 acts, 0 refused\n"), + "{text}" + ); + let attack_line = format!( + "\n Adrasteia attack [{}] target=Brannoch says \"Adrasteia strikes.\"\n", name("attack") - )), "{text}"); - assert!(text.contains(&format!( - " - Brannoch speak [{}] says \"Brannoch answers.\" -", + ); + assert!(text.contains(&attack_line), "{text}"); + let speak_line = format!( + "\n Brannoch speak [{}] says \"Brannoch answers.\"\n", name("speak") - )), "{text}"); - assert!(text.ends_with(&format!( - " tokens: {} this tick, {} spent, {} remaining -", + ); + assert!(text.contains(&speak_line), "{text}"); + let tokens_line = format!( + " tokens: {} this tick, {} spent, {} remaining\n", checkpoint.tokens, reports[0].spent, reports[0].remaining - )), "{text}"); + ); + assert!(text.ends_with(&tokens_line), "{text}"); let speak = acts.iter().find(|act| act.verb == "speak").unwrap(); assert_eq!(speak.speaker_label, "Brannoch"); assert_eq!( @@ -825,13 +848,16 @@ fn an_error_ends_a_cell_as_the_run_must_treat_it() { expected: ControllerMode::OperationalAgent, }, ControllerError::AmbiguousOpportunity, - ControllerError::OpportunityMismatch, ] { assert!(matches!( classify(&gone), CellEnd::Refused(RefusalReason::OpportunityGone) )); } + assert!(matches!( + classify(&ControllerError::OpportunityMismatch), + CellEnd::Refused(RefusalReason::ScopeMoved) + )); assert!(matches!( classify(&ControllerError::CommandMismatch), CellEnd::Fatal("controller_error") From 06e30dc921582da79b6f70f5d8ab353b6c18977b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 09:14:07 +0200 Subject: [PATCH 12/62] Verse binary tests: a failed run exits nonzero, and zero counts are refused Pins the exit status of a Failed run against a provider that refuses every call, and the positive-integer guard for ticks and token cap. Builds; the earlier mutation pass named these as survivors. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight-verse/tests/verse_binary.rs | 65 +++++++++++++++++-- 1 file changed, 58 insertions(+), 7 deletions(-) diff --git a/crates/ghostlight-verse/tests/verse_binary.rs b/crates/ghostlight-verse/tests/verse_binary.rs index 269d145..eda0cc6 100644 --- a/crates/ghostlight-verse/tests/verse_binary.rs +++ b/crates/ghostlight-verse/tests/verse_binary.rs @@ -54,6 +54,15 @@ struct Responder { impl Responder { fn start() -> Self { + Self::answering(200) + } + + /// A provider that refuses every call with this status. + fn refusing(status: u16) -> Self { + Self::answering(status) + } + + fn answering(status: u16) -> Self { let listener = TcpListener::bind("127.0.0.1:0").unwrap(); let address = listener.local_addr().unwrap(); let served = Arc::new(AtomicUsize::new(0)); @@ -62,7 +71,7 @@ impl Responder { for stream in listener.incoming() { let Ok(stream) = stream else { return }; let counter = counter.clone(); - std::thread::spawn(move || serve(stream, &counter)); + std::thread::spawn(move || serve(stream, &counter, status)); } }); Self { address, served } @@ -73,7 +82,7 @@ impl Responder { } } -fn serve(mut stream: TcpStream, served: &AtomicUsize) { +fn serve(mut stream: TcpStream, served: &AtomicUsize, status: u16) { let mut raw = Vec::new(); let mut chunk = [0u8; 4096]; let (head_end, length) = loop { @@ -127,7 +136,7 @@ fn serve(mut stream: TcpStream, served: &AtomicUsize) { }) .to_string(); let response = format!( - "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{reply}", + "HTTP/1.1 {status} Scripted\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{reply}", reply.len() ); let _ = stream.write_all(response.as_bytes()); @@ -158,9 +167,7 @@ fn binary_run_prints_acts_and_spend() { assert!(made.status.success(), "{}", stderr(&made)); let made = stdout(&made); assert!(made.contains("factions 2\n") && made.contains("phase Active\n"), "{made}"); - for file in ["world.cc"] { - assert!(state.path().join(file).is_file()); - } + assert!(state.path().join("world.cc").is_file()); let responder = Responder::start(); let output = run(state.path(), &responder, "2", "1000000"); @@ -223,6 +230,26 @@ fn a_cap_the_first_call_could_pass_ends_the_run_with_nothing_spent() { assert_eq!(responder.served.load(Ordering::SeqCst), 0, "no call was made"); } +#[test] +fn a_run_that_fails_says_so_and_exits_nonzero() { + let state = tempfile::tempdir().unwrap(); + assert!(genesis(state.path()).status.success()); + let responder = Responder::refusing(500); + let output = run(state.path(), &responder, "2", "1000000"); + assert!(!output.status.success()); + let text = stdout(&output); + assert!(text.contains("tick 0 open: "), "{text}"); + assert!( + text.ends_with("status: Failed (inference_recovery_required); 0 of 2 ticks closed\n"), + "{text}" + ); + assert_eq!( + responder.served.load(Ordering::SeqCst), + 1, + "the run stopped at the first refusal" + ); +} + #[test] fn the_command_refuses_what_it_cannot_run_and_never_echoes_a_value() { let state = tempfile::tempdir().unwrap(); @@ -239,7 +266,7 @@ fn the_command_refuses_what_it_cannot_run_and_never_echoes_a_value() { let secret = "sk-not-a-real-key-0000"; let key = state.path().join(secret); - let cases: [(Vec<&str>, &str); 6] = [ + let cases: [(Vec<&str>, &str); 9] = [ (vec![], "usage"), (vec!["fly"], "unknown command"), ( @@ -254,6 +281,30 @@ fn the_command_refuses_what_it_cannot_run_and_never_echoes_a_value() { ], "--ticks is not a positive integer", ), + ( + vec![ + "run", "--state", state.path().to_str().unwrap(), "--ticks", "0", + "--token-cap", "1", "--tick-span-minutes", "60", "--model", "local/x", + "--base-url", "http://127.0.0.1:1", + ], + "--ticks is not a positive integer", + ), + ( + vec![ + "run", "--state", state.path().to_str().unwrap(), "--ticks", "1", + "--token-cap", "0", "--tick-span-minutes", "60", "--model", "local/x", + "--base-url", "http://127.0.0.1:1", + ], + "--token-cap is not a positive integer", + ), + ( + vec![ + "run", "--state", state.path().to_str().unwrap(), "--ticks", "1", + "--token-cap", "1", "--tick-span-minutes", "60", "--model", "gpt-4", + "--base-url", "http://127.0.0.1:1", + ], + "--model names no inference lane", + ), ( // A hosted model needs its key file; a missing one is named by flag. vec![ From 06a8373fbd655d427e7430218a4b983db35262cd Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 10:08:52 +0200 Subject: [PATCH 13/62] Verse run test: a resumed tick reports only the acts the resuming run committed Pins the operator-log cut at the tick's start revision against the act the interrupted run left at that very revision. Builds; the mutation pass named it. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/verse/run/tests.rs | 19 ++++++++++++++++--- 1 file changed, 16 insertions(+), 3 deletions(-) diff --git a/crates/ghostlight/src/verse/run/tests.rs b/crates/ghostlight/src/verse/run/tests.rs index 8b98e88..d4e378c 100644 --- a/crates/ghostlight/src/verse/run/tests.rs +++ b/crates/ghostlight/src/verse/run/tests.rs @@ -429,10 +429,16 @@ async fn budget_exhausted_stops_before_the_clock_and_resumes() { vec![ended.record.clone()] ); - // A later run, under its own cap, finishes the tick: the first faction's - // act is answered from the kernel's ledger and costs no call. + // A later run, under its own cap, finishes the tick and costs no call for + // the act already committed. let second = ScriptedPort::speaking(); - let resumed = verse.run_quietly(second.clone(), u64::MAX, 2).await; + let mut resumed_reports = Vec::new(); + let resumed = verse + .run(second.clone(), u64::MAX, 2, |report| { + resumed_reports.push(report.clone()); + ControlFlow::Continue(()) + }) + .await; assert_eq!(resumed.record.status, RunStatus::Completed); assert_eq!(resumed.record.sequence, 2); let ticks: Vec = resumed.record.checkpoints.iter().map(|c| c.tick).collect(); @@ -452,6 +458,13 @@ async fn budget_exhausted_stops_before_the_clock_and_resumes() { assert_eq!(closing.refusals.len(), 1); assert_eq!(closing.refusals[0].reason, RefusalReason::ScopeMoved); assert_eq!(resumed.record.ticks_closed(), 2); + // The tick reports the act this run committed and not the one it found. + assert_eq!(resumed_reports[0].acts.len(), 1); + assert!(resumed_reports[0].acts[0].revision > reports[0].acts[0].revision); + assert_ne!( + resumed_reports[0].acts[0].speaker_label, + reports[0].acts[0].speaker_label + ); assert_eq!(resumed.record.checkpoints[1].committed.len(), 2); } From 5277973afbe51caf62c020bb52860b46eb16e692 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 12:11:07 +0200 Subject: [PATCH 14/62] Verse run: a tick is (world, clock-advance ordinal), its span and cells frozen in a tick document The tick index is the world's TimeAdvanced count, read through a driver-only mailbox door, and derive_cover takes it instead of now and the span. A tick document ghostlight.verse_tick.v1 is written before the first cell and read by any later run, so a resume under another span or cover budget finishes the open tick as it was opened. The clock id is clock_command_id(world, ordinal) and is pinned by a test. TickIndex::of and run_tick's per-call derive_cover are gone. Fixes findings resume-rederives-tick-identity, tick-identity-collides-across-spans, clock-id-unpinned. Not yet built or run at this commit. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/consumer.rs | 5 +- crates/ghostlight/src/controllers.rs | 3 +- crates/ghostlight/src/cover.rs | 29 +- crates/ghostlight/src/lib.rs | 13 + crates/ghostlight/src/mailbox.rs | 34 ++ crates/ghostlight/src/verse/run.rs | 387 +++++++++++++++----- crates/ghostlight/src/verse/run/tests.rs | 432 +++++++++++++++++++++-- 7 files changed, 768 insertions(+), 135 deletions(-) diff --git a/crates/ghostlight/src/consumer.rs b/crates/ghostlight/src/consumer.rs index 2df43ad..e764dde 100644 --- a/crates/ghostlight/src/consumer.rs +++ b/crates/ghostlight/src/consumer.rs @@ -469,7 +469,7 @@ mod tests { AuthorityTargetRef, CallerId, CausalBoundary, CommandBody, CommandEnvelope, CommitmentKind, ComponentOp, CoverBudget, DecisionOpportunity, DecisionScope, Declaration, DraftHandle, EntityDeclaration, EntityId, EntityKind, EvidenceRef, FactDeclaration, FactStandingRef, - FictionalMinutes, JurisdictionKey, NewController, PatchGround, Quantity, Ref, + FictionalMinutes, JurisdictionKey, NewController, PatchGround, Quantity, Ref, TickIndex, RouteDeclaration, Statement, SubjectDeclaration, SubjectId, SubjectKind, SystemCapability, WorldEffect, WorldId, WorldKernel, WorldMailbox, WorldScaleIntentRef, agency_graph, commit_digest, derive_boundaries, derive_cover, derive_opportunities, @@ -736,8 +736,7 @@ mod tests { assert!(!graph.subjects.contains(&mirror.first)); let cover = derive_cover( snapshot.world_id, - snapshot.now, - 30, + TickIndex(0), &opportunities, &graph, CoverBudget { diff --git a/crates/ghostlight/src/controllers.rs b/crates/ghostlight/src/controllers.rs index 5d3e573..798309a 100644 --- a/crates/ghostlight/src/controllers.rs +++ b/crates/ghostlight/src/controllers.rs @@ -14699,8 +14699,7 @@ mod tests { let graph = mailbox.agency_graph().await.unwrap(); let cover = derive_cover( snapshot.world_id, - snapshot.now, - 60, + TickIndex(snapshot.now.0 / 60), &snapshot.opportunities, &graph, CoverBudget { diff --git a/crates/ghostlight/src/cover.rs b/crates/ghostlight/src/cover.rs index 02c0adb..5d1ce42 100644 --- a/crates/ghostlight/src/cover.rs +++ b/crates/ghostlight/src/cover.rs @@ -11,7 +11,6 @@ //! The cover is a partition, never a filter: every active subject is in exactly //! one cell every tick. The budget decides at what resolution, not whether. -use super::clock::FictionalMinutes; use super::{ControllerMode, DecisionOpportunity, SubjectId, WorldId}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -29,25 +28,18 @@ pub(crate) const AGENCY_STAR_THRESHOLD: usize = 16; /// the name, not by a second namespace. const COVER_NAMESPACE: &str = "ghostlight.cover.v1"; -/// Monotone tick index. Derived from the world clock, never stored. It is -/// threaded into every derived command id, so a resumed tick over the same -/// snapshot re-derives the same ids and the kernel's idempotency ledger answers -/// with the original receipts instead of double-committing. +/// A tick's ordinal: the number of clock advances the world had committed when +/// the tick opened. The driver reads it from the world and freezes it in the +/// tick document; it is never derived from `now`, so it does not depend on the +/// span. It is threaded into every derived command id, so a resumed tick +/// re-submits the same ids and the kernel's idempotency ledger answers with the +/// original receipts instead of double-committing. #[derive( Clone, Copy, Debug, PartialEq, Eq, PartialOrd, Ord, Hash, Serialize, Deserialize, Default, )] #[serde(transparent)] pub struct TickIndex(pub u64); -impl TickIndex { - /// Floor division by the span one `AdvanceTime` carries. The driver submits - /// the clock *after* the cells run, so every cell in one tick sees the same - /// `now` and therefore the same index. - pub(crate) fn of(now: FictionalMinutes, tick_minutes: u32) -> Self { - Self(now.0 / u64::from(tick_minutes.max(1))) - } -} - /// How a subject was represented this tick. Derived from the cell, carried /// nowhere in world state: representation is a compute budget, not world truth. #[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -254,7 +246,7 @@ impl AgencyGraph { } } -/// The partition. Pure over `(world, now, opportunities, graph, budget)`. +/// The partition. Pure over `(world, tick, opportunities, graph, budget)`. /// /// `opportunities` arrives in the kernel's one attention order (pressure, then /// attention debt, then id) and is never re-sorted and never filtered here: a @@ -262,13 +254,11 @@ impl AgencyGraph { /// is a fact about cognition rather than a scheduling preference. pub fn derive_cover( world: WorldId, - now: FictionalMinutes, - tick_minutes: u32, + tick: TickIndex, opportunities: &[DecisionOpportunity], graph: &AgencyGraph, budget: CoverBudget, ) -> Cover { - let tick = TickIndex::of(now, tick_minutes); let active: Vec<&DecisionOpportunity> = opportunities .iter() .filter(|opportunity| opportunity.controller_mode != ControllerMode::Human) @@ -601,8 +591,7 @@ mod tests { ) -> Cover { derive_cover( WorldId::nil_for_test(), - FictionalMinutes(tick * 60), - 60, + TickIndex(tick), opportunities, graph, budget, diff --git a/crates/ghostlight/src/lib.rs b/crates/ghostlight/src/lib.rs index ada2c6d..be64414 100644 --- a/crates/ghostlight/src/lib.rs +++ b/crates/ghostlight/src/lib.rs @@ -1609,6 +1609,19 @@ impl WorldKernel { )) } + /// How many clock advances the world has committed: the next tick's + /// ordinal, held by the world and read from its journal. Separate from + /// `snapshot` for the reason `opportunities` is: only the tick driver + /// reads it. + fn clock_advances(&self) -> Result { + self.journal.ensure_healthy()?; + Ok(self + .journal + .commits() + .filter(|commit| matches!(commit.effect, WorldEffect::TimeAdvanced { .. })) + .count() as u64) + } + /// The scheduler's adjacency projection. Separate from `snapshot` for the /// same reason `operator_log` is: it is wider than any subject view, so a /// `WorldSnapshot` field carrying it would be reachable from every prompt diff --git a/crates/ghostlight/src/mailbox.rs b/crates/ghostlight/src/mailbox.rs index 4c162ce..c9b232b 100644 --- a/crates/ghostlight/src/mailbox.rs +++ b/crates/ghostlight/src/mailbox.rs @@ -87,6 +87,11 @@ enum Request { Opportunities { reply: oneshot::Sender, KernelError>>, }, + /// The tick ordinal's one door: how many clock advances the world has + /// committed. Held by the tick driver, as `Opportunities` is. + ClockAdvances { + reply: oneshot::Sender>, + }, ControllerReceipt { command_id: CommandId, opportunity: super::DecisionOpportunity, @@ -506,6 +511,22 @@ impl WorldMailbox { .map_err(MailboxError::Kernel) } + /// The tick driver's ordinal: how many clock advances the world has + /// committed. It is on `WorldMailbox` and on no controller port, as + /// `opportunities` is. + pub async fn clock_advances(&self) -> Result { + let (reply, response) = oneshot::channel(); + self.sender + .send(Request::ClockAdvances { reply }) + .await + .map_err(|_| MailboxError::Unavailable)?; + + response + .await + .map_err(|_| MailboxError::Unavailable)? + .map_err(MailboxError::Kernel) + } + pub async fn operator_log(&self) -> Result, MailboxError> { let (reply, response) = oneshot::channel(); self.sender @@ -988,6 +1009,19 @@ async fn run_owner(mut owned: OwnedWorld, mut receiver: mpsc::Receiver) return; } } + Request::ClockAdvances { reply } => { + let result = match &owned { + OwnedWorld::Empty(_) => Err(KernelError::WorldNotCreated), + OwnedWorld::Live(kernel) => kernel.clock_advances(), + }; + let owner_must_stop = result + .as_ref() + .is_err_and(KernelError::requires_owner_restart); + let _ = reply.send(result); + if owner_must_stop { + return; + } + } Request::ControllerReceipt { command_id, opportunity, diff --git a/crates/ghostlight/src/verse/run.rs b/crates/ghostlight/src/verse/run.rs index 3dc83dd..acb0200 100644 --- a/crates/ghostlight/src/verse/run.rs +++ b/crates/ghostlight/src/verse/run.rs @@ -1,17 +1,22 @@ //! The manual verse run: the tick driver and its record. //! //! One invocation advances the world tick by tick. A tick reads the world, -//! derives the budgeted cover, runs each cell through the controller organ, -//! advances the clock once by the tick span, and checkpoints. The driver owns -//! the order of those steps and nothing else: cells decide what factions do, -//! the recording port owns spend, the kernel owns the world. +//! opens (or finds open) its tick document, runs each frozen cell through the +//! controller organ, advances the clock once by the frozen span, and +//! checkpoints. The driver owns the order of those steps and nothing else: +//! cells decide what factions do, the recording port owns spend, the kernel +//! owns time. //! -//! The world's own clock is the resume point. A tick whose cells did not all -//! finish never advances the clock, so a later run derives the same tick index, -//! the same cells and the same command ids, the controller-work rows pick up -//! where they stopped, and the kernel answers every re-submission with the -//! original receipt. A run record therefore reports a run and is never read to -//! resume one. +//! A tick is named by the world's clock-advance count, never by `now`. The +//! resume point is the open tick document (world, ordinal), written before the +//! tick's first cell and read by any later run: it froze the span, the cover +//! budget and the cells when the tick opened, so a run under another span or +//! budget finishes the open tick as it was opened and applies its own config +//! from the next tick it opens. A tick whose cells did not all finish never +//! advances the clock, so the same ordinal, cells and command ids come back, +//! the controller-work rows pick up where they stopped, and the kernel answers +//! every re-submission with the original receipt. A run record reports a run +//! and is never read to resume one. //! //! A run starts only when it is invoked: nothing here waits, schedules or //! repeats. @@ -21,10 +26,12 @@ use crate::controllers::{ CellRun, ControllerError, ControllerPendingReason, ControllerRunner, InferenceFaultClass, NarrativeRun, OperationalRun, SubmissionDisposition, }; -use crate::cover::{Cell, Constituent, CoverBudget, CoverBudgetError, derive_cover}; +use crate::cover::{ + Cell, CellId, Constituent, CoverBudget, CoverBudgetError, TickIndex, derive_cover, +}; use crate::{ - CommandId, DecisionOpportunity, MailboxError, OperatorEvent, RenderPath, SubmitReceipt, - TickMinutes, WorldId, WorldMailbox, + AgencyGraph, CommandId, DecisionOpportunity, MailboxError, OperatorEvent, RenderPath, + SubjectId, SubmitReceipt, TickMinutes, WorldId, WorldMailbox, WorldSnapshot, }; use chrono::Utc; use cultcache_rs::{CacheBackingStore, CultCacheEnvelope, OwnedRedbMessagePackBackingStore}; @@ -35,6 +42,9 @@ use thiserror::Error; /// The run record document's schema. pub const RUN_RECORD_SCHEMA: &str = "ghostlight.verse_run.v1"; const RUN_ROW: &str = "verse_run.v1"; +/// The tick document's schema. +pub const TICK_RECORD_SCHEMA: &str = "ghostlight.verse_tick.v1"; +const TICK_ROW: &str = "verse_tick.v1"; const CLOCK_NAMESPACE: &str = "ghostlight.verse.clock"; /// Why a run could not be recorded or started. Every variant names a kind of @@ -98,11 +108,12 @@ pub struct Refusal { } /// A tick as the run left it. A closed tick has all its cells run and the -/// clock advanced by the run's span. The tick a run ended inside is recorded -/// too, unclosed, so what its cells committed is not lost; a later run derives -/// that tick again from the world's clock. +/// clock advanced by the tick's frozen span. The tick a run ended inside is +/// recorded too, unclosed, so what its cells committed is not lost; a later +/// run finds that tick's document open and works in it. #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] pub struct TickCheckpoint { + /// The tick's ordinal, as its tick document names it. pub tick: u64, pub closed: bool, pub cells: u32, @@ -122,6 +133,58 @@ pub struct RecordedCoverBudget { pub urgency_slots: u16, } +/// A cell as the tick document froze it: the partition's identity and its +/// members, decided once when the tick opened. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct FrozenCell { + pub id: CellId, + /// True for a `Cell::Group`, even one a refresh has left a single member. + pub group: bool, + pub members: Vec, +} + +impl FrozenCell { + fn of(cell: &Cell) -> Self { + let (id, group) = match cell { + Cell::Singleton { id, .. } => (*id, false), + Cell::Group { id, .. } => (*id, true), + }; + Self { + id, + group, + members: cell.members().iter().map(|member| member.subject).collect(), + } + } +} + +/// What closed a tick. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub enum TickClose { + /// The run with this sequence submitted the tick's clock advance. + Clock { run: u64 }, + /// A later run found the world's ordinal past the tick with the document + /// still open: its clock landed and the close was lost, or another clock + /// writer moved time. + World, +} + +/// What a tick decided when it opened. Document `ghostlight.verse_tick.v1`, +/// keyed (world, ordinal). Written once before the tick's first cell, closed +/// once, never reopened or deleted. +#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +pub struct VerseTick { + pub world_id: WorldId, + pub ordinal: u64, + pub opened_now: u64, + pub opened_revision: u64, + pub span_minutes: u32, + pub cover_budget: RecordedCoverBudget, + pub oversubscribed: bool, + pub cells: Vec, + pub opened_by_run: u64, + pub closed: Option, +} + /// One invocation of the verse runner: what it was asked, what it did. /// Document `ghostlight.verse_run.v1`, keyed (world, run sequence). #[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] @@ -235,8 +298,19 @@ fn row_key(world: WorldId, sequence: u64) -> String { format!("{}/{sequence:020}", world.key()) } -/// Run records in one CultCache file. A run is saved when it begins and again -/// each time it moves, so a row is replaced by its own later state. +fn tick_key(world: WorldId, ordinal: u64) -> String { + format!("{}/{ordinal:020}", world.key()) +} + +/// Every row of the file, split by type. +struct Rows { + runs: Vec, + ticks: Vec, +} + +/// Run records and tick documents in one CultCache file. A run is saved when +/// it begins and again each time it moves, so a row is replaced by its own +/// later state. A tick is opened once and closed once. pub struct VerseRunStore { store: Mutex, } @@ -257,27 +331,40 @@ impl VerseRunStore { Ok(opened) } - fn records(&self) -> Result, VerseRunError> { + fn records(&self) -> Result { let rows = self .store .lock() .map_err(|_| VerseRunError::Store)? .pull_all() .map_err(|_| VerseRunError::Store)?; - let mut records = Vec::new(); + let mut runs = Vec::new(); + let mut ticks = Vec::new(); for row in &rows { - if row.r#type != RUN_ROW || row.schema_id.as_deref() != Some(RUN_RECORD_SCHEMA) { - return Err(VerseRunError::Corrupt); + match (row.r#type.as_str(), row.schema_id.as_deref()) { + (RUN_ROW, Some(RUN_RECORD_SCHEMA)) => { + let record: VerseRunRecord = + canonical(row).map_err(|_| VerseRunError::Corrupt)?; + if row.key != row_key(record.world_id, record.sequence) + || record.sequence == 0 + { + return Err(VerseRunError::Corrupt); + } + runs.push(record); + } + (TICK_ROW, Some(TICK_RECORD_SCHEMA)) => { + let tick: VerseTick = canonical(row).map_err(|_| VerseRunError::Corrupt)?; + if row.key != tick_key(tick.world_id, tick.ordinal) { + return Err(VerseRunError::Corrupt); + } + ticks.push(tick); + } + _ => return Err(VerseRunError::Corrupt), } - let record: VerseRunRecord = - canonical(row).map_err(|_| VerseRunError::Corrupt)?; - if row.key != row_key(record.world_id, record.sequence) || record.sequence == 0 { - return Err(VerseRunError::Corrupt); - } - records.push(record); } - records.sort_by_key(|record| (record.world_id.key(), record.sequence)); - Ok(records) + runs.sort_by_key(|record| (record.world_id.key(), record.sequence)); + ticks.sort_by_key(|tick| (tick.world_id.key(), tick.ordinal)); + Ok(Rows { runs, ticks }) } /// The sequence the next run of this world must carry. Sequences count from @@ -285,6 +372,7 @@ impl VerseRunStore { pub fn next_sequence(&self, world: WorldId) -> Result { Ok(self .records()? + .runs .iter() .filter(|record| record.world_id == world) .map(|record| record.sequence) @@ -297,11 +385,72 @@ impl VerseRunStore { pub fn runs(&self, world: WorldId) -> Result, VerseRunError> { Ok(self .records()? + .runs .into_iter() .filter(|record| record.world_id == world) .collect()) } + /// The tick document of (world, ordinal), when one was opened. + pub fn tick(&self, world: WorldId, ordinal: u64) -> Result, VerseRunError> { + Ok(self + .records()? + .ticks + .into_iter() + .find(|tick| tick.world_id == world && tick.ordinal == ordinal)) + } + + /// Writes a tick document that does not exist yet, open. + pub fn open_tick(&self, tick: &VerseTick) -> Result<(), VerseRunError> { + if tick.closed.is_some() || self.tick(tick.world_id, tick.ordinal)?.is_some() { + return Err(VerseRunError::OutOfSequence); + } + self.put_tick(tick) + } + + /// Closes an open tick document. + pub fn close_tick( + &self, + world: WorldId, + ordinal: u64, + close: TickClose, + ) -> Result<(), VerseRunError> { + let Some(mut tick) = self.tick(world, ordinal)? else { + return Err(VerseRunError::OutOfSequence); + }; + if tick.closed.is_some() { + return Err(VerseRunError::OutOfSequence); + } + tick.closed = Some(close); + self.put_tick(&tick) + } + + /// Closes, as the world's, every open tick of the world below `ordinal`. + pub fn close_ticks_before(&self, world: WorldId, ordinal: u64) -> Result<(), VerseRunError> { + for mut tick in self.records()?.ticks { + if tick.world_id == world && tick.ordinal < ordinal && tick.closed.is_none() { + tick.closed = Some(TickClose::World); + self.put_tick(&tick)?; + } + } + Ok(()) + } + + fn put_tick(&self, tick: &VerseTick) -> Result<(), VerseRunError> { + let row = CultCacheEnvelope { + key: tick_key(tick.world_id, tick.ordinal), + r#type: TICK_ROW.into(), + payload: rmp_serde::to_vec_named(tick).map_err(|_| VerseRunError::Store)?, + stored_at: Utc::now().to_rfc3339(), + schema_id: Some(TICK_RECORD_SCHEMA.into()), + }; + self.store + .lock() + .map_err(|_| VerseRunError::Store)? + .push(&row) + .map_err(|_| VerseRunError::Store) + } + /// Writes the record: the next run of its world, or a run already saved. pub fn save(&self, record: &VerseRunRecord) -> Result<(), VerseRunError> { if record.sequence == 0 || record.sequence > self.next_sequence(record.world_id)? { @@ -404,36 +553,38 @@ fn absorb( } } -/// The cell with each member's opportunity read fresh. The cover is a -/// partition of subjects, fixed for the tick; an opportunity is the world's -/// current binding for a subject, and an earlier cell's act can move it (a -/// word spoken in a shared room changes what its listeners know). A subject -/// that no longer holds an opportunity leaves the cell, and an empty cell is -/// gone. -fn refreshed(cell: &Cell, fresh: &[DecisionOpportunity]) -> Option { - let current = |member: &Constituent| { - fresh - .iter() - .find(|opportunity| opportunity.scope == member.opportunity.scope) - .map(|opportunity| Constituent { - subject: member.subject, - opportunity: opportunity.clone(), - }) - }; - match cell { - Cell::Singleton { id, tick, member } => current(member).map(|member| Cell::Singleton { - id: *id, - tick: *tick, +/// The frozen cell with each member bound to the opportunity the world holds +/// for that subject now. The partition is fixed when the tick opens; an +/// opportunity is the world's current binding for a subject, and an earlier +/// cell's act can move it (a word spoken in a shared room changes what its +/// listeners know). A subject that no longer holds an opportunity leaves the +/// cell, and an empty cell is gone. +fn refreshed(frozen: &FrozenCell, tick: TickIndex, fresh: &[DecisionOpportunity]) -> Option { + let members: Vec = frozen + .members + .iter() + .filter_map(|subject| { + fresh + .iter() + .find(|opportunity| opportunity.scope.subject_id == *subject) + .map(|opportunity| Constituent { + subject: *subject, + opportunity: opportunity.clone(), + }) + }) + .collect(); + if frozen.group { + (!members.is_empty()).then(|| Cell::Group { + id: frozen.id, + tick, + members, + }) + } else { + members.into_iter().next().map(|member| Cell::Singleton { + id: frozen.id, + tick, member, - }), - Cell::Group { id, tick, members } => { - let members: Vec = members.iter().filter_map(current).collect(); - (!members.is_empty()).then(|| Cell::Group { - id: *id, - tick: *tick, - members, - }) - } + }) } } @@ -493,6 +644,8 @@ pub async fn run_manual( mailbox, runner, port, + store, + sequence, config, cover_budget, spent_before, @@ -535,40 +688,60 @@ pub fn call_run_id(sequence: u64) -> super::RunId { .expect("a decimal sequence is a canonical run id") } -/// One tick. The report is absent only when the world could not be read at all. +/// The id of a tick's clock advance: the world and the ordinal, nothing the +/// run holds. A retry in the same tick has the same body and is answered +/// `AlreadyApplied`. +pub(crate) fn clock_command_id(world: WorldId, ordinal: u64) -> CommandId { + CommandId::derived(CLOCK_NAMESPACE, &[&world.text(), &ordinal.to_string()]) +} + +/// One tick. The report is absent only when the world or the tick document +/// could not be read at all. +#[allow(clippy::too_many_arguments)] async fn run_tick( mailbox: &WorldMailbox, runner: &ControllerRunner, port: &RecordingInferencePort, + store: &VerseRunStore, + sequence: u64, config: &VerseRunConfig, cover_budget: CoverBudget, spent_before: u64, ) -> (TickEnd, Option) { let read = async { let snapshot = mailbox.snapshot().await?; + let ordinal = mailbox.clock_advances().await?; let opportunities = mailbox.opportunities().await?; let graph = mailbox.agency_graph().await?; - Ok::<_, MailboxError>((snapshot, opportunities, graph)) + Ok::<_, MailboxError>((snapshot, ordinal, opportunities, graph)) } .await; - let Ok((snapshot, opportunities, graph)) = read else { + let Ok((snapshot, ordinal, opportunities, graph)) = read else { return (TickEnd::Open(failed("world_unavailable")), None); }; - let span = config.tick_span; - let cover = derive_cover( - snapshot.world_id, - snapshot.now, - span.minutes(), + let world = snapshot.world_id; + let Ok(tick) = open_or_find( + store, + ordinal, + sequence, + config, + cover_budget, + &snapshot, &opportunities, &graph, - cover_budget, - ); + ) else { + return (TickEnd::Open(failed("run_store")), None); + }; + let index = TickIndex(ordinal); + let Some(span) = TickMinutes::new(tick.span_minutes) else { + return (TickEnd::Open(failed("run_store")), None); + }; let mut committed = Vec::new(); let mut refusals = Vec::new(); let mut open: Option = None; - for (index, cell) in cover.cells.iter().enumerate() { - let index = index as u32; + for (position, frozen) in tick.cells.iter().enumerate() { + let position = position as u32; let fresh = match mailbox.opportunities().await { Ok(fresh) => fresh, Err(_) => { @@ -576,16 +749,16 @@ async fn run_tick( break; } }; - let Some(cell) = refreshed(cell, &fresh) else { + let Some(cell) = refreshed(frozen, index, &fresh) else { refusals.push(Refusal { - cell: index, + cell: position, reason: RefusalReason::OpportunityGone, }); continue; }; match runner.run_cell(&cell).await { Ok(run) => { - if let Some(code) = absorb(run, index, &mut committed, &mut refusals) { + if let Some(code) = absorb(run, position, &mut committed, &mut refusals) { open = Some(failed(code)); break; } @@ -600,7 +773,7 @@ async fn run_tick( break; } CellEnd::Refused(reason) => refusals.push(Refusal { - cell: index, + cell: position, reason, }), }, @@ -609,21 +782,26 @@ async fn run_tick( let closed = open.is_none(); if closed { - let clock = CommandId::derived( - CLOCK_NAMESPACE, - &[&snapshot.world_id.text(), &cover.tick.0.to_string()], - ); - if mailbox.submit_clock(clock, span).await.is_err() { + if mailbox + .submit_clock(clock_command_id(world, ordinal), span) + .await + .is_err() + { open = Some(failed("clock_refused")); + } else if store + .close_tick(world, ordinal, TickClose::Clock { run: sequence }) + .is_err() + { + open = Some(failed("run_store")); } } let spent = port.spent(); let report = TickReport { checkpoint: TickCheckpoint { - tick: cover.tick.0, + tick: ordinal, closed: open.is_none(), - cells: cover.cells.len() as u32, + cells: tick.cells.len() as u32, committed, refusals, tokens: spent.saturating_sub(spent_before), @@ -643,5 +821,50 @@ async fn run_tick( (open.map_or(TickEnd::Closed, TickEnd::Open), Some(report)) } +/// The tick document for `ordinal`: the open one a run left, or a new one +/// frozen from a cover derived now. An open tick the world has moved past is +/// closed as the world's first. A document already closed at the world's +/// current ordinal is a store that disagrees with the world. +#[allow(clippy::too_many_arguments)] +fn open_or_find( + store: &VerseRunStore, + ordinal: u64, + sequence: u64, + config: &VerseRunConfig, + cover_budget: CoverBudget, + snapshot: &WorldSnapshot, + opportunities: &[DecisionOpportunity], + graph: &AgencyGraph, +) -> Result { + let world = snapshot.world_id; + store.close_ticks_before(world, ordinal)?; + if let Some(tick) = store.tick(world, ordinal)? { + return if tick.closed.is_none() { + Ok(tick) + } else { + Err(VerseRunError::OutOfSequence) + }; + } + let cover = derive_cover(world, TickIndex(ordinal), opportunities, graph, cover_budget); + let tick = VerseTick { + world_id: world, + ordinal, + opened_now: snapshot.now.0, + opened_revision: snapshot.revision, + span_minutes: config.tick_span.minutes(), + cover_budget: RecordedCoverBudget { + cells: cover_budget.cells, + constituent_cap: cover_budget.constituent_cap, + urgency_slots: cover_budget.urgency_slots, + }, + oversubscribed: cover.oversubscribed, + cells: cover.cells.iter().map(FrozenCell::of).collect(), + opened_by_run: sequence, + closed: None, + }; + store.open_tick(&tick)?; + Ok(tick) +} + #[cfg(test)] mod tests; diff --git a/crates/ghostlight/src/verse/run/tests.rs b/crates/ghostlight/src/verse/run/tests.rs index d4e378c..79f2f83 100644 --- a/crates/ghostlight/src/verse/run/tests.rs +++ b/crates/ghostlight/src/verse/run/tests.rs @@ -220,6 +220,17 @@ impl Harness { cap: u64, ticks: u32, observe: impl FnMut(&TickReport) -> ControlFlow<()>, + ) -> RunOutcome { + self.run_under(inner, cap, config(ticks), observe).await + } + + /// One run under the given config, recorded under the next sequence. + async fn run_under( + &self, + inner: Arc, + cap: u64, + config: VerseRunConfig, + observe: impl FnMut(&TickReport) -> ControlFlow<()>, ) -> RunOutcome { let world = self.mailbox.snapshot().await.unwrap(); let sequence = self.runs.next_sequence(world.world_id).unwrap(); @@ -241,7 +252,7 @@ impl Harness { &runner, &port, &self.runs, - &config(ticks), + &config, sequence, observe, ) @@ -254,6 +265,15 @@ impl Harness { .await } + async fn run_quietly_under( + &self, + inner: Arc, + config: VerseRunConfig, + ) -> RunOutcome { + self.run_under(inner, u64::MAX, config, |_| ControlFlow::Continue(())) + .await + } + async fn now(&self) -> u64 { self.mailbox.snapshot().await.unwrap().now.0 } @@ -279,8 +299,6 @@ impl Harness { async fn scripted_run_commits_and_checkpoints_every_tick() { let (_dir, verse) = Harness::fresh().await; let world = verse.mailbox.snapshot().await.unwrap(); - let opportunities = verse.mailbox.opportunities().await.unwrap(); - let graph = verse.mailbox.agency_graph().await.unwrap(); let port = ScriptedPort::speaking(); let mut reports = Vec::new(); let outcome = verse @@ -300,25 +318,28 @@ async fn scripted_run_commits_and_checkpoints_every_tick() { assert_eq!(verse.acts().await, 6); assert_eq!(port.calls(), 6); - // Every committed command id is the one the cover derives for that cell, - // in cell order. + // Every committed command id is the one the tick document's frozen cells + // derive, in cell order, and the document is closed by this run's clock. for checkpoint in &outcome.record.checkpoints { - let cover = derive_cover( - world.world_id, - FictionalMinutes(checkpoint.tick * u64::from(SPAN)), - SPAN, - &opportunities, - &graph, - two_cells(), - ); - let expected: Vec = cover + let tick = verse + .runs + .tick(world.world_id, checkpoint.tick) + .unwrap() + .unwrap(); + assert_eq!(tick.closed, Some(TickClose::Clock { run: 1 })); + assert_eq!(tick.opened_by_run, 1); + assert_eq!(tick.span_minutes, SPAN); + let expected: Vec = tick .cells .iter() - .map(|cell| match cell { - Cell::Singleton { id, tick, member } => { - CommandId::for_cell_constituent(world.world_id, *id, member.subject, *tick) - } - Cell::Group { .. } => panic!("two factions under two cells are singletons"), + .map(|cell| { + assert!(!cell.group, "two factions under two cells are singletons"); + CommandId::for_cell_constituent( + world.world_id, + cell.id, + cell.members[0], + TickIndex(checkpoint.tick), + ) }) .collect(); assert_eq!(expected.len(), 2); @@ -441,6 +462,10 @@ async fn budget_exhausted_stops_before_the_clock_and_resumes() { .await; assert_eq!(resumed.record.status, RunStatus::Completed); assert_eq!(resumed.record.sequence, 2); + // The resumed tick is the document run 1 opened, closed by run 2. + let opened = verse.runs.tick(world.world_id, 0).unwrap().unwrap(); + assert_eq!(opened.opened_by_run, 1); + assert_eq!(opened.closed, Some(TickClose::Clock { run: 2 })); let ticks: Vec = resumed.record.checkpoints.iter().map(|c| c.tick).collect(); assert_eq!(ticks, vec![0, 1], "the open tick is the first this run closes"); assert_eq!(verse.now().await, 2 * u64::from(SPAN)); @@ -771,16 +796,14 @@ async fn a_cell_is_run_on_what_the_world_holds_for_its_members_now() { let world = verse.mailbox.snapshot().await.unwrap().world_id; let singles = derive_cover( world, - FictionalMinutes(0), - SPAN, + TickIndex(0), &opportunities, &graph, two_cells(), ); let group = derive_cover( world, - FictionalMinutes(0), - SPAN, + TickIndex(0), &opportunities, &graph, CoverBudget { @@ -803,13 +826,16 @@ async fn a_cell_is_run_on_what_the_world_holds_for_its_members_now() { let Cell::Singleton { member: held, .. } = cell else { panic!("two subjects under two cells are singletons") }; - let Some(Cell::Singleton { member, .. }) = refreshed(cell, &moved) else { + let frozen = FrozenCell::of(cell); + let Some(Cell::Singleton { member, .. }) = refreshed(&frozen, TickIndex(0), &moved) else { panic!("a subject that still has an opportunity keeps its cell") }; assert_ne!(member.opportunity, held.opportunity); assert!(moved.contains(&member.opportunity)); } - let Some(Cell::Group { members: now, .. }) = refreshed(grouped, &moved) else { + let frozen_group = FrozenCell::of(grouped); + let Some(Cell::Group { members: now, .. }) = refreshed(&frozen_group, TickIndex(0), &moved) + else { panic!("a group keeps its members") }; assert_eq!(now.len(), 2); @@ -817,7 +843,8 @@ async fn a_cell_is_run_on_what_the_world_holds_for_its_members_now() { // A subject with no opportunity leaves its cell, and an empty cell is gone. let one = &opportunities[..1]; - let Some(Cell::Group { members: left, .. }) = refreshed(grouped, one) else { + let Some(Cell::Group { members: left, .. }) = refreshed(&frozen_group, TickIndex(0), one) + else { panic!("one member remains") }; assert_eq!(left.len(), 1); @@ -825,10 +852,10 @@ async fn a_cell_is_run_on_what_the_world_holds_for_its_members_now() { let gone = singles .cells .iter() - .filter(|cell| refreshed(cell, one).is_none()) + .filter(|cell| refreshed(&FrozenCell::of(cell), TickIndex(0), one).is_none()) .count(); assert_eq!(gone, 1); - assert!(refreshed(grouped, &[]).is_none()); + assert!(refreshed(&frozen_group, TickIndex(0), &[]).is_none()); } #[test] @@ -1038,3 +1065,352 @@ fn the_run_store_refuses_rows_that_are_not_canonical_run_records() { ); } } + +/// A port that answers like `ScriptedPort::speaking` and fails the `at`th +/// provider call it sees, retryably. +struct FaultAt { + inner: Arc, + at: usize, + seen: AtomicUsize, +} + +#[async_trait] +impl InferencePort for FaultAt { + fn prepare(&self, request: InferenceRequest) -> Result { + self.inner.prepare(request) + } + + async fn infer(&self, request: PreparedInference) -> Result { + if self.seen.fetch_add(1, Ordering::SeqCst) == self.at { + return Err(InferenceFault::retryable("the provider is busy")); + } + self.inner.infer(request).await + } +} + +fn fault_at(at: usize) -> Arc { + Arc::new(FaultAt { + inner: ScriptedPort::speaking(), + at, + seen: AtomicUsize::new(0), + }) +} + +fn config_of(ticks: u32, span: u32, cells: u16) -> VerseRunConfig { + VerseRunConfig { + ticks, + tick_span: TickMinutes::new(span).unwrap(), + cover: CoverBudget { + cells, + constituent_cap: 8, + urgency_slots: 0, + }, + } +} + +/// Run 1 closes tick 0 (two cells, 60 minutes). Run 2 commits one faction's act +/// in tick 1 and ends pending. Run 3 resumes under `resume`. Asserts that each +/// faction acted exactly once between the end of run 1 and the end of run 3. +async fn resumed_after_a_pending_cell( + resume: VerseRunConfig, +) -> (tempfile::TempDir, Harness, RunOutcome) { + let (dir, verse) = Harness::fresh().await; + let first = verse + .run_quietly_under(ScriptedPort::speaking(), config_of(1, 60, 2)) + .await; + assert_eq!(first.record.status, RunStatus::Completed); + let mark = verse.mailbox.snapshot().await.unwrap().revision; + + let ended = verse + .run_quietly_under(fault_at(1), config_of(1, 60, 2)) + .await; + assert_eq!( + ended.record.status, + RunStatus::Failed { + reason: "inference_retryable".into() + } + ); + let opened: Vec<_> = verse + .mailbox + .operator_log() + .await + .unwrap() + .into_iter() + .filter(|event| event.revision > mark) + .map(|event| event.speaker) + .collect(); + assert_eq!(opened.len(), 1, "one faction acted in the open tick"); + + let resumed = verse + .run_quietly_under(ScriptedPort::speaking(), resume) + .await; + assert_eq!(resumed.record.status, RunStatus::Completed); + let since: Vec<_> = verse + .mailbox + .operator_log() + .await + .unwrap() + .into_iter() + .filter(|event| event.revision > mark) + .map(|event| event.speaker) + .collect(); + assert_eq!(since.len(), 2, "each faction acted once in the open tick"); + assert_eq!( + since.iter().filter(|speaker| **speaker == opened[0]).count(), + 1, + "the faction that acted before the pause did not act again" + ); + (dir, verse, resumed) +} + +#[tokio::test] +async fn a_resume_under_another_span_finishes_the_open_tick_under_its_own() { + let (_dir, verse, resumed) = resumed_after_a_pending_cell(config_of(1, 30, 2)).await; + // Tick 1's clock advance is the frozen 60 minutes, not the resuming 30. + assert_eq!(verse.now().await, 120); + let world = verse.mailbox.snapshot().await.unwrap().world_id; + let tick = verse.runs.tick(world, 1).unwrap().unwrap(); + assert_eq!(tick.span_minutes, 60); + assert_eq!(tick.opened_by_run, 2); + assert_eq!(tick.closed, Some(TickClose::Clock { run: 3 })); + let [closing] = resumed.record.checkpoints.as_slice() else { + panic!("the resuming run closed one tick") + }; + assert_eq!(closing.tick, 1); + assert!(closing.closed); +} + +#[tokio::test] +async fn a_resume_under_another_cover_budget_keeps_the_frozen_cells() { + // One cell would be a group of both factions; the open tick keeps its two + // singletons. + let (_dir, verse, resumed) = resumed_after_a_pending_cell(config_of(1, 60, 1)).await; + let world = verse.mailbox.snapshot().await.unwrap().world_id; + let tick = verse.runs.tick(world, 1).unwrap().unwrap(); + assert_eq!(tick.cover_budget.cells, 2); + assert_eq!(tick.cells.len(), 2); + assert!(tick.cells.iter().all(|cell| !cell.group)); + assert_eq!(resumed.record.checkpoints[0].cells, 2); + assert_eq!(verse.now().await, 120); +} + +#[tokio::test] +async fn a_new_span_opens_a_new_tick_and_never_reuses_a_closed_one() { + let (_dir, verse) = Harness::fresh().await; + let first = verse + .run_quietly_under(ScriptedPort::speaking(), config_of(2, 60, 2)) + .await; + assert_eq!(first.record.status, RunStatus::Completed); + assert_eq!(verse.now().await, 120); + let acts = verse.acts().await; + + let port = ScriptedPort::speaking(); + let second = verse + .run_quietly_under(port.clone(), config_of(2, 120, 2)) + .await; + assert_eq!(second.record.status, RunStatus::Completed); + let ordinals: Vec = second.record.checkpoints.iter().map(|c| c.tick).collect(); + assert_eq!(ordinals, vec![2, 3]); + assert_eq!(verse.now().await, 120 + 240); + assert_eq!(verse.acts().await, acts + 4); + assert_eq!(port.calls(), 4); + let before: Vec = first + .record + .checkpoints + .iter() + .flat_map(|c| c.committed.clone()) + .collect(); + for checkpoint in &second.record.checkpoints { + assert!(checkpoint.committed.iter().all(|id| !before.contains(id))); + } +} + +#[tokio::test] +async fn a_closed_ticks_clock_is_answered_from_the_kernels_ledger() { + let (_dir, verse) = Harness::fresh().await; + verse.run_quietly(ScriptedPort::speaking(), u64::MAX, 2).await; + let world = verse.mailbox.snapshot().await.unwrap().world_id; + assert_eq!(verse.now().await, 120); + for ordinal in 0..2 { + let receipt = verse + .mailbox + .submit_clock( + clock_command_id(world, ordinal), + TickMinutes::new(SPAN).unwrap(), + ) + .await + .unwrap(); + assert!( + matches!(receipt, SubmitReceipt::AlreadyApplied(_)), + "the clock of tick {ordinal} was not the id the run submitted" + ); + } + assert_eq!(verse.now().await, 120); +} + +#[tokio::test] +async fn an_open_tick_the_world_moved_past_is_closed_by_the_world() { + let (_probe_dir, probe) = Harness::fresh().await; + let measuring = ScriptedPort::speaking(); + probe.run_quietly(measuring.clone(), u64::MAX, 1).await; + let first_bound = measuring.bounds()[0]; + + let (_dir, verse) = Harness::fresh().await; + let world = verse.mailbox.snapshot().await.unwrap().world_id; + let ended = verse + .run_under( + ScriptedPort::speaking(), + first_bound + 64, + config(1), + |_| ControlFlow::Continue(()), + ) + .await; + assert_eq!(ended.record.status, RunStatus::BudgetExhausted); + assert_eq!(verse.acts().await, 1); + // Another writer moves time without closing the tick. + verse + .mailbox + .submit_clock(CommandId::new(), TickMinutes::new(SPAN).unwrap()) + .await + .unwrap(); + assert_eq!(verse.now().await, 60); + + let resumed = verse + .run_quietly(ScriptedPort::speaking(), u64::MAX, 1) + .await; + assert_eq!(resumed.record.status, RunStatus::Completed); + let abandoned = verse.runs.tick(world, 0).unwrap().unwrap(); + assert_eq!(abandoned.closed, Some(TickClose::World)); + let next = verse.runs.tick(world, 1).unwrap().unwrap(); + assert_eq!(next.opened_by_run, 2); + assert_eq!(next.closed, Some(TickClose::Clock { run: 2 })); + let [checkpoint] = resumed.record.checkpoints.as_slice() else { + panic!("one tick, one checkpoint") + }; + assert_eq!(checkpoint.tick, 1); + assert_eq!(checkpoint.committed.len(), 2, "a fresh cover ran both factions"); + assert_eq!(verse.acts().await, 3); + assert_eq!(verse.now().await, 120); + // No faction has two acts in one ordinal: ordinal 1 opened after run 1's + // act, so its acts are the ones past the tick's opening revision. + let log = verse.mailbox.operator_log().await.unwrap(); + for speaker in log.iter().map(|event| event.speaker) { + let in_first = log + .iter() + .filter(|event| event.speaker == speaker && event.revision <= next.opened_revision) + .count(); + let in_second = log + .iter() + .filter(|event| event.speaker == speaker && event.revision > next.opened_revision) + .count(); + assert!(in_first <= 1 && in_second <= 1); + } +} + +fn tick_doc(world: WorldId, ordinal: u64) -> VerseTick { + VerseTick { + world_id: world, + ordinal, + opened_now: 0, + opened_revision: 3, + span_minutes: SPAN, + cover_budget: RecordedCoverBudget { + cells: 2, + constituent_cap: 8, + urgency_slots: 0, + }, + oversubscribed: false, + cells: Vec::new(), + opened_by_run: 1, + closed: None, + } +} + +#[test] +fn the_tick_store_opens_once_closes_once_and_refuses_non_canonical_rows() { + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("runs.cc"); + let store = VerseRunStore::open(&path).unwrap(); + let world = WorldId::nil_for_test(); + let other = WorldId::issue(); + + store.open_tick(&tick_doc(world, 0)).unwrap(); + assert!(matches!( + store.open_tick(&tick_doc(world, 0)), + Err(VerseRunError::OutOfSequence) + )); + // A tick opened already closed is not an open. + let mut born_closed = tick_doc(world, 5); + born_closed.closed = Some(TickClose::World); + assert!(matches!( + store.open_tick(&born_closed), + Err(VerseRunError::OutOfSequence) + )); + assert!(matches!( + store.close_tick(world, 9, TickClose::World), + Err(VerseRunError::OutOfSequence) + )); + store + .close_tick(world, 0, TickClose::Clock { run: 4 }) + .unwrap(); + assert!(matches!( + store.close_tick(world, 0, TickClose::World), + Err(VerseRunError::OutOfSequence) + )); + + // The world closes the open ticks below an ordinal and no others. + for ordinal in 1..4 { + store.open_tick(&tick_doc(world, ordinal)).unwrap(); + } + store.open_tick(&tick_doc(other, 1)).unwrap(); + store.close_ticks_before(world, 3).unwrap(); + let closed_by = |store: &VerseRunStore, world, ordinal| { + store.tick(world, ordinal).unwrap().unwrap().closed + }; + assert_eq!(closed_by(&store, world, 0), Some(TickClose::Clock { run: 4 })); + assert_eq!(closed_by(&store, world, 1), Some(TickClose::World)); + assert_eq!(closed_by(&store, world, 2), Some(TickClose::World)); + assert_eq!(closed_by(&store, world, 3), None); + assert_eq!(closed_by(&store, other, 1), None); + assert!(store.tick(world, 7).unwrap().is_none()); + + // Tick documents and run records share the file and reopen intact. + store.save(&record(world, 1)).unwrap(); + drop(store); + let store = VerseRunStore::open(&path).unwrap(); + assert_eq!(store.runs(world).unwrap(), vec![record(world, 1)]); + assert_eq!(closed_by(&store, world, 2), Some(TickClose::World)); + + let good = tick_doc(world, 0); + let envelope = |key: String, row_type: &str, schema: &str, payload: Vec| CultCacheEnvelope { + key, + r#type: row_type.into(), + payload, + stored_at: Utc::now().to_rfc3339(), + schema_id: Some(schema.into()), + }; + let named = rmp_serde::to_vec_named(&good).unwrap(); + let cases = [ + envelope(tick_key(world, 9), TICK_ROW, TICK_RECORD_SCHEMA, named.clone()), + envelope(tick_key(world, 0), TICK_ROW, "ghostlight.verse_tick.v2", named.clone()), + envelope(tick_key(world, 0), TICK_ROW, TICK_RECORD_SCHEMA, vec![0xc0]), + envelope( + tick_key(world, 0), + TICK_ROW, + TICK_RECORD_SCHEMA, + rmp_serde::to_vec(&good).unwrap(), + ), + envelope(tick_key(world, 0), "something_else.v1", TICK_RECORD_SCHEMA, named), + ]; + for (index, row) in cases.into_iter().enumerate() { + let file = dir.path().join(format!("tick-case-{index}.cc")); + { + let mut raw = OwnedRedbMessagePackBackingStore::new(&file).unwrap(); + raw.push(&row).unwrap(); + } + assert!( + matches!(VerseRunStore::open(&file), Err(VerseRunError::Corrupt)), + "tick case {index} was accepted" + ); + } +} From be7d9eec757cff6d9da1a89b40c99073d5dee2d6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 12:14:25 +0200 Subject: [PATCH 15/62] Verse run test: a resumed tick runs its frozen cells in order and answers the finished one from its row Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/verse/run/tests.rs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/crates/ghostlight/src/verse/run/tests.rs b/crates/ghostlight/src/verse/run/tests.rs index 79f2f83..a1c9b7b 100644 --- a/crates/ghostlight/src/verse/run/tests.rs +++ b/crates/ghostlight/src/verse/run/tests.rs @@ -471,17 +471,16 @@ async fn budget_exhausted_stops_before_the_clock_and_resumes() { assert_eq!(verse.now().await, 2 * u64::from(SPAN)); assert_eq!(second.calls(), 3, "one call finished tick 0, two ran tick 1"); assert_eq!(verse.acts().await, 4, "no act was committed twice"); - // Both factions still hold opportunities. The one the first run left in - // flight finishes; the one that already acted has had its scope moved by - // that act and by its neighbour's, so its cell is refused rather than run - // again. + // The frozen cells run in their frozen order: the cell that already acted + // answers with its original receipt and costs no call, and the one the + // first run left in flight finishes. let closing = &resumed.record.checkpoints[0]; assert!(closing.closed); assert_eq!(closing.cells, 2); - assert_eq!(closing.committed.len(), 1); - assert_ne!(closing.committed[0], open.committed[0]); - assert_eq!(closing.refusals.len(), 1); - assert_eq!(closing.refusals[0].reason, RefusalReason::ScopeMoved); + assert_eq!(closing.committed.len(), 2, "{closing:?}"); + assert_eq!(closing.committed[0], open.committed[0], "{closing:?}"); + assert_ne!(closing.committed[1], open.committed[0], "{closing:?}"); + assert!(closing.refusals.is_empty(), "{closing:?}"); assert_eq!(resumed.record.ticks_closed(), 2); // The tick reports the act this run committed and not the one it found. assert_eq!(resumed_reports[0].acts.len(), 1); From 278033b90ec12d365c34da4dd2643370a4b11d05 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 12:45:24 +0200 Subject: [PATCH 16/62] Verse run test: a refreshed member is bound to its own subject's opportunity Kills the cargo-mutants survivor on the subject match in refreshed. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/verse/run/tests.rs | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/crates/ghostlight/src/verse/run/tests.rs b/crates/ghostlight/src/verse/run/tests.rs index a1c9b7b..f37203a 100644 --- a/crates/ghostlight/src/verse/run/tests.rs +++ b/crates/ghostlight/src/verse/run/tests.rs @@ -831,6 +831,9 @@ async fn a_cell_is_run_on_what_the_world_holds_for_its_members_now() { }; assert_ne!(member.opportunity, held.opportunity); assert!(moved.contains(&member.opportunity)); + // The member is bound to its own subject's opportunity, not a neighbour's. + assert_eq!(member.subject, held.subject); + assert_eq!(member.opportunity.scope.subject_id, member.subject); } let frozen_group = FrozenCell::of(grouped); let Some(Cell::Group { members: now, .. }) = refreshed(&frozen_group, TickIndex(0), &moved) @@ -839,6 +842,10 @@ async fn a_cell_is_run_on_what_the_world_holds_for_its_members_now() { }; assert_eq!(now.len(), 2); assert!(now.iter().all(|member| moved.contains(&member.opportunity))); + assert!( + now.iter() + .all(|member| member.opportunity.scope.subject_id == member.subject) + ); // A subject with no opportunity leaves its cell, and an empty cell is gone. let one = &opportunities[..1]; From 158e8f9f3d9e22f7d927bb93ed1fca67b33f0359 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 12:55:46 +0200 Subject: [PATCH 17/62] Controller lookup-first: a committed row answers by command id; grouped refusals typed; exercises in attention order A row that carries its submission body is resumed whatever the caller's scope digest (the world answers it by command id); a row still in flight keeps the digest gate. World and scope must always match. GroupedRun.refused names each constituent that did not commit (LeftBeforeSelection, ScopeChanged, WorldRefused); needs keeps decode problems only. A group submits declines first, then exercises in the world's attention order read once. Not verified at this commit (written before the first build). Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/controllers.rs | 557 +++++++++++++++++++++++++-- crates/ghostlight/src/verse/run.rs | 9 +- 2 files changed, 535 insertions(+), 31 deletions(-) diff --git a/crates/ghostlight/src/controllers.rs b/crates/ghostlight/src/controllers.rs index 798309a..f9b6618 100644 --- a/crates/ghostlight/src/controllers.rs +++ b/crates/ghostlight/src/controllers.rs @@ -924,6 +924,11 @@ impl ControllerWork { } impl NarrativeCheckpoint { + /// The row holds its submission body: the next step is the world's answer. + fn awaits_world(&self) -> bool { + matches!(self, Self::ReadyToSubmit { .. } | Self::NoProposal { .. }) + } + fn command_id(&self) -> CommandId { match self { Self::Projector { command_id, .. } @@ -1100,6 +1105,11 @@ impl OperationalCheckpoint { } } + /// The row holds its submission body: the next step is the world's answer. + fn awaits_world(&self) -> bool { + matches!(self, Self::ReadyToSubmit { .. } | Self::NoProposal { .. }) + } + fn opportunity(&self) -> &DecisionOpportunity { match self { Self::AgentInFlight { opportunity, .. } @@ -1584,12 +1594,26 @@ fn turn_matches_opportunity(turn: &PersonaTurn, opportunity: &DecisionOpportunit && opportunity.scope_digest.as_str() == turn.binding().scope_digest } -/// Two opportunity values that bind the same proposal. The revision is a receipt -/// and an ordering value; the scope digest is the binding. -fn binds_same_scope(left: &DecisionOpportunity, right: &DecisionOpportunity) -> bool { - left.world_id == right.world_id - && left.scope == right.scope - && left.scope_digest == right.scope_digest +/// Two opportunity values that name the same decision: the same world and the +/// same decision scope. The scope digest is not part of the name. It says what +/// the scope looked like when a proposal was reasoned, so it gates work that is +/// still reasoning, never a row whose submission body the world answers by +/// command id. +fn same_decision(left: &DecisionOpportunity, right: &DecisionOpportunity) -> bool { + left.world_id == right.world_id && left.scope == right.scope +} + +/// Whether a persisted row may be resumed against the caller's opportunity. +/// A row that awaits the world carries its submission body, and the world +/// answers it by command id (its receipt, or a `ScopeChanged` refusal), so the +/// caller's digest cannot change what that command is. A row still reasoning +/// keeps the digest check, so no tokens are spent on a scope that moved. +fn row_answers_caller( + row: &DecisionOpportunity, + caller: &DecisionOpportunity, + awaits_world: bool, +) -> bool { + same_decision(row, caller) && (awaits_world || row.scope_digest == caller.scope_digest) } fn derive_narrative_capture( @@ -2843,6 +2867,24 @@ pub struct ConstituentSubmission { pub submission: SubmissionDisposition, } +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ConstituentRefusal { + pub subject: SubjectId, + pub reason: ConstituentRefusalReason, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum ConstituentRefusalReason { + /// The subject's opportunity moved between the cover and the cell, so it was + /// not selected and nothing was submitted for it. + LeftBeforeSelection, + /// The kernel refused its proposal because the scope it was reasoned from + /// no longer held (a neighbour's act moved it). + ScopeChanged, + /// The kernel refused its command for any other reason. + WorldRefused, +} + /// One coarse cell's outcome: one inference, N ordinary one-opportunity /// submissions, and whatever the decode could not attribute. #[derive(Debug)] @@ -2850,7 +2892,11 @@ pub struct GroupedRun { pub cell: CellId, pub resolution: Resolution, pub submissions: Vec, + /// Decode problems the grouped decode could not attribute to a constituent. pub needs: Vec, + /// Constituents of this cell that did not commit, one entry each, with the + /// reason. A constituent is in `submissions` or here, never lost. + pub refused: Vec, /// Set when the run stopped before every constituent was submitted. The /// constituents already in `submissions` are committed; the rest resume from /// the persisted row against the kernel's idempotency ledger. @@ -3036,7 +3082,7 @@ impl ControllerRunner { } match self.work.lookup(command_id).await? { ControllerWorkLookup::Confirmed(ControllerWork::Narrative(checkpoint)) => { - if !binds_same_scope(checkpoint.opportunity(), opportunity) { + if !row_answers_caller(checkpoint.opportunity(), opportunity, checkpoint.awaits_world()) { return Err(ControllerError::OpportunityMismatch); } return self @@ -3044,7 +3090,7 @@ impl ControllerRunner { .await; } ControllerWorkLookup::CustodyUncertain(ControllerWork::Narrative(checkpoint)) => { - if !binds_same_scope(checkpoint.opportunity(), opportunity) { + if !row_answers_caller(checkpoint.opportunity(), opportunity, checkpoint.awaits_world()) { return Err(ControllerError::OpportunityMismatch); } return Ok(narrative_pending( @@ -3116,7 +3162,7 @@ impl ControllerRunner { } match self.work.lookup(command_id).await? { ControllerWorkLookup::Confirmed(ControllerWork::Operational(checkpoint)) => { - if !binds_same_scope(checkpoint.opportunity(), opportunity) { + if !row_answers_caller(checkpoint.opportunity(), opportunity, checkpoint.awaits_world()) { return Err(ControllerError::OpportunityMismatch); } return self @@ -3124,7 +3170,7 @@ impl ControllerRunner { .await; } ControllerWorkLookup::CustodyUncertain(ControllerWork::Operational(checkpoint)) => { - if !binds_same_scope(checkpoint.opportunity(), opportunity) { + if !row_answers_caller(checkpoint.opportunity(), opportunity, checkpoint.awaits_world()) { return Err(ControllerError::OpportunityMismatch); } return Ok(operational_pending( @@ -3411,7 +3457,7 @@ impl ControllerRunner { .map_err(ControllerError::Snapshot)?; let mut selected = Vec::new(); let mut constituents = Vec::new(); - let mut needs = Vec::new(); + let mut refused = Vec::new(); for member in members { match select_one(&snapshot, &member.opportunity) { Ok(decision) => { @@ -3432,8 +3478,9 @@ impl ControllerRunner { // cell was not active when the cell ran. It is dropped rather // than declined: declining a stale opportunity would be refused // anyway, and the drop is recorded where a reader can see it. - Err(error) => needs.push(ControllerNeed { - detail: format!("a constituent left the cell before selection: {error}"), + Err(_) => refused.push(ConstituentRefusal { + subject: member.subject, + reason: ConstituentRefusalReason::LeftBeforeSelection, }), } } @@ -3442,7 +3489,8 @@ impl ControllerRunner { cell, resolution: Resolution::Coarse { constituents: 0 }, submissions: Vec::new(), - needs, + needs: Vec::new(), + refused, pending: None, }); } @@ -3500,7 +3548,7 @@ impl ControllerRunner { // The constituents that left the cell are reported beside the run they // were dropped from, not written into the row: they produced no // cognition, so they are not cognition evidence. - run.needs.splice(0..0, needs); + run.refused.splice(0..0, refused); Ok(run) } @@ -3649,8 +3697,29 @@ impl ControllerRunner { let resolution = Resolution::Coarse { constituents: constituents.len(), }; - let mut needs = capture.needs; + let needs = capture.needs; let mut submissions = Vec::new(); + let mut refused = Vec::new(); + // The world's attention order, read once: among exercises the subject the + // world would attend first lands first, so a member refused behind a + // neighbour's act (and so ahead of it next tick) is the one that lands + // next time. `order_opportunities` starves nobody. + let attention = match self.mailbox.snapshot().await { + Ok(snapshot) => snapshot.opportunities, + Err(MailboxError::Unavailable) => { + return Ok(grouped_pending( + checkpoint.clone(), + ControllerPendingReason::WorldUnavailable, + )); + } + Err(MailboxError::OutcomeUnknown { .. }) => { + return Ok(grouped_pending( + checkpoint.clone(), + ControllerPendingReason::WorldOutcomeUnknown, + )); + } + Err(error) => return Err(ControllerError::Snapshot(error)), + }; // Declines first. A decline changes no other subject's scope, so every // silent constituent's turn is consumed. An exercise can change what its // neighbours bound to, and co-located subjects are exactly @@ -3659,7 +3728,14 @@ impl ControllerRunner { // outcome; ordering the non-mutating submissions ahead of it loses no // turns and costs nothing. let mut ordered: Vec<(usize, &ConstituentWork)> = constituents.iter().enumerate().collect(); - ordered.sort_by_key(|(handle, _)| capture.proposals.contains_key(handle)); + ordered.sort_by_key(|(handle, constituent)| { + let exercises = capture.proposals.contains_key(handle); + let place = attention + .iter() + .position(|opportunity| opportunity.scope.subject_id == constituent.subject) + .unwrap_or(usize::MAX); + (exercises, if exercises { place } else { 0 }) + }); for (handle, constituent) in ordered { let command = match capture.proposals.get(&handle) { Some(invocation) => ControllerWorldCommand::Exercise(invocation.clone()), @@ -3681,14 +3757,19 @@ impl ControllerRunner { resolution, submissions, needs, + refused, pending: Some(reason), }); } // One constituent's refusal is that constituent's outcome. The // cell is not a transaction: every other handle's proposal was // bound to its own opportunity and is unaffected. - Err(ControllerError::World(error)) => needs.push(ControllerNeed { - detail: format!("constituent c{handle} was refused by the world: {error}"), + Err(ControllerError::World(error)) => refused.push(ConstituentRefusal { + subject: constituent.subject, + reason: match error { + KernelError::ScopeChanged { .. } => ConstituentRefusalReason::ScopeChanged, + _ => ConstituentRefusalReason::WorldRefused, + }, }), Err(error) => return Err(error), } @@ -3704,6 +3785,7 @@ impl ControllerRunner { resolution, submissions, needs, + refused, pending: None, }) } @@ -6055,6 +6137,7 @@ fn grouped_pending(checkpoint: GroupedCheckpoint, reason: ControllerPendingReaso }, submissions: Vec::new(), needs: Vec::new(), + refused: Vec::new(), pending: Some(reason), } } @@ -15159,13 +15242,8 @@ mod tests { run.submissions[0].submission, SubmissionDisposition::Completed(_) )); - assert!( - run.needs - .iter() - .any(|need| need.detail.contains("scope changed")), - "the refused act was not recorded: {:?}", - run.needs - ); + assert_eq!(run.refused.len(), 1, "the refused act was not recorded"); + assert_eq!(run.refused[0].reason, ConstituentRefusalReason::ScopeChanged); assert_eq!(mailbox.operator_log().await.unwrap().len(), 1); drop(runner); @@ -19130,4 +19208,431 @@ mod tests { drop(mailbox); task.await.unwrap(); } + + /// The lookup-first tests use two subjects in one room, so one subject's + /// act moves the other's scope digest as well as its own. + fn fresh_opportunity(snapshot: &WorldSnapshot, subject: SubjectId) -> DecisionOpportunity { + snapshot + .opportunities + .iter() + .find(|opportunity| opportunity.scope.subject_id == subject) + .expect("the subject still holds an opportunity") + .clone() + } + + fn operational_speaks(text: &str, receipt: &str) -> Result { + output( + vec![InferenceEvent::ToolCall { + call_id: "speak".into(), + name: INTERPRETER_SPEAK_TOOL.into(), + arguments: json!({ "text": text }).to_string(), + }], + receipt, + ) + } + + fn narrative_turn_outputs( + tag: &str, + speech: &str, + ) -> Vec> { + vec![ + output( + vec![InferenceEvent::Text(format!("{tag} watches the water."))], + &format!("{tag}-projector"), + ), + output( + vec![InferenceEvent::Text(format!("I say, \"{speech}\""))], + &format!("{tag}-persona"), + ), + output( + vec![ + InferenceEvent::ToolCall { + call_id: "call_speak".into(), + name: INTERPRETER_SPEAK_TOOL.into(), + arguments: json!({ "source_quote": speech }).to_string(), + }, + InferenceEvent::ToolCall { + call_id: "call_finish".into(), + name: FINISH_INTERPRETATION_TOOL.into(), + arguments: "{}".into(), + }, + ], + &format!("{tag}-interpreter"), + ), + ] + } + + #[tokio::test] + async fn a_committed_operational_row_is_answered_with_its_receipt_after_its_scope_moved() { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::OperationalAgent, + NewController::OperationalAgent, + ]) + .await; + // Exactly two provider answers: a third call would panic the port. + let (runner, _store) = grouped_runner( + &mailbox, + vec![ + operational_speaks("The hinge is flooding.", "first"), + operational_speaks("Then we close the span.", "second"), + ], + ); + let snapshot = mailbox.snapshot().await.unwrap(); + let first = snapshot.opportunities[0].clone(); + let second = snapshot.opportunities[1].clone(); + let first_command = CommandId::new(); + let OperationalRun::Completed(committed) = runner + .run_operational(first_command, &first) + .await + .unwrap() + else { + panic!("the first act remained pending") + }; + let SubmissionDisposition::Completed(SubmitReceipt::Applied(original)) = + committed.submission + else { + panic!("the first act did not commit") + }; + // Another subject speaks in the commons. + let after_first = mailbox.snapshot().await.unwrap(); + let second_now = fresh_opportunity(&after_first, second.scope.subject_id); + assert!(matches!( + runner + .run_operational(CommandId::new(), &second_now) + .await + .unwrap(), + OperationalRun::Completed(_) + )); + + let moved = mailbox.snapshot().await.unwrap(); + let fresh = fresh_opportunity(&moved, first.scope.subject_id); + assert_ne!(fresh.scope_digest, first.scope_digest, "the scope did not move"); + let OperationalRun::Completed(answered) = runner + .run_operational(first_command, &fresh) + .await + .unwrap() + else { + panic!("the committed row was not answered") + }; + assert!( + matches!( + &answered.submission, + SubmissionDisposition::PreviouslyConfirmed(receipt) if *receipt == original + ), + "{:?}", + answered.submission + ); + assert_eq!(mailbox.operator_log().await.unwrap().len(), 2); + + drop(runner); + drop(mailbox); + task.await.unwrap(); + } + + #[tokio::test] + async fn a_committed_narrative_row_is_answered_with_its_receipt_after_its_scope_moved() { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::NarrativePersona, + NewController::NarrativePersona, + ]) + .await; + let mut outputs = narrative_turn_outputs("Mara", "The rain has teeth tonight."); + outputs.extend(narrative_turn_outputs("Tobin", "Then we close the span.")); + let (runner, _store) = grouped_runner(&mailbox, outputs); + let snapshot = mailbox.snapshot().await.unwrap(); + let first = snapshot.opportunities[0].clone(); + let second = snapshot.opportunities[1].clone(); + let first_command = CommandId::new(); + let NarrativeRun::Completed(committed) = runner + .run_narrative(first_command, &first) + .await + .unwrap() + else { + panic!("the first turn remained pending") + }; + let SubmissionDisposition::Completed(SubmitReceipt::Applied(original)) = + committed.submission + else { + panic!("the first turn did not commit") + }; + let after_first = mailbox.snapshot().await.unwrap(); + let second_now = fresh_opportunity(&after_first, second.scope.subject_id); + assert!(matches!( + runner + .run_narrative(CommandId::new(), &second_now) + .await + .unwrap(), + NarrativeRun::Completed(_) + )); + + let moved = mailbox.snapshot().await.unwrap(); + let fresh = fresh_opportunity(&moved, first.scope.subject_id); + assert_ne!(fresh.scope_digest, first.scope_digest, "the scope did not move"); + let NarrativeRun::Completed(answered) = runner + .run_narrative(first_command, &fresh) + .await + .unwrap() + else { + panic!("the committed row was not answered") + }; + assert!( + matches!( + &answered.submission, + SubmissionDisposition::PreviouslyConfirmed(receipt) if *receipt == original + ), + "{:?}", + answered.submission + ); + assert_eq!(mailbox.operator_log().await.unwrap().len(), 2); + + drop(runner); + drop(mailbox); + task.await.unwrap(); + } + + #[tokio::test] + async fn a_row_in_flight_whose_scope_moved_is_refused_before_inference() { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::OperationalAgent, + NewController::OperationalAgent, + ]) + .await; + // The first call is pending, the second commits, and a third would + // panic the port: the refused rerun must not reach the provider. + let (runner, _store) = grouped_runner( + &mailbox, + vec![ + Err(InferenceFault::retryable("the provider is busy")), + operational_speaks("Then we close the span.", "second"), + ], + ); + let snapshot = mailbox.snapshot().await.unwrap(); + let first = snapshot.opportunities[0].clone(); + let second = snapshot.opportunities[1].clone(); + let first_command = CommandId::new(); + assert!(matches!( + runner + .run_operational(first_command, &first) + .await + .unwrap(), + OperationalRun::Pending(_) + )); + assert!(matches!( + runner + .run_operational(CommandId::new(), &second) + .await + .unwrap(), + OperationalRun::Completed(_) + )); + let moved = mailbox.snapshot().await.unwrap(); + let fresh = fresh_opportunity(&moved, first.scope.subject_id); + assert_ne!(fresh.scope_digest, first.scope_digest, "the scope did not move"); + let error = runner + .run_operational(first_command, &fresh) + .await + .unwrap_err(); + assert!(matches!(error, ControllerError::OpportunityMismatch)); + assert_eq!(mailbox.operator_log().await.unwrap().len(), 1); + + drop(runner); + drop(mailbox); + task.await.unwrap(); + } + + #[tokio::test] + async fn a_committed_row_is_never_read_for_another_decision() { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::OperationalAgent, + NewController::OperationalAgent, + ]) + .await; + let (runner, _store) = grouped_runner( + &mailbox, + vec![operational_speaks("The hinge is flooding.", "first")], + ); + let snapshot = mailbox.snapshot().await.unwrap(); + let first = snapshot.opportunities[0].clone(); + let other = snapshot.opportunities[1].clone(); + let command = CommandId::new(); + assert!(matches!( + runner.run_operational(command, &first).await.unwrap(), + OperationalRun::Completed(_) + )); + let error = runner.run_operational(command, &other).await.unwrap_err(); + assert!(matches!(error, ControllerError::OpportunityMismatch)); + let mut foreign = first.clone(); + foreign.world_id = WorldId::issue(); + let error = runner.run_operational(command, &foreign).await.unwrap_err(); + assert!(matches!(error, ControllerError::OpportunityMismatch)); + + drop(runner); + drop(mailbox); + task.await.unwrap(); + } + + #[tokio::test] + async fn a_grouped_member_the_world_refuses_is_named_in_refused() { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::OperationalAgent, + NewController::OperationalAgent, + ]) + .await; + let cell = one_group(&mailbox).await; + let Cell::Group { members, .. } = &cell else { + panic!("the cell is not coarse") + }; + let (runner, _store) = grouped_runner( + &mailbox, + vec![output( + vec![ + speak_call("c0", "c0__speak", "The hinge is flooding."), + speak_call("c1", "c1__speak", "Then we close the span."), + ], + "two-acts", + )], + ); + let CellRun::Grouped(run) = runner.run_cell(&cell).await.unwrap() else { + panic!("a coarse cell did not run the grouped lane") + }; + assert_eq!(run.submissions.len(), 1); + let committed = run.submissions[0].subject; + let refused_subject = members + .iter() + .map(|member| member.subject) + .find(|subject| *subject != committed) + .unwrap(); + assert_eq!( + run.refused, + vec![ConstituentRefusal { + subject: refused_subject, + reason: ConstituentRefusalReason::ScopeChanged, + }] + ); + assert!(run.needs.is_empty(), "{:?}", run.needs); + + drop(runner); + drop(mailbox); + task.await.unwrap(); + } + + #[tokio::test] + async fn a_grouped_member_that_left_the_cell_is_named_in_refused() { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::OperationalAgent, + NewController::OperationalAgent, + ]) + .await; + let cell = one_group(&mailbox).await; + let Cell::Group { id, tick, members } = cell else { + panic!("the cell is not coarse") + }; + // One member's bound opportunity is stale (its digest is not the + // world's); the other is bound to the world as it is. + let world = mailbox.snapshot().await.unwrap(); + let leaver = members[1].subject; + let mut stale = members[1].opportunity.clone(); + stale.scope_digest = members[0].opportunity.scope_digest.clone(); + let bound = vec![ + Constituent { + subject: members[0].subject, + opportunity: fresh_opportunity(&world, members[0].subject), + }, + Constituent { + subject: leaver, + opportunity: stale, + }, + ]; + let (runner, _store) = grouped_runner( + &mailbox, + vec![output( + vec![speak_call("c0", "c0__speak", "The hinge is flooding.")], + "lone-act", + )], + ); + let cell = Cell::Group { + id, + tick, + members: bound, + }; + let CellRun::Grouped(run) = runner.run_cell(&cell).await.unwrap() else { + panic!("a coarse cell did not run the grouped lane") + }; + assert_eq!( + run.refused, + vec![ConstituentRefusal { + subject: leaver, + reason: ConstituentRefusalReason::LeftBeforeSelection, + }] + ); + assert!(run.needs.is_empty(), "{:?}", run.needs); + + drop(runner); + drop(mailbox); + task.await.unwrap(); + } + + #[tokio::test] + async fn a_grouped_cell_submits_exercises_in_attention_order() { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::OperationalAgent, + NewController::OperationalAgent, + ]) + .await; + let both_act = |tag: &str| { + output( + vec![ + speak_call("c0", "c0__speak", "The hinge is flooding."), + speak_call("c1", "c1__speak", "Then we close the span."), + ], + tag, + ) + }; + let (runner, _store) = grouped_runner(&mailbox, vec![both_act("one"), both_act("two")]); + + let first_cell = one_group(&mailbox).await; + let Cell::Group { members, .. } = &first_cell else { + panic!("the cell is not coarse") + }; + let low = members[0].subject; + let CellRun::Grouped(first) = runner.run_cell(&first_cell).await.unwrap() else { + panic!("a coarse cell did not run the grouped lane") + }; + assert_eq!(first.submissions.len(), 1); + assert_eq!(first.refused.len(), 1); + let refused_first = first.refused[0].subject; + assert_eq!(first.submissions[0].subject, low); + assert_ne!(refused_first, low); + + // Time passes; the refused member has waited, so the world attends it first. + let owner = PrincipalId::new("owner"); + let before = mailbox.snapshot().await.unwrap(); + mailbox + .submit_fixture( + CommandEnvelope { + id: CommandId::new(), + world_id: before.world_id, + expected_revision: before.revision, + caller: CallerId::Principal(owner.clone()), + body: CommandBody::AdvanceTime { + minutes: crate::TickMinutes::new(60).unwrap(), + }, + }, + &AuthenticatedCaller::fixture(CallerId::Principal(owner)), + ) + .await + .unwrap(); + let second_cell = one_group(&mailbox).await; + let CellRun::Grouped(second) = runner.run_cell(&second_cell).await.unwrap() else { + panic!("a coarse cell did not run the grouped lane") + }; + assert_eq!(second.submissions.len(), 1); + assert_eq!( + second.submissions[0].subject, refused_first, + "the member refused last time did not land first this time" + ); + + drop(runner); + drop(mailbox); + task.await.unwrap(); + } } diff --git a/crates/ghostlight/src/verse/run.rs b/crates/ghostlight/src/verse/run.rs index acb0200..d8e00d7 100644 --- a/crates/ghostlight/src/verse/run.rs +++ b/crates/ghostlight/src/verse/run.rs @@ -92,11 +92,10 @@ pub enum RefusalReason { WorldRejected, /// The cell's subject no longer holds an opportunity. OpportunityGone, - /// The cell's subject holds an opportunity whose scope moved since the - /// cell's work was bound. A cell that committed earlier in an interrupted - /// tick ends this way when a run resumes the tick: its own act, or a - /// neighbour's in the shared room, changed what it knows. Nothing is - /// committed twice. + /// The cell's in-flight work was bound to a scope that has since moved, + /// so it is refused before any inference. A cell whose command already + /// committed does not end this way: the controller answers it with its + /// receipt whatever the scope has done since. ScopeMoved, } From adb8cbff2b4ad564d6c91c3d087f02b5b42686ff Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 12:57:38 +0200 Subject: [PATCH 18/62] Test: a committed cell is answered whichever cell the resume reaches first The cell that acted is second in the frozen order; the first is in flight when the run ends. Closes the order dependence budget_exhausted_stops_before_the_clock_and_resumes only passed around. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/verse/run/tests.rs | 50 ++++++++++++++++++++++++ 1 file changed, 50 insertions(+) diff --git a/crates/ghostlight/src/verse/run/tests.rs b/crates/ghostlight/src/verse/run/tests.rs index f37203a..3a29215 100644 --- a/crates/ghostlight/src/verse/run/tests.rs +++ b/crates/ghostlight/src/verse/run/tests.rs @@ -492,6 +492,56 @@ async fn budget_exhausted_stops_before_the_clock_and_resumes() { assert_eq!(resumed.record.checkpoints[1].committed.len(), 2); } +/// The answer for a committed cell does not depend on where it sits in the +/// tick's frozen order. Here the cell that acted is the second one: the first +/// is still in flight when the run ends, the second commits by another driver, +/// and the resuming run reaches the committed cell last. +#[tokio::test] +async fn a_committed_cell_is_answered_whichever_cell_the_resume_reaches_first() { + let (_dir, verse) = Harness::fresh().await; + let ended = verse + .run_quietly_under(fault_at(0), config_of(1, 60, 2)) + .await; + assert_eq!( + ended.record.status, + RunStatus::Failed { + reason: "inference_retryable".into() + } + ); + assert_eq!(verse.acts().await, 0, "the first cell was left in flight"); + let world = verse.mailbox.snapshot().await.unwrap().world_id; + let tick = verse.runs.tick(world, 0).unwrap().unwrap(); + assert_eq!(tick.cells.len(), 2); + + // The second frozen cell commits on its own. + let other = ScriptedPort::speaking(); + let runner = ControllerRunner::open( + verse.mailbox.clone(), + other.clone(), + verse.work.clone(), + models(), + ) + .unwrap(); + let fresh = verse.mailbox.opportunities().await.unwrap(); + let second = refreshed(&tick.cells[1], TickIndex(0), &fresh).unwrap(); + runner.run_cell(&second).await.unwrap(); + assert_eq!(verse.acts().await, 1); + + let resuming = ScriptedPort::speaking(); + let resumed = verse + .run_quietly_under(resuming.clone(), config_of(1, 60, 2)) + .await; + assert_eq!(resumed.record.status, RunStatus::Completed); + let [closing] = resumed.record.checkpoints.as_slice() else { + panic!("the resuming run closed one tick") + }; + assert!(closing.closed); + assert!(closing.refusals.is_empty(), "{closing:?}"); + assert_eq!(closing.committed.len(), 2, "{closing:?}"); + assert_eq!(resuming.calls(), 1, "only the cell left in flight reached the provider"); + assert_eq!(verse.acts().await, 2, "no act was committed twice"); +} + #[tokio::test] async fn recorded_run_replays_to_the_same_journal() { let live_dir = tempfile::tempdir().unwrap(); From 2b495e1f33b5a57e215e1589f3e8491ee78e00a1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 13:05:34 +0200 Subject: [PATCH 19/62] Test: soul_b refused-constituent test reads the typed refusal, not needs text Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/controllers.rs | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/crates/ghostlight/src/controllers.rs b/crates/ghostlight/src/controllers.rs index f9b6618..52f6541 100644 --- a/crates/ghostlight/src/controllers.rs +++ b/crates/ghostlight/src/controllers.rs @@ -15411,12 +15411,9 @@ mod tests { panic!("a coarse cell did not run the grouped lane") }; assert_eq!(run.submissions.len(), 1); - assert!( - run.needs - .iter() - .any(|need| need.detail.contains("scope changed")), - "{:?}", - run.needs + assert_eq!( + run.refused.iter().map(|entry| (entry.subject, entry.reason)).collect::>(), + vec![(second, ConstituentRefusalReason::ScopeChanged)] ); let after = digest_of(&mailbox.snapshot().await.unwrap(), second) From db12c26b31eff7d93e29dc0328617b906bd1e75b Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 13:07:06 +0200 Subject: [PATCH 20/62] Test: the committed cell is answered; the in-flight cell sharing its room is refused before inference Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/verse/run/tests.rs | 20 +++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/crates/ghostlight/src/verse/run/tests.rs b/crates/ghostlight/src/verse/run/tests.rs index 3a29215..6ce9a8e 100644 --- a/crates/ghostlight/src/verse/run/tests.rs +++ b/crates/ghostlight/src/verse/run/tests.rs @@ -495,7 +495,10 @@ async fn budget_exhausted_stops_before_the_clock_and_resumes() { /// The answer for a committed cell does not depend on where it sits in the /// tick's frozen order. Here the cell that acted is the second one: the first /// is still in flight when the run ends, the second commits by another driver, -/// and the resuming run reaches the committed cell last. +/// and the resuming run reaches the committed cell last. The factions share a +/// room, so the committed act moved the in-flight cell's scope: that cell is +/// refused before inference (its row still reasons), while the committed cell is +/// answered with its receipt, whatever its own scope has done since. #[tokio::test] async fn a_committed_cell_is_answered_whichever_cell_the_resume_reaches_first() { let (_dir, verse) = Harness::fresh().await; @@ -536,10 +539,17 @@ async fn a_committed_cell_is_answered_whichever_cell_the_resume_reaches_first() panic!("the resuming run closed one tick") }; assert!(closing.closed); - assert!(closing.refusals.is_empty(), "{closing:?}"); - assert_eq!(closing.committed.len(), 2, "{closing:?}"); - assert_eq!(resuming.calls(), 1, "only the cell left in flight reached the provider"); - assert_eq!(verse.acts().await, 2, "no act was committed twice"); + assert_eq!( + closing.refusals, + vec![Refusal { + cell: 0, + reason: RefusalReason::ScopeMoved + }], + "{closing:?}" + ); + assert_eq!(closing.committed.len(), 1, "{closing:?}"); + assert_eq!(resuming.calls(), 0, "no provider call was spent on a moved scope"); + assert_eq!(verse.acts().await, 1, "no act was committed twice"); } #[tokio::test] From 9bf793257374f6cf6546773a685622d9dc6676a0 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 13:35:43 +0200 Subject: [PATCH 21/62] Tests: custody-uncertain rows, narrative in-flight gate, and committed cells answered in either order The run-level order-reversed case now commits both cells through another driver, each one's act having moved the other's scope, in either order; the resuming run answers both with their receipts. Not built before this commit. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/controllers.rs | 155 +++++++++++++++++++++++ crates/ghostlight/src/verse/run/tests.rs | 95 +++++++------- 2 files changed, 206 insertions(+), 44 deletions(-) diff --git a/crates/ghostlight/src/controllers.rs b/crates/ghostlight/src/controllers.rs index 52f6541..3e120fd 100644 --- a/crates/ghostlight/src/controllers.rs +++ b/crates/ghostlight/src/controllers.rs @@ -19632,4 +19632,159 @@ mod tests { drop(mailbox); task.await.unwrap(); } + + /// The same rows as the store sees them while its custody is uncertain. + struct UncertainStore(Arc); + + #[async_trait] + impl ControllerWorkStore for UncertainStore { + async fn lookup( + &self, + command_id: CommandId, + ) -> Result { + Ok(match self.0.lookup(command_id).await? { + ControllerWorkLookup::Confirmed(work) => ControllerWorkLookup::CustodyUncertain(work), + other => other, + }) + } + + async fn persist( + &self, + work: &ControllerWork, + ) -> Result { + self.0.persist(work).await + } + + async fn custody_probe(&self) -> Result { + self.0.custody_probe().await + } + + async fn elaboration_in_flight( + &self, + ) -> Result, ControllerWorkStoreError> { + self.0.elaboration_in_flight().await + } + } + + fn uncertain_runner(mailbox: &WorldMailbox, store: &Arc) -> ControllerRunner { + let port = Arc::new(RecordingPort { + outputs: Mutex::new(Vec::new()), + persisted_before_interpreter: Arc::new(AtomicBool::new(true)), + }); + ControllerRunner::open(mailbox.clone(), port, Arc::new(UncertainStore(store.clone())), models()) + .expect("the fixture ports open") + } + + #[tokio::test] + async fn a_row_read_in_uncertain_custody_is_reported_pending_for_its_own_decision_only() { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::OperationalAgent, + NewController::OperationalAgent, + NewController::NarrativePersona, + NewController::NarrativePersona, + ]) + .await; + let snapshot = mailbox.snapshot().await.unwrap(); + let by_mode = |mode: ControllerMode| { + snapshot + .opportunities + .iter() + .filter(|opportunity| opportunity.controller_mode == mode) + .cloned() + .collect::>() + }; + let operational = by_mode(ControllerMode::OperationalAgent); + let narrative = by_mode(ControllerMode::NarrativePersona); + let mut outputs = vec![operational_speaks("The hinge is flooding.", "operational")]; + outputs.extend(narrative_turn_outputs("Mara", "The rain has teeth tonight.")); + let (runner, store) = grouped_runner(&mailbox, outputs); + let operational_command = CommandId::new(); + let narrative_command = CommandId::new(); + runner + .run_operational(operational_command, &operational[0]) + .await + .unwrap(); + runner + .run_narrative(narrative_command, &narrative[0]) + .await + .unwrap(); + + let uncertain = uncertain_runner(&mailbox, &store); + let moved = mailbox.snapshot().await.unwrap(); + let operational_now = fresh_opportunity(&moved, operational[0].scope.subject_id); + let narrative_now = fresh_opportunity(&moved, narrative[0].scope.subject_id); + assert_ne!(operational_now.scope_digest, operational[0].scope_digest); + assert_ne!(narrative_now.scope_digest, narrative[0].scope_digest); + let OperationalRun::Pending(pending) = uncertain + .run_operational(operational_command, &operational_now) + .await + .unwrap() + else { + panic!("an operational row in uncertain custody was not reported pending") + }; + assert_eq!(pending.reason(), ControllerPendingReason::StoreReopenRequired); + let NarrativeRun::Pending(pending) = uncertain + .run_narrative(narrative_command, &narrative_now) + .await + .unwrap() + else { + panic!("a narrative row in uncertain custody was not reported pending") + }; + assert_eq!(pending.reason(), ControllerPendingReason::StoreReopenRequired); + + let other_operational = fresh_opportunity(&moved, operational[1].scope.subject_id); + let other_narrative = fresh_opportunity(&moved, narrative[1].scope.subject_id); + assert!(matches!( + uncertain + .run_operational(operational_command, &other_operational) + .await, + Err(ControllerError::OpportunityMismatch) + )); + assert!(matches!( + uncertain + .run_narrative(narrative_command, &other_narrative) + .await, + Err(ControllerError::OpportunityMismatch) + )); + + drop((runner, uncertain)); + drop(mailbox); + task.await.unwrap(); + } + + #[tokio::test] + async fn a_narrative_row_in_flight_whose_scope_moved_is_refused_before_inference() { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::NarrativePersona, + NewController::NarrativePersona, + ]) + .await; + // The first turn's projector call is pending; the second subject's turn + // commits; a further call would panic the port. + let mut outputs = vec![Err(InferenceFault::retryable("the provider is busy"))]; + outputs.extend(narrative_turn_outputs("Tobin", "Then we close the span.")); + let (runner, _store) = grouped_runner(&mailbox, outputs); + let snapshot = mailbox.snapshot().await.unwrap(); + let first = snapshot.opportunities[0].clone(); + let second = snapshot.opportunities[1].clone(); + let command = CommandId::new(); + assert!(matches!( + runner.run_narrative(command, &first).await.unwrap(), + NarrativeRun::Pending(_) + )); + assert!(matches!( + runner.run_narrative(CommandId::new(), &second).await.unwrap(), + NarrativeRun::Completed(_) + )); + let moved = mailbox.snapshot().await.unwrap(); + let fresh = fresh_opportunity(&moved, first.scope.subject_id); + assert_ne!(fresh.scope_digest, first.scope_digest, "the scope did not move"); + let error = runner.run_narrative(command, &fresh).await.unwrap_err(); + assert!(matches!(error, ControllerError::OpportunityMismatch)); + assert_eq!(mailbox.operator_log().await.unwrap().len(), 1); + + drop(runner); + drop(mailbox); + task.await.unwrap(); + } } diff --git a/crates/ghostlight/src/verse/run/tests.rs b/crates/ghostlight/src/verse/run/tests.rs index 6ce9a8e..8c1ea3b 100644 --- a/crates/ghostlight/src/verse/run/tests.rs +++ b/crates/ghostlight/src/verse/run/tests.rs @@ -492,64 +492,71 @@ async fn budget_exhausted_stops_before_the_clock_and_resumes() { assert_eq!(resumed.record.checkpoints[1].committed.len(), 2); } -/// The answer for a committed cell does not depend on where it sits in the -/// tick's frozen order. Here the cell that acted is the second one: the first -/// is still in flight when the run ends, the second commits by another driver, -/// and the resuming run reaches the committed cell last. The factions share a -/// room, so the committed act moved the in-flight cell's scope: that cell is -/// refused before inference (its row still reasons), while the committed cell is -/// answered with its receipt, whatever its own scope has done since. -#[tokio::test] -async fn a_committed_cell_is_answered_whichever_cell_the_resume_reaches_first() { +/// Both cells of a tick committed through another driver before any run opened +/// it, one after the other, so each one's act has since moved the other's +/// scope. The run that opens the tick reaches both committed rows, in whatever +/// order it froze, and answers each with its receipt: no inference, no refusal, +/// no second act. `reverse` flips which cell the other driver committed first, +/// so the answer is shown not to depend on the order the cells are reached in. +async fn resumed_after_both_cells_committed_elsewhere(reverse: bool) { let (_dir, verse) = Harness::fresh().await; - let ended = verse - .run_quietly_under(fault_at(0), config_of(1, 60, 2)) - .await; - assert_eq!( - ended.record.status, - RunStatus::Failed { - reason: "inference_retryable".into() - } + let snapshot = verse.mailbox.snapshot().await.unwrap(); + let graph = verse.mailbox.agency_graph().await.unwrap(); + let cover = derive_cover( + snapshot.world_id, + TickIndex(0), + &snapshot.opportunities, + &graph, + CoverBudget { + cells: 2, + constituent_cap: 8, + urgency_slots: 0, + }, ); - assert_eq!(verse.acts().await, 0, "the first cell was left in flight"); - let world = verse.mailbox.snapshot().await.unwrap().world_id; - let tick = verse.runs.tick(world, 0).unwrap().unwrap(); - assert_eq!(tick.cells.len(), 2); - - // The second frozen cell commits on its own. - let other = ScriptedPort::speaking(); + assert_eq!(cover.cells.len(), 2); + let elsewhere = ScriptedPort::speaking(); let runner = ControllerRunner::open( verse.mailbox.clone(), - other.clone(), + elsewhere.clone(), verse.work.clone(), models(), ) .unwrap(); - let fresh = verse.mailbox.opportunities().await.unwrap(); - let second = refreshed(&tick.cells[1], TickIndex(0), &fresh).unwrap(); - runner.run_cell(&second).await.unwrap(); - assert_eq!(verse.acts().await, 1); + let order: Vec<&Cell> = if reverse { + cover.cells.iter().rev().collect() + } else { + cover.cells.iter().collect() + }; + for cell in order { + let fresh = verse.mailbox.opportunities().await.unwrap(); + let bound = refreshed(&FrozenCell::of(cell), TickIndex(0), &fresh).unwrap(); + runner.run_cell(&bound).await.unwrap(); + } + assert_eq!(verse.acts().await, 2); let resuming = ScriptedPort::speaking(); - let resumed = verse + let ran = verse .run_quietly_under(resuming.clone(), config_of(1, 60, 2)) .await; - assert_eq!(resumed.record.status, RunStatus::Completed); - let [closing] = resumed.record.checkpoints.as_slice() else { - panic!("the resuming run closed one tick") + assert_eq!(ran.record.status, RunStatus::Completed); + let [closing] = ran.record.checkpoints.as_slice() else { + panic!("the run closed one tick") }; assert!(closing.closed); - assert_eq!( - closing.refusals, - vec![Refusal { - cell: 0, - reason: RefusalReason::ScopeMoved - }], - "{closing:?}" - ); - assert_eq!(closing.committed.len(), 1, "{closing:?}"); - assert_eq!(resuming.calls(), 0, "no provider call was spent on a moved scope"); - assert_eq!(verse.acts().await, 1, "no act was committed twice"); + assert!(closing.refusals.is_empty(), "{closing:?}"); + assert_eq!(closing.committed.len(), 2, "{closing:?}"); + assert_eq!(resuming.calls(), 0, "a committed cell was asked again"); + assert_eq!(verse.acts().await, 2, "no act was committed twice"); +} + +#[tokio::test] +async fn committed_cells_are_answered_in_the_order_the_tick_froze() { + resumed_after_both_cells_committed_elsewhere(false).await; +} + +#[tokio::test] +async fn committed_cells_are_answered_against_the_order_the_tick_froze() { + resumed_after_both_cells_committed_elsewhere(true).await; } #[tokio::test] From cb1be44f92239b9b28ead4995c5ffe1e3f5271ba Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 13:43:23 +0200 Subject: [PATCH 22/62] Test: the custody-uncertain fixture binds the narrative subject as the world holds it Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/controllers.rs | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/crates/ghostlight/src/controllers.rs b/crates/ghostlight/src/controllers.rs index 3e120fd..f9ca0f4 100644 --- a/crates/ghostlight/src/controllers.rs +++ b/crates/ghostlight/src/controllers.rs @@ -19704,8 +19704,14 @@ mod tests { .run_operational(operational_command, &operational[0]) .await .unwrap(); + // The operational act moved the narrative subject's scope: bind it as + // the world now holds it. + let narrative_bound = fresh_opportunity( + &mailbox.snapshot().await.unwrap(), + narrative[0].scope.subject_id, + ); runner - .run_narrative(narrative_command, &narrative[0]) + .run_narrative(narrative_command, &narrative_bound) .await .unwrap(); @@ -19714,7 +19720,7 @@ mod tests { let operational_now = fresh_opportunity(&moved, operational[0].scope.subject_id); let narrative_now = fresh_opportunity(&moved, narrative[0].scope.subject_id); assert_ne!(operational_now.scope_digest, operational[0].scope_digest); - assert_ne!(narrative_now.scope_digest, narrative[0].scope_digest); + assert_ne!(narrative_now.scope_digest, narrative_bound.scope_digest); let OperationalRun::Pending(pending) = uncertain .run_operational(operational_command, &operational_now) .await From 61042670d18f77bf6b52a79899cf1623f2fb7676 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 13:51:44 +0200 Subject: [PATCH 23/62] Test: a third subject speaks so the custody-uncertain fixture's scopes all move Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/controllers.rs | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/crates/ghostlight/src/controllers.rs b/crates/ghostlight/src/controllers.rs index f9ca0f4..563b2a5 100644 --- a/crates/ghostlight/src/controllers.rs +++ b/crates/ghostlight/src/controllers.rs @@ -19697,6 +19697,7 @@ mod tests { let narrative = by_mode(ControllerMode::NarrativePersona); let mut outputs = vec![operational_speaks("The hinge is flooding.", "operational")]; outputs.extend(narrative_turn_outputs("Mara", "The rain has teeth tonight.")); + outputs.push(operational_speaks("Then we close the span.", "other")); let (runner, store) = grouped_runner(&mailbox, outputs); let operational_command = CommandId::new(); let narrative_command = CommandId::new(); @@ -19714,6 +19715,15 @@ mod tests { .run_narrative(narrative_command, &narrative_bound) .await .unwrap(); + // Another subject speaks in the commons, so every scope moves. + let other_bound = fresh_opportunity( + &mailbox.snapshot().await.unwrap(), + operational[1].scope.subject_id, + ); + runner + .run_operational(CommandId::new(), &other_bound) + .await + .unwrap(); let uncertain = uncertain_runner(&mailbox, &store); let moved = mailbox.snapshot().await.unwrap(); From b5dc76dd75cef47efa54bbf068027c227775ad7c Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 14:19:42 +0200 Subject: [PATCH 24/62] VerseRunStore: tick writers are private to the driver, open_tick is the store's insert-if-absent open_tick, close_tick and close_ticks_before lose `pub`: run_tick through open_or_find is the only caller, and a compile_fail doctest pins that an exported-type holder cannot call them. open_tick uses OwnedRedbMessagePackBackingStore::insert_entry_if_absent in one write transaction instead of read-then-push, so two opens of one ordinal cannot both succeed. Fixes s2 tick-rows-have-a-public-writer and open-tick-is-check-then-put. Builds. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/verse/run.rs | 54 +++++++++++++++++++++--------- 1 file changed, 39 insertions(+), 15 deletions(-) diff --git a/crates/ghostlight/src/verse/run.rs b/crates/ghostlight/src/verse/run.rs index d8e00d7..7b78198 100644 --- a/crates/ghostlight/src/verse/run.rs +++ b/crates/ghostlight/src/verse/run.rs @@ -301,6 +301,16 @@ fn tick_key(world: WorldId, ordinal: u64) -> String { format!("{}/{ordinal:020}", world.key()) } +fn tick_row(tick: &VerseTick) -> Result { + Ok(CultCacheEnvelope { + key: tick_key(tick.world_id, tick.ordinal), + r#type: TICK_ROW.into(), + payload: rmp_serde::to_vec_named(tick).map_err(|_| VerseRunError::Store)?, + stored_at: Utc::now().to_rfc3339(), + schema_id: Some(TICK_RECORD_SCHEMA.into()), + }) +} + /// Every row of the file, split by type. struct Rows { runs: Vec, @@ -309,7 +319,14 @@ struct Rows { /// Run records and tick documents in one CultCache file. A run is saved when /// it begins and again each time it moves, so a row is replaced by its own -/// later state. A tick is opened once and closed once. +/// later state. A tick is opened once and closed once, and only by the tick +/// driver: the writers are private to this module. +/// +/// ```compile_fail +/// fn wedge(store: &ghostlight::VerseRunStore, world: ghostlight::WorldId) { +/// store.close_ticks_before(world, 1).unwrap(); +/// } +/// ``` pub struct VerseRunStore { store: Mutex, } @@ -399,16 +416,29 @@ impl VerseRunStore { .find(|tick| tick.world_id == world && tick.ordinal == ordinal)) } - /// Writes a tick document that does not exist yet, open. - pub fn open_tick(&self, tick: &VerseTick) -> Result<(), VerseRunError> { - if tick.closed.is_some() || self.tick(tick.world_id, tick.ordinal)?.is_some() { + /// Writes a tick document that does not exist yet, open. The store's own + /// insert-if-absent decides, so two opens of one ordinal cannot both win. + /// Private: the tick driver is the only writer of tick rows. + fn open_tick(&self, tick: &VerseTick) -> Result<(), VerseRunError> { + if tick.closed.is_some() { return Err(VerseRunError::OutOfSequence); } - self.put_tick(tick) + let row = tick_row(tick)?; + let inserted = self + .store + .lock() + .map_err(|_| VerseRunError::Store)? + .insert_entry_if_absent(row) + .map_err(|_| VerseRunError::Store)?; + if inserted { + Ok(()) + } else { + Err(VerseRunError::OutOfSequence) + } } - /// Closes an open tick document. - pub fn close_tick( + /// Closes an open tick document. Private, like every tick writer. + fn close_tick( &self, world: WorldId, ordinal: u64, @@ -425,7 +455,7 @@ impl VerseRunStore { } /// Closes, as the world's, every open tick of the world below `ordinal`. - pub fn close_ticks_before(&self, world: WorldId, ordinal: u64) -> Result<(), VerseRunError> { + fn close_ticks_before(&self, world: WorldId, ordinal: u64) -> Result<(), VerseRunError> { for mut tick in self.records()?.ticks { if tick.world_id == world && tick.ordinal < ordinal && tick.closed.is_none() { tick.closed = Some(TickClose::World); @@ -436,13 +466,7 @@ impl VerseRunStore { } fn put_tick(&self, tick: &VerseTick) -> Result<(), VerseRunError> { - let row = CultCacheEnvelope { - key: tick_key(tick.world_id, tick.ordinal), - r#type: TICK_ROW.into(), - payload: rmp_serde::to_vec_named(tick).map_err(|_| VerseRunError::Store)?, - stored_at: Utc::now().to_rfc3339(), - schema_id: Some(TICK_RECORD_SCHEMA.into()), - }; + let row = tick_row(tick)?; self.store .lock() .map_err(|_| VerseRunError::Store)? From 5cf005eb2c8a1d2a5d3dc0249efa1d740100812a Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 14:19:43 +0200 Subject: [PATCH 25/62] Pin the tick ordinal edges and the racing open Lifts Soul's p8 (closed tick at the current ordinal refused before any cell) and p9 (a 45-minute external advance moves the ordinal) into tests, and a racing-open test for the create-only open. Fixes s2 ordinal-edges-unpinned. Builds. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/verse/run/tests.rs | 132 +++++++++++++++++++++++ 1 file changed, 132 insertions(+) diff --git a/crates/ghostlight/src/verse/run/tests.rs b/crates/ghostlight/src/verse/run/tests.rs index 8c1ea3b..fab6367 100644 --- a/crates/ghostlight/src/verse/run/tests.rs +++ b/crates/ghostlight/src/verse/run/tests.rs @@ -1487,3 +1487,135 @@ fn the_tick_store_opens_once_closes_once_and_refuses_non_canonical_rows() { ); } } + +/// A restored world keeps its run store: the closed tick document at the +/// world's own ordinal is refused before any provider call or act. +#[tokio::test] +async fn a_closed_tick_at_the_worlds_current_ordinal_is_refused_before_any_cell() { + let live = tempfile::tempdir().unwrap(); + create_world(live.path()).await; + let backup = live.path().join("world.backup"); + std::fs::copy(live.path().join("world.cc"), &backup).unwrap(); + { + let verse = Harness::open(live.path()).await; + let ran = verse + .run_quietly_under(ScriptedPort::speaking(), config_of(2, 60, 2)) + .await; + assert_eq!(ran.record.status, RunStatus::Completed); + let Harness { + mailbox, + _task, + calls, + runs, + work, + } = verse; + drop((calls, runs, work, mailbox)); + _task.await.unwrap(); + } + let restored = tempfile::tempdir().unwrap(); + std::fs::copy(&backup, restored.path().join("world.cc")).unwrap(); + std::fs::copy(live.path().join("runs.cc"), restored.path().join("runs.cc")).unwrap(); + let verse = Harness::open(restored.path()).await; + let world = verse.mailbox.snapshot().await.unwrap().world_id; + assert_eq!(verse.mailbox.clock_advances().await.unwrap(), 0); + assert!(verse.runs.tick(world, 0).unwrap().unwrap().closed.is_some()); + let acts = verse.acts().await; + + let port = ScriptedPort::speaking(); + let refused = verse.run_quietly_under(port.clone(), config_of(1, 60, 2)).await; + assert_eq!( + refused.record.status, + RunStatus::Failed { + reason: "run_store".into() + } + ); + assert_eq!(port.calls(), 0, "no provider call before the refusal"); + assert_eq!(verse.acts().await, acts); + assert_eq!(verse.now().await, 0); +} + +/// A port that moves the world's clock through another writer on its first +/// call, then answers like the wrapped port. +struct ClockMovedMid { + inner: Arc, + mailbox: WorldMailbox, + seen: AtomicUsize, + minutes: u32, +} + +#[async_trait] +impl InferencePort for ClockMovedMid { + fn prepare(&self, request: InferenceRequest) -> Result { + self.inner.prepare(request) + } + + async fn infer(&self, request: PreparedInference) -> Result { + if self.seen.fetch_add(1, Ordering::SeqCst) == 0 { + self.mailbox + .submit_clock(CommandId::new(), TickMinutes::new(self.minutes).unwrap()) + .await + .unwrap(); + } + self.inner.infer(request).await + } +} + +/// The ordinal counts every clock advance the world committed, whatever its +/// length: a 45-minute advance by another writer inside an open tick moves +/// it, so the next tick opens at ordinal 2 and ordinal 1 never has a document. +#[tokio::test] +async fn every_clock_advance_moves_the_ordinal_whatever_its_length() { + let (_dir, verse) = Harness::fresh().await; + let world = verse.mailbox.snapshot().await.unwrap().world_id; + let port = Arc::new(ClockMovedMid { + inner: ScriptedPort::speaking(), + mailbox: verse.mailbox.clone(), + seen: AtomicUsize::new(0), + minutes: 45, + }); + let first = verse.run_quietly_under(port, config_of(1, 60, 2)).await; + assert_eq!(first.record.status, RunStatus::Completed); + assert_eq!(verse.mailbox.clock_advances().await.unwrap(), 2); + assert_eq!( + verse.runs.tick(world, 0).unwrap().unwrap().closed, + Some(TickClose::Clock { run: 1 }) + ); + + let second = verse + .run_quietly_under(ScriptedPort::speaking(), config_of(1, 60, 2)) + .await; + assert_eq!(second.record.status, RunStatus::Completed); + assert_eq!(second.record.checkpoints[0].tick, 2); + assert!(verse.runs.tick(world, 1).unwrap().is_none()); + assert_eq!(verse.now().await, 45 + 60 + 60); +} + +/// Two opens of one ordinal cannot both succeed, even raced. +#[test] +fn two_racing_opens_of_one_tick_cannot_both_succeed() { + let dir = tempfile::tempdir().unwrap(); + let store = VerseRunStore::open(dir.path().join("runs.cc")).unwrap(); + let world = WorldId::nil_for_test(); + for ordinal in 0..100u64 { + let barrier = std::sync::Barrier::new(2); + let opened = std::thread::scope(|scope| { + let handles: Vec<_> = (1..=2u64) + .map(|run| { + let (store, barrier) = (&store, &barrier); + scope.spawn(move || { + let mut doc = tick_doc(world, ordinal); + doc.opened_by_run = run; + barrier.wait(); + store.open_tick(&doc).is_ok() + }) + }) + .collect(); + handles + .into_iter() + .map(|handle| handle.join().unwrap()) + .filter(|ok| *ok) + .count() + }); + assert_eq!(opened, 1, "ordinal {ordinal} was opened {opened} times"); + } +} From 2a888da955299fe59ce13ed88f5dd2a6cf53f2e6 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 14:46:47 +0200 Subject: [PATCH 26/62] Verse command: a lane that cannot open names the flag or fault kind, never the endpoint open_inference's error was wrapped with .context and printed through the anyhow chain, so a refused non-loopback --base-url put the operator's address on stderr. lane_fault maps each ControllerOpenError variant to one fixed message; ControllerOpenError is exported for it. Fixes local-endpoint-echoed. Builds; the workspace is unchanged elsewhere. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight-verse/src/main.rs | 30 +++++++++++++++++-- crates/ghostlight-verse/tests/verse_binary.rs | 29 +++++++++++++++++- crates/ghostlight/src/lib.rs | 1 + 3 files changed, 56 insertions(+), 4 deletions(-) diff --git a/crates/ghostlight-verse/src/main.rs b/crates/ghostlight-verse/src/main.rs index 52f0c50..43c97b2 100644 --- a/crates/ghostlight-verse/src/main.rs +++ b/crates/ghostlight-verse/src/main.rs @@ -9,10 +9,10 @@ //! `calls.cc`. The provider key is read from a file at run time and appears //! nowhere else. -use anyhow::{Context, Result, bail}; +use anyhow::{Context, Result, anyhow, bail}; use chrono::{Duration, Utc}; use ghostlight::{ - CommandBody, CommandId, ControllerModels, ControllerRunner, DEFAULT_HOSTED_MODEL_PREFIX, + CommandBody, CommandId, ControllerModels, ControllerOpenError, ControllerRunner, DEFAULT_HOSTED_MODEL_PREFIX, DEFAULT_LOCAL_MODEL_PREFIX, CallRecordStore, CoverBudget, CreateVerseWorldIntent, HostedBinding, Lens, LensWeights, LocalBinding, PrincipalCommandIntent, RecordingInferencePort, RunStart, RunStatus, TickMinutes, VerifiedPrincipalEvidence, VerseRunConfig, VerseRunStore, @@ -290,5 +290,29 @@ fn open_lane( bail!("--model names no inference lane (expected a hosted/ or local/ prefix)"); } open_inference(None, None, local, hosted, &models.each()) - .context("the inference lane cannot open") + .map_err(|error| anyhow!(lane_fault(&error))) } + +/// What a lane that cannot open says: the flag or the kind of fault, never a +/// value the operator passed. +fn lane_fault(error: &ControllerOpenError) -> &'static str { + match error { + ControllerOpenError::InvalidModels => "--model is not an exact nonempty identifier", + ControllerOpenError::Connector(_) => "the connector lane cannot open", + ControllerOpenError::WorkStore(_) => "the controller work journal cannot open", + ControllerOpenError::UnroutableModel { .. } => "--model is claimed by no inference lane", + ControllerOpenError::SdkSidecarMissing { .. } => "the SDK sidecar entry is not a file", + ControllerOpenError::LocalEndpointNotLoopback { .. } => { + "--base-url for a local model must be a loopback address" + } + ControllerOpenError::HostedEndpointNotHttps => { + "--base-url for a hosted model must be an https URL" + } + ControllerOpenError::HostedKeyUnreadable => { + "--key-file is unreadable, empty, or not one run of visible ASCII" + } + ControllerOpenError::SharedModelPrefix { .. } => "two inference lanes claim one model prefix", + ControllerOpenError::EmptyModelPrefix { .. } => "an inference lane's model prefix is empty", + } +} + diff --git a/crates/ghostlight-verse/tests/verse_binary.rs b/crates/ghostlight-verse/tests/verse_binary.rs index eda0cc6..e224c21 100644 --- a/crates/ghostlight-verse/tests/verse_binary.rs +++ b/crates/ghostlight-verse/tests/verse_binary.rs @@ -312,7 +312,7 @@ fn the_command_refuses_what_it_cannot_run_and_never_echoes_a_value() { "--token-cap", "1", "--tick-span-minutes", "60", "--model", "hosted/x", "--base-url", "https://api.example.test", "--key-file", key.to_str().unwrap(), ], - "the inference lane cannot open", + "--key-file is unreadable", ), ( vec![ @@ -332,3 +332,30 @@ fn the_command_refuses_what_it_cannot_run_and_never_echoes_a_value() { assert!(!error.contains("many"), "{arguments:?} echoed a value: {error}"); } } + +#[test] +fn a_non_loopback_local_endpoint_is_refused_without_echoing_it() { + let state = tempfile::tempdir().unwrap(); + assert!(genesis(state.path()).status.success()); + let output = verse(&[ + "run", + "--state", + state.path().to_str().unwrap(), + "--ticks", + "1", + "--token-cap", + "1000", + "--tick-span-minutes", + "60", + "--model", + "local/x", + "--base-url", + "http://10.77.0.99:4242", + ]); + assert!(!output.status.success()); + let error = stderr(&output); + assert!(error.contains("--base-url"), "{error}"); + assert!(!error.contains("10.77.0.99"), "the endpoint was echoed: {error}"); + // The lane failed before any run was claimed. + assert!(!stdout(&output).contains("run 1"), "{}", stdout(&output)); +} diff --git a/crates/ghostlight/src/lib.rs b/crates/ghostlight/src/lib.rs index be64414..d59b1c5 100644 --- a/crates/ghostlight/src/lib.rs +++ b/crates/ghostlight/src/lib.rs @@ -85,6 +85,7 @@ pub use consumer::{ }; pub use controllers::{ CONTROLLER_WORK_SCHEMA, CellRun, ConnectorBinding, ControllerError, ControllerModels, + ControllerOpenError, ControllerNeed, ControllerPendingReason, ControllerRunner, ControllerWork, ControllerWorkCustody, ControllerWorkLookup, ControllerWorkStore, ControllerWorkStoreError, ControllerWorkWrite, From 92275cbe7cf2cd7e710723a188707719f79bc161 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 14:47:18 +0200 Subject: [PATCH 27/62] Verse run: an operator log that cannot be read ends the run after the tick's checkpoint The tick's acts came from operator_log().map(..).unwrap_or_default(), so a failed read reported a tick of no acts. acts_since returns None for an unreadable log and run_tick ends the run Failed(operator_log_unavailable) with the checkpoint as it stood. Fixes operator-log-error-swallowed. Seam test: the log cannot be made to fail through the kernel's doors. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/verse/run.rs | 35 +++++++++++++++++------- crates/ghostlight/src/verse/run/tests.rs | 24 ++++++++++++++++ 2 files changed, 49 insertions(+), 10 deletions(-) diff --git a/crates/ghostlight/src/verse/run.rs b/crates/ghostlight/src/verse/run.rs index 7b78198..be4a9d8 100644 --- a/crates/ghostlight/src/verse/run.rs +++ b/crates/ghostlight/src/verse/run.rs @@ -820,30 +820,45 @@ async fn run_tick( } let spent = port.spent(); + // The act listing is the tick's report to the operator: a log that cannot + // be read ends the run after the tick's checkpoint, closed or not. + let checkpoint_closed = open.is_none(); + let acts = match acts_since(mailbox.operator_log().await, snapshot.revision) { + Some(acts) => acts, + None => { + open = Some(failed("operator_log_unavailable")); + Vec::new() + } + }; let report = TickReport { checkpoint: TickCheckpoint { tick: ordinal, - closed: open.is_none(), + closed: checkpoint_closed, cells: tick.cells.len() as u32, committed, refusals, tokens: spent.saturating_sub(spent_before), }, - acts: mailbox - .operator_log() - .await - .map(|log| { - log.into_iter() - .filter(|event| event.revision > snapshot.revision) - .collect() - }) - .unwrap_or_default(), + acts, spent, remaining: port.cap().saturating_sub(spent), }; (open.map_or(TickEnd::Closed, TickEnd::Open), Some(report)) } +/// The acts a tick committed: the operator log's events past the tick's +/// opening revision. A log that cannot be read is `None`, never an empty tick. +fn acts_since( + log: Result, MailboxError>, + since: u64, +) -> Option> { + log.ok().map(|log| { + log.into_iter() + .filter(|event| event.revision > since) + .collect() + }) +} + /// The tick document for `ordinal`: the open one a run left, or a new one /// frozen from a cover derived now. An open tick the world has moved past is /// closed as the world's first. A document already closed at the world's diff --git a/crates/ghostlight/src/verse/run/tests.rs b/crates/ghostlight/src/verse/run/tests.rs index fab6367..0698484 100644 --- a/crates/ghostlight/src/verse/run/tests.rs +++ b/crates/ghostlight/src/verse/run/tests.rs @@ -1037,6 +1037,30 @@ async fn a_port_opened_for_another_run_is_refused_before_anything_is_written() { assert!(verse.runs.runs(world.world_id).unwrap().is_empty()); } +#[tokio::test] +async fn an_unreadable_operator_log_is_no_tick_report() { + let (_dir, verse) = Harness::fresh().await; + verse.run_quietly(ScriptedPort::speaking(), u64::MAX, 2).await; + let log = verse.mailbox.operator_log().await.unwrap(); + assert!(log.len() >= 4, "two ticks of two cells acted"); + + // A log that cannot be read is no tick report, whatever the revision. + assert!(acts_since(Err(MailboxError::Unavailable), 0).is_none()); + assert!(acts_since(Err(MailboxError::Unavailable), u64::MAX).is_none()); + + // A log that can is cut at the tick's opening revision. + assert_eq!(acts_since(Ok(log.clone()), 0), Some(log.clone())); + let middle = log[1].revision; + let later: Vec<_> = log.iter().filter(|e| e.revision > middle).cloned().collect(); + assert!(!later.is_empty() && later.len() < log.len()); + assert_eq!(acts_since(Ok(log.clone()), middle), Some(later)); + assert_eq!( + acts_since(Ok(log), u64::MAX), + Some(Vec::new()), + "an empty tick is a readable one" + ); +} + fn record(world: WorldId, sequence: u64) -> VerseRunRecord { VerseRunRecord { world_id: world, From d1883e99733ee311d8e36ab1f626de7bde19c1b1 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 14:47:52 +0200 Subject: [PATCH 28/62] Verse run: the run store claims a run's sequence before its call ledger opens next_sequence was a public read followed by a caller-chosen sequence on save, so a second run could take number 1 and overwrite run 1's record, and a start refused after its ledger opened bound that cap to the number the next run was handed. VerseRunStore::claim now allocates and writes the Running record in one insert-if-absent and returns a non-Clone RunClaim; next_sequence and save are private and save is update-only. run_manual takes the claim, refuses a port whose run id or cap is not the claim's, a claim for another world, or a config other than the claim's, and abandons the claim as Failed in each case. The binary claims before opening the ledger and abandons a start it cannot make. Fixes old-sequence-overwrites-record and refused-start-binds-cap. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight-verse/src/main.rs | 50 +++- crates/ghostlight/src/lib.rs | 2 +- crates/ghostlight/src/verse/mod.rs | 2 +- crates/ghostlight/src/verse/run.rs | 180 ++++++++++--- crates/ghostlight/src/verse/run/tests.rs | 315 ++++++++++++++++------- 5 files changed, 400 insertions(+), 149 deletions(-) diff --git a/crates/ghostlight-verse/src/main.rs b/crates/ghostlight-verse/src/main.rs index 43c97b2..57fec74 100644 --- a/crates/ghostlight-verse/src/main.rs +++ b/crates/ghostlight-verse/src/main.rs @@ -195,13 +195,40 @@ async fn run(flags: Flags) -> Result { let runs = VerseRunStore::open(state.join("runs.cc")).context("the run records cannot be opened")?; let work = open_controller_work(state.join("controller-work.cc")) .context("the controller work store cannot be opened")?; - let sequence = runs.next_sequence(world.world_id).context("the run records cannot be read")?; - let port = Arc::new( - RecordingInferencePort::open(inner, calls, call_run_id(sequence), cap, &RunStart::of(&world)) - .context("the run's call records cannot be opened")?, - ); - let runner = ControllerRunner::open(mailbox.clone(), port.clone(), work, models) - .context("the controller organ cannot open")?; + let config = VerseRunConfig { + ticks, + tick_span, + cover: COVER, + }; + let claim = runs + .claim(&world, cap, &config) + .context("the run cannot be claimed")?; + let sequence = claim.sequence(); + // A start that fails after the claim ends the claimed run, so it binds + // nothing: the next claim takes the next number. + let started = RecordingInferencePort::open( + inner, + calls, + call_run_id(sequence), + cap, + &RunStart::of(&world), + ) + .context("the run's call records cannot be opened") + .and_then(|port| { + let port = Arc::new(port); + ControllerRunner::open(mailbox.clone(), port.clone(), work, models) + .map(|runner| (port, runner)) + .context("the controller organ cannot open") + }); + let (port, runner) = match started { + Ok(started) => started, + Err(error) => { + return Err(match runs.abandon(claim, "start_refused") { + Ok(()) => error, + Err(_) => error.context("the refused run's record could not be ended"), + }); + } + }; println!( "run {sequence}: {ticks} ticks of {span} minutes, cap {cap} tokens, model {model}" @@ -220,12 +247,8 @@ async fn run(flags: Flags) -> Result { &runner, &port, &runs, - &VerseRunConfig { - ticks, - tick_span, - cover: COVER, - }, - sequence, + claim, + &config, |report| { print!("{report}"); if interrupted.load(Ordering::SeqCst) { @@ -315,4 +338,3 @@ fn lane_fault(error: &ControllerOpenError) -> &'static str { ControllerOpenError::EmptyModelPrefix { .. } => "an inference lane's model prefix is empty", } } - diff --git a/crates/ghostlight/src/lib.rs b/crates/ghostlight/src/lib.rs index d59b1c5..a6c4f2e 100644 --- a/crates/ghostlight/src/lib.rs +++ b/crates/ghostlight/src/lib.rs @@ -146,7 +146,7 @@ pub use table::{ pub use vault::{VaultError, VaultEvidenceSource}; pub use verse::{ CALL_RECORD_SCHEMA, CallRecordError, CallRecordStore, CreateVerseWorldIntent, ProviderCallRecord, - RUN_RECORD_SCHEMA, RecordedCoverBudget, RecordingInferencePort, Refusal, RefusalReason, + RUN_RECORD_SCHEMA, RecordedCoverBudget, RecordingInferencePort, Refusal, RefusalReason, RunClaim, ReplayInferencePort, RunField, RunId, RunOutcome, RunStart, RunStatus, TickCheckpoint, TickReport, VerseGenesisError, VerseRunConfig, VerseRunError, VerseRunRecord, VerseRunStore, call_run_id, run_manual, diff --git a/crates/ghostlight/src/verse/mod.rs b/crates/ghostlight/src/verse/mod.rs index 443b999..7822187 100644 --- a/crates/ghostlight/src/verse/mod.rs +++ b/crates/ghostlight/src/verse/mod.rs @@ -13,7 +13,7 @@ pub use calls::{ RecordingInferencePort, ReplayInferencePort, RunField, RunId, RunStart, }; pub use run::{ - RUN_RECORD_SCHEMA, RecordedCoverBudget, Refusal, RefusalReason, RunOutcome, RunStatus, + RUN_RECORD_SCHEMA, RecordedCoverBudget, Refusal, RefusalReason, RunClaim, RunOutcome, RunStatus, TickCheckpoint, TickReport, VerseRunConfig, VerseRunError, VerseRunRecord, VerseRunStore, call_run_id, run_manual, }; diff --git a/crates/ghostlight/src/verse/run.rs b/crates/ghostlight/src/verse/run.rs index be4a9d8..179653d 100644 --- a/crates/ghostlight/src/verse/run.rs +++ b/crates/ghostlight/src/verse/run.rs @@ -59,6 +59,8 @@ pub enum VerseRunError { OutOfSequence, #[error("the recording port belongs to another run")] PortNotThisRun, + #[error("the run's configuration is not the one its claim recorded")] + ConfigNotThisClaim, #[error("the cover budget is not valid: {0}")] Budget(#[from] CoverBudgetError), #[error(transparent)] @@ -311,15 +313,43 @@ fn tick_row(tick: &VerseTick) -> Result { }) } +fn run_row(record: &VerseRunRecord) -> Result { + Ok(CultCacheEnvelope { + key: row_key(record.world_id, record.sequence), + r#type: RUN_ROW.into(), + payload: rmp_serde::to_vec_named(record).map_err(|_| VerseRunError::Store)?, + stored_at: Utc::now().to_rfc3339(), + schema_id: Some(RUN_RECORD_SCHEMA.into()), + }) +} + +/// A run's number and cap, claimed from the run store before anything of the +/// run exists. The call ledger's run id derives from it ([`call_run_id`]), and +/// [`run_manual`] runs only the record it names. Not `Clone`: a claim runs once +/// or is abandoned. +#[derive(Debug)] +pub struct RunClaim { + world_id: WorldId, + sequence: u64, + token_cap: u64, +} + +impl RunClaim { + /// The run's number, the one [`call_run_id`] names. + pub fn sequence(&self) -> u64 { + self.sequence + } +} + /// Every row of the file, split by type. struct Rows { runs: Vec, ticks: Vec, } -/// Run records and tick documents in one CultCache file. A run is saved when -/// it begins and again each time it moves, so a row is replaced by its own -/// later state. A tick is opened once and closed once, and only by the tick +/// Run records and tick documents in one CultCache file. A run is claimed +/// when it begins and saved again each time it moves, so a row is replaced by +/// its own later state. A tick is opened once and closed once, and only by the tick /// driver: the writers are private to this module. /// /// ```compile_fail @@ -383,9 +413,9 @@ impl VerseRunStore { Ok(Rows { runs, ticks }) } - /// The sequence the next run of this world must carry. Sequences count from - /// one. - pub fn next_sequence(&self, world: WorldId) -> Result { + /// The sequence the next claim of this world takes. Sequences count from + /// one. Private: a caller never chooses a run's number, `claim` does. + fn next_sequence(&self, world: WorldId) -> Result { Ok(self .records()? .runs @@ -474,22 +504,78 @@ impl VerseRunStore { .map_err(|_| VerseRunError::Store) } - /// Writes the record: the next run of its world, or a run already saved. - pub fn save(&self, record: &VerseRunRecord) -> Result<(), VerseRunError> { - if record.sequence == 0 || record.sequence > self.next_sequence(record.world_id)? { + /// Claims the next run of the snapshot's world: writes its `Running` + /// record, with the cap and config it was claimed under, in one insert that + /// cannot overwrite a record. The returned claim is the only way to run it; + /// a claim that never runs stays `Running` (a process that died) or is + /// ended by [`abandon`](Self::abandon), and its number is never issued + /// again. + pub fn claim( + &self, + snapshot: &WorldSnapshot, + token_cap: u64, + config: &VerseRunConfig, + ) -> Result { + let cover_budget = config.cover.validated()?; + let record = VerseRunRecord { + world_id: snapshot.world_id, + sequence: self.next_sequence(snapshot.world_id)?, + token_cap, + ticks_requested: config.ticks, + tick_span_minutes: config.tick_span.minutes(), + cover_budget: RecordedCoverBudget { + cells: cover_budget.cells, + constituent_cap: cover_budget.constituent_cap, + urgency_slots: cover_budget.urgency_slots, + }, + start_revision: snapshot.revision, + checkpoints: Vec::new(), + status: RunStatus::Running, + }; + let inserted = self + .store + .lock() + .map_err(|_| VerseRunError::Store)? + .insert_entry_if_absent(run_row(&record)?) + .map_err(|_| VerseRunError::Store)?; + if !inserted { return Err(VerseRunError::OutOfSequence); } - let row = CultCacheEnvelope { - key: row_key(record.world_id, record.sequence), - r#type: RUN_ROW.into(), - payload: rmp_serde::to_vec_named(record).map_err(|_| VerseRunError::Store)?, - stored_at: Utc::now().to_rfc3339(), - schema_id: Some(RUN_RECORD_SCHEMA.into()), - }; + Ok(RunClaim { + world_id: record.world_id, + sequence: record.sequence, + token_cap, + }) + } + + /// Ends a claim that never ran: its record becomes `Failed { reason }`. + pub fn abandon(&self, claim: RunClaim, reason: &'static str) -> Result<(), VerseRunError> { + let mut record = self.claimed(&claim)?; + record.status = failed(reason); + self.save(&record) + } + + /// The record a claim names. + fn claimed(&self, claim: &RunClaim) -> Result { + self.records()? + .runs + .into_iter() + .find(|record| record.world_id == claim.world_id && record.sequence == claim.sequence) + .ok_or(VerseRunError::OutOfSequence) + } + + /// Replaces a claimed record with its later state. A sequence with no + /// record is refused: only `claim` creates one. + fn save(&self, record: &VerseRunRecord) -> Result<(), VerseRunError> { + self.claimed(&RunClaim { + world_id: record.world_id, + sequence: record.sequence, + token_cap: record.token_cap, + })?; self.store .lock() .map_err(|_| VerseRunError::Store)? - .push(&row) + .push(&run_row(record)?) .map_err(|_| VerseRunError::Store) } } @@ -627,39 +713,43 @@ fn failed(reason: &str) -> RunStatus { /// Runs `config.ticks` ticks from the world's present clock under the cap the /// recording port was opened with. `observe` sees each tick as it ends, /// including the one a run ends inside, and may stop the run after a finished -/// tick by breaking. `sequence` is the run's number from -/// [`VerseRunStore::next_sequence`]; the port must have been opened for it -/// ([`call_run_id`]). +/// tick by breaking. `claim` is the run's number from +/// [`VerseRunStore::claim`]; the port must have been opened for it +/// ([`call_run_id`]) under the cap the claim recorded, and `config` is the one +/// the claim was made with. A claim this refuses is abandoned, so a refused +/// start binds nothing. pub async fn run_manual( mailbox: &WorldMailbox, runner: &ControllerRunner, port: &RecordingInferencePort, store: &VerseRunStore, + claim: RunClaim, config: &VerseRunConfig, - sequence: u64, mut observe: impl FnMut(&TickReport) -> ControlFlow<()>, ) -> Result { - if port.run_id() != &call_run_id(sequence) { + let begun = match mailbox.snapshot().await { + Ok(begun) => begun, + Err(error) => { + store.abandon(claim, "world_unavailable")?; + return Err(error.into()); + } + }; + if port.run_id() != &call_run_id(claim.sequence) + || port.cap() != claim.token_cap + || begun.world_id != claim.world_id + { + store.abandon(claim, "port_not_this_run")?; return Err(VerseRunError::PortNotThisRun); } - let cover_budget = config.cover.validated()?; - let begun = mailbox.snapshot().await?; - let mut record = VerseRunRecord { - world_id: begun.world_id, - sequence, - token_cap: port.cap(), - ticks_requested: config.ticks, - tick_span_minutes: config.tick_span.minutes(), - cover_budget: RecordedCoverBudget { - cells: cover_budget.cells, - constituent_cap: cover_budget.constituent_cap, - urgency_slots: cover_budget.urgency_slots, - }, - start_revision: begun.revision, - checkpoints: Vec::new(), - status: RunStatus::Running, + let sequence = claim.sequence; + let mut record = store.claimed(&claim)?; + let cover_budget = match config.cover.validated() { + Ok(cover_budget) if record_matches(&record, config, cover_budget) => cover_budget, + _ => { + store.abandon(claim, "config_not_this_claim")?; + return Err(VerseRunError::ConfigNotThisClaim); + } }; - store.save(&record)?; let mut spent_before = port.spent(); for completed in 0..config.ticks { @@ -704,6 +794,18 @@ pub async fn run_manual( Ok(RunOutcome { record }) } +/// Whether the record a claim wrote was made under this config. +fn record_matches(record: &VerseRunRecord, config: &VerseRunConfig, cover: CoverBudget) -> bool { + record.ticks_requested == config.ticks + && record.tick_span_minutes == config.tick_span.minutes() + && record.cover_budget + == RecordedCoverBudget { + cells: cover.cells, + constituent_cap: cover.constituent_cap, + urgency_slots: cover.urgency_slots, + } +} + /// The key a run's provider calls are recorded under: the run's sequence, so a /// run record and its call records name each other. pub fn call_run_id(sequence: u64) -> super::RunId { diff --git a/crates/ghostlight/src/verse/run/tests.rs b/crates/ghostlight/src/verse/run/tests.rs index 0698484..82da589 100644 --- a/crates/ghostlight/src/verse/run/tests.rs +++ b/crates/ghostlight/src/verse/run/tests.rs @@ -213,7 +213,7 @@ impl Harness { (dir, harness) } - /// One run over `inner`, recorded under the next sequence. + /// One run over `inner`, claimed and recorded under the next sequence. async fn run( &self, inner: Arc, @@ -224,7 +224,8 @@ impl Harness { self.run_under(inner, cap, config(ticks), observe).await } - /// One run under the given config, recorded under the next sequence. + /// One run under the given config, claimed and recorded under the next + /// sequence. async fn run_under( &self, inner: Arc, @@ -233,7 +234,8 @@ impl Harness { observe: impl FnMut(&TickReport) -> ControlFlow<()>, ) -> RunOutcome { let world = self.mailbox.snapshot().await.unwrap(); - let sequence = self.runs.next_sequence(world.world_id).unwrap(); + let claim = self.runs.claim(&world, cap, &config).unwrap(); + let sequence = claim.sequence(); let port = Arc::new( RecordingInferencePort::open( inner, @@ -252,8 +254,8 @@ impl Harness { &runner, &port, &self.runs, + claim, &config, - sequence, observe, ) .await @@ -977,17 +979,21 @@ fn an_error_ends_a_cell_as_the_run_must_treat_it() { )); } -#[tokio::test] -async fn a_port_opened_for_another_run_is_refused_before_anything_is_written() { - let (_dir, verse) = Harness::fresh().await; - let world = verse.mailbox.snapshot().await.unwrap(); +/// A recording port over the harness's call store for `sequence` under `cap`, +/// and a runner on it. +fn port_and_runner( + verse: &Harness, + world: &WorldSnapshot, + sequence: u64, + cap: u64, +) -> (Arc, ControllerRunner) { let port = Arc::new( RecordingInferencePort::open( ScriptedPort::speaking(), verse.calls.clone(), - call_run_id(7), - 1_000, - &RunStart::of(&world), + call_run_id(sequence), + cap, + &RunStart::of(world), ) .unwrap(), ); @@ -998,43 +1004,215 @@ async fn a_port_opened_for_another_run_is_refused_before_anything_is_written() { models(), ) .unwrap(); - let error = run_manual( + (port, runner) +} + +/// Runs `claim` on the given port and runner, which must refuse it. +async fn refused( + verse: &Harness, + port: &RecordingInferencePort, + runner: &ControllerRunner, + claim: RunClaim, + config: &VerseRunConfig, +) -> VerseRunError { + run_manual( &verse.mailbox, - &runner, - &port, + runner, + port, &verse.runs, - &config(1), - 1, + claim, + config, |_| ControlFlow::Continue(()), ) .await - .unwrap_err(); - assert!(matches!(error, VerseRunError::PortNotThisRun)); - assert!(verse.runs.runs(world.world_id).unwrap().is_empty()); + .unwrap_err() +} - // A cover budget with no cells is refused before the record exists. +#[tokio::test] +async fn a_run_started_on_the_wrong_port_world_or_config_is_refused_and_its_claim_ends_failed() { + let (_dir, verse) = Harness::fresh().await; + let world = verse.mailbox.snapshot().await.unwrap(); + let (_other_dir, elsewhere) = Harness::fresh().await; + let strange = elsewhere.mailbox.snapshot().await.unwrap(); + + // A cover budget with no cells cannot be claimed: no record exists. let mut bad = config(1); bad.cover.cells = 0; - let own = Arc::new( - RecordingInferencePort::open( - ScriptedPort::speaking(), - verse.calls.clone(), - call_run_id(1), - 1_000, - &RunStart::of(&world), - ) - .unwrap(), - ); - let error = run_manual(&verse.mailbox, &runner, &own, &verse.runs, &bad, 1, |_| { - ControlFlow::Continue(()) - }) - .await - .unwrap_err(); assert!(matches!( - error, - VerseRunError::Budget(CoverBudgetError::NoCells) + verse.runs.claim(&world, 1_000, &bad), + Err(VerseRunError::Budget(CoverBudgetError::NoCells)) )); assert!(verse.runs.runs(world.world_id).unwrap().is_empty()); + + // Run 1: a port opened for another run's number. + let claim = verse.runs.claim(&world, 1_000, &config(1)).unwrap(); + let (port, runner) = port_and_runner(&verse, &world, 7, 1_000); + let error = refused(&verse, &port, &runner, claim, &config(1)).await; + assert!(matches!(error, VerseRunError::PortNotThisRun)); + + // Run 2: a port under another cap than the claim recorded. + let claim = verse.runs.claim(&world, 1_000, &config(1)).unwrap(); + let (port, runner) = port_and_runner(&verse, &world, claim.sequence(), 5_000); + let error = refused(&verse, &port, &runner, claim, &config(1)).await; + assert!(matches!(error, VerseRunError::PortNotThisRun)); + + // Run 3: a config other than the one the claim was made with. + let claim = verse.runs.claim(&world, 1_000, &config(1)).unwrap(); + let (port, runner) = port_and_runner(&verse, &world, claim.sequence(), 1_000); + let error = refused(&verse, &port, &runner, claim, &config(2)).await; + assert!(matches!(error, VerseRunError::ConfigNotThisClaim)); + + // Another world's claim 1, on this world's mailbox and a port that fits it. + let claim = verse.runs.claim(&strange, 1_000, &config(1)).unwrap(); + let (port, runner) = port_and_runner(&verse, &world, claim.sequence(), 1_000); + let error = refused(&verse, &port, &runner, claim, &config(1)).await; + assert!(matches!(error, VerseRunError::PortNotThisRun)); + + let statuses = |world: &WorldSnapshot| -> Vec<(u64, RunStatus)> { + verse + .runs + .runs(world.world_id) + .unwrap() + .into_iter() + .map(|record| (record.sequence, record.status)) + .collect() + }; + assert_eq!( + statuses(&world), + vec![ + (1, failed("port_not_this_run")), + (2, failed("port_not_this_run")), + (3, failed("config_not_this_claim")), + ] + ); + assert_eq!(statuses(&strange), vec![(1, failed("port_not_this_run"))]); + assert!(verse.runs.tick(world.world_id, 0).unwrap().is_none()); + assert!(verse.runs.tick(strange.world_id, 0).unwrap().is_none()); + assert_eq!(verse.mailbox.operator_log().await.unwrap().len(), 0); +} + +fn record(world: WorldId, sequence: u64) -> VerseRunRecord { + VerseRunRecord { + world_id: world, + sequence, + token_cap: 10, + ticks_requested: 1, + tick_span_minutes: SPAN, + cover_budget: RecordedCoverBudget { + cells: 2, + constituent_cap: 8, + urgency_slots: 0, + }, + start_revision: 0, + checkpoints: Vec::new(), + status: RunStatus::Running, + } +} + +#[tokio::test] +async fn the_run_store_claims_runs_per_world_and_replaces_a_claimed_run_with_its_later_state() { + let (_one_dir, one) = Harness::fresh().await; + let (_other_dir, other) = Harness::fresh().await; + let one = one.mailbox.snapshot().await.unwrap(); + let other = other.mailbox.snapshot().await.unwrap(); + let dir = tempfile::tempdir().unwrap(); + let path = dir.path().join("runs.cc"); + let store = VerseRunStore::open(&path).unwrap(); + + // Claims number runs from one, per world, and write the Running record. + let first = store.claim(&one, 10, &config(3)).unwrap(); + let second = store.claim(&one, 20, &config(1)).unwrap(); + let elsewhere = store.claim(&other, 10, &config(1)).unwrap(); + assert_eq!( + (first.sequence(), second.sequence(), elsewhere.sequence()), + (1, 2, 1) + ); + let claimed = store.runs(one.world_id).unwrap(); + assert_eq!(claimed.len(), 2); + assert_eq!(claimed[0].token_cap, 10); + assert_eq!(claimed[0].ticks_requested, 3); + assert_eq!(claimed[1].token_cap, 20); + assert_eq!(claimed[0].start_revision, one.revision); + assert_eq!(claimed[0].status, RunStatus::Running); + + // A record is only ever a claimed one: a sequence with no record is refused. + assert!(matches!( + store.save(&record(one.world_id, 3)), + Err(VerseRunError::OutOfSequence) + )); + assert!(matches!( + store.save(&record(one.world_id, 0)), + Err(VerseRunError::OutOfSequence) + )); + assert_eq!(store.runs(one.world_id).unwrap().len(), 2); + + // Saving a claimed run again replaces it, and the file reopens to the + // same rows. + let mut finished = claimed[0].clone(); + finished.status = RunStatus::Completed; + store.save(&finished).unwrap(); + drop(store); + let store = VerseRunStore::open(&path).unwrap(); + assert_eq!( + store.runs(one.world_id).unwrap(), + vec![finished, claimed[1].clone()] + ); + assert_eq!(store.runs(other.world_id).unwrap().len(), 1); +} + +#[tokio::test] +async fn a_claim_is_never_reissued_even_when_its_run_never_started() { + let (_dir, verse) = Harness::fresh().await; + let world = verse.mailbox.snapshot().await.unwrap(); + + // Run 1 is claimed, then nothing happens: the process died. + let lost = verse.runs.claim(&world, 1_000_000_000, &config(1)).unwrap(); + assert_eq!(lost.sequence(), 1); + let before = verse.runs.runs(world.world_id).unwrap(); + drop(lost); + + // The next run is run 2 and leaves run 1's record exactly as it was. + let outcome = verse + .run_quietly(ScriptedPort::speaking(), 1_000_000_000, 1) + .await; + assert_eq!(outcome.record.sequence, 2); + assert_eq!(outcome.record.status, RunStatus::Completed); + let after = verse.runs.runs(world.world_id).unwrap(); + assert_eq!(after.len(), 2); + assert_eq!(after[0], before[0], "run 1's record was overwritten"); + assert_eq!(after[0].status, RunStatus::Running); + assert_eq!(after[1], outcome.record); +} + +#[tokio::test] +async fn a_start_refused_after_the_claim_leaves_the_next_run_free_to_choose_its_cap() { + let (_dir, verse) = Harness::fresh().await; + let world = verse.mailbox.snapshot().await.unwrap(); + + // Run 1 is claimed and its call ledger opens under a cap, then the start + // is refused before the run begins. + let claim = verse.runs.claim(&world, 1_000, &config(1)).unwrap(); + let ledger = RecordingInferencePort::open( + ScriptedPort::speaking(), + verse.calls.clone(), + call_run_id(claim.sequence()), + 1_000, + &RunStart::of(&world), + ) + .unwrap(); + drop(ledger); + verse.runs.abandon(claim, "start_refused").unwrap(); + + // The operator tries again under another cap: it is a new run, and runs. + let outcome = verse + .run_quietly(ScriptedPort::speaking(), 50_000, 1) + .await; + assert_eq!(outcome.record.sequence, 2); + assert_eq!(outcome.record.token_cap, 50_000); + assert_eq!(outcome.record.status, RunStatus::Completed); + let runs = verse.runs.runs(world.world_id).unwrap(); + assert_eq!(runs[0].status, failed("start_refused")); + assert_eq!(runs[0].token_cap, 1_000); } #[tokio::test] @@ -1061,62 +1239,6 @@ async fn an_unreadable_operator_log_is_no_tick_report() { ); } -fn record(world: WorldId, sequence: u64) -> VerseRunRecord { - VerseRunRecord { - world_id: world, - sequence, - token_cap: 10, - ticks_requested: 1, - tick_span_minutes: SPAN, - cover_budget: RecordedCoverBudget { - cells: 2, - constituent_cap: 8, - urgency_slots: 0, - }, - start_revision: 0, - checkpoints: Vec::new(), - status: RunStatus::Running, - } -} - -#[test] -fn the_run_store_numbers_runs_per_world_and_replaces_a_run_with_its_later_state() { - let dir = tempfile::tempdir().unwrap(); - let path = dir.path().join("runs.cc"); - let store = VerseRunStore::open(&path).unwrap(); - let one = WorldId::nil_for_test(); - let other = WorldId::issue(); - assert_eq!(store.next_sequence(one).unwrap(), 1); - - // A run can only begin as the next in its world. - assert!(matches!( - store.save(&record(one, 2)), - Err(VerseRunError::OutOfSequence) - )); - assert!(matches!( - store.save(&record(one, 0)), - Err(VerseRunError::OutOfSequence) - )); - store.save(&record(one, 1)).unwrap(); - assert_eq!(store.next_sequence(one).unwrap(), 2); - assert_eq!( - store.next_sequence(other).unwrap(), - 1, - "sequences are per world" - ); - - // Saving a run again replaces it, and the file reopens to the same rows. - let mut finished = record(one, 1); - finished.status = RunStatus::Completed; - store.save(&finished).unwrap(); - store.save(&record(one, 2)).unwrap(); - store.save(&record(other, 1)).unwrap(); - drop(store); - let store = VerseRunStore::open(&path).unwrap(); - assert_eq!(store.runs(one).unwrap(), vec![finished, record(one, 2)]); - assert_eq!(store.runs(other).unwrap(), vec![record(other, 1)]); -} - #[test] fn the_run_store_refuses_rows_that_are_not_canonical_run_records() { let dir = tempfile::tempdir().unwrap(); @@ -1472,7 +1594,12 @@ fn the_tick_store_opens_once_closes_once_and_refuses_non_canonical_rows() { assert!(store.tick(world, 7).unwrap().is_none()); // Tick documents and run records share the file and reopen intact. - store.save(&record(world, 1)).unwrap(); + store + .store + .lock() + .unwrap() + .push(&run_row(&record(world, 1)).unwrap()) + .unwrap(); drop(store); let store = VerseRunStore::open(&path).unwrap(); assert_eq!(store.runs(world).unwrap(), vec![record(world, 1)]); From 5f2e629c32f335fff66881a50e7e93ee6ee51da9 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 15:22:22 +0200 Subject: [PATCH 29/62] Controller lookup tests: committed declines, never-sent rows, uncertain custody, snapshot loss Pins the lookup rule's untested halves: a committed NoProposal row is answered with its receipt after its scope moved (both lanes); a ReadyToSubmit or NoProposal row that never reached the world ends World(ScopeChanged) with no provider call in the operational lane and is lowered once more (one provider call) and committed in the narrative lane; an in-flight row read in uncertain custody keeps the digest gate (both lanes); a grouped cell whose world cannot be read at submission is pending WorldUnavailable with nothing submitted. Fault injection lives in test fixtures only. Test-only change; builds. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/controllers.rs | 539 +++++++++++++++++++++++++++ 1 file changed, 539 insertions(+) diff --git a/crates/ghostlight/src/controllers.rs b/crates/ghostlight/src/controllers.rs index 563b2a5..908988b 100644 --- a/crates/ghostlight/src/controllers.rs +++ b/crates/ghostlight/src/controllers.rs @@ -19803,4 +19803,543 @@ mod tests { drop(mailbox); task.await.unwrap(); } + + fn operational_declines(receipt: &str) -> Result { + output( + vec![ + InferenceEvent::ToolCall { + call_id: "need".into(), + name: RECORD_NEED_TOOL.into(), + arguments: json!({"detail":"No current intervention is warranted."}) + .to_string(), + }, + InferenceEvent::ToolCall { + call_id: "finish".into(), + name: FINISH_WITHOUT_PROPOSAL_TOOL.into(), + arguments: "{}".into(), + }, + ], + receipt, + ) + } + + fn narrative_declines(tag: &str) -> Vec> { + let source = format!("{tag} lets the question pass."); + vec![ + output( + vec![InferenceEvent::Text(format!("{tag} watches the water."))], + &format!("{tag}-projector"), + ), + output( + vec![InferenceEvent::Text(source.clone())], + &format!("{tag}-persona"), + ), + output( + vec![ + InferenceEvent::ToolCall { + call_id: "gap".into(), + name: INTERPRETER_RECORD_GAP_TOOL.into(), + arguments: json!({ + "kind":"unresolved", + "source_quote":source, + "detail":"The prose expresses no supported world action." + }) + .to_string(), + }, + InferenceEvent::ToolCall { + call_id: "finish".into(), + name: FINISH_INTERPRETATION_TOOL.into(), + arguments: "{}".into(), + }, + ], + &format!("{tag}-interpreter"), + ), + ] + } + + #[tokio::test] + async fn a_committed_operational_decline_is_answered_with_its_receipt_after_its_scope_moved() { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::OperationalAgent, + NewController::OperationalAgent, + ]) + .await; + // Exactly two provider answers: a third call would panic the port. + let (runner, _store) = grouped_runner( + &mailbox, + vec![ + operational_declines("first"), + operational_speaks("Then we close the span.", "second"), + ], + ); + let snapshot = mailbox.snapshot().await.unwrap(); + let first = snapshot.opportunities[0].clone(); + let second = snapshot.opportunities[1].clone(); + let first_command = CommandId::new(); + let OperationalRun::Completed(committed) = runner + .run_operational(first_command, &first) + .await + .unwrap() + else { + panic!("the first decline remained pending") + }; + let SubmissionDisposition::NoProposal(SubmitReceipt::Applied(original)) = + committed.submission + else { + panic!("the first turn did not commit a decline") + }; + let second_now = + fresh_opportunity(&mailbox.snapshot().await.unwrap(), second.scope.subject_id); + assert!(matches!( + runner + .run_operational(CommandId::new(), &second_now) + .await + .unwrap(), + OperationalRun::Completed(_) + )); + let fresh = fresh_opportunity(&mailbox.snapshot().await.unwrap(), first.scope.subject_id); + assert_ne!(fresh.scope_digest, first.scope_digest, "the scope did not move"); + let OperationalRun::Completed(answered) = runner + .run_operational(first_command, &fresh) + .await + .unwrap() + else { + panic!("the committed decline was not answered") + }; + assert!( + matches!( + &answered.submission, + SubmissionDisposition::NoProposal(SubmitReceipt::AlreadyApplied(receipt)) + if *receipt == original + ), + "{:?}", + answered.submission + ); + assert_eq!(mailbox.operator_log().await.unwrap().len(), 1); + + drop(runner); + drop(mailbox); + task.await.unwrap(); + } + + #[tokio::test] + async fn a_committed_narrative_decline_is_answered_with_its_receipt_after_its_scope_moved() { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::NarrativePersona, + NewController::NarrativePersona, + ]) + .await; + let mut outputs = narrative_declines("Mara"); + outputs.extend(narrative_turn_outputs("Tobin", "Then we close the span.")); + let (runner, _store) = grouped_runner(&mailbox, outputs); + let snapshot = mailbox.snapshot().await.unwrap(); + let first = snapshot.opportunities[0].clone(); + let second = snapshot.opportunities[1].clone(); + let first_command = CommandId::new(); + let NarrativeRun::Completed(committed) = runner + .run_narrative(first_command, &first) + .await + .unwrap() + else { + panic!("the first decline remained pending") + }; + let SubmissionDisposition::NoProposal(SubmitReceipt::Applied(original)) = + committed.submission + else { + panic!("the first turn did not commit a decline") + }; + let second_now = + fresh_opportunity(&mailbox.snapshot().await.unwrap(), second.scope.subject_id); + assert!(matches!( + runner + .run_narrative(CommandId::new(), &second_now) + .await + .unwrap(), + NarrativeRun::Completed(_) + )); + let fresh = fresh_opportunity(&mailbox.snapshot().await.unwrap(), first.scope.subject_id); + assert_ne!(fresh.scope_digest, first.scope_digest, "the scope did not move"); + let NarrativeRun::Completed(answered) = runner + .run_narrative(first_command, &fresh) + .await + .unwrap() + else { + panic!("the committed decline was not answered") + }; + assert!( + matches!( + &answered.submission, + SubmissionDisposition::NoProposal(SubmitReceipt::AlreadyApplied(receipt)) + if *receipt == original + ), + "{:?}", + answered.submission + ); + assert_eq!(mailbox.operator_log().await.unwrap().len(), 1); + + drop(runner); + drop(mailbox); + task.await.unwrap(); + } + + /// An in-flight operational row read in uncertain custody after its scope + /// moved is refused as a mismatch, not reported pending; read against its + /// own scope it is pending. + #[tokio::test] + async fn an_operational_row_in_flight_in_uncertain_custody_keeps_the_digest_gate() { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::OperationalAgent, + NewController::OperationalAgent, + ]) + .await; + let (runner, store) = grouped_runner( + &mailbox, + vec![ + Err(InferenceFault::retryable("the provider is busy")), + operational_speaks("Then we close the span.", "second"), + ], + ); + let snapshot = mailbox.snapshot().await.unwrap(); + let first = snapshot.opportunities[0].clone(); + let second = snapshot.opportunities[1].clone(); + let command = CommandId::new(); + assert!(matches!( + runner.run_operational(command, &first).await.unwrap(), + OperationalRun::Pending(_) + )); + assert!(matches!( + runner + .run_operational(CommandId::new(), &second) + .await + .unwrap(), + OperationalRun::Completed(_) + )); + let fresh = fresh_opportunity(&mailbox.snapshot().await.unwrap(), first.scope.subject_id); + assert_ne!(fresh.scope_digest, first.scope_digest, "the scope did not move"); + let uncertain = uncertain_runner(&mailbox, &store); + assert!(matches!( + uncertain.run_operational(command, &fresh).await, + Err(ControllerError::OpportunityMismatch) + )); + let OperationalRun::Pending(pending) = + uncertain.run_operational(command, &first).await.unwrap() + else { + panic!("a row bound to the caller's own scope was not reported pending") + }; + assert_eq!(pending.reason(), ControllerPendingReason::StoreReopenRequired); + + drop((runner, uncertain)); + drop(mailbox); + task.await.unwrap(); + } + + #[tokio::test] + async fn a_narrative_row_in_flight_in_uncertain_custody_keeps_the_digest_gate() { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::NarrativePersona, + NewController::NarrativePersona, + ]) + .await; + let mut outputs = vec![Err(InferenceFault::retryable("the provider is busy"))]; + outputs.extend(narrative_turn_outputs("Tobin", "Then we close the span.")); + let (runner, store) = grouped_runner(&mailbox, outputs); + let snapshot = mailbox.snapshot().await.unwrap(); + let first = snapshot.opportunities[0].clone(); + let second = snapshot.opportunities[1].clone(); + let command = CommandId::new(); + assert!(matches!( + runner.run_narrative(command, &first).await.unwrap(), + NarrativeRun::Pending(_) + )); + assert!(matches!( + runner + .run_narrative(CommandId::new(), &second) + .await + .unwrap(), + NarrativeRun::Completed(_) + )); + let fresh = fresh_opportunity(&mailbox.snapshot().await.unwrap(), first.scope.subject_id); + assert_ne!(fresh.scope_digest, first.scope_digest, "the scope did not move"); + let uncertain = uncertain_runner(&mailbox, &store); + assert!(matches!( + uncertain.run_narrative(command, &fresh).await, + Err(ControllerError::OpportunityMismatch) + )); + let NarrativeRun::Pending(pending) = + uncertain.run_narrative(command, &first).await.unwrap() + else { + panic!("a row bound to the caller's own scope was not reported pending") + }; + assert_eq!(pending.reason(), ControllerPendingReason::StoreReopenRequired); + + drop((runner, uncertain)); + drop(mailbox); + task.await.unwrap(); + } + + /// A provider port that severs the world's owner task once the answer is + /// in, so the next read of the world finds the mailbox unavailable. The + /// fixture's only fault injection: production code has no such seam. + struct SeversWorldAfterAnswer { + inner: RecordingPort, + world: tokio::task::AbortHandle, + } + + #[async_trait] + impl InferencePort for SeversWorldAfterAnswer { + fn prepare(&self, request: InferenceRequest) -> Result { + self.inner.prepare(request) + } + + async fn infer( + &self, + request: PreparedInference, + ) -> Result { + let answer = self.inner.infer(request).await; + self.world.abort(); + tokio::task::yield_now().await; + answer + } + } + + #[tokio::test] + async fn a_grouped_cell_whose_world_cannot_be_read_at_submission_is_pending_with_nothing_submitted() + { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::OperationalAgent, + NewController::OperationalAgent, + ]) + .await; + let both_act = output( + vec![ + speak_call("c0", "c0__speak", "The hinge is flooding."), + speak_call("c1", "c1__speak", "Then we close the span."), + ], + "one", + ); + let persisted = Arc::new(AtomicBool::new(true)); + let port = Arc::new(SeversWorldAfterAnswer { + inner: RecordingPort { + outputs: Mutex::new(vec![both_act]), + persisted_before_interpreter: persisted.clone(), + }, + world: task.abort_handle(), + }); + let store = Arc::new(RecordingWorkStore { + persisted, + work: Mutex::new(BTreeMap::new()), + }); + let runner = ControllerRunner::open(mailbox.clone(), port, store.clone(), models()) + .expect("the fixture ports open"); + let cell = one_group(&mailbox).await; + let CellRun::Grouped(run) = runner.run_cell(&cell).await.unwrap() else { + panic!("a coarse cell did not run the grouped lane") + }; + assert_eq!(run.pending, Some(ControllerPendingReason::WorldUnavailable)); + assert!(run.submissions.is_empty(), "{:?}", run.submissions); + assert!(run.refused.is_empty(), "{:?}", run.refused); + assert!( + !store.work.lock().unwrap().is_empty(), + "the cell's row was not kept for the resume" + ); + + drop(runner); + drop(mailbox); + assert!(task.await.unwrap_err().is_cancelled()); + } + + /// A store that answers the write of a row that awaits the world with + /// uncertain custody, so the run ends pending with the submission body + /// persisted but never sent. + struct NeverSentStore { + inner: Arc, + armed: AtomicBool, + } + + #[async_trait] + impl ControllerWorkStore for NeverSentStore { + async fn lookup( + &self, + command_id: CommandId, + ) -> Result { + self.inner.lookup(command_id).await + } + + async fn persist( + &self, + work: &ControllerWork, + ) -> Result { + let written = self.inner.persist(work).await?; + let awaiting = match work { + ControllerWork::Narrative(row) => row.awaits_world(), + ControllerWork::Operational(row) => row.awaits_world(), + _ => false, + }; + if awaiting && self.armed.load(Ordering::SeqCst) { + return Ok(ControllerWorkWrite::CustodyUncertain); + } + Ok(written) + } + + async fn custody_probe(&self) -> Result { + self.inner.custody_probe().await + } + + async fn elaboration_in_flight( + &self, + ) -> Result, ControllerWorkStoreError> { + self.inner.elaboration_in_flight().await + } + } + + fn runner_over( + mailbox: &WorldMailbox, + outputs: Vec>, + store: Arc, + ) -> (ControllerRunner, Arc) { + let port = Arc::new(RecordingPort { + outputs: Mutex::new(outputs), + persisted_before_interpreter: Arc::new(AtomicBool::new(true)), + }); + ( + ControllerRunner::open(mailbox.clone(), port.clone(), store, models()) + .expect("the fixture ports open"), + port, + ) + } + + fn recording_store() -> Arc { + Arc::new(RecordingWorkStore { + persisted: Arc::new(AtomicBool::new(true)), + work: Mutex::new(BTreeMap::new()), + }) + } + + /// An operational row that awaits the world but never reached it, then a + /// neighbour moves the scope, then the same command is rerun against the + /// scope as it now stands. The row is submitted and the world refuses its + /// stale body: no provider call, nothing new in the log. + async fn operational_row_never_sent_then_scope_moved( + first_output: Result, + ) { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::OperationalAgent, + NewController::OperationalAgent, + ]) + .await; + let inner = recording_store(); + let never_sent = Arc::new(NeverSentStore { + inner: inner.clone(), + armed: AtomicBool::new(true), + }); + let (a, _) = runner_over(&mailbox, vec![first_output], never_sent.clone()); + let snapshot = mailbox.snapshot().await.unwrap(); + let first = snapshot.opportunities[0].clone(); + let second = snapshot.opportunities[1].clone(); + let command = CommandId::new(); + assert!(matches!( + a.run_operational(command, &first).await, + Ok(OperationalRun::Pending(_)) + )); + never_sent.armed.store(false, Ordering::SeqCst); + let (b, _) = runner_over( + &mailbox, + vec![operational_speaks("Then we close the span.", "second")], + inner.clone(), + ); + assert!(matches!( + b.run_operational(CommandId::new(), &second).await.unwrap(), + OperationalRun::Completed(_) + )); + let log_before = mailbox.operator_log().await.unwrap().len(); + let fresh = fresh_opportunity(&mailbox.snapshot().await.unwrap(), first.scope.subject_id); + assert_ne!(fresh.scope_digest, first.scope_digest, "the scope did not move"); + let (c, port) = runner_over(&mailbox, Vec::new(), inner.clone()); + assert!(matches!( + c.run_operational(command, &fresh).await, + Err(ControllerError::World(KernelError::ScopeChanged { .. })) + )); + assert_eq!(mailbox.operator_log().await.unwrap().len(), log_before); + assert!(port.outputs.lock().unwrap().is_empty()); + + drop((a, b, c)); + drop(mailbox); + task.await.unwrap(); + } + + #[tokio::test] + async fn an_operational_row_ready_to_submit_but_never_sent_is_refused_by_the_world_after_its_scope_moved() + { + operational_row_never_sent_then_scope_moved(operational_speaks( + "The hinge is flooding.", + "first", + )) + .await; + } + + #[tokio::test] + async fn an_operational_decline_never_sent_is_refused_by_the_world_after_its_scope_moved() { + operational_row_never_sent_then_scope_moved(operational_declines("first")).await; + } + + /// The narrative lane answers the same refusal with its one bounded + /// re-lowering: one Interpreter call against the scope as it now stands, + /// and the re-lowered act commits. + #[tokio::test] + async fn a_narrative_row_ready_to_submit_but_never_sent_is_lowered_once_more_after_its_scope_moved() + { + let (_directory, mailbox, task) = active_cell_mailbox(vec![ + NewController::NarrativePersona, + NewController::NarrativePersona, + ]) + .await; + let inner = recording_store(); + let never_sent = Arc::new(NeverSentStore { + inner: inner.clone(), + armed: AtomicBool::new(true), + }); + let (a, _) = runner_over( + &mailbox, + narrative_turn_outputs("Mara", "The rain has teeth tonight."), + never_sent.clone(), + ); + let snapshot = mailbox.snapshot().await.unwrap(); + let first = snapshot.opportunities[0].clone(); + let second = snapshot.opportunities[1].clone(); + let command = CommandId::new(); + assert!(matches!( + a.run_narrative(command, &first).await, + Ok(NarrativeRun::Pending(_)) + )); + never_sent.armed.store(false, Ordering::SeqCst); + let (b, _) = runner_over( + &mailbox, + narrative_turn_outputs("Tobin", "Then we close the span."), + inner.clone(), + ); + assert!(matches!( + b.run_narrative(CommandId::new(), &second).await.unwrap(), + NarrativeRun::Completed(_) + )); + let log_before = mailbox.operator_log().await.unwrap().len(); + let fresh = fresh_opportunity(&mailbox.snapshot().await.unwrap(), first.scope.subject_id); + assert_ne!(fresh.scope_digest, first.scope_digest, "the scope did not move"); + // One answer is the bound; the second would be a second round. + let relowering = || narrative_turn_outputs("Mara", "The rain has teeth tonight.").remove(2); + let (c, port) = runner_over(&mailbox, vec![relowering(), relowering()], inner.clone()); + assert!(matches!( + c.run_narrative(command, &fresh).await, + Ok(NarrativeRun::Completed(_)) + )); + assert_eq!( + port.outputs.lock().unwrap().len(), + 1, + "the re-lowering did not spend exactly one provider call" + ); + assert_eq!(mailbox.operator_log().await.unwrap().len(), log_before + 1); + + drop((a, b, c)); + drop(mailbox); + task.await.unwrap(); + } } From 706d0c3792ecc7abc379970d2831ab5982b1e1ad Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 15:22:22 +0200 Subject: [PATCH 30/62] Verse run: the driver labels a world ScopeChanged refusal ScopeMoved One fact, one label across lanes (ruling unsent-awaiting-row-is-awaiting). RefusalReason::ScopeMoved's doc names the unsent-awaiting state. Builds. Co-Authored-By: Claude Opus 5.5 --- crates/ghostlight/src/verse/run.rs | 16 +++++++++++----- crates/ghostlight/src/verse/run/tests.rs | 22 ++++++++++++++++++++++ 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/crates/ghostlight/src/verse/run.rs b/crates/ghostlight/src/verse/run.rs index 179653d..8fe9ba1 100644 --- a/crates/ghostlight/src/verse/run.rs +++ b/crates/ghostlight/src/verse/run.rs @@ -30,7 +30,8 @@ use crate::cover::{ Cell, CellId, Constituent, CoverBudget, CoverBudgetError, TickIndex, derive_cover, }; use crate::{ - AgencyGraph, CommandId, DecisionOpportunity, MailboxError, OperatorEvent, RenderPath, + AgencyGraph, CommandId, DecisionOpportunity, KernelError, MailboxError, OperatorEvent, + RenderPath, SubjectId, SubmitReceipt, TickMinutes, WorldId, WorldMailbox, WorldSnapshot, }; use chrono::Utc; @@ -94,10 +95,12 @@ pub enum RefusalReason { WorldRejected, /// The cell's subject no longer holds an opportunity. OpportunityGone, - /// The cell's in-flight work was bound to a scope that has since moved, - /// so it is refused before any inference. A cell whose command already - /// committed does not end this way: the controller answers it with its - /// receipt whatever the scope has done since. + /// The cell's work was bound to a scope that has since moved. Work still + /// reasoning is refused before any inference. Work whose submission body + /// was persisted but never sent is submitted and refused by the world with + /// `ScopeChanged`. A cell whose command already committed does not end + /// this way: the controller answers it with its receipt whatever the scope + /// has done since. ScopeMoved, } @@ -595,6 +598,9 @@ fn classify(error: &ControllerError) -> CellEnd { CellEnd::Budget } _ if error.requires_quarantine() => CellEnd::Fatal("controller_quarantined"), + ControllerError::World(KernelError::ScopeChanged { .. }) => { + CellEnd::Refused(RefusalReason::ScopeMoved) + } ControllerError::World(_) => CellEnd::Refused(RefusalReason::WorldRejected), ControllerError::NoOpportunity { .. } | ControllerError::AmbiguousOpportunity => { CellEnd::Refused(RefusalReason::OpportunityGone) diff --git a/crates/ghostlight/src/verse/run/tests.rs b/crates/ghostlight/src/verse/run/tests.rs index 82da589..e4bfe4e 100644 --- a/crates/ghostlight/src/verse/run/tests.rs +++ b/crates/ghostlight/src/verse/run/tests.rs @@ -979,6 +979,28 @@ fn an_error_ends_a_cell_as_the_run_must_treat_it() { )); } +/// One fact, one label: the world refusing a body whose scope moved is +/// `ScopeMoved`, the same label the controller's own refusal of in-flight work +/// carries, and not the catch-all `WorldRejected`. +#[tokio::test] +async fn a_world_refusal_for_a_moved_scope_is_scope_moved() { + let (_dir, verse) = Harness::fresh().await; + let opportunity = verse.mailbox.snapshot().await.unwrap().opportunities[0].clone(); + let moved = ControllerError::World(KernelError::ScopeChanged { + scope: opportunity.scope.clone(), + expected: opportunity.scope_digest.clone(), + actual: opportunity.scope_digest.clone(), + }); + assert!(matches!( + classify(&moved), + CellEnd::Refused(RefusalReason::ScopeMoved) + )); + assert!(matches!( + classify(&ControllerError::World(KernelError::AffordanceDenied)), + CellEnd::Refused(RefusalReason::WorldRejected) + )); +} + /// A recording port over the harness's call store for `sequence` under `cap`, /// and a runner on it. fn port_and_runner( From 2dabb85009f8e1f9c5d571cd5e920d085e53d7b2 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sun, 11 Oct 2026 15:36:31 +0200 Subject: [PATCH 31/62] Verse run: every member of a tick's frozen cover ends committed or refused, by subject Refusal names its subject. absorb reads GroupedRun.refused (LeftBeforeSelection -> OpportunityGone, ScopeChanged -> ScopeMoved, WorldRefused -> WorldRejected) and records an interrupted singleton's member; refreshed returns the frozen members it found no opportunity for; a cell-level refusal records every member of the cell. TickReport carries the refused subjects' labels and Display prints "refused: cell N