From 901cfcd58794acc35b24be3d1e149fd2df0f6c3d Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Sat, 29 Aug 2026 23:48:30 +0200 Subject: [PATCH] Build cacheable embodied newsroom --- crates/ghostlight-dungeon/src/agent.rs | 100 +- .../src/bin/ghostlight-strategic-smoke.rs | 4 + crates/ghostlight-dungeon/src/compiler.rs | 2 +- crates/ghostlight-dungeon/src/consumer.rs | 1 + crates/ghostlight-dungeon/src/mesh.rs | 4 +- crates/ghostlight-dungeon/src/model.rs | 144 +- .../ghostlight-dungeon/src/model_connector.rs | 121 +- .../ghostlight-dungeon/src/native_cultmesh.rs | 10 +- crates/ghostlight-dungeon/src/newspaper.rs | 1218 +++++++++++------ crates/ghostlight-dungeon/src/registry.rs | 116 +- notes/fresh-workspace-handoff.md | 33 +- notes/ghostlight-current-system-map.md | 166 ++- state/evidence.jsonl | 4 +- state/ghostlight-state.cultcache.jsonl | 2 + state/map.yaml | 5 +- state/scratch.md | 2 +- 16 files changed, 1286 insertions(+), 646 deletions(-) diff --git a/crates/ghostlight-dungeon/src/agent.rs b/crates/ghostlight-dungeon/src/agent.rs index d089f43..f29d72f 100644 --- a/crates/ghostlight-dungeon/src/agent.rs +++ b/crates/ghostlight-dungeon/src/agent.rs @@ -1,6 +1,6 @@ use crate::model::{ - ModelPort, ModelStageReceipt, ModelStageRequest, mark_model_receipt_semantic_invalid, - run_validated_stage, + ModelInputItem, ModelPort, ModelStageReceipt, ModelStageRequest, + mark_model_receipt_semantic_invalid, render_model_input, run_validated_conversation_stage, }; use async_trait::async_trait; use serde::{Serialize, de::DeserializeOwned}; @@ -147,7 +147,10 @@ pub async fn run_model_agent_progress( } let mut receipts = Vec::new(); - let mut transcript = String::new(); + let mut input = vec![ModelInputItem::user(format!( + "{}\n\nAGENT STEP: 1 of {}. Choose one typed tool action admitted by the current output schema. The harness will execute it against the frozen state and return the real tool observation. Do not claim success yourself; only an accepted tool result can end the task.", + spec.instructions, spec.max_steps + ))]; let mut last_observation = None; for step in 0..spec.max_steps { let action_schema = match tool.action_schema() { @@ -162,37 +165,18 @@ pub async fn run_model_agent_progress( }); } }; - let action_schema_text = match serde_json::to_string(&action_schema) { - Ok(schema) => schema, - Err(error) => { - return Err(ModelAgentFailure { - message: format!( - "model agent {} could not serialize its current action schema: {error}", - spec.stage - ), - receipts, - }); - } - }; let source_receipt_ids = causal_source_ids(&spec.source_receipt_ids, &receipts); let request = ModelStageRequest { stage: spec.stage.clone(), model: spec.model.clone(), snapshot_binding: spec.snapshot_binding.clone(), - lived_stream: format!( - "CURRENT LEGAL TOOL ACTION SCHEMA:\n{}\n\n{}{}\n\nAGENT STEP: {} of {}. Choose one typed tool action admitted by the current schema. The harness will execute it against the frozen state and return the real tool observation. Do not claim success yourself; only an accepted tool result can end the task.", - action_schema_text, - spec.instructions, - transcript, - step + 1, - spec.max_steps, - ), + lived_stream: render_model_input(&input), output_schema: Some(action_schema), source_receipt_ids, temperature: spec.temperature, max_output_tokens: spec.max_output_tokens, }; - let mut stage = match run_validated_stage(port, &request).await { + let mut stage = match run_validated_conversation_stage(port, &request, &input).await { Ok(stage) => stage, Err(error) => { return Err(ModelAgentFailure { @@ -212,12 +196,14 @@ pub async fn run_model_agent_progress( "message":"agent returned no typed action", }); mark_model_receipt_semantic_invalid(&mut stage.receipt, &finding); - transcript.push_str(&tool_observation( + append_agent_observation( + &mut input, + &stage.narrative, step, + spec.max_steps, "rejected", - &serde_json::Value::Null, &finding, - )); + ); last_observation = Some(finding); receipts.push(stage.receipt); continue; @@ -231,7 +217,14 @@ pub async fn run_model_agent_progress( "message":error.to_string(), }); mark_model_receipt_semantic_invalid(&mut stage.receipt, &finding); - transcript.push_str(&tool_observation(step, "rejected", &action_value, &finding)); + append_agent_observation( + &mut input, + &stage.narrative, + step, + spec.max_steps, + "rejected", + &finding, + ); last_observation = Some(finding); receipts.push(stage.receipt); continue; @@ -266,12 +259,14 @@ pub async fn run_model_agent_progress( return Err(ModelAgentFailure { message, receipts }); } }; - transcript.push_str(&tool_observation( + append_agent_observation( + &mut input, + &stage.narrative, step, + spec.max_steps, "continued", - &action_value, &observation, - )); + ); last_observation = Some(observation); receipts.push(stage.receipt); receipts.extend(tool_receipts); @@ -305,7 +300,14 @@ pub async fn run_model_agent_progress( } }; mark_model_receipt_semantic_invalid(&mut stage.receipt, &finding); - transcript.push_str(&tool_observation(step, "rejected", &action_value, &finding)); + append_agent_observation( + &mut input, + &stage.narrative, + step, + spec.max_steps, + "rejected", + &finding, + ); last_observation = Some(finding); receipts.push(stage.receipt); receipts.extend(tool_receipts); @@ -346,18 +348,23 @@ pub(crate) fn causal_source_ids(base: &[String], receipts: &[ModelStageReceipt]) .collect() } -fn tool_observation( +fn append_agent_observation( + input: &mut Vec, + assistant_output: &str, step: usize, + max_steps: usize, status: &str, - action: &serde_json::Value, observation: &serde_json::Value, -) -> String { - format!( - "\n\nTOOL OBSERVATION AFTER AGENT STEP {} ({status}):\n{}\nPREVIOUS TOOL ACTION:\n{}\nReturn the next complete typed tool action. Preserve frozen identity and useful valid draft work; change only what the observation actually rejects or requests.", - step + 1, - serde_json::to_string(observation).unwrap_or_else(|_| "null".into()), - serde_json::to_string(action).unwrap_or_else(|_| "null".into()), - ) +) { + input.push(ModelInputItem::assistant(assistant_output)); + if step + 1 < max_steps { + input.push(ModelInputItem::user(format!( + "TOOL OBSERVATION AFTER AGENT STEP {} ({status}):\n{}\n\nAGENT STEP: {} of {max_steps}. Return the next complete typed tool action. Preserve frozen identity and useful valid draft work; change only what the observation actually rejects or requests.", + step + 1, + serde_json::to_string(observation).unwrap_or_else(|_| "null".into()), + step + 2, + ))); + } } #[cfg(test)] @@ -556,7 +563,13 @@ mod tests { ); let requests = model.requests.lock().unwrap(); assert!(requests[1].lived_stream.contains("wrong_value")); - assert!(requests[1].lived_stream.contains("PREVIOUS TOOL ACTION")); + assert!(requests[1].lived_stream.contains("ASSISTANT:")); + assert!( + requests[1] + .lived_stream + .starts_with(&requests[0].lived_stream) + ); + assert_eq!(requests[0].output_schema, requests[1].output_schema); } #[tokio::test] @@ -633,11 +646,10 @@ mod tests { "accepted" ); assert!( - requests[1] + !requests[1] .lived_stream .contains("CURRENT LEGAL TOOL ACTION SCHEMA") ); - assert!(requests[1].lived_stream.contains(r#""const":"accepted""#)); } #[tokio::test] diff --git a/crates/ghostlight-dungeon/src/bin/ghostlight-strategic-smoke.rs b/crates/ghostlight-dungeon/src/bin/ghostlight-strategic-smoke.rs index 886e5ea..14aa0be 100644 --- a/crates/ghostlight-dungeon/src/bin/ghostlight-strategic-smoke.rs +++ b/crates/ghostlight-dungeon/src/bin/ghostlight-strategic-smoke.rs @@ -2013,9 +2013,11 @@ async fn main() -> anyhow::Result<()> { let issue_campaign = completed_wave_issue_campaign(&wave_reports, report_index)?; let issue_title = format!("{newspaper_title} — Issue {wave_index}"); let source_campaign_digest = strategic_smoke_digest(&issue_campaign)?; + let newsroom = ghostlight_dungeon::newspaper::canopy_ledger_newsroom(); let editorial_contract_digest = strategic_smoke_digest(&serde_json::json!({ "title":&issue_title, "editorial_voice":&newspaper_voice, + "newsroom":&newsroom, "max_articles":5, }))?; let recomposition_path = @@ -2040,6 +2042,7 @@ async fn main() -> anyhow::Result<()> { &issue_campaign, &issue_title, &newspaper_voice, + &newsroom, 5, &store, import.import, @@ -2397,6 +2400,7 @@ async fn compose_persisted_newspaper( campaign, title, editorial_voice, + &ghostlight_dungeon::newspaper::canopy_ledger_newsroom(), max_articles, store, ) diff --git a/crates/ghostlight-dungeon/src/compiler.rs b/crates/ghostlight-dungeon/src/compiler.rs index 639b04a..5f58634 100644 --- a/crates/ghostlight-dungeon/src/compiler.rs +++ b/crates/ghostlight-dungeon/src/compiler.rs @@ -8310,7 +8310,7 @@ mod tests { && request.lived_stream.contains("duplicate_statement") && request.lived_stream.contains("fact:refuge_authority") && request.lived_stream.contains("fact:refuge_resources") - && request.lived_stream.contains("PREVIOUS TOOL ACTION"), + && request.lived_stream.contains("ASSISTANT:"), Ordering::SeqCst, ); Ok(serde_json::json!({ diff --git a/crates/ghostlight-dungeon/src/consumer.rs b/crates/ghostlight-dungeon/src/consumer.rs index 96c9236..fc07417 100644 --- a/crates/ghostlight-dungeon/src/consumer.rs +++ b/crates/ghostlight-dungeon/src/consumer.rs @@ -323,6 +323,7 @@ pub struct WorldNewspaperRequest { pub title: String, #[schemars(length(min = 1, max = 600))] pub editorial_voice: String, + pub newsroom: crate::newspaper::WorldNewspaperNewsroom, #[schemars(range(min = 1, max = 6))] pub max_articles: u16, } diff --git a/crates/ghostlight-dungeon/src/mesh.rs b/crates/ghostlight-dungeon/src/mesh.rs index e2de4d9..6639515 100644 --- a/crates/ghostlight-dungeon/src/mesh.rs +++ b/crates/ghostlight-dungeon/src/mesh.rs @@ -594,7 +594,7 @@ fn schema_catalog() -> Value { "ghostlight.external_proposal_list.v1": schemars::schema_for!(crate::consumer::ExternalProposalList), "ghostlight.external_proposal_acknowledgement.v1": schemars::schema_for!(crate::consumer::ExternalProposalAcknowledgement), "ghostlight.external_proposal_receipt.v1": schemars::schema_for!(crate::consumer::ExternalProposalReceipt), - "ghostlight.world_newspaper_request.v2": schemars::schema_for!(crate::consumer::WorldNewspaperRequest), + "ghostlight.world_newspaper_request.v3": schemars::schema_for!(crate::consumer::WorldNewspaperRequest), "ghostlight.world_newspaper_issue.v3": schemars::schema_for!(crate::newspaper::WorldNewspaperIssue) }) } @@ -627,7 +627,7 @@ mod tests { assert!(schemas["ghostlight.campaign.v1"]["$schema"].is_string()); assert!(schemas["ghostlight.session_zero.v1"]["$schema"].is_string()); assert!(schemas["ghostlight.vault_source_manifest.v1"]["$schema"].is_string()); - assert!(schemas["ghostlight.world_newspaper_request.v2"]["$schema"].is_string()); + assert!(schemas["ghostlight.world_newspaper_request.v3"]["$schema"].is_string()); assert!(schemas["ghostlight.world_newspaper_issue.v3"]["$schema"].is_string()); assert!(!schemas.contains_key("ghostlight.persona_stage_receipt.v1")); assert!(!schemas.contains_key("ghostlight.world_mutation.v1")); diff --git a/crates/ghostlight-dungeon/src/model.rs b/crates/ghostlight-dungeon/src/model.rs index 3b93d5f..5ae9557 100644 --- a/crates/ghostlight-dungeon/src/model.rs +++ b/crates/ghostlight-dungeon/src/model.rs @@ -32,6 +32,44 @@ pub struct ModelStageRequest { pub max_output_tokens: Option, } +#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(tag = "role", rename_all = "snake_case", deny_unknown_fields)] +pub enum ModelInputItem { + UserText { text: String }, + AssistantText { text: String }, +} + +impl ModelInputItem { + pub fn user(text: impl Into) -> Self { + Self::UserText { text: text.into() } + } + + pub fn assistant(text: impl Into) -> Self { + Self::AssistantText { text: text.into() } + } + + fn text(&self) -> &str { + match self { + Self::UserText { text } | Self::AssistantText { text } => text, + } + } +} + +pub fn render_model_input(items: &[ModelInputItem]) -> String { + items + .iter() + .map(|item| match item { + ModelInputItem::UserText { text } => format!("USER:\n{text}"), + ModelInputItem::AssistantText { text } => format!("ASSISTANT:\n{text}"), + }) + .collect::>() + .join("\n\n") +} + +fn model_input_chars(items: &[ModelInputItem]) -> usize { + items.iter().map(|item| item.text().chars().count()).sum() +} + #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq)] pub struct ModelStageReceipt { pub schema: String, @@ -165,6 +203,15 @@ pub trait ModelPort: Send + Sync { ..Default::default() }) } + async fn run_observed_conversation( + &self, + request: &ModelStageRequest, + input: &[ModelInputItem], + ) -> Result { + let mut projected = request.clone(); + projected.lived_stream = render_model_input(input); + self.run_observed(&projected).await + } fn provider(&self) -> &'static str; fn attempt_timeout(&self, _request: &ModelStageRequest) -> std::time::Duration { std::time::Duration::from_secs(45) @@ -175,13 +222,36 @@ pub async fn run_validated_stage( port: &dyn ModelPort, request: &ModelStageRequest, ) -> Result { - run_validated_stage_with_timeout(port, request, port.attempt_timeout(request)).await + run_validated_stage_inner(port, request, port.attempt_timeout(request), None).await +} + +pub async fn run_validated_conversation_stage( + port: &dyn ModelPort, + request: &ModelStageRequest, + input: &[ModelInputItem], +) -> Result { + run_validated_stage_inner( + port, + request, + port.attempt_timeout(request), + Some(input.to_vec()), + ) + .await } pub async fn run_validated_stage_with_timeout( port: &dyn ModelPort, request: &ModelStageRequest, timeout: std::time::Duration, +) -> Result { + run_validated_stage_inner(port, request, timeout, None).await +} + +async fn run_validated_stage_inner( + port: &dyn ModelPort, + request: &ModelStageRequest, + timeout: std::time::Duration, + mut conversation: Option>, ) -> Result { let validator = request .output_schema @@ -202,10 +272,15 @@ pub async fn run_validated_stage_with_timeout( let mut transport_attempt = 0; loop { let started = Instant::now(); - let (failure_kind, diagnostic) = match tokio::time::timeout( - timeout, - port.run_observed(&attempt_request), - ) + let (failure_kind, diagnostic) = match tokio::time::timeout(timeout, async { + match conversation.as_deref() { + Some(input) => { + port.run_observed_conversation(&attempt_request, input) + .await + } + None => port.run_observed(&attempt_request).await, + } + }) .await { Ok(Ok(output)) => break (output, started.elapsed().as_millis() as u64), @@ -216,7 +291,10 @@ pub async fn run_validated_stage_with_timeout( "model stage {} timed out after {} seconds with {} input characters", request.stage, timeout.as_secs(), - attempt_request.lived_stream.chars().count() + conversation.as_deref().map_or_else( + || attempt_request.lived_stream.chars().count(), + model_input_chars, + ) ), ), }; @@ -269,8 +347,11 @@ pub async fn run_validated_stage_with_timeout( .expect("attempt was just recorded") .local_validation_error = Some("provider returned an empty response".into()); if attempt == 0 { - attempt_request.lived_stream.push_str( - "\n\nLOCAL VALIDATOR: The previous response was empty. Return one complete response against the same snapshot and output contract.", + append_validation_retry( + &mut attempt_request, + &mut conversation, + &output, + "LOCAL VALIDATOR: The previous response was empty. Return one complete response against the same snapshot and output contract.".into(), ); continue; } @@ -299,9 +380,14 @@ pub async fn run_validated_stage_with_timeout( .expect("attempt was just recorded") .local_validation_error = Some(bounded_validation_error(&diagnostic)); if attempt == 0 { - attempt_request.lived_stream.push_str(&format!( - "\n\nLOCAL VALIDATOR REJECTED THE PREVIOUS JSON: {diagnostic}\nReturn one corrected complete JSON object against the same snapshot and schema." - )); + append_validation_retry( + &mut attempt_request, + &mut conversation, + &output, + format!( + "LOCAL VALIDATOR REJECTED THE PREVIOUS JSON: {diagnostic}\nReturn one corrected complete JSON object against the same snapshot and schema." + ), + ); continue; } return Err(anyhow!( @@ -319,9 +405,14 @@ pub async fn run_validated_stage_with_timeout( .last_mut() .expect("attempt was just recorded") .local_validation_error = Some(bounded_validation_error(&error)); - attempt_request.lived_stream.push_str(&format!( - "\n\nLOCAL VALIDATOR COULD NOT PARSE THE PREVIOUS RESPONSE AS JSON: {error}\nReturn one corrected complete JSON object against the same snapshot and schema." - )); + append_validation_retry( + &mut attempt_request, + &mut conversation, + &output, + format!( + "LOCAL VALIDATOR COULD NOT PARSE THE PREVIOUS RESPONSE AS JSON: {error}\nReturn one corrected complete JSON object against the same snapshot and schema." + ), + ); continue; } Err(error) => { @@ -345,6 +436,9 @@ pub async fn run_validated_stage_with_timeout( let receipt_model = resolved_model.unwrap_or_else(|| attempt_request.model.clone()); let mut receipted_request = attempt_request.clone(); receipted_request.model.clone_from(&receipt_model); + if let Some(input) = conversation.as_deref() { + receipted_request.lived_stream = render_model_input(input); + } let request_bytes = serde_json::to_vec(&receipted_request)?; let provider = port.provider().to_owned(); let request_hash = format!("sha256:{:x}", Sha256::digest(&request_bytes)); @@ -380,7 +474,10 @@ pub async fn run_validated_stage_with_timeout( latency_ms: stage_started.elapsed().as_millis() as u64, validation_result: "valid".into(), local_validation_error: None, - input_chars: attempt_request.lived_stream.chars().count(), + input_chars: conversation.as_deref().map_or_else( + || attempt_request.lived_stream.chars().count(), + model_input_chars, + ), output_chars: output.chars().count(), provider_attempts, }, @@ -389,6 +486,23 @@ pub async fn run_validated_stage_with_timeout( unreachable!() } +fn append_validation_retry( + request: &mut ModelStageRequest, + conversation: &mut Option>, + rejected_output: &str, + diagnostic: String, +) { + if let Some(input) = conversation.as_mut() { + if !rejected_output.trim().is_empty() { + input.push(ModelInputItem::assistant(rejected_output)); + } + input.push(ModelInputItem::user(diagnostic)); + } else { + request.lived_stream.push_str("\n\n"); + request.lived_stream.push_str(&diagnostic); + } +} + fn bounded_validation_error(error: &impl std::fmt::Display) -> String { error.to_string().chars().take(1_000).collect() } diff --git a/crates/ghostlight-dungeon/src/model_connector.rs b/crates/ghostlight-dungeon/src/model_connector.rs index 90fd39e..ec12592 100644 --- a/crates/ghostlight-dungeon/src/model_connector.rs +++ b/crates/ghostlight-dungeon/src/model_connector.rs @@ -15,8 +15,8 @@ use tokio::sync::Semaphore; use zeroize::Zeroizing; use crate::model::{ - MODEL_BALANCED, MODEL_CAPABLE, MODEL_FAST, ModelPort, ModelProviderOutput, ModelStageRequest, - ModelTokenUsage, + MODEL_BALANCED, MODEL_CAPABLE, MODEL_FAST, ModelInputItem, ModelPort, ModelProviderOutput, + ModelStageRequest, ModelTokenUsage, }; const MAX_FRAME_BYTES: usize = 1_052_672; @@ -90,6 +90,14 @@ impl CodexConnectorModelPort { } fn invoke(&self, request: &ModelStageRequest) -> Result { + self.invoke_with_input(request, None) + } + + fn invoke_with_input( + &self, + request: &ModelStageRequest, + conversation: Option<&[ModelInputItem]>, + ) -> Result { let request_id = format!("ghostlight-model-{}", uuid::Uuid::new_v4()); let resolved_model = match request.model.as_str() { MODEL_FAST => self.fast_model.clone(), @@ -103,9 +111,26 @@ impl CodexConnectorModelPort { resolved_model, "Execute the supplied Ghostlight model stage. Treat it as projected context, obey its output contract, and return only the requested public answer.", ); - provider_request.input = vec![CodexInputItem::UserText { - text: request.lived_stream.clone(), - }]; + provider_request.input = conversation.map_or_else( + || { + vec![CodexInputItem::UserText { + text: request.lived_stream.clone(), + }] + }, + |input| { + input + .iter() + .map(|item| match item { + ModelInputItem::UserText { text } => { + CodexInputItem::UserText { text: text.clone() } + } + ModelInputItem::AssistantText { text } => { + CodexInputItem::AssistantText { text: text.clone() } + } + }) + .collect() + }, + ); provider_request.reasoning_effort = Some( if matches!(request.model.as_str(), MODEL_BALANCED | MODEL_CAPABLE) { "medium" @@ -126,7 +151,11 @@ impl CodexConnectorModelPort { provider_request.max_output_tokens = request.max_output_tokens; provider_request.prompt_cache_key = Some(prompt_cache_key(request)?); - let native_request_sha256 = Sha256::digest(rmp_serde::to_vec(request)?).into(); + let native_request_sha256 = Sha256::digest(match conversation { + Some(input) => rmp_serde::to_vec_named(&(request, input))?, + None => rmp_serde::to_vec(request)?, + }) + .into(); let invocation = CodexTransportInvocation::new( self.caller_runtime_id.clone(), unix_ms()?.saturating_add(REQUEST_EXPIRY.as_millis() as u64), @@ -159,6 +188,27 @@ impl ModelPort for CodexConnectorModelPort { .await? } + async fn run_observed_conversation( + &self, + request: &ModelStageRequest, + input: &[ModelInputItem], + ) -> Result { + let permit = self + .request_gate + .clone() + .acquire_owned() + .await + .map_err(|_| anyhow::anyhow!("CodexConnector request gate closed"))?; + let port = self.clone(); + let request = request.clone(); + let input = input.to_vec(); + tokio::task::spawn_blocking(move || { + let _permit = permit; + port.invoke_with_input(&request, Some(&input)) + }) + .await? + } + fn provider(&self) -> &'static str { "codex-connector" } @@ -673,8 +723,7 @@ mod tests { }; #[tokio::test] - async fn connector_port_uses_the_shared_v2_transport_and_exact_provider_request() -> Result<()> - { + async fn connector_port_preserves_append_only_roles_in_the_provider_request() -> Result<()> { let listener = TcpListener::bind("127.0.0.1:0")?; let endpoint = listener.local_addr()?; let server = std::thread::spawn(move || -> Result { @@ -728,23 +777,27 @@ mod tests { 1, )?; assert_eq!(port.request_gate.available_permits(), 1); - let output = port - .run_observed(&ModelStageRequest { - stage: "test_interpreter".to_string(), - model: MODEL_BALANCED.to_string(), - snapshot_binding: "revision:7".to_string(), - lived_stream: "Return the typed answer.".to_string(), - output_schema: Some(serde_json::json!({ - "type":"object", - "required":["answer"], - "properties":{"answer":{"type":"string"}}, - "additionalProperties":false - })), - source_receipt_ids: Vec::new(), - temperature: Some(0.0), - max_output_tokens: Some(512), - }) - .await?; + let request = ModelStageRequest { + stage: "test_interpreter".to_string(), + model: MODEL_BALANCED.to_string(), + snapshot_binding: "revision:7".to_string(), + lived_stream: "Return the typed answer.".to_string(), + output_schema: Some(serde_json::json!({ + "type":"object", + "required":["answer"], + "properties":{"answer":{"type":"string"}}, + "additionalProperties":false + })), + source_receipt_ids: Vec::new(), + temperature: Some(0.0), + max_output_tokens: Some(512), + }; + let input = vec![ + ModelInputItem::user("Choose one action."), + ModelInputItem::assistant("{\"answer\":\"draft\"}"), + ModelInputItem::user("The tool rejected that draft; repair it."), + ]; + let output = port.run_observed_conversation(&request, &input).await?; let invocation = server.join().expect("server thread")?; assert_eq!(invocation.request.model, "gpt-5.6-terra"); assert_eq!(invocation.request.max_output_tokens, Some(512)); @@ -752,6 +805,16 @@ mod tests { invocation.request.output_format_name.as_deref(), Some("test_interpreter") ); + assert!(matches!( + invocation.request.input.as_slice(), + [ + CodexInputItem::UserText { text: first }, + CodexInputItem::AssistantText { text: second }, + CodexInputItem::UserText { text: third }, + ] if first == "Choose one action." + && second == "{\"answer\":\"draft\"}" + && third == "The tool rejected that draft; repair it." + )); assert_eq!(output.content, "{\"answer\":\"ready\"}"); assert_eq!( output.token_usage.expect("usage").prompt_cache_hit_tokens, @@ -974,6 +1037,14 @@ mod tests { prompt_cache_key(&request)?, prompt_cache_key(&changed_context)? ); + changed_context.output_schema = Some(serde_json::json!({ + "type":"object", + "properties":{"different_action":{"type":"string"}} + })); + assert_ne!( + prompt_cache_key(&request)?, + prompt_cache_key(&changed_context)? + ); Ok(()) } } diff --git a/crates/ghostlight-dungeon/src/native_cultmesh.rs b/crates/ghostlight-dungeon/src/native_cultmesh.rs index 8e17bde..2161c75 100644 --- a/crates/ghostlight-dungeon/src/native_cultmesh.rs +++ b/crates/ghostlight-dungeon/src/native_cultmesh.rs @@ -304,10 +304,10 @@ async fn handle_operation_inner( success_response(request, "ghostlight.external_proposal_receipt.v1", &receipt) } ghostlight_dungeon::consumer::COMPOSE_NEWSPAPER_OPERATION => { - require_schema(payload_schema, "ghostlight.world_newspaper_request.v2")?; + require_schema(payload_schema, "ghostlight.world_newspaper_request.v3")?; let command: ghostlight_dungeon::consumer::WorldNewspaperRequest = decode_request(request)?; - require_schema(&command.schema, "ghostlight.world_newspaper_request.v2")?; + require_schema(&command.schema, "ghostlight.world_newspaper_request.v3")?; let model = state .model .as_deref() @@ -525,14 +525,14 @@ mod tests { assert!( require_schema( "ghostlight.world_newspaper_request.v1", - "ghostlight.world_newspaper_request.v2" + "ghostlight.world_newspaper_request.v3" ) .is_err() ); assert!( require_schema( - "ghostlight.world_newspaper_request.v2", - "ghostlight.world_newspaper_request.v2" + "ghostlight.world_newspaper_request.v3", + "ghostlight.world_newspaper_request.v3" ) .is_ok() ); diff --git a/crates/ghostlight-dungeon/src/newspaper.rs b/crates/ghostlight-dungeon/src/newspaper.rs index 1229da0..4e7eec7 100644 --- a/crates/ghostlight-dungeon/src/newspaper.rs +++ b/crates/ghostlight-dungeon/src/newspaper.rs @@ -27,7 +27,7 @@ use std::{ const MAX_FRONT_PAGE_ARTICLES: usize = 6; const MAX_PUBLIC_RECORD_QUERY_RESULTS: usize = 24; const MAX_NARRATIVE_SELECTION_STEPS: usize = 12; -const NEWSROOM_CONTRACT_VERSION: &str = "canopy-ledger-night-editor.v8"; +const NEWSROOM_CONTRACT_VERSION: &str = "character-newsroom.v9"; const LEGACY_RECONCILIATION_CONTRACT_VERSION: &str = "canopy-ledger-night-editor.v7"; const EDITION_LABEL: &str = "Current Edition"; const ALLOWED_SECTIONS: [&str; 6] = [ @@ -38,13 +38,121 @@ const ALLOWED_SECTIONS: [&str; 6] = [ "Dispatches", "Comment", ]; -const ALLOWED_BYLINES: [&str; 5] = [ - "By our own correspondent", - "By the political editor", - "By the trade correspondent", - "Staff report", - "Editorial", -]; + +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct WorldNewspaperStaffMember { + pub id: String, + pub name: String, + #[schemars(length(min = 1, max = 500))] + pub character: String, + #[schemars(length(min = 1, max = 6))] + pub biases: Vec, + #[schemars(length(min = 1, max = 6))] + pub preferences: Vec, +} + +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct WorldNewspaperJournalist { + pub staff: WorldNewspaperStaffMember, + #[schemars(length(min = 1, max = 60))] + pub byline: String, +} + +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] +#[serde(deny_unknown_fields)] +pub struct WorldNewspaperNewsroom { + pub assignment_editor: WorldNewspaperStaffMember, + #[schemars(length(min = 1, max = 6))] + pub journalists: Vec, + pub copy_editor: WorldNewspaperStaffMember, + pub night_editor: WorldNewspaperStaffMember, +} + +pub fn canopy_ledger_newsroom() -> WorldNewspaperNewsroom { + WorldNewspaperNewsroom { + assignment_editor: WorldNewspaperStaffMember { + id: "veyra-kest".into(), + name: "Veyra Kest".into(), + character: "A veteran city editor who treats official vagueness as a confession delivered without the useful nouns.".into(), + biases: vec![ + "Institutional euphemism usually protects whoever already has a seal and a chair.".into(), + "A public consequence outranks an announced intention.".into(), + ], + preferences: vec![ + "Conflict with named opposing actors and visible stakes.".into(), + "Continuing stories whose new turn changes what readers should fear or demand.".into(), + ], + }, + journalists: vec![ + WorldNewspaperJournalist { + staff: WorldNewspaperStaffMember { + id: "aven-tarl".into(), + name: "Aven Tarl".into(), + character: "A court reporter with immaculate cuffs, a long memory for humiliation, and no respect for inherited dignity once it becomes funny.".into(), + biases: vec!["Power is most legible when it is embarrassed in public.".into()], + preferences: vec!["Court scandal, succession, hypocrisy, and status reversals.".into()], + }, + byline: "Aven Tarl".into(), + }, + WorldNewspaperJournalist { + staff: WorldNewspaperStaffMember { + id: "mera-quill".into(), + name: "Mera Quill".into(), + character: "A street correspondent who listens first to witnesses, children, mourners, and whoever must clean the room after policy leaves it.".into(), + biases: vec!["Procedure is often the name authority gives to somebody else's pain.".into()], + preferences: vec!["Bodies, witnesses, private cost, public anger, and grotesque physical evidence.".into()], + }, + byline: "Mera Quill".into(), + }, + WorldNewspaperJournalist { + staff: WorldNewspaperStaffMember { + id: "ossan-reed".into(), + name: "Ossan Reed".into(), + character: "A former works clerk who can smell a failing pump, a cooked ledger, and management's preferred order for blaming the shift.".into(), + biases: vec!["Infrastructure failures are political decisions with tools attached.".into()], + preferences: vec!["Labor, shortages, hazardous works, guild conflict, and material consequences.".into()], + }, + byline: "Ossan Reed".into(), + }, + WorldNewspaperJournalist { + staff: WorldNewspaperStaffMember { + id: "lysa-fen".into(), + name: "Lysa Fen".into(), + character: "A border correspondent who reads every truce as a maneuver and every maneuver as somebody else's future funeral.".into(), + biases: vec!["Security claims deserve to be read from the exposed side of the boundary.".into()], + preferences: vec!["Borders, armed orders, treaties, migrations, and rival countermoves.".into()], + }, + byline: "Lysa Fen".into(), + }, + WorldNewspaperJournalist { + staff: WorldNewspaperStaffMember { + id: "corin-vey".into(), + name: "Corin Vey".into(), + character: "A trade columnist who believes every noble abstraction eventually arrives as a toll, an empty stall, or a favor owed.".into(), + biases: vec!["Markets reveal alliances that proclamations try to hide.".into()], + preferences: vec!["Trade, tolls, communes, debt, patronage, and who profits from disorder.".into()], + }, + byline: "Corin Vey".into(), + }, + ], + copy_editor: WorldNewspaperStaffMember { + id: "dalen-marr".into(), + name: "Dalen Marr".into(), + character: "A copy editor with a jeweller's patience for exact language and a personal grudge against causation smuggled through a comma.".into(), + biases: vec!["Certainty is expensive and reporters spend it too freely.".into()], + preferences: vec!["Complete factual query lists, exact quoted passages, and no proposed prose.".into()], + }, + night_editor: WorldNewspaperStaffMember { + id: "meret-sorn".into(), + name: "Meret Sorn".into(), + character: "A deadline editor who can remove a libelous hinge without sanding the blade off the sentence around it.".into(), + biases: vec!["A late correction is survivable; a page that says nothing is not worth printing.".into()], + preferences: vec!["The smallest repair that clears every lodged objection and preserves the reporter's case.".into()], + }, + } +} #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema, PartialEq, Eq)] pub struct WorldNewspaperIssue { @@ -381,6 +489,10 @@ pub struct WorldNewspaperEditorialAgenda { #[serde(deny_unknown_fields)] pub struct WorldNewspaperStoryPitch { pub lead: bool, + #[serde(default)] + pub section: String, + #[serde(default)] + pub journalist_id: String, #[schemars(length(min = 1))] pub citations: Vec, #[serde(default)] @@ -502,6 +614,7 @@ struct EditorialSupportingRecord { struct NarrativeSelectionWorkbench<'a> { records: &'a [PublicRecordProjection], + newsroom: &'a WorldNewspaperNewsroom, max_articles: usize, visible_record_ids: BTreeSet, completed_queries: BTreeSet, @@ -509,15 +622,6 @@ struct NarrativeSelectionWorkbench<'a> { } impl NarrativeSelectionWorkbench<'_> { - fn known_channels(&self) -> Vec { - self.records - .iter() - .map(|record| record.channel.clone()) - .collect::>() - .into_iter() - .collect() - } - fn query(&mut self, mut query: PublicRecordQuery) -> Result { normalize_query_terms(&mut query.terms, "query term")?; normalize_query_terms(&mut query.entity_names, "query entity")?; @@ -598,7 +702,7 @@ impl NarrativeSelectionWorkbench<'_> { "agenda cites public record {record_id} without querying it" )); } - validate_editorial_agenda(self.records, &agenda, self.max_articles)?; + validate_editorial_agenda(self.records, self.newsroom, &agenda, self.max_articles)?; let candidate_digest = editorial_agenda_candidate_digest(&agenda)?; let mut pitches = agenda .story_pitches @@ -750,8 +854,13 @@ impl ModelAgentTool for NarrativeSelectionWorkbench<'_> { type Finding = NarrativeSelectionFinding; fn action_schema(&self) -> std::result::Result { - let citations = self.visible_record_ids.iter().cloned().collect::>(); - let story_budget = self.max_articles.min(self.records.len()); + let story_budget = self.max_articles; + let journalist_ids = self + .newsroom + .journalists + .iter() + .map(|journalist| journalist.staff.id.clone()) + .collect::>(); let propose_schema = serde_json::json!({ "type":"object", "additionalProperties":false, @@ -774,6 +883,8 @@ impl ModelAgentTool for NarrativeSelectionWorkbench<'_> { "additionalProperties":false, "required":[ "lead", + "section", + "journalist_id", "citations", "focus_citation", "narrative_claim", @@ -782,14 +893,15 @@ impl ModelAgentTool for NarrativeSelectionWorkbench<'_> { ], "properties":{ "lead":{"type":"boolean"}, + "section":{"type":"string","enum":ALLOWED_SECTIONS}, + "journalist_id":{"type":"string","enum":journalist_ids}, "citations":{ "type":"array", "minItems":1, - "maxItems":citations.len(), "uniqueItems":true, - "items":{"type":"string","enum":citations} + "items":{"type":"string","minLength":1,"maxLength":240} }, - "focus_citation":{"type":"string","enum":citations}, + "focus_citation":{"type":"string","minLength":1,"maxLength":240}, "narrative_claim":{"type":"string","minLength":1,"maxLength":500}, "tension":{"type":"string","minLength":1,"maxLength":500}, "public_question":{"type":"string","minLength":1,"maxLength":500} @@ -798,14 +910,10 @@ impl ModelAgentTool for NarrativeSelectionWorkbench<'_> { } } }); - let cursor_schema = if citations.is_empty() { - serde_json::json!({"type":"null"}) - } else { - serde_json::json!({"anyOf":[ - {"type":"string","enum":citations}, - {"type":"null"} - ]}) - }; + let cursor_schema = serde_json::json!({"anyOf":[ + {"type":"string","minLength":1,"maxLength":240}, + {"type":"null"} + ]}); let assertion_status_schema = serde_json::to_value(schema_for!(WorldNewspaperAssertionStatus)) .map_err(|error| error.to_string())?; @@ -830,32 +938,26 @@ impl ModelAgentTool for NarrativeSelectionWorkbench<'_> { "assertion_statuses":{"type":"array","maxItems":5,"uniqueItems":true, "items":{"type":"string","enum":assertion_statuses}}, "channels":{"type":"array","maxItems":6,"uniqueItems":true, - "items":{"type":"string","enum":self.known_channels()}}, + "items":{"type":"string","minLength":1,"maxLength":240}}, "order":{"type":"string","enum":["newest","oldest"]}, "cursor":cursor_schema, "limit":{"type":"integer","minimum":1,"maximum":MAX_PUBLIC_RECORD_QUERY_RESULTS} } }); - let mut commands = vec![query_schema]; - if !citations.is_empty() { - commands.push(propose_schema); - } - if let Some((candidate_digest, _)) = &self.pending_agenda { - commands.push(serde_json::json!({ + let commands = vec![ + query_schema, + propose_schema, + serde_json::json!({ "type":"object", "additionalProperties":false, "required":["tool","candidate_digest"], "properties":{ "tool":{"const":"commit_agenda"}, - "candidate_digest":{"const":candidate_digest} + "candidate_digest":{"type":"string","minLength":71,"maxLength":71} } - })); - } - let command_schema = if commands.len() == 1 { - commands.remove(0) - } else { - serde_json::json!({"oneOf":commands}) - }; + }), + ]; + let command_schema = serde_json::json!({"oneOf":commands}); let mut schema = serde_json::json!({ "type":"object", "additionalProperties":false, @@ -966,6 +1068,95 @@ fn validate_editorial_frame(value: &str, label: &str) -> Result<()> { Ok(()) } +fn validate_staff_member(member: &WorldNewspaperStaffMember, label: &str) -> Result<()> { + validate_single_line(&member.id, 80, &format!("{label} ID"))?; + validate_single_line(&member.name, 80, &format!("{label} name"))?; + validate_editorial_frame(&member.character, &format!("{label} character"))?; + if member.biases.is_empty() + || member.biases.len() > 6 + || member.preferences.is_empty() + || member.preferences.len() > 6 + { + return Err(anyhow!("{label} has an invalid disposition")); + } + for bias in &member.biases { + validate_editorial_frame(bias, &format!("{label} bias"))?; + } + for preference in &member.preferences { + validate_editorial_frame(preference, &format!("{label} preference"))?; + } + Ok(()) +} + +fn validate_newsroom(newsroom: &WorldNewspaperNewsroom) -> Result<()> { + if newsroom.journalists.is_empty() || newsroom.journalists.len() > MAX_FRONT_PAGE_ARTICLES { + return Err(anyhow!("newsroom must employ one to six journalists")); + } + validate_staff_member(&newsroom.assignment_editor, "assignment editor")?; + validate_staff_member(&newsroom.copy_editor, "copy editor")?; + validate_staff_member(&newsroom.night_editor, "Night Editor")?; + let mut staff_ids = BTreeSet::from([ + newsroom.assignment_editor.id.as_str(), + newsroom.copy_editor.id.as_str(), + newsroom.night_editor.id.as_str(), + ]); + let mut staff_names = BTreeSet::from([ + newsroom.assignment_editor.name.as_str(), + newsroom.copy_editor.name.as_str(), + newsroom.night_editor.name.as_str(), + ]); + if staff_ids.len() != 3 || staff_names.len() != 3 { + return Err(anyhow!("newsroom staff identities must be unique")); + } + let mut bylines = BTreeSet::new(); + for journalist in &newsroom.journalists { + validate_staff_member(&journalist.staff, "journalist")?; + validate_single_line(&journalist.byline, 60, "journalist byline")?; + if !staff_ids.insert(&journalist.staff.id) + || !staff_names.insert(&journalist.staff.name) + || !bylines.insert(journalist.byline.as_str()) + { + return Err(anyhow!( + "newsroom staff identities and bylines must be unique" + )); + } + } + Ok(()) +} + +fn journalist_by_id<'a>( + newsroom: &'a WorldNewspaperNewsroom, + journalist_id: &str, +) -> Result<&'a WorldNewspaperJournalist> { + newsroom + .journalists + .iter() + .find(|journalist| journalist.staff.id == journalist_id) + .ok_or_else(|| anyhow!("editorial pitch assigned an unknown journalist {journalist_id}")) +} + +fn normalize_legacy_agenda( + agenda: &mut WorldNewspaperEditorialAgenda, + newsroom: &WorldNewspaperNewsroom, +) { + for (index, pitch) in agenda.story_pitches.iter_mut().enumerate() { + if pitch.section.is_empty() { + pitch.section = if index == 0 { + "Front Page" + } else { + "Dispatches" + } + .into(); + } + if pitch.journalist_id.is_empty() { + pitch.journalist_id = newsroom.journalists[index % newsroom.journalists.len()] + .staff + .id + .clone(); + } + } +} + fn selected_record_ids<'a>(record_ids: &'a [String], label: &str) -> Result> { if record_ids.is_empty() { return Err(anyhow!("{label} selected no public record")); @@ -982,6 +1173,7 @@ fn selected_record_ids<'a>(record_ids: &'a [String], label: &str) -> Result Result<()> { @@ -1002,6 +1194,13 @@ fn validate_editorial_agenda( "editorial agenda must designate exactly its first pitch as lead" )); } + if (index == 0 && pitch.section != "Front Page") + || (index > 0 && pitch.section == "Front Page") + || !ALLOWED_SECTIONS.contains(&pitch.section.as_str()) + { + return Err(anyhow!("editorial pitch {index} chose an invalid section")); + } + journalist_by_id(newsroom, &pitch.journalist_id)?; let selected = selected_record_ids(&pitch.citations, &format!("editorial pitch {index}"))?; if !selected.contains(pitch.focus_citation.as_str()) { return Err(anyhow!( @@ -1024,6 +1223,7 @@ fn validate_editorial_agenda( fn validate_editorial_alignment( draft: &EditorialPageDraft, + newsroom: &WorldNewspaperNewsroom, agenda: &WorldNewspaperEditorialAgenda, ) -> Result<()> { if draft.articles.len() != agenda.story_pitches.len() { @@ -1039,6 +1239,12 @@ fn validate_editorial_alignment( "article {index} does not implement its admitted citation grouping" )); } + let journalist = journalist_by_id(newsroom, &pitch.journalist_id)?; + if article.section != pitch.section || article.byline != journalist.byline { + return Err(anyhow!( + "article {index} changed its assigned section or journalist" + )); + } } Ok(()) } @@ -1069,6 +1275,99 @@ struct EditorialArticleDraft { paragraphs: Vec, } +#[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] +#[serde(tag = "tool", rename_all = "snake_case", deny_unknown_fields)] +enum JournalistAction { + FileStory { + #[schemars(length(min = 1, max = 100))] + headline: String, + #[schemars(length(min = 1, max = 220))] + deck: String, + #[schemars(length(max = 100))] + dateline: String, + #[schemars(length(min = 2, max = 5))] + paragraphs: Vec, + }, +} + +#[derive(Clone, Debug, Serialize, PartialEq, Eq)] +#[serde(tag = "kind", rename_all = "snake_case")] +enum JournalistFinding { + StoryRejected { reason: String }, +} + +struct JournalistWorkbench<'a> { + records: &'a [PublicRecordProjection], + article_index: usize, + section: &'a str, + byline: &'a str, + citations: &'a [String], +} + +#[async_trait] +impl ModelAgentTool for JournalistWorkbench<'_> { + type Action = JournalistAction; + type Output = EditorialArticleDraft; + type Finding = JournalistFinding; + + fn action_schema(&self) -> std::result::Result { + let mut schema = serde_json::json!({ + "type":"object", + "additionalProperties":false, + "required":["tool","headline","deck","dateline","paragraphs"], + "properties":{ + "tool":{"const":"file_story"}, + "headline":{"type":"string","minLength":1,"maxLength":100}, + "deck":{"type":"string","minLength":1,"maxLength":220}, + "dateline":{"type":"string","maxLength":100}, + "paragraphs":{ + "type":"array", + "minItems":2, + "maxItems":5, + "items":{"type":"string"} + } + } + }); + crate::model_connector::project_strict_responses_schema(&mut schema) + .map_err(|error| error.to_string())?; + Ok(schema) + } + + async fn invoke( + &mut self, + action: Self::Action, + _context: &ModelAgentToolContext, + ) -> ModelAgentToolOutcome { + let JournalistAction::FileStory { + headline, + deck, + dateline, + paragraphs, + } = action; + let article = EditorialArticleDraft { + section: self.section.into(), + headline, + deck, + byline: self.byline.into(), + dateline, + citations: self.citations.to_vec(), + paragraphs, + }; + match validate_editorial_article(self.records, &article, self.article_index) { + Ok(()) => ModelAgentToolOutcome::Accepted { + output: article, + receipts: Vec::new(), + }, + Err(error) => ModelAgentToolOutcome::Rejected { + finding: JournalistFinding::StoryRejected { + reason: error.to_string().chars().take(500).collect(), + }, + receipts: Vec::new(), + }, + } + } +} + #[derive(Clone, Debug, Serialize, Deserialize, JsonSchema)] #[serde(tag = "tool", rename_all = "snake_case", deny_unknown_fields)] enum NightEditorCloseAction { @@ -1126,6 +1425,7 @@ struct NightEditorCloseOutput { struct PreparedNewspaper { title: String, editorial_voice: String, + newsroom: WorldNewspaperNewsroom, records: Vec, source_receipt_ids: Vec, publication_task_binding: String, @@ -1138,6 +1438,7 @@ fn prepare_newspaper( campaign: &Campaign, title: impl Into, editorial_voice: impl Into, + newsroom: &WorldNewspaperNewsroom, max_articles: usize, ) -> Result { if !(1..=MAX_FRONT_PAGE_ARTICLES).contains(&max_articles) { @@ -1160,14 +1461,22 @@ fn prepare_newspaper( { return Err(anyhow!("newspaper editorial voice is invalid")); } + validate_newsroom(newsroom)?; let records = public_news_records(campaign)?; let source_receipt_ids = records .iter() .map(|record| record.record_id.clone()) .collect::>(); let publication_task_binding = - publication_task_binding(campaign, &title, &editorial_voice, max_articles)?; - let binding = editorial_binding(campaign, &title, &editorial_voice, max_articles, &records)?; + publication_task_binding(campaign, &title, &editorial_voice, newsroom, max_articles)?; + let binding = editorial_binding( + campaign, + &title, + &editorial_voice, + newsroom, + max_articles, + &records, + )?; let legacy_publication_task_binding = publication_task_binding_for_contract( LEGACY_RECONCILIATION_CONTRACT_VERSION, campaign, @@ -1186,6 +1495,7 @@ fn prepare_newspaper( Ok(PreparedNewspaper { title, editorial_voice, + newsroom: newsroom.clone(), records, source_receipt_ids, publication_task_binding, @@ -1203,11 +1513,16 @@ async fn select_editorial_agenda( crate::agent::ModelAgentRun, crate::agent::ModelAgentFailure, > { + let assignment_staff = serde_json::json!({ + "assignment_editor": &prepared.newsroom.assignment_editor, + "journalists": &prepared.newsroom.journalists, + }); let instructions = format!( - "You are the narrative editor of `{}`. Construct one compelling editorial agenda by investigating a frozen public ledger containing {} canonical news records. No intermediate source object owns these facts and no initial viewport is privileged. Use query_public_records to inspect the ledger. An empty query browses it in the requested order; literal terms, exact public entity names, assertion status, channel, and an inspected cursor may narrow or page it. Query responses are bounded context over stable record IDs, not summaries that replace the ledger. Search backward for causes when recent administrative responses hide the original rupture; search by actors, places, objects, institutions, or consequences to find opposition and countermoves. Never cite a record until the workbench has returned it.\n\nA newspaper is not a neutral transcript. Select a dominant throughline that matters to this publication's readers; connect records that expose conflict, responsibility, hypocrisy, lived stakes, scandal, named opposition, public reaction, or material consequence. Omitted true facts remain true and unreported. Use every relevant inspected record that the actual story needs; article count and bounded copy define page space. Do not cover a record merely because it exists. Treat bookkeeping about someone retaining a memory as context unless the act of remembering itself caused a public consequence. The first pitch is the lead and must set lead=true; every later pitch must set lead=false. A foundational public record may support more than one continuing story when each pitch uses it for a distinct throughline. Every pitch must choose one focus_citation from its stable record IDs: the concrete fact the headline and lede should make impossible to ignore. When a later procedural update belongs to an older vivid incident, query and cite both: identify what is newly changed, acknowledge the continuing incident, and do not let routine handling impersonate the original news. narrative_claim states the pointed story the publication can responsibly construct from those records. narrative_claim, tension, public_question, dominant_throughline, and reader_stake are editorial framing and hypotheses, not evidence. They may be pointed, skeptical, or insinuating, but they must not invent an event, person, institution, place, motive, quotation, outcome, private knowledge, or factual status. The downstream editor may state facts only from the admitted record IDs and will be constrained to the exact groupings. The copy desk judges factual claims independently.\n\nPropose the agenda first. The workbench will dereference every focus record and return a front-page proof that places the proposed lead beside the stories it would bury. Treat that as an editorial meeting, not a schema ceremony: compare actual reader consequence, revise the agenda or query again when the proof exposes a stronger lead or a paperwork-shaped angle, and commit only the candidate you are prepared to print.\n\nPUBLICATION VOICE:\n{}", - prepared.title, - prepared.records.len(), - prepared.editorial_voice, + "You are {editor}, assignment editor of `{title}`. Investigate its frozen public ledger with query_public_records, then assign a page. An empty query browses; terms, exact entity names, status, channel, and an inspected cursor narrow or page. Cite only returned record IDs. Search backward when procedure hides the rupture and sideways for opposition, countermoves, reaction, scandal, and lived cost.\n\nChoose one dominant throughline, then only stories that sharpen or complicate it. The first pitch is the Front Page lead; later pitches use another allowed section. For every pitch choose the staff journalist whose biases and preferences will produce the strongest treatment. Give that journalist the exact record grouping, one focus record that the lede cannot bury, a pointed narrative claim, the live tension, and the public question. A routine update to a vivid continuing incident should cite both and say what changed. Remembering, filing, warning, and planning are context unless they themselves produce a public consequence. Editorial frames may insinuate and judge but cannot invent concrete facts. The copy editor, not you or the reporters, owns fact checking.\n\nPropose before committing. The workbench returns the lead beside what it would bury; revise or query again if the proof exposes a stronger page.\n\nPUBLICATION VOICE:\n{voice}\n\nSTAFF BOOK:\n{staff}", + editor = prepared.newsroom.assignment_editor.name, + title = prepared.title, + voice = prepared.editorial_voice, + staff = serde_json::to_string(&assignment_staff).unwrap_or_default(), ); let spec = ModelAgentSpec { stage: "newspaper_narrative_selection_agent_action".into(), @@ -1221,6 +1536,7 @@ async fn select_editorial_agenda( }; let mut tool = NarrativeSelectionWorkbench { records: &prepared.records, + newsroom: &prepared.newsroom, max_articles, visible_record_ids: BTreeSet::new(), completed_queries: BTreeSet::new(), @@ -1243,6 +1559,96 @@ fn editorial_page_digest(draft: &EditorialPageDraft) -> Result { )) } +async fn run_assigned_journalists( + model: &dyn ModelPort, + prepared: &PreparedNewspaper, + agenda: &WorldNewspaperEditorialAgenda, + selection_receipts: &[ModelStageReceipt], +) -> std::result::Result< + (EditorialPageDraft, Vec), + crate::agent::ModelAgentFailure, +> { + let mut articles = Vec::with_capacity(agenda.story_pitches.len()); + let mut receipts = Vec::new(); + for (index, pitch) in agenda.story_pitches.iter().enumerate() { + let journalist = match journalist_by_id(&prepared.newsroom, &pitch.journalist_id) { + Ok(journalist) => journalist, + Err(error) => { + return Err(crate::agent::ModelAgentFailure { + message: error.to_string(), + receipts, + }); + } + }; + let selected = pitch.citations.iter().cloned().collect::>(); + let source_json = + serde_json::to_string(&public_records_for_ids(&prepared.records, &selected)) + .unwrap_or_else(|_| "[]".into()); + let assignment = serde_json::json!({ + "dominant_throughline": agenda.dominant_throughline, + "reader_stake": agenda.reader_stake, + "pitch": pitch, + "journalist": journalist, + "records": serde_json::from_str::(&source_json) + .unwrap_or_else(|_| serde_json::json!([])), + }); + let instructions = format!( + "You are {name}, filing one assigned story for `{title}`. You are a partisan storyteller with a nose for the material your character and biases care about, not the newsroom's fact checker. Make the strongest memorable case this assignment supports: lead with the focus event, use selection and juxtaposition to imply meaning, expose hypocrisy or conflict, and make public or bodily cost vivid. A continuing story should say what changed without treating routine handling as the lede.\n\nUse only the supplied records for concrete events, people, institutions, places, identity attributes, chronology, quotations, motives, and outcomes. Preserve disputes through natural attribution. You may be cutting, emotional, insinuating, metaphorical, or politically judgmental without pretending the insinuation is a sourced causal fact. Do not discuss records, citations, verification, assertion statuses, or missing evidence in reader copy. The copy editor will fact-check the filed story later. File two to five substantial paragraphs. The lead needs a supplied place as dateline when one exists; otherwise leave dateline empty.\n\nPUBLICATION VOICE:\n{voice}\n\nASSIGNMENT PACKET:\n{assignment}", + name = journalist.staff.name, + title = prepared.title, + voice = prepared.editorial_voice, + assignment = serde_json::to_string(&assignment).unwrap_or_default(), + ); + let mut causal_sources = pitch + .citations + .iter() + .cloned() + .chain( + selection_receipts + .iter() + .map(|receipt| receipt.storage_key().to_owned()), + ) + .collect::>() + .into_iter() + .collect::>(); + causal_sources.sort(); + let spec = ModelAgentSpec { + stage: "newspaper_journalist_agent_action".into(), + model: MODEL_CAPABLE.into(), + snapshot_binding: format!( + "{}:journalist:{}:article:{}", + prepared.binding, journalist.staff.id, index + ), + instructions, + source_receipt_ids: causal_sources, + temperature: Some(0.9), + max_output_tokens: Some(2_200), + max_steps: 2, + }; + let mut workbench = JournalistWorkbench { + records: &prepared.records, + article_index: index, + section: &pitch.section, + byline: &journalist.byline, + citations: &pitch.citations, + }; + match crate::agent::run_model_agent(model, &spec, &mut workbench).await { + Ok(run) => { + articles.push(run.output); + receipts.extend(run.receipts); + } + Err(mut failure) => { + receipts.append(&mut failure.receipts); + return Err(crate::agent::ModelAgentFailure { + message: failure.message, + receipts, + }); + } + } + } + Ok((EditorialPageDraft { articles }, receipts)) +} + fn legacy_copy_desk_report( verdict: WorldNewspaperGroundingVerdict, ) -> WorldNewspaperCopyDeskReport { @@ -1572,16 +1978,17 @@ fn validate_reconciliation_checkpoint( | WorldNewspaperCheckpointOrigin::Reconciliation => { let editor_index = receipts .iter() - .position(|receipt| receipt.stage == "newspaper_editor"); + .position(|receipt| receipt.stage == "newspaper_journalist_agent_action"); checkpoint.editorial_agenda.is_some() && editor_index.is_some_and(|index| { index > 0 && receipts[..index].iter().all(|receipt| { receipt.stage == "newspaper_narrative_selection_agent_action" }) - && receipts - .get(index + 1) - .map(|receipt| receipt.stage.as_str()) + && receipts[index..receipts.len().saturating_sub(1)] + .iter() + .all(|receipt| receipt.stage == "newspaper_journalist_agent_action") + && receipts.last().map(|receipt| receipt.stage.as_str()) == Some("newspaper_copy_desk") }) } @@ -1598,8 +2005,8 @@ fn validate_reconciliation_checkpoint( } validate_editorial_draft(&prepared.records, &checkpoint.draft, max_articles)?; if let Some(agenda) = &checkpoint.editorial_agenda { - validate_editorial_agenda(&prepared.records, agenda, max_articles)?; - validate_editorial_alignment(&checkpoint.draft, agenda)?; + validate_editorial_agenda(&prepared.records, &prepared.newsroom, agenda, max_articles)?; + validate_editorial_alignment(&checkpoint.draft, &prepared.newsroom, agenda)?; } validate_desk_rejection(&checkpoint.draft, &checkpoint.rejection)?; let latest_review_stage = receipts @@ -1799,11 +2206,16 @@ fn validate_close_checkpoint( } validate_editorial_agenda( &prepared.records, + &prepared.newsroom, &checkpoint.editorial_agenda, max_articles, )?; validate_editorial_draft(&prepared.records, &checkpoint.draft, max_articles)?; - validate_editorial_alignment(&checkpoint.draft, &checkpoint.editorial_agenda)?; + validate_editorial_alignment( + &checkpoint.draft, + &prepared.newsroom, + &checkpoint.editorial_agenda, + )?; validate_copy_desk_report(&checkpoint.draft, &checkpoint.copy_desk)?; Ok(()) } @@ -1925,7 +2337,8 @@ fn validate_persisted_composition( } if composition.issue.articles.is_empty() { if persisted.source_checkpoint_id.is_some() - || composition.issue.id != empty_issue_id(campaign, &prepared.title)? + || composition.issue.id + != empty_issue_id(campaign, &prepared.title, &prepared.newsroom)? || composition.issue.at != campaign.world_time || composition.issue.lead_article_id.is_some() || composition.issue.editorial_agenda.is_some() @@ -2041,7 +2454,7 @@ fn validate_persisted_composition( && (addressed_queries != expected_queries || addressed_queries.len() != composition.press_close.addressed_query_indices.len() - || !changed_articles.is_superset(&required_changed_articles) + || changed_articles != required_changed_articles || changed_articles.len() != composition.press_close.changed_article_indices.len() || changed_articles .iter() @@ -2078,8 +2491,11 @@ async fn close_world_newspaper( mut receipts: Vec, ) -> Result { validate_close_checkpoint(store, &checkpoint, prepared, max_articles, &receipts)?; - let source_json = - source_json_for_agenda(&prepared.records, Some(&checkpoint.editorial_agenda))?; + let source_json = source_json_for_copy_queries( + &prepared.records, + &checkpoint.editorial_agenda, + &checkpoint.copy_desk, + )?; let progress = match run_night_editor_close( model, &prepared.records, @@ -2087,6 +2503,7 @@ async fn close_world_newspaper( &prepared.binding, &prepared.title, &prepared.editorial_voice, + &prepared.newsroom, &checkpoint.editorial_agenda, &source_json, &prepared.source_receipt_ids, @@ -2177,10 +2594,11 @@ pub async fn advance_world_newspaper( campaign: &Campaign, title: impl Into, editorial_voice: impl Into, + newsroom: &WorldNewspaperNewsroom, max_articles: usize, store: &CampaignStore, ) -> Result { - let prepared = prepare_newspaper(campaign, title, editorial_voice, max_articles)?; + let prepared = prepare_newspaper(campaign, title, editorial_voice, newsroom, max_articles)?; if let Some((_, persisted)) = store.load::( "world_newspaper_composition.v3", &prepared.publication_task_binding, @@ -2219,7 +2637,7 @@ pub async fn advance_world_newspaper( if prepared.records.is_empty() { let issue = WorldNewspaperIssue { schema: "ghostlight.world_newspaper_issue.v3".into(), - id: empty_issue_id(campaign, &prepared.title)?, + id: empty_issue_id(campaign, &prepared.title, &prepared.newsroom)?, title: prepared.title.clone(), edition_label: "No edition issued".into(), at: campaign.world_time, @@ -2263,76 +2681,33 @@ pub async fn advance_world_newspaper( .await .map_err(|failure| composition_failure(failure.message, failure.receipts))?; let agenda = agenda_run.output; - let editorial_schema = editorial_schema(&prepared.records, &agenda)?; - let agenda_json = serde_json::to_string_pretty(&agenda)?; let selected_source_json = source_json_for_agenda(&prepared.records, Some(&agenda))?; - let base_prompt = format!( - "OUTPUT JSON SCHEMA (follow exactly):\n{}\n\nYou are `{title}`'s lead writer. Your job is not to transmit a neutral inventory of verified facts. Use the admitted facts as raw material for the publication's chosen narrative: decide what they mean together for your readers, assign blame and importance as clearly signalled interpretation, juxtapose hypocrisy with consequence, ask who benefits, and make one memorable case about the world they inhabit. The narrative agenda owns selection, article order, focus citation, narrative claim, and exact citation groupings. Implement every pitch in order and use exactly that pitch's citations for the corresponding article. Center each headline and lede on the most consequential concrete fact supported by its focus_citation, then use the other selected records to substantiate or complicate the narrative_claim. Record bookkeeping, memory retention, maintained warnings, and routine procedure are not automatically the news merely because they are recent. When a later update belongs to an older vivid incident, distinguish what changed from what persists and tell readers plainly that this is a continuing story; do not parade the handling protocol in front of the incident itself. The agenda's narrative_claim, tension, public_question, dominant_throughline, and reader_stake are the point of the work, not source evidence. Do not recover omitted sources or invent a connective fact merely because the agenda suggests one. The first article must use section `Front Page` and, when its citations name a place, use one of those supplied place names as its dateline. Later articles use the other supplied newspaper sections.\n\nWrite completely in the publication voice for readers who live in this world. Build a readable throughline: lead with the vivid consequence, identify opposing named actors and countermoves when the cited facts supply them, make lived material stakes legible, and let later stories echo or complicate the lead. Attribute claims and evidence naturally to the named institution, notice, witness, or public act that supplied them. Preserve genuinely disputed claims with attribution or ordinary words such as alleged or disputed. Do not turn source limitations into the subject of the article: readers need the event and its consequence, not repeated explanations of what a ledger fails to establish. The copy desk will mark every factual query once; the Night Editor will receive that complete list and close the page once at deadline. Write declaratively and let ordinary tense and attribution carry uncertainty.\n\nHeadlines report consequences rather than state transitions. Decks add context instead of repeating headlines. Paragraphs explain why events matter to local readers, connect institutional moves, and vary their rhythm without explaining proper nouns like a setting guide. Dry barbs, metaphor, political characterization, rhetorical judgment, selection, juxtaposition, and clearly signalled insinuation are your proper tools when they introduce no new concrete fact. This is a newspaper with a point of view, not parody, an evidence inventory, or a world-state transcript.\n\nUse only the selected records for each article and preserve the names and identity attributes they actually supply. Never invent quotations, people, offices, places, numbers, documents, motives, chronology, outcomes, or private knowledge. Use only the allowed generic bylines and supplied place names. The newspaper contract owns a neutral edition label; do not invent or print a calendar, date, price, circulation claim, weather report, advertisement, or notice absent from the desk. Do not mention the fact desk, citations, agenda, assertion statuses, verification process, or phrases such as `the record does not establish` in reader-facing copy. Never end a headline with an ellipsis.\n\nPUBLICATION VOICE:\n{editorial_voice}\n\nADMITTED NARRATIVE AGENDA:\n{agenda_json}\n\nNEWSROOM FACT DESK:\n{source_json}", - serde_json::to_string(&editorial_schema)?, - title = prepared.title, - editorial_voice = prepared.editorial_voice, - agenda_json = agenda_json, - source_json = selected_source_json, - ); let mut receipts = agenda_run.receipts; - let editor_sources = prepared - .source_receipt_ids - .iter() - .cloned() - .chain( - receipts - .iter() - .map(|receipt| receipt.storage_key().to_owned()), - ) - .collect::>() - .into_iter() - .collect(); - let request = ModelStageRequest { - stage: "newspaper_editor".into(), - model: MODEL_CAPABLE.into(), - snapshot_binding: prepared.binding.clone(), - lived_stream: base_prompt, - output_schema: Some(editorial_schema), - source_receipt_ids: editor_sources, - temperature: Some(0.75), - max_output_tokens: Some(4_500), - }; - let editor_output = run_validated_stage(model, &request).await?; - receipts.push(editor_output.receipt); - let editor_receipt_index = receipts.len() - 1; - let editor_structured = match editor_output.structured { - Some(structured) => structured, - None => { - let error = anyhow!("newspaper editor returned no structured output"); - mark_semantic_invalid(&mut receipts[editor_receipt_index], &error); - return Err(composition_failure(error.to_string(), receipts)); - } - }; - let draft: EditorialPageDraft = match serde_json::from_value(editor_structured) { - Ok(draft) => draft, - Err(error) => { - let error = anyhow!("newspaper editor returned an invalid page: {error}"); - mark_semantic_invalid(&mut receipts[editor_receipt_index], &error); - return Err(composition_failure(error.to_string(), receipts)); - } - }; - - if let Err(error) = validate_editorial_draft(&prepared.records, &draft, max_articles) { - mark_semantic_invalid(&mut receipts[editor_receipt_index], &error); - return Err(composition_failure(error.to_string(), receipts)); - } - if let Err(error) = validate_editorial_alignment(&draft, &agenda) { - mark_semantic_invalid(&mut receipts[editor_receipt_index], &error); - return Err(composition_failure(error.to_string(), receipts)); - } + let (draft, journalist_receipts) = + run_assigned_journalists(model, &prepared, &agenda, &receipts) + .await + .map_err(|failure| { + let mut all_receipts = receipts.clone(); + all_receipts.extend(failure.receipts); + composition_failure(failure.message, all_receipts) + })?; + receipts.extend(journalist_receipts); + validate_editorial_draft(&prepared.records, &draft, max_articles)?; + validate_editorial_alignment(&draft, &prepared.newsroom, &agenda)?; store.persist_model_stage_receipts(&receipts)?; - let editor_output_hash = receipts[editor_receipt_index].output_hash.clone(); + let page_digest = editorial_page_digest(&draft)?; + let journalist_receipt_ids = receipts + .iter() + .filter(|receipt| receipt.stage == "newspaper_journalist_agent_action") + .map(|receipt| receipt.storage_key().to_owned()) + .collect::>(); let copy_desk = match run_copy_desk( model, - format!("{}:draft:{editor_output_hash}", prepared.binding), + format!("{}:draft:{page_digest}", prepared.binding), &selected_source_json, + &prepared.newsroom.copy_editor, &prepared.source_receipt_ids, - &[receipts[editor_receipt_index].storage_key().to_owned()], + &journalist_receipt_ids, &draft, &mut receipts, ) @@ -2383,6 +2758,7 @@ async fn compose_world_newspaper( campaign, title, editorial_voice, + &canopy_ledger_newsroom(), max_articles, &store, ) @@ -2400,11 +2776,16 @@ pub fn admit_world_newspaper_reconciliation_import( campaign: &Campaign, title: impl Into, editorial_voice: impl Into, + newsroom: &WorldNewspaperNewsroom, max_articles: usize, store: &CampaignStore, import: WorldNewspaperReconciliationImport, ) -> Result { - let prepared = prepare_newspaper(campaign, title, editorial_voice, max_articles)?; + let prepared = prepare_newspaper(campaign, title, editorial_voice, newsroom, max_articles)?; + let mut import = import; + if let Some(agenda) = &mut import.editorial_agenda { + normalize_legacy_agenda(agenda, newsroom); + } let existing_tip = load_legacy_reconciliation_tip(store, &prepared, max_articles)?; if import.schema != "ghostlight.world_newspaper_reconciliation_import.v1" || !import.source_witness_digest.starts_with("sha256:") @@ -2478,19 +2859,7 @@ impl ModelAgentTool for NightEditorCloseWorkbench<'_> { if let Some(schema) = rewrite_schema.as_object_mut() { schema.remove("$schema"); } - let required_article_indices = self - .copy_desk - .queries - .iter() - .map(|query| usize::from(query.article_index)) - .collect::>() - .into_iter() - .map(|index| serde_json::Value::from(index as u64)) - .collect::>(); - let article_indices = (0..self.draft.articles.len()) - .map(serde_json::Value::from) - .collect::>(); - let query_indices = (0..self.copy_desk.queries.len()) + let article_indices = (0..self.max_articles) .map(serde_json::Value::from) .collect::>(); rewrite_schema["properties"]["article_index"] = serde_json::json!({ @@ -2505,15 +2874,13 @@ impl ModelAgentTool for NightEditorCloseWorkbench<'_> { "tool":{"const":"submit_close"}, "addressed_query_indices":{ "type":"array", - "minItems":query_indices.len(), - "maxItems":query_indices.len(), "uniqueItems":true, - "items":{"type":"integer", "enum":query_indices} + "items":{"type":"integer", "minimum":0, "maximum":65535} }, "rewrites":{ "type":"array", - "minItems":required_article_indices.len(), - "maxItems":self.draft.articles.len(), + "minItems":0, + "maxItems":self.max_articles, "uniqueItems":true, "items":rewrite_schema } @@ -2611,9 +2978,9 @@ fn apply_night_editor_close( article.dateline = rewrite.dateline; article.paragraphs = rewrite.paragraphs; } - if !rewritten.is_superset(&affected) { + if rewritten != affected { return Err(anyhow!( - "the Night Editor must close every copy-queried article in one bounded pass" + "the Night Editor must rewrite exactly the copy-queried articles in one bounded pass" )); } Ok(NightEditorCloseOutput { @@ -2631,6 +2998,7 @@ async fn run_night_editor_close( binding: &str, title: &str, editorial_voice: &str, + newsroom: &WorldNewspaperNewsroom, agenda: &WorldNewspaperEditorialAgenda, source_json: &str, source_receipt_ids: &[String], @@ -2646,14 +3014,55 @@ async fn run_night_editor_close( .enumerate() .map(|(query_index, query)| serde_json::json!({"query_index":query_index, "query":query})) .collect::>(); + let affected_article_indices = copy_desk + .queries + .iter() + .map(|query| usize::from(query.article_index)) + .collect::>(); + let affected_assignments = agenda + .story_pitches + .iter() + .enumerate() + .filter(|(article_index, _)| affected_article_indices.contains(article_index)) + .map(|(article_index, pitch)| { + serde_json::json!({"article_index": article_index, "assignment": pitch}) + }) + .collect::>(); + let queried_articles = draft + .articles + .iter() + .enumerate() + .filter(|(article_index, _)| affected_article_indices.contains(article_index)) + .map(|(article_index, article)| { + serde_json::json!({"article_index": article_index, "article": article}) + }) + .collect::>(); + let affected_journalist_ids = copy_desk + .queries + .iter() + .filter_map(|query| agenda.story_pitches.get(usize::from(query.article_index))) + .map(|pitch| pitch.journalist_id.as_str()) + .collect::>(); + let affected_journalists = newsroom + .journalists + .iter() + .filter(|journalist| affected_journalist_ids.contains(journalist.staff.id.as_str())) + .collect::>(); let instructions = format!( - "You are the Night Editor of `{title}`. The press closes after this one action. The copy desk has marked the current page and supplied its complete factual query list. Work every numbered query while preserving the edition's selected stories, argument, urgency, and house voice. Submit one complete reader-facing replacement for every queried article and mark every query_index addressed. You may also replace any other article that needs a sharper headline, lede, throughline, rhythm, or ending before deadline. The deterministic press workbench freezes article selection, order, sections, bylines, citations, and agenda. It checks only that the close is structurally complete and source-bounded; nobody rereads or grades your final prose before the forms go downstairs. You may make mistakes. A later edition may have to correct them.\n\nDo not turn this into compliance copy. Preserve the story while resolving the marked claims. Lead with the consequential fact, name pressure and opposition when the admitted facts supply them, and keep lived stakes ahead of routine administration. Selection, ordering, contrast, metaphor, political characterization, pointed questions, and source-safe juxtaposition may create meaning without asserting an unsupported cause. Keep distinct reports grammatically distinct when their causal relationship is not established. Attribute disputes naturally. Do not mention the copy desk, queries, citations, verification, support boundaries, or phrases such as `the record does not establish` in reader-facing copy. Do not invent events, entities, motives, outcomes, chronology, spatial relationships, affiliations, identity attributes, or private knowledge. If no article needs a change and there are no queries, submit an unchanged close with empty query and rewrite lists.\n\nPUBLICATION VOICE:\n{editorial_voice}\n\nADMITTED NARRATIVE AGENDA:\n{agenda_json}\n\nFROZEN NEWSROOM FACT DESK:\n{source_json}\n\nCURRENT PAGE:\n{draft_json}\n\nCOPY-DESK ASSESSMENT:\n{copy_assessment}\n\nNUMBERED COPY-DESK QUERIES:\n{query_json}", + "You are {night_editor}, Night Editor of `{title}`. The copy editor has lodged one complete numbered factual checklist and the press closes after this action. Address every item, replacing exactly the articles named by that checklist. Preserve each reporter's argument, urgency, style, and insinuation while making the smallest changes that resolve the cited defect. Do not select stories, widen sources, or improve unrelated articles. Sections, bylines, citations, story order, and assignments are frozen. Nobody rereads the final prose before printing; a later edition may correct a mistake.\n\nKeep distinct reports grammatically distinct when causation is not established and attribute disputes naturally. Do not expose the checklist or verification machinery in reader copy. If there are no queries, submit an unchanged close with empty lists.\n\nNIGHT EDITOR:\n{night_profile}\n\nAFFECTED REPORTERS:\n{reporters}\n\nPUBLICATION VOICE:\n{editorial_voice}\n\nAFFECTED ASSIGNMENTS:\n{agenda_json}\n\nFACTS FOR QUERIED ARTICLES:\n{source_json}\n\nQUERIED ARTICLES:\n{draft_json}\n\nNUMBERED QUERIES:\n{query_json}", + night_editor = newsroom.night_editor.name, title = title, + night_profile = serde_json::to_string(&newsroom.night_editor).unwrap_or_default(), + reporters = serde_json::to_string(&affected_journalists).unwrap_or_default(), editorial_voice = editorial_voice, - agenda_json = serde_json::to_string_pretty(&agenda).unwrap_or_default(), + agenda_json = serde_json::to_string_pretty(&serde_json::json!({ + "dominant_throughline": agenda.dominant_throughline, + "reader_stake": agenda.reader_stake, + "assignments": affected_assignments, + })) + .unwrap_or_default(), source_json = source_json, - draft_json = serde_json::to_string_pretty(&draft).unwrap_or_default(), - copy_assessment = copy_desk.assessment, + draft_json = serde_json::to_string_pretty(&queried_articles).unwrap_or_default(), query_json = serde_json::to_string_pretty(&numbered_queries).unwrap_or_default(), ); let mut causal_sources = source_receipt_ids @@ -2692,6 +3101,7 @@ async fn run_copy_desk( model: &dyn ModelPort, snapshot_binding: String, source_json: &str, + copy_editor: &WorldNewspaperStaffMember, source_receipt_ids: &[String], editorial_source_receipt_ids: &[String], draft: &EditorialPageDraft, @@ -2703,8 +3113,9 @@ async fn run_copy_desk( model: MODEL_CAPABLE.into(), snapshot_binding, lived_stream: format!( - "OUTPUT JSON SCHEMA (follow exactly):\n{}\n\nAct as a strict copy desk, not a rewriting model or publication judge. Compare every reader-facing factual claim in the proposed fantasy newspaper page with only its cited notes in the bounded newsroom fact desk. Query invented or overconfident facts, quotations, identities, offices, places, numbers, motives, outcomes, chronology, spatial relationships, or private knowledge. Treat each fact's assertion_status as an exhaustive boundary: attempt_committed_outcome_unknown never supports completion; course_committed_embedded_actions_not_completed supports the adopted course but not completion of acts named inside it; public_declaration supports only the declaration; material_change_committed supports the stated material change. Treat each named person's supported_identity_attributes as exhaustive. If it is empty, query pronouns, gender, title, kinship, office, membership, or affiliation not independently stated in that fact, and require the name or identity-neutral wording. A person acting within, at, beside, or through a named population or institution does not by itself belong to it. Shared location, a cordon, a route, barriers, and nearby evidence do not establish unstated containment, adjacency, or surrounding geometry. When cited notes dispute a document, accusation, identity, outcome, or authority, require explicit attribution or qualified language; one public claim does not silently settle another note's dispute. Distinguish an institution publishing a notice about evidence from displaying, releasing, or publishing the physical objects themselves. Query copy that exposes the fact desk, citations, verification work, or state transitions instead of reporting news.\n\nThe five allowed generic bylines are publication role labels supplied by the newspaper contract, not claims about new people, witnesses, or reporting acts; never query an allowed byline for lacking source evidence. Metaphor, dry wit, rhetorical contrast, plainly signalled opinion, and political characterization are editorial language rather than world facts when they introduce no concrete entity, occurrence, status, motive, quotation, number, spatial relationship, or private knowledge. Do not demand a source sentence for such language. Still query a rhetorical phrase when it smuggles in a concrete outcome, such as treating a proposed kiln closure as completed, or turns missing evidence into proof that something did not happen. Return the complete bounded query set at once rather than revealing one defect per pass. Every claim_or_phrase must copy one exact contiguous reader-facing substring that occurs exactly once in its named article; never paraphrase the defect in that field. A neutral contract-owned edition label is not part of the proposed model copy. An empty queries list means the desk has no objections. Never propose replacement copy.\n\nNEWSROOM FACT DESK:\n{}\n\nPROPOSED PAGE:\n{}", - serde_json::to_string(&verifier_schema)?, + "You are {}, the publication's copy editor. Act as the sole factual query desk, not a rewriting model or publication judge. Compare every concrete reader-facing claim with only its cited notes. Query invented or overconfident facts, quotations, identities, offices, places, numbers, motives, outcomes, chronology, spatial relationships, affiliations, or private knowledge. Assertion status is exhaustive: an attempted or adopted course does not prove embedded acts completed, and a public declaration proves only the declaration. Supported identity attributes are exhaustive; an empty list supports no pronoun, title, kinship, office, membership, or affiliation. Shared location does not establish unstated geometry. Preserve disputes through attribution.\n\nMetaphor, wit, contrast, opinion, political characterization, and juxtaposition are editorial language unless they smuggle in a concrete fact or causal outcome. Return the complete query set at once. Each claim_or_phrase must be an exact unique substring of its article. Never propose replacement copy and never query a staff byline merely because the world ledger does not name the employee.\n\nCOPY EDITOR:\n{}\n\nFACT DESK:\n{}\n\nPROPOSED PAGE:\n{}", + copy_editor.name, + serde_json::to_string(copy_editor)?, source_json, serde_json::to_string_pretty(draft)?, ), @@ -3082,55 +3493,23 @@ fn source_json_for_agenda( ))?) } -fn editorial_schema( +fn source_json_for_copy_queries( records: &[PublicRecordProjection], agenda: &WorldNewspaperEditorialAgenda, -) -> Result { - let mut schema = serde_json::to_value(schema_for!(EditorialPageDraft))?; - let selected_citations = agenda - .story_pitches - .iter() - .flat_map(|pitch| pitch.citations.iter().cloned()) - .collect::>(); - let citations = selected_citations.iter().cloned().collect::>(); - let mut datelines = records - .iter() - .filter(|record| selected_citations.contains(&record.record_id)) - .flat_map(|record| record.facts.iter()) - .flat_map(|fact| fact.places.iter().cloned()) - .collect::>(); - datelines.insert(String::new()); - *schema - .pointer_mut("/properties/articles/maxItems") - .ok_or_else(|| anyhow!("editorial schema omitted article budget"))? = - agenda.story_pitches.len().into(); - *schema - .pointer_mut("/properties/articles/minItems") - .ok_or_else(|| anyhow!("editorial schema omitted minimum article count"))? = - agenda.story_pitches.len().into(); - *schema - .pointer_mut("/$defs/EditorialArticleDraft/properties/citations/items") - .ok_or_else(|| anyhow!("editorial schema omitted citation items"))? = - serde_json::json!({"type":"string","enum":citations}); - if let Some(citation_array) = schema - .pointer_mut("/$defs/EditorialArticleDraft/properties/citations") - .and_then(serde_json::Value::as_object_mut) - { - citation_array.insert("uniqueItems".into(), true.into()); + copy_desk: &WorldNewspaperCopyDeskReport, +) -> Result { + let mut record_ids = BTreeSet::new(); + for query in ©_desk.queries { + let pitch = agenda + .story_pitches + .get(usize::from(query.article_index)) + .ok_or_else(|| anyhow!("copy-desk query names an unknown article"))?; + record_ids.extend(pitch.citations.iter().cloned()); } - *schema - .pointer_mut("/$defs/EditorialArticleDraft/properties/dateline") - .ok_or_else(|| anyhow!("editorial schema omitted dateline"))? = - serde_json::json!({"type":"string","enum":datelines}); - *schema - .pointer_mut("/$defs/EditorialArticleDraft/properties/section") - .ok_or_else(|| anyhow!("editorial schema omitted section"))? = - serde_json::json!({"type":"string","enum":ALLOWED_SECTIONS}); - *schema - .pointer_mut("/$defs/EditorialArticleDraft/properties/byline") - .ok_or_else(|| anyhow!("editorial schema omitted byline"))? = - serde_json::json!({"type":"string","enum":ALLOWED_BYLINES}); - Ok(schema) + Ok(serde_json::to_string_pretty(&public_records_for_ids( + records, + &record_ids, + ))?) } fn validate_editorial_draft( @@ -3141,10 +3520,47 @@ fn validate_editorial_draft( if draft.articles.is_empty() || draft.articles.len() > max_articles.min(records.len()) { return Err(anyhow!("editorial page exceeded its story budget")); } + let mut headlines = BTreeSet::new(); + for (index, article) in draft.articles.iter().enumerate() { + validate_editorial_article(records, article, index)?; + if !headlines.insert(article.headline.to_lowercase()) { + return Err(anyhow!("front page repeats a headline")); + } + } + Ok(()) +} + +fn validate_editorial_article( + records: &[PublicRecordProjection], + article: &EditorialArticleDraft, + index: usize, +) -> Result<()> { + if (index == 0 && article.section != "Front Page") + || (index > 0 && article.section == "Front Page") + || !ALLOWED_SECTIONS.contains(&article.section.as_str()) + { + return Err(anyhow!("article {index} used an invalid section")); + } + validate_single_line(&article.headline, 100, "headline")?; + validate_single_line(&article.deck, 220, "deck")?; + validate_single_line(&article.byline, 60, "byline")?; + if article.headline.ends_with('…') || article.headline.ends_with("...") { + return Err(anyhow!("article {index} headline ends in truncation")); + } + if article.headline.eq_ignore_ascii_case(&article.deck) { + return Err(anyhow!("article {index} deck repeats its headline")); + } let known_sources = records .iter() .map(|record| record.record_id.as_str()) .collect::>(); + for citation in selected_record_ids(&article.citations, &format!("article {index}"))? { + if !known_sources.contains(citation) { + return Err(anyhow!( + "article {index} cites unknown public record {citation}" + )); + } + } let source_datelines = records .iter() .map(|record| { @@ -3158,107 +3574,60 @@ fn validate_editorial_draft( ) }) .collect::>(); - let source_summaries = records + let cited_dateline_supported = article + .citations .iter() - .map(|record| { - ( - record.record_id.as_str(), - record - .facts - .iter() - .map(|fact| fact.account.trim()) - .collect::>(), - ) - }) - .collect::>(); - let mut headlines = BTreeSet::new(); - for (index, article) in draft.articles.iter().enumerate() { - if (index == 0 && article.section != "Front Page") - || (index > 0 && article.section == "Front Page") + .any(|citation| source_datelines[citation.as_str()].contains(article.dateline.as_str())); + let cited_place_available = article + .citations + .iter() + .any(|citation| !source_datelines[citation.as_str()].is_empty()); + if !article.dateline.is_empty() && !cited_dateline_supported { + return Err(anyhow!( + "article {index} invented or misattributed a dateline" + )); + } + if index == 0 && article.dateline.is_empty() && cited_place_available { + return Err(anyhow!( + "the lead article must use a cited source place as its dateline" + )); + } + if !(2..=5).contains(&article.paragraphs.len()) { + return Err(anyhow!( + "article {index} must contain two to five paragraphs" + )); + } + for paragraph in &article.paragraphs { + let text = paragraph.trim(); + if text != paragraph + || text.chars().count() < 40 + || text.chars().count() > 900 + || text.contains(['\r', '\n']) { + return Err(anyhow!("article {index} contains a malformed paragraph")); + } + if article.citations.iter().any(|citation| { + records + .iter() + .find(|record| record.record_id == *citation) + .is_some_and(|record| record.facts.iter().any(|fact| fact.account.trim() == text)) + }) { return Err(anyhow!( - "only the lead article may use the Front Page section" + "article {index} printed a source summary as final copy" )); } - if !ALLOWED_SECTIONS.contains(&article.section.as_str()) - || !ALLOWED_BYLINES.contains(&article.byline.as_str()) - { - return Err(anyhow!( - "article {index} used an unowned presentation label" - )); - } - validate_single_line(&article.headline, 100, "headline")?; - validate_single_line(&article.deck, 220, "deck")?; - validate_single_line(&article.byline, 60, "byline")?; - if article.headline.ends_with('…') || article.headline.ends_with("...") { - return Err(anyhow!("article {index} headline ends in truncation")); - } - if article.headline.eq_ignore_ascii_case(&article.deck) { - return Err(anyhow!("article {index} deck repeats its headline")); - } - if !headlines.insert(article.headline.to_lowercase()) { - return Err(anyhow!("front page repeats a headline")); - } - let selected = selected_record_ids(&article.citations, &format!("article {index}"))?; - for citation in selected { - if !known_sources.contains(citation) { - return Err(anyhow!( - "article {index} cites unknown public record {citation}" - )); - } - } - let cited_dateline_supported = article.citations.iter().any(|citation| { - source_datelines[citation.as_str()].contains(article.dateline.as_str()) - }); - let cited_place_available = article - .citations - .iter() - .any(|citation| !source_datelines[citation.as_str()].is_empty()); - if !article.dateline.is_empty() && !cited_dateline_supported { - return Err(anyhow!( - "article {index} invented or misattributed a dateline" - )); - } - if index == 0 && article.dateline.is_empty() && cited_place_available { - return Err(anyhow!( - "the lead article must use a cited source place as its dateline" - )); - } - if !(2..=5).contains(&article.paragraphs.len()) { - return Err(anyhow!( - "article {index} must contain two to five paragraphs" - )); - } - for paragraph in &article.paragraphs { - let text = paragraph.trim(); - if text != paragraph - || text.chars().count() < 40 - || text.chars().count() > 900 - || text.contains(['\r', '\n']) - { - return Err(anyhow!("article {index} contains a malformed paragraph")); - } - if article.citations.iter().any(|citation| { - source_summaries[citation.as_str()] - .iter() - .any(|summary| *summary == text) - }) { - return Err(anyhow!( - "article {index} printed a source summary as final copy" - )); - } - } - let reader_copy = format!( + } + validate_no_reader_plumbing( + &format!( "{} {} {} {} {}", article.headline, article.deck, article.byline, article.dateline, article.paragraphs.join(" ") - ); - validate_no_reader_plumbing(&reader_copy, &format!("article {index}"))?; - } - Ok(()) + ), + &format!("article {index}"), + ) } fn validate_no_reader_plumbing(value: &str, label: &str) -> Result<()> { @@ -3511,15 +3880,24 @@ fn publication_task_binding( campaign: &Campaign, title: &str, voice: &str, + newsroom: &WorldNewspaperNewsroom, max_articles: usize, ) -> Result { - publication_task_binding_for_contract( + let bytes = rmp_serde::to_vec_named(&( NEWSROOM_CONTRACT_VERSION, - campaign, + campaign.id, + campaign.revision, title, voice, + newsroom, max_articles, - ) + ))?; + Ok(format!( + "campaign:{}:revision:{}:newspaper-task:sha256:{:x}", + campaign.id, + campaign.revision, + Sha256::digest(bytes) + )) } fn publication_task_binding_for_contract( @@ -3549,17 +3927,27 @@ fn editorial_binding( campaign: &Campaign, title: &str, voice: &str, + newsroom: &WorldNewspaperNewsroom, max_articles: usize, records: &[PublicRecordProjection], ) -> Result { - editorial_binding_for_contract( + let bytes = rmp_serde::to_vec_named(&( NEWSROOM_CONTRACT_VERSION, - campaign, + campaign.id, + campaign.revision, + campaign.world_time, title, voice, + newsroom, max_articles, records, - ) + ))?; + Ok(format!( + "campaign:{}:revision:{}:newspaper:sha256:{:x}", + campaign.id, + campaign.revision, + Sha256::digest(bytes) + )) } fn editorial_binding_for_contract( @@ -3588,12 +3976,17 @@ fn editorial_binding_for_contract( )) } -fn empty_issue_id(campaign: &Campaign, title: &str) -> Result { +fn empty_issue_id( + campaign: &Campaign, + title: &str, + newsroom: &WorldNewspaperNewsroom, +) -> Result { let identity = rmp_serde::to_vec_named(&( NEWSROOM_CONTRACT_VERSION, campaign.id, campaign.revision, title, + newsroom, "no-edition", ))?; Ok(format!("newspaper:sha256:{:x}", Sha256::digest(identity))) @@ -3653,36 +4046,19 @@ mod tests { async fn run(&self, request: &ModelStageRequest) -> Result { self.requests.lock().unwrap().push(request.clone()); if request.stage == "newspaper_narrative_selection_agent_action" - && request - .output_schema - .as_ref() - .is_some_and(|schema| schema.to_string().contains("commit_agenda")) + && request.lived_stream.contains("candidate_digest") { - fn candidate_digest(schema: &serde_json::Value) -> Option<&str> { - if schema - .pointer("/properties/tool/const") - .and_then(serde_json::Value::as_str) - == Some("commit_agenda") - { - return schema - .pointer("/properties/candidate_digest/const") - .and_then(serde_json::Value::as_str); - } - match schema { - serde_json::Value::Array(values) => { - values.iter().find_map(candidate_digest) - } - serde_json::Value::Object(values) => { - values.values().find_map(candidate_digest) - } - _ => None, - } - } let digest = request - .output_schema - .as_ref() - .and_then(candidate_digest) - .ok_or_else(|| anyhow!("fixture commit schema omitted candidate digest"))?; + .lived_stream + .match_indices("sha256:") + .filter_map(|(index, _)| request.lived_stream.get(index..index + 71)) + .filter(|candidate| { + candidate[7..] + .chars() + .all(|character| character.is_ascii_hexdigit()) + }) + .last() + .ok_or_else(|| anyhow!("fixture transcript omitted candidate digest"))?; return Ok(serde_json::json!({ "command":{ "tool":"commit_agenda", @@ -3865,10 +4241,11 @@ mod tests { const QUERY_ALL_RECORDS: &str = r#"{"command":{"tool":"query_public_records","terms":[],"match_terms":"all","entity_names":[],"assertion_statuses":[],"channels":[],"order":"newest","cursor":null,"limit":24}}"#; const QUERY_FOUNDING_CRISIS: &str = r#"{"command":{"tool":"query_public_records","terms":["speaking child","severed delegation hands","failed grain pumps"],"match_terms":"any","entity_names":[],"assertion_statuses":[],"channels":[],"order":"oldest","cursor":null,"limit":24}}"#; - const ONE_STORY_AGENDA: &str = r#"{"command":{"tool":"propose_agenda","dominant_throughline":"The court made its private gambling debt a public crisis of custody and punished the official who exposed it.","reader_stake":"Readers must decide whether dismissal protects the seal or merely the court from its own confession.","story_pitches":[{"lead":true,"citations":["news:seal-scandal"],"focus_citation":"news:seal-scandal","narrative_claim":"The pawned royal seal and the treasurer's dismissal are one scandal.","tension":"The court admits the loss while directing the immediate consequence at the bearer of that admission.","public_question":"Who is being held accountable for the missing seal?"}]}}"#; - const TWO_STORY_AGENDA: &str = r#"{"command":{"tool":"propose_agenda","dominant_throughline":"Court custody fails at both the royal seal and the western gate.","reader_stake":"Readers depend on institutions that announce damage only after access or authority has already been compromised.","story_pitches":[{"lead":true,"citations":["news:seal-scandal"],"focus_citation":"news:seal-scandal","narrative_claim":"The pawned seal and dismissal are the court's crisis of custody.","tension":"The confession exposes the loss while the treasurer absorbs the immediate institutional consequence.","public_question":"Who is being held accountable for the missing seal?"},{"lead":false,"citations":["news:west-gate"],"focus_citation":"news:west-gate","narrative_claim":"The gate closure is a practical echo of neglected custody.","tension":"A cracked hinge will close a route at moonrise while travellers wait for a reopening time.","public_question":"How long will the western route remain closed?"}]}}"#; - const ACCEPTED_PAGE: &str = r#"{"articles":[{"section":"Front Page","headline":"Court Sells the Crown's Seal, Then the Treasurer","deck":"A gambling debt reaches the throne room and leaves one official carrying the blame.","byline":"By the political editor","dateline":"Room","citations":["news:seal-scandal"],"paragraphs":["The Thorn Court has admitted that its royal seal was pawned to cover a dragon's gambling debt, a confession that turns private embarrassment into a public question of custody.","The treasurer who delivered that admission in open court was dismissed soon afterward. The court has explained the firing; it has not made the seal any less pawned."]}]}"#; - const TWO_ARTICLE_PAGE: &str = r#"{"articles":[{"section":"Front Page","headline":"Court Sells the Crown's Seal, Then the Treasurer","deck":"A gambling debt reaches the throne room and leaves one official carrying the blame.","byline":"By the political editor","dateline":"Room","citations":["news:seal-scandal"],"paragraphs":["The Thorn Court has admitted that its royal seal was pawned to cover a dragon's gambling debt, a confession that turns private embarrassment into a public question of custody.","The treasurer who delivered that admission in open court was dismissed soon afterward. The court has explained the firing; it has not made the seal any less pawned."]},{"section":"Dispatches","headline":"West Gate to Close at Moonrise","deck":"Masons will replace a cracked hinge after the palace bell keeper's warning.","byline":"Staff report","dateline":"Yard","citations":["news:west-gate"],"paragraphs":["Officials warn the west gate is unsafe, and the palace bell keeper says it will close at moonrise while masons replace its cracked hinge.","Travellers using the gate have been told when it will close, though no reopening hour was included in the announcement."]}]}"#; + const ONE_STORY_AGENDA: &str = r#"{"command":{"tool":"propose_agenda","dominant_throughline":"The court made its private gambling debt a public crisis of custody and punished the official who exposed it.","reader_stake":"Readers must decide whether dismissal protects the seal or merely the court from its own confession.","story_pitches":[{"lead":true,"section":"Front Page","journalist_id":"aven-tarl","citations":["news:seal-scandal"],"focus_citation":"news:seal-scandal","narrative_claim":"The pawned royal seal and the treasurer's dismissal are one scandal.","tension":"The court admits the loss while directing the immediate consequence at the bearer of that admission.","public_question":"Who is being held accountable for the missing seal?"}]}}"#; + const TWO_STORY_AGENDA: &str = r#"{"command":{"tool":"propose_agenda","dominant_throughline":"Court custody fails at both the royal seal and the western gate.","reader_stake":"Readers depend on institutions that announce damage only after access or authority has already been compromised.","story_pitches":[{"lead":true,"section":"Front Page","journalist_id":"aven-tarl","citations":["news:seal-scandal"],"focus_citation":"news:seal-scandal","narrative_claim":"The pawned seal and dismissal are the court's crisis of custody.","tension":"The confession exposes the loss while the treasurer absorbs the immediate institutional consequence.","public_question":"Who is being held accountable for the missing seal?"},{"lead":false,"section":"Dispatches","journalist_id":"lysa-fen","citations":["news:west-gate"],"focus_citation":"news:west-gate","narrative_claim":"The gate closure is a practical echo of neglected custody.","tension":"A cracked hinge will close a route at moonrise while travellers wait for a reopening time.","public_question":"How long will the western route remain closed?"}]}}"#; + const ACCEPTED_STORY_ACTION: &str = r#"{"tool":"file_story","headline":"Court Sells the Crown's Seal, Then the Treasurer","deck":"A gambling debt reaches the throne room and leaves one official carrying the blame.","dateline":"Room","paragraphs":["The Thorn Court has admitted that its royal seal was pawned to cover a dragon's gambling debt, a confession that turns private embarrassment into a public question of custody.","The treasurer who delivered that admission in open court was dismissed soon afterward. The court has explained the firing; it has not made the seal any less pawned."]}"#; + const ACCEPTED_PAGE: &str = r#"{"articles":[{"section":"Front Page","headline":"Court Sells the Crown's Seal, Then the Treasurer","deck":"A gambling debt reaches the throne room and leaves one official carrying the blame.","byline":"Aven Tarl","dateline":"Room","citations":["news:seal-scandal"],"paragraphs":["The Thorn Court has admitted that its royal seal was pawned to cover a dragon's gambling debt, a confession that turns private embarrassment into a public question of custody.","The treasurer who delivered that admission in open court was dismissed soon afterward. The court has explained the firing; it has not made the seal any less pawned."]}]}"#; + const TWO_ARTICLE_PAGE: &str = r#"{"articles":[{"section":"Front Page","headline":"Court Sells the Crown's Seal, Then the Treasurer","deck":"A gambling debt reaches the throne room and leaves one official carrying the blame.","byline":"Aven Tarl","dateline":"Room","citations":["news:seal-scandal"],"paragraphs":["The Thorn Court has admitted that its royal seal was pawned to cover a dragon's gambling debt, a confession that turns private embarrassment into a public question of custody.","The treasurer who delivered that admission in open court was dismissed soon afterward. The court has explained the firing; it has not made the seal any less pawned."]},{"section":"Dispatches","headline":"West Gate to Close at Moonrise","deck":"Masons will replace a cracked hinge after the palace bell keeper's warning.","byline":"Lysa Fen","dateline":"Yard","citations":["news:west-gate"],"paragraphs":["Officials warn the west gate is unsafe, and the palace bell keeper says it will close at moonrise while masons replace its cracked hinge.","Travellers using the gate have been told when it will close, though no reopening hour was included in the announcement."]}]}"#; const UNCHANGED_NIGHT_CLOSE: &str = r#"{"tool":"submit_close","addressed_query_indices":[],"rewrites":[]}"#; const QUERY_DECK_CLOSE: &str = r#"{"tool":"submit_close","addressed_query_indices":[0],"rewrites":[{"article_index":0,"headline":"Court Sells the Crown's Seal, Then the Treasurer","deck":"The court admits the pawned seal and dismisses the official who carried the confession.","dateline":"Room","paragraphs":["The Thorn Court has admitted that its royal seal was pawned to cover a dragon's gambling debt, a confession that turns private embarrassment into a public question of custody.","The treasurer who delivered that admission in open court was dismissed soon afterward. The court has explained the firing; it has not made the seal any less pawned."]}]}"#; @@ -3898,6 +4275,8 @@ mod tests { story_pitches: vec![ WorldNewspaperStoryPitch { lead: false, + section: "Front Page".into(), + journalist_id: "aven-tarl".into(), citations: vec!["news:seal-scandal".into()], focus_citation: "news:seal-scandal".into(), narrative_claim: "The pawned seal opens the custody crisis.".into(), @@ -3906,6 +4285,8 @@ mod tests { }, WorldNewspaperStoryPitch { lead: true, + section: "Dispatches".into(), + journalist_id: "lysa-fen".into(), citations: vec!["news:seal-scandal".into()], focus_citation: "news:seal-scandal".into(), narrative_claim: "The gate repeats the pattern.".into(), @@ -3916,7 +4297,7 @@ mod tests { }; assert!( - validate_editorial_agenda(&records, &invalid, 4) + validate_editorial_agenda(&records, &canopy_ledger_newsroom(), &invalid, 4) .unwrap_err() .to_string() .contains("exactly its first pitch as lead") @@ -3936,21 +4317,26 @@ mod tests { reader_stake, story_pitches, }; - validate_editorial_agenda(&records, &admitted, 4).unwrap(); + validate_editorial_agenda(&records, &canopy_ledger_newsroom(), &admitted, 4).unwrap(); let mut duplicate = admitted.clone(); duplicate.story_pitches[1] .citations .push("news:seal-scandal".into()); - validate_editorial_agenda(&records, &duplicate, 4).unwrap(); + validate_editorial_agenda(&records, &canopy_ledger_newsroom(), &duplicate, 4).unwrap(); let mut repeated_within_pitch = admitted.clone(); repeated_within_pitch.story_pitches[0] .citations .push("news:seal-scandal".into()); assert!( - validate_editorial_agenda(&records, &repeated_within_pitch, 4) - .unwrap_err() - .to_string() - .contains("repeats a public record ID") + validate_editorial_agenda( + &records, + &canopy_ledger_newsroom(), + &repeated_within_pitch, + 4, + ) + .unwrap_err() + .to_string() + .contains("repeats a public record ID") ); let mut widened: EditorialPageDraft = serde_json::from_str(ACCEPTED_PAGE).unwrap(); @@ -3958,6 +4344,7 @@ mod tests { assert!( validate_editorial_alignment( &widened, + &canopy_ledger_newsroom(), &WorldNewspaperEditorialAgenda { dominant_throughline: admitted.dominant_throughline.clone(), reader_stake: admitted.reader_stake.clone(), @@ -3973,6 +4360,7 @@ mod tests { #[tokio::test] async fn narrative_workbench_queries_foundational_context_by_stable_record_id() { let records = public_news_records(&campaign_with_archive_news()).unwrap(); + let newsroom = canopy_ledger_newsroom(); assert_eq!(records.len(), 45); assert!( records @@ -3981,13 +4369,15 @@ mod tests { ); let mut tool = NarrativeSelectionWorkbench { records: &records, + newsroom: &newsroom, max_articles: 3, visible_record_ids: BTreeSet::new(), completed_queries: BTreeSet::new(), pending_agenda: None, }; let initial_schema = tool.action_schema().unwrap().to_string(); - assert!(!initial_schema.contains("propose_agenda")); + assert!(initial_schema.contains("propose_agenda")); + assert!(initial_schema.contains("commit_agenda")); let retrieval = tool .invoke( @@ -4023,12 +4413,7 @@ mod tests { assert_eq!(found[0].record_id, "news:archive:00"); assert!(found[0].facts[0].account.contains("speaking child")); assert!(next_cursor.is_none()); - assert!( - tool.action_schema() - .unwrap() - .to_string() - .contains("news:archive:00") - ); + assert_eq!(tool.action_schema().unwrap().to_string(), initial_schema); let ModelAgentToolOutcome::Rejected { finding, .. } = tool .invoke( @@ -4059,6 +4444,8 @@ mod tests { reader_stake: "Readers still bear the consequences of the founding rupture.".into(), story_pitches: vec![WorldNewspaperStoryPitch { lead: true, + section: "Front Page".into(), + journalist_id: "mera-quill".into(), citations: vec!["news:archive:00".into()], focus_citation: "news:archive:00".into(), narrative_claim: "The oldest dossier is the fact later notices avoid.".into(), @@ -4106,6 +4493,8 @@ mod tests { reader_stake: "Readers still bear the consequences of the founding rupture.".into(), story_pitches: vec![WorldNewspaperStoryPitch { lead: true, + section: "Front Page".into(), + journalist_id: "mera-quill".into(), citations: vec!["news:archive:00".into()], focus_citation: "news:archive:00".into(), narrative_claim: @@ -4182,8 +4571,10 @@ mod tests { #[test] fn narrative_workbench_pages_the_complete_ledger_from_an_inspected_cursor() { let records = public_news_records(&campaign_with_archive_news()).unwrap(); + let newsroom = canopy_ledger_newsroom(); let mut tool = NarrativeSelectionWorkbench { records: &records, + newsroom: &newsroom, max_articles: 3, visible_record_ids: BTreeSet::new(), completed_queries: BTreeSet::new(), @@ -4232,12 +4623,13 @@ mod tests { &campaign_with_archive_news(), "The Canopy Ledger", "Independent and pointed.", + &canopy_ledger_newsroom(), 3, ) .unwrap(); let model = ScriptedNewspaperModel::new([ QUERY_FOUNDING_CRISIS, - r#"{"command":{"tool":"propose_agenda","dominant_throughline":"The founding crisis survived its administrative aftermath.","reader_stake":"Readers still bear the consequences while institutions manage the paperwork.","story_pitches":[{"lead":true,"citations":["news:archive:00"],"focus_citation":"news:archive:00","narrative_claim":"The original public record is the fact later notices cannot domesticate.","tension":"Administrative responses multiply while the founding rupture remains unresolved.","public_question":"Who benefits when the cause leaves the front page?"}]}}"#, + r#"{"command":{"tool":"propose_agenda","dominant_throughline":"The founding crisis survived its administrative aftermath.","reader_stake":"Readers still bear the consequences while institutions manage the paperwork.","story_pitches":[{"lead":true,"section":"Front Page","journalist_id":"mera-quill","citations":["news:archive:00"],"focus_citation":"news:archive:00","narrative_claim":"The original public record is the fact later notices cannot domesticate.","tension":"Administrative responses multiply while the founding rupture remains unresolved.","public_question":"Who benefits when the cause leaves the front page?"}]}}"#, ]); let run = select_editorial_agenda(&model, &prepared, 3).await.unwrap(); @@ -4255,6 +4647,8 @@ mod tests { let records = public_news_records(&campaign_with_archive_news()).unwrap(); let pitch = WorldNewspaperStoryPitch { lead: true, + section: "Front Page".into(), + journalist_id: "aven-tarl".into(), citations: (0..8) .map(|index| format!("news:archive:{index:02}")) .collect(), @@ -4268,13 +4662,13 @@ mod tests { reader_stake: "Readers bear the consequences of that failure.".into(), story_pitches: vec![pitch], }; - validate_editorial_agenda(&records, &agenda, 2).unwrap(); + validate_editorial_agenda(&records, &canopy_ledger_newsroom(), &agenda, 2).unwrap(); let mut unfocused = agenda.clone(); unfocused.story_pitches[0].citations.truncate(4); unfocused.story_pitches[0].focus_citation = "news:archive:07".into(); assert!( - validate_editorial_agenda(&records, &unfocused, 2) + validate_editorial_agenda(&records, &canopy_ledger_newsroom(), &unfocused, 2) .unwrap_err() .to_string() .contains("focus record is not in its selected record set") @@ -4309,6 +4703,23 @@ mod tests { ], }; let records = public_news_records(&campaign_with_two_news()).unwrap(); + let action: NarrativeSelectionAction = serde_json::from_str(TWO_STORY_AGENDA).unwrap(); + let NarrativeSelectionCommand::ProposeAgenda { + dominant_throughline, + reader_stake, + story_pitches, + } = action.command + else { + panic!("fixture must propose an agenda") + }; + let agenda = WorldNewspaperEditorialAgenda { + dominant_throughline, + reader_stake, + story_pitches, + }; + let night_sources = source_json_for_copy_queries(&records, &agenda, &report).unwrap(); + assert!(night_sources.contains("news:seal-scandal")); + assert!(!night_sources.contains("news:west-gate")); let schema = NightEditorCloseWorkbench { records: &records, max_articles: 2, @@ -4317,45 +4728,45 @@ mod tests { } .action_schema() .unwrap(); - assert_eq!(schema["properties"]["rewrites"]["minItems"], 1); + assert_eq!(schema["properties"]["rewrites"]["minItems"], 0); assert_eq!(schema["properties"]["rewrites"]["maxItems"], 2); assert_eq!( schema["properties"]["rewrites"]["items"]["properties"]["article_index"]["enum"], serde_json::json!([0, 1]) ); + let stable_schema = NightEditorCloseWorkbench { + records: &records, + max_articles: 2, + draft: draft.clone(), + copy_desk: WorldNewspaperCopyDeskReport { + assessment: "No queries.".into(), + queries: Vec::new(), + }, + } + .action_schema() + .unwrap(); + assert_eq!(schema, stable_schema); let closed = apply_night_editor_close( &draft, &report, NightEditorCloseAction::SubmitClose { addressed_query_indices: vec![0, 1], - rewrites: vec![ - NightEditorArticleClose { - article_index: 0, - headline: "Pawned Seal, Dismissed Messenger".into(), - deck: "The court admitted one loss and imposed another.".into(), - dateline: "Room".into(), - paragraphs: vec![ - "The Thorn Court admitted that its royal seal was pawned to cover a dragon's gambling debt.".into(), - "The treasurer carried that admission into open court and was dismissed soon afterward. Readers may decide which act embarrassed the court more.".into(), - ], - }, - NightEditorArticleClose { - article_index: 1, - headline: "Moonrise Will Shut the West Gate".into(), - deck: "A cracked hinge turns the evening bell into a travel deadline.".into(), - dateline: "Yard".into(), - paragraphs: vec![ - "The west gate will close at moonrise while masons replace the cracked hinge named in the palace warning.".into(), - "Travellers have a closing hour but no announced reopening hour, which is the sort of precision institutions prefer to distribute unevenly.".into(), - ], - }, - ], + rewrites: vec![NightEditorArticleClose { + article_index: 0, + headline: "Pawned Seal, Dismissed Messenger".into(), + deck: "The court admitted one loss and imposed another.".into(), + dateline: "Room".into(), + paragraphs: vec![ + "The Thorn Court admitted that its royal seal was pawned to cover a dragon's gambling debt.".into(), + "The treasurer carried that admission into open court and was dismissed soon afterward. Readers may decide which act embarrassed the court more.".into(), + ], + }], }, ) .unwrap(); assert_eq!(closed.addressed_query_indices, [0, 1]); - assert_eq!(closed.changed_article_indices, [0, 1]); + assert_eq!(closed.changed_article_indices, [0]); assert_eq!(closed.draft.articles[0].section, draft.articles[0].section); assert_eq!(closed.draft.articles[0].byline, draft.articles[0].byline); assert_eq!( @@ -4368,7 +4779,7 @@ mod tests { closed.draft.articles[1].citations, draft.articles[1].citations ); - assert_ne!( + assert_eq!( closed.draft.articles[1].headline, draft.articles[1].headline ); @@ -4391,7 +4802,7 @@ mod tests { let model = ScriptedNewspaperModel::new([ QUERY_ALL_RECORDS, ONE_STORY_AGENDA, - ACCEPTED_PAGE, + ACCEPTED_STORY_ACTION, ACCEPTING_COPY_DESK, UNCHANGED_NIGHT_CLOSE, ]); @@ -4420,6 +4831,17 @@ mod tests { ["news:seal-scandal"] ); assert_eq!(composition.issue.edition_label, EDITION_LABEL); + assert_eq!( + composition + .issue + .editorial_agenda + .as_ref() + .unwrap() + .story_pitches[0] + .journalist_id, + "aven-tarl" + ); + assert_eq!(composition.issue.articles[0].byline, "Aven Tarl"); assert!(page.contains("Court Sells the Crown's Seal")); assert!(page.contains("*Current Edition*")); assert!(page.contains("The court has explained the firing")); @@ -4460,17 +4882,32 @@ mod tests { requests[2].stage, "newspaper_narrative_selection_agent_action" ); - assert_eq!(requests[3].stage, "newspaper_editor"); + assert_eq!(requests[3].stage, "newspaper_journalist_agent_action"); assert_eq!(requests[4].stage, "newspaper_copy_desk"); assert_eq!( requests[5].stage, "newspaper_night_editor_close_agent_action" ); + assert!(requests[3].lived_stream.contains("ASSIGNMENT PACKET")); + assert!(requests[0].lived_stream.contains("Veyra Kest")); + assert!(requests[0].lived_stream.contains("Mera Quill")); + assert!(requests[3].lived_stream.contains("Aven Tarl")); + assert!(!requests[3].lived_stream.contains("Mera Quill")); + assert!(requests[4].lived_stream.contains("Dalen Marr")); + assert!(requests[5].lived_stream.contains("Meret Sorn")); + assert!(!requests[5].lived_stream.contains("recover drama")); assert!( - requests[3] + requests[1] .lived_stream - .contains("ADMITTED NARRATIVE AGENDA") + .starts_with(&requests[0].lived_stream) ); + assert!( + requests[2] + .lived_stream + .starts_with(&requests[1].lived_stream) + ); + assert_eq!(requests[0].output_schema, requests[1].output_schema); + assert_eq!(requests[1].output_schema, requests[2].output_schema); assert!( requests[3] .source_receipt_ids @@ -4645,7 +5082,7 @@ mod tests { let model = ScriptedNewspaperModel::new([ QUERY_ALL_RECORDS, ONE_STORY_AGENDA, - ACCEPTED_PAGE, + ACCEPTED_STORY_ACTION, REJECTED, QUERY_DECK_CLOSE, ]); @@ -4701,7 +5138,7 @@ mod tests { let model = ScriptedNewspaperModel::new([ QUERY_ALL_RECORDS, ONE_STORY_AGENDA, - ACCEPTED_PAGE, + ACCEPTED_STORY_ACTION, REJECTED, INVALID_CLOSE, ]); @@ -4744,7 +5181,7 @@ mod tests { } #[tokio::test] - async fn close_checkpoint_resumes_without_replaying_writer_or_copy_desk() { + async fn close_checkpoint_resumes_without_replaying_reporter_or_copy_desk() { const REJECTED: &str = r#"{"assessment":"The deck overstates where the admitted debt reached.","queries":[{"article_index":0,"category":"unsupported_fact","claim_or_phrase":"A gambling debt reaches the throne room and leaves one official carrying the blame.","reason":"The cited source records the pawned seal and dismissal but does not locate the debt in the throne room."}]}"#; let campaign = campaign_with_news(); let directory = tempfile::tempdir().unwrap(); @@ -4752,7 +5189,7 @@ mod tests { let first_model = ScriptedNewspaperModel::new([ QUERY_ALL_RECORDS, ONE_STORY_AGENDA, - ACCEPTED_PAGE, + ACCEPTED_STORY_ACTION, REJECTED, ]); let error = advance_world_newspaper( @@ -4760,6 +5197,7 @@ mod tests { &campaign, "The Underdeep Clarion", "A skeptical court broadsheet with dry restraint.", + &canopy_ledger_newsroom(), 4, &store, ) @@ -4784,6 +5222,7 @@ mod tests { &campaign, "The Underdeep Clarion", "A skeptical court broadsheet with dry restraint.", + &canopy_ledger_newsroom(), 4, &store, ) @@ -4805,6 +5244,7 @@ mod tests { &campaign, "The Underdeep Clarion", "A skeptical court broadsheet with dry restraint.", + &canopy_ledger_newsroom(), 4, &store, ) @@ -4819,6 +5259,7 @@ mod tests { &campaign, "The Underdeep Clarion", "A skeptical court broadsheet with dry restraint.", + &canopy_ledger_newsroom(), 4, ) .unwrap(); @@ -4839,7 +5280,7 @@ mod tests { } #[tokio::test] - async fn legacy_v7_copy_tip_becomes_one_v8_night_close_without_replay() { + async fn legacy_v7_copy_tip_becomes_one_v9_night_close_without_replay() { const LEGACY_REJECTION: &str = r#"{"accepted":false,"assessment":"The deck overstates where the admitted debt reached.","findings":[{"article_index":0,"category":"unsupported_fact","claim_or_phrase":"A gambling debt reaches the throne room and leaves one official carrying the blame.","reason":"The cited source records the pawned seal and dismissal but does not locate the debt in the throne room."}]}"#; const CURRENT_COPY_REPORT: &str = r#"{"assessment":"The deck overstates where the admitted debt reached.","queries":[{"article_index":0,"category":"unsupported_fact","claim_or_phrase":"A gambling debt reaches the throne room and leaves one official carrying the blame.","reason":"The cited source records the pawned seal and dismissal but does not locate the debt in the throne room."}]}"#; let campaign = campaign_with_news(); @@ -4847,6 +5288,7 @@ mod tests { &campaign, "The Underdeep Clarion", "A skeptical court broadsheet with dry restraint.", + &canopy_ledger_newsroom(), 4, ) .unwrap(); @@ -4856,7 +5298,7 @@ mod tests { let source_model = ScriptedNewspaperModel::new([ QUERY_ALL_RECORDS, ONE_STORY_AGENDA, - ACCEPTED_PAGE, + ACCEPTED_STORY_ACTION, CURRENT_COPY_REPORT, ]); let error = advance_world_newspaper( @@ -4864,6 +5306,7 @@ mod tests { &campaign, "The Underdeep Clarion", "A skeptical court broadsheet with dry restraint.", + &canopy_ledger_newsroom(), 4, &source_store, ) @@ -4877,6 +5320,9 @@ mod tests { let mut receipt_keys = BTreeMap::::new(); for receipt in &mut legacy_receipts { let old_key = receipt.storage_key().to_owned(); + if receipt.stage == "newspaper_journalist_agent_action" { + receipt.stage = "newspaper_editor".into(); + } for source_id in &mut receipt.source_receipt_ids { if let Some(rebound) = receipt_keys.get(source_id) { *source_id = rebound.clone(); @@ -4895,6 +5341,7 @@ mod tests { &campaign, "The Underdeep Clarion", "A skeptical court broadsheet with dry restraint.", + &canopy_ledger_newsroom(), 4, &store, WorldNewspaperReconciliationImport { @@ -4929,6 +5376,7 @@ mod tests { &campaign, "The Underdeep Clarion", "A skeptical court broadsheet with dry restraint.", + &canopy_ledger_newsroom(), 4, &store, ) @@ -4963,7 +5411,7 @@ mod tests { #[tokio::test] async fn reader_projection_escapes_model_and_consumer_markdown() { - const MARKDOWN_PAGE: &str = r#"{"articles":[{"section":"Front Page","headline":"[Court](https://headline.invalid) Faces ","deck":"The *royal* debt now reaches every keeper of the seal.","byline":"By the political editor","dateline":"Room","citations":["news:seal-scandal"],"paragraphs":["- The Thorn Court admitted the royal seal was pawned to cover a dragon's gambling debt; cannot make the confession ~~prettier~~.","1. The dismissed treasurer leaves readers with a [public record](https://copy.invalid) and the court with a seal that remains pawned."]}]}"#; + const MARKDOWN_PAGE: &str = r#"{"tool":"file_story","headline":"[Court](https://headline.invalid) Faces ","deck":"The *royal* debt now reaches every keeper of the seal.","dateline":"Room","paragraphs":["- The Thorn Court admitted the royal seal was pawned to cover a dragon's gambling debt; cannot make the confession ~~prettier~~.","1. The dismissed treasurer leaves readers with a [public record](https://copy.invalid) and the court with a seal that remains pawned."]}"#; let campaign = campaign_with_news(); let model = ScriptedNewspaperModel::new([ QUERY_ALL_RECORDS, @@ -5021,13 +5469,13 @@ mod tests { #[tokio::test] async fn article_identity_covers_the_published_copy() { - const ALTERNATE_PAGE: &str = r#"{"articles":[{"section":"Front Page","headline":"Court Sells the Crown's Seal, Then the Treasurer","deck":"The same scandal leaves the throne defending both its custody and its judgment.","byline":"By the political editor","dateline":"Room","citations":["news:seal-scandal"],"paragraphs":["The Thorn Court has admitted that its royal seal was pawned to cover a dragon's gambling debt, a confession that turns private embarrassment into a public question of custody.","The treasurer who delivered that admission in open court was dismissed soon afterward. The court has explained the firing; it has not made the seal any less pawned."]}]}"#; + const ALTERNATE_PAGE: &str = r#"{"tool":"file_story","headline":"Court Sells the Crown's Seal, Then the Treasurer","deck":"The same scandal leaves the throne defending both its custody and its judgment.","dateline":"Room","paragraphs":["The Thorn Court has admitted that its royal seal was pawned to cover a dragon's gambling debt, a confession that turns private embarrassment into a public question of custody.","The treasurer who delivered that admission in open court was dismissed soon afterward. The court has explained the firing; it has not made the seal any less pawned."]}"#; let campaign = campaign_with_news(); let first = compose_world_newspaper( &ScriptedNewspaperModel::new([ QUERY_ALL_RECORDS, ONE_STORY_AGENDA, - ACCEPTED_PAGE, + ACCEPTED_STORY_ACTION, ACCEPTING_COPY_DESK, UNCHANGED_NIGHT_CLOSE, ]), diff --git a/crates/ghostlight-dungeon/src/registry.rs b/crates/ghostlight-dungeon/src/registry.rs index 22638a6..d16d7ee 100644 --- a/crates/ghostlight-dungeon/src/registry.rs +++ b/crates/ghostlight-dungeon/src/registry.rs @@ -381,7 +381,7 @@ impl CampaignRegistry { request: WorldNewspaperRequest, model: &dyn ModelPort, ) -> Result { - if request.schema != "ghostlight.world_newspaper_request.v2" + if request.schema != "ghostlight.world_newspaper_request.v3" || request.max_articles == 0 || request.max_articles > 6 || request.editorial_voice.trim().is_empty() @@ -414,6 +414,7 @@ impl CampaignRegistry { &campaign, request.title, request.editorial_voice, + &request.newsroom, usize::from(request.max_articles), &runtime.store, ) @@ -684,58 +685,26 @@ mod tests { struct RejectingRegistryNewspaperModel; - fn first_schema_property_enum<'a>( - schema: &'a serde_json::Value, - property: &str, - ) -> Option<&'a str> { - match schema { - serde_json::Value::Object(object) => object - .get("properties") - .and_then(serde_json::Value::as_object) - .and_then(|properties| properties.get(property)) - .and_then(|value| value.pointer("/items/enum/0")) - .and_then(serde_json::Value::as_str) - .or_else(|| { - object - .values() - .find_map(|value| first_schema_property_enum(value, property)) - }), - serde_json::Value::Array(values) => values - .iter() - .find_map(|value| first_schema_property_enum(value, property)), - _ => None, - } - } - - fn first_schema_property_const<'a>( - schema: &'a serde_json::Value, - property: &str, - ) -> Option<&'a str> { - match schema { - serde_json::Value::Object(object) => object - .get("properties") - .and_then(serde_json::Value::as_object) - .and_then(|properties| properties.get(property)) - .and_then(|value| value.get("const")) - .and_then(serde_json::Value::as_str) - .or_else(|| { - object - .values() - .find_map(|value| first_schema_property_const(value, property)) - }), - serde_json::Value::Array(values) => values - .iter() - .find_map(|value| first_schema_property_const(value, property)), - _ => None, - } + fn last_transcript_value(stream: &str, prefix: &str) -> Option { + let start = stream.match_indices(prefix).last()?.0 + prefix.len(); + let tail = stream.get(start..)?; + Some(tail.split('"').next()?.to_owned()) } fn registry_newspaper_narrative_response(request: &ModelStageRequest) -> Result { - let schema = request - .output_schema - .as_ref() - .ok_or_else(|| anyhow!("newspaper fixture lost its narrative action schema"))?; - if !schema.to_string().contains("propose_agenda") { + if let Some(candidate_digest) = + last_transcript_value(&request.lived_stream, "\"candidate_digest\":\"") + { + return Ok(serde_json::json!({ + "command":{ + "tool":"commit_agenda", + "candidate_digest":candidate_digest, + } + }) + .to_string()); + } + let Some(citation) = last_transcript_value(&request.lived_stream, "\"record_id\":\"") + else { return Ok(serde_json::json!({ "command":{ "tool":"query_public_records", @@ -750,20 +719,7 @@ mod tests { } }) .to_string()); - } - if schema.to_string().contains("commit_agenda") { - let candidate_digest = first_schema_property_const(schema, "candidate_digest") - .ok_or_else(|| anyhow!("newspaper fixture lost its reviewed agenda digest"))?; - return Ok(serde_json::json!({ - "command":{ - "tool":"commit_agenda", - "candidate_digest":candidate_digest, - } - }) - .to_string()); - } - let citation = first_schema_property_enum(schema, "citations") - .ok_or_else(|| anyhow!("newspaper fixture lost its narrative citation enum"))?; + }; Ok(serde_json::json!({ "command":{ "tool":"propose_agenda", @@ -771,6 +727,8 @@ mod tests { "reader_stake":"Readers depend on whether the court can distinguish an allegation from a judgment.", "story_pitches":[{ "lead":true, + "section":"Front Page", + "journalist_id":"aven-tarl", "citations":[citation], "focus_citation":citation, "narrative_claim":"The singular accusation against three auditors is itself the scandal.", @@ -782,25 +740,16 @@ mod tests { .to_string()) } - fn registry_newspaper_editor_response(request: &ModelStageRequest) -> Result { - let citation = request - .output_schema - .as_ref() - .and_then(|schema| first_schema_property_enum(schema, "citations")) - .ok_or_else(|| anyhow!("newspaper fixture lost its citation enum"))?; + fn registry_newspaper_editor_response(_request: &ModelStageRequest) -> Result { Ok(serde_json::json!({ - "articles":[{ - "section":"Front Page", - "headline":"Court Auditors Face a Singular Accusation", - "deck":"The inner court has turned a granary inquiry into a question of witchcraft and arithmetic.", - "byline":"By the political editor", - "dateline":"Room", - "citations":[citation], - "paragraphs":[ - "The inner court has accused three granary auditors of being one witch in a long coat, placing the allegation into the public record.", - "The charge leaves the auditors answering a court that has made one person out of three, at least for purposes of blame." - ] - }] + "tool":"file_story", + "headline":"Court Auditors Face a Singular Accusation", + "deck":"The inner court has turned a granary inquiry into a question of witchcraft and arithmetic.", + "dateline":"Room", + "paragraphs":[ + "The inner court has accused three granary auditors of being one witch in a long coat, placing the allegation into the public record.", + "The charge leaves the auditors answering a court that has made one person out of three, at least for purposes of blame." + ] }) .to_string()) } @@ -1482,13 +1431,14 @@ mod tests { let (_, repeated) = registry.admit_world_seed(request).await.unwrap(); assert_eq!(repeated.seed_digest, receipt.seed_digest); let newspaper_request = crate::consumer::WorldNewspaperRequest { - schema: "ghostlight.world_newspaper_request.v2".into(), + schema: "ghostlight.world_newspaper_request.v3".into(), campaign_id: campaign.id, authority_id: "authority:external-hold".into(), owner_id: "fixture-consumer".into(), authority_key: authority_key.into(), title: "The Consumer Gazette".into(), editorial_voice: "A skeptical regional court broadsheet.".into(), + newsroom: crate::newspaper::canopy_ledger_newsroom(), max_articles: 4, }; let mut denied_newspaper = newspaper_request.clone(); diff --git a/notes/fresh-workspace-handoff.md b/notes/fresh-workspace-handoff.md index d4eff96..314cc15 100644 --- a/notes/fresh-workspace-handoff.md +++ b/notes/fresh-workspace-handoff.md @@ -1362,6 +1362,24 @@ disturbing” from cordon and warnings to barriers and path; line 19 changes historical stalled lifts into “remains stalled.” These findings do not reopen the printed edition or block delivery. +The source-grounded prompt audit falsified v8 as the target newsroom +architecture. Run 59 narrative selection used ten provider calls, 1,373,208 +input characters, 582,184 prompt tokens, and zero cached tokens. +`character-newsroom.v9` now uses typed append-only model items, stable selector +and Night action schemas, stable stage/model/schema cache routing, and no +prompt-embedded tool schemas or repeated prior actions. Veyra Kest sees the +complete embodied publication roster and assigns every story to a journalist whose character, +biases, and preferences inform that choice. Each reporter sees only their own +profile, assignment, and exact cited records. Dalen Marr alone raises one +complete factual checklist. Meret Sorn receives only queried articles, their +assignments and cited facts, and the affected reporter profiles; she gets one +chance to resolve exactly those objections while preserving the story. The +one-shot whole-page writer and generic byline registry are deleted. +Deterministic press admission still owns structure, provenance, query +disposition, and closure. Focused gates pass 475/475 library tests, 16/16 +strategic-smoke tests, and all-target checks. No provider or simulation run has +been launched for v9. + Run 54 exposed one separate upstream publicity defect: a public strategic activity propagates its channel to private `MemberMemory` outcomes, which is how Orin Pell retaining a memory became a public news row. The newsroom should @@ -1369,13 +1387,14 @@ ignore that bookkeeping, but revision 27 remains immutable. Future kernel work must require an outcome's own observable or communicated public scope before materializing a `NewsIssue`. -1. Deliver the actual immutable Run 59 copy and its press/artifact proof. Do not - rerun, rewrite, or withhold the edition. -2. Preserve the two grounding corrections and editorial diagnostics above as - later publication debt. After delivery, move to the publication-Persona and - correction-memory owner. -3. Defer multiple publication Personas, TeX layout, and engraved cuts until - separately authorized after Run 59 delivery. +1. Preserve immutable Run 59 and its later-publication correction debt; do not + rewrite or resimulate it. +2. Exercise `character-newsroom.v9` only in a separately authorized + newsroom-only provider run. Measure actual cached tokens and judge the + embodied reporters' copy rather than treating local cache invariants as + provider acceptance. +3. Defer multiple publications, TeX layout, and engraved cuts until separately + authorized. 4. Per-wave issues may use daily-like reporting windows, while a final composition may be an equally current weekly-style issue with a broader window. Both are selective journalism; broad fact availability increases diff --git a/notes/ghostlight-current-system-map.md b/notes/ghostlight-current-system-map.md index 34b65d2..f9755b0 100644 --- a/notes/ghostlight-current-system-map.md +++ b/notes/ghostlight-current-system-map.md @@ -1288,21 +1288,26 @@ The deliberative newsroom boundary is live under this authority map: - **Owner:** canonical `NewsIssue` plus `Event` rows own public provenance and facts. `Event::public_assertion_status` owns attempt, declaration, committed-course, material-outcome, and unspecified-account semantics. The - narrative-selection agent owns assignment: story choice, order, focal record, - exact citation grouping, throughline, tension, and reader question. Its + in-world assignment editor owns assignment: story choice, order, section, + journalist, focal record, exact citation grouping, throughline, tension, and + reader question. Its deterministic workbench owns query admission, inspected-record membership, agenda validation, focus-record dereferencing, candidate identity, and exact - commit admission. The lead writer owns one complete reader-facing page from - that assignment. The copy desk owns one complete factual query report over - the writer's page; it neither rewrites nor accepts publication. The Night + commit admission. Each assigned in-world journalist owns one reader-facing + story and receives only that pitch, its exact records, and that journalist's + character, biases, and preferences. The copy editor owns one complete factual + query report over the assembled page; it neither rewrites nor accepts + publication. The Night Editor owns one deadline close over that page and report. The deterministic press witness owns structural admission, lineage, and exact before/after evidence for what printed; it does not judge prose. Errors discovered after close belong to a later edition's correction memory, not a same-edition - repair loop. V8 defines that ownership boundary but does not yet persist a - typed correction record. `WorldKernel` remains the only world-event writer. + repair loop. V9 defines that ownership boundary but does not yet persist a + typed correction record. Publication employees are not world actors or event + writers. `WorldKernel` remains the only world-event writer. - **Inputs:** one exact campaign revision, publication title and voice, article - budget, source-receipt ancestry, and v8 newsroom contract. Through the + budget, a validated embodied newsroom roster, source-receipt ancestry, and + `character-newsroom.v9` contract. Through the existing generic agent harness, the selector issues deterministic bounded queries over stable `NewsIssue` IDs using literal terms, exact asserted public entity names, assertion statuses, channels, newest/oldest ordering, @@ -1319,11 +1324,13 @@ The deliberative newsroom boundary is live under this authority map: submits `commit_agenda` with the exact digest of the current reviewed candidate. The committed agenda carries one dominant throughline, reader stake, and ordered story pitches. Every pitch names stable public-record IDs, - chooses one focus record, and supplies bounded narrative claim, tension, and - public question. Article count plus bounded paragraphs and output tokens + chooses one focus record, section, and journalist, and supplies bounded + narrative claim, tension, and public question. Article count plus bounded paragraphs and output tokens define page space. There is no citation-count page budget. A foundational record may support several continuing stories when each pitch uses it for a - distinct throughline. The writer emits one page; the copy desk emits one + distinct throughline. One journalist agent files each assigned story and the + page is assembled deterministically from assignment-owned section, byline, + and citations plus journalist-owned prose. The copy desk emits one assessment plus the complete bounded set of exact factual query passages; the Night Editor emits one close action; and accepted composition v3 carries the printed issue, copy report, press-close witness, and exact receipt chain. @@ -1345,38 +1352,52 @@ The deliberative newsroom boundary is live under this authority map: would bury, and returns the exact candidate digest. This is an observation, not acceptance. The agent must reconsider the actual consequences in that proof and explicitly commit the current digest; a replacement proposal - retires the prior candidate as commit authority. The downstream editor sees - only the committed agenda, dereferences exactly its selected IDs, centers - each article on its focus record's consequential fact, and may not widen or - regroup the selection. Record bookkeeping, memory retention, maintained + retires the prior candidate as commit authority. Each assigned journalist + sees only the publication voice, shared reporting contract, their own + character, the page throughline, one pitch, and that pitch's exact records. + They center their story on its focus record and may not widen or regroup the + selection. Record bookkeeping, memory retention, maintained warnings, and procedure are not automatically the lede merely because they - are recent. The writer, copy desk, and Night Editor see the same - selected-record projection. + are recent. The copy editor sees the assembled page and selected facts. The + Night Editor sees only queried articles, their assignments and cited facts, + the numbered checklist, its own profile, and the profiles of the affected + reporters. - **Desk and press path:** accepted stable IDs lower once into the existing self-contained semantic audit with source news IDs, channels, reliability, exact account text, assertion status, event IDs, supported identity attributes, institutions, populations, and places. Reader-facing numeric labels are assigned only during lowering. The copy desk reads the selected - fact projection and writer page exactly once, returning all factual queries + fact projection and assembled page exactly once, returning all factual queries at once as exact unique passages with reasons. The immutable close checkpoint binds that report, page, agenda, task, sources, and receipts before the Night - Editor acts. The Night Editor receives the assignment, selected facts, page, - and numbered query list, then gets one action to disposition every query and - provide complete replacement prose for each queried article. In that same - close it may optionally replace any other article to sharpen its headline, - lede, throughline, rhythm, or ending. The press workbench freezes article - selection, order, section, byline, citations, and agenda while allowing prose - replacement across any existing article. It validates structure and source + Editor acts. The Night Editor receives the affected assignments, queried + articles and facts, and numbered query list, then gets one action to + disposition every query and provide complete replacement prose for exactly + each queried article. The + press workbench freezes article selection, order, section, byline, citations, + and agenda and forbids changes to every unqueried article. It validates structure and source membership, not whether the final language is true or compelling. A structurally invalid - close exhausts the one action and prints the checkpointed writer page with an + close exhausts the one action and prints the checkpointed journalist page with an explicit non-applied close witness. There is no post-close model reread. +- **Agent context and cache path:** the generic harness sends typed append-only + user/assistant items instead of flattening every turn into one new user + transcript. Selector query, proposal, and commit share one stable structured + action schema, so each later provider request preserves the exact previous + prefix. The Night close schema is likewise stable across checklist contents; + its private workbench enforces the exact queried set. Tool schemas stay in the + provider output contract rather than being duplicated in prompt text. + Prompt-cache routing binds stage, model class, and exact output schema; + changing world context does not mint another routing key, while changing the + contract does. Each journalist receives only one pitch and its records. Copy + receives the complete selected desk because it alone checks the page; Night + receives only the subset named by Copy's checklist. - **Invariants:** private events do not enter the query surface; every selected stable ID resolves at the bound revision; story order, selection, citation - grouping, and focus remain agenda-owned; no proposal reaches the writer + grouping, focus, section, and journalist remain agenda-owned; no proposal reaches the journalists without deterministic proof observation and exact-current-digest commit; omitted public facts remain true; assignment, writing, copy queries, close, - lowering, and witness creation cannot mutate the world. There is one writer - pass, one copy report, and one admitted Night Editor close. No model verdict + lowering, and witness creation cannot mutate the world. There is one bounded + reporter pass per pitch, one copy report, and one admitted Night Editor close. No model verdict rereads the printed page, no rejected close opens an iterative rewrite loop, and no external evaluation can reopen an immutable edition. The witness must preserve exact source and printed page digests, receipt ancestry, every query @@ -1387,77 +1408,74 @@ The deliberative newsroom boundary is live under this authority map: state. The deterministic proof cannot rank stories or commit an agenda; it exposes the model's proposed ordering against exact focus facts. A proposal cannot bypass review by returning as accepted output, and a missing or stale - candidate digest cannot commit. The editor cannot widen the admitted record - set or write a second page. The copy desk cannot rewrite, suppress, or accept + candidate digest cannot commit. A journalist cannot change section, byline, + citations, assignment, another journalist's prose, or the admitted record + set. The copy desk cannot rewrite, suppress, or accept prose, select stories, close the press, or withhold the complete query set for a later pass. The Night Editor cannot widen sources, add, remove, or reorder stories, alter sections, bylines, citations, or agenda, invoke another model - judge, or reopen a completed close. The Night Editor may change the - reader-facing prose of any existing article during that one close, but every - copy-query-bearing article is mandatory. The press witness cannot certify + judge, reopen a completed close, or touch an article absent from the copy + checklist. Every copy-query-bearing article and no other article is mandatory. + The press witness cannot certify factual or editorial quality; it can only prove the exact admitted process and bytes. Renderers, strategic smoke, Registry, external blind reviewers, and grounding reviewers cannot write the page, desk report, close, correction, world state, or acceptance state. Caches cannot decide record equivalence, assertion - status, story relevance, or fact completion. + status, story relevance, fact completion, or staff assignment. - **Shared paths:** fresh composition queries stable records, proposes one or more agendas, observes the deterministic front-page proof for each proposal, - and commits exactly one current candidate before invoking the writer. Fresh - work then follows one path: writer, copy report, immutable close checkpoint, + and commits exactly one current candidate before invoking the assigned + journalists. Fresh work then follows one path: one bounded reporter agent per + pitch, deterministic page assembly, copy report, immutable close checkpoint, one Night Editor action, deterministic press witness, lowering, persistence. Registry, live per-wave composition, missing-issue recovery, and final digest all delegate to `advance_world_newspaper`. Resume begins from the immutable - close checkpoint and does not rerun assignment, writer, or copy desk. A + close checkpoint and does not rerun assignment, journalists, or copy desk. A persisted composition is revalidated against its close checkpoint and exact stored receipts, then returned without model work. - **Persistence and compatibility:** the live internal contract is - `canopy-ledger-night-editor.v8`. Close checkpoint v1 binds the assigned agenda, - writer page, single copy report, task and exact editorial bindings, source + `character-newsroom.v9`. Public request schema v3 carries the publication + roster and binds it into the task and editorial identities. Close checkpoint + v1 binds the assigned agenda, assembled page, single copy report, task and exact editorial bindings, source checkpoint when present, and immutable receipt chain. Composition v3 stores the printed issue, copy report, press-close witness, and receipts. Public issue schema v3 remains unchanged. Candidate agendas and front-page proofs remain ephemeral. V7 reconciliation checkpoint v3 is read-only resume provenance: an explicit external import may materialize the exact immutable Run 58 copy - tip once, but v8 never appends to or advances that chain. Only a v7 tip whose - rejecting owner is the copy desk can become one v8 close checkpoint; its + tip once, but v9 never appends to or advances that chain. Only a v7 tip whose + rejecting owner is the copy desk can become one v9 close checkpoint; its draft, agenda, query report, bindings, and receipt ancestry must validate exactly. A v7 Night Editor rejection fails closed rather than being recast as - copy evidence. Run 58's v7 copy tip is imported into one v8 Night Editor - close without replaying ledger queries, agenda selection, writer, copy desk, + copy evidence. Run 58's v7 copy tip is imported into one v9 Night Editor + close without replaying ledger queries, agenda selection, journalists, copy desk, world mechanics, strategic waves, or accepted per-wave publications. -- **Cut line:** `NewsroomSource`, transient ordinal selection IDs, fact-bundle - deduplication, fixed newest-32/preceding-12/foundational-12 windows, - per-story and total citation ceilings, global record uniqueness across - articles, and the newspaper-local event-kind status mapping are removed. - The direct `submit_agenda` acceptance path is also removed: query plus a - structurally valid proposal is insufficient authority to invoke the editor. - Phrase replacements, finding references, target-field and - paragraph addressing, byte-span resolution, overlap ordering, empty-phrase - deletion, and article deletion remain absent. V8 additionally deletes the - publication-review verdict union, editorial-quality verdict, generic rewrite - desk, bounded multi-action rewrite cadence, repeated copy-desk calls, Night - Editor rereview, reconciliation advancement, and pending-as-editorial-state - path. No compatibility path may retain those operations as live writers. - Whole-article prose is the sole Night Editor close unit. Query-bearing - articles are mandatory, any other existing article is optional, and the - admitted agenda plus deterministic close merge owns everything around them. - Query projection and front-page proof reuse the generic agent harness and - existing public-record, agenda, accepted issue, and audit shapes. No - dependency, crate, binary, daemon, persistent schema, service, cache, event - writer, target platform, code-generation path, or world-simulation pass was - added. +- **Cut line:** the generic byline list and one-shot whole-page writer are + deleted. Assignment-owned section, journalist, byline, citations, and order + are no longer regenerated in prose output. Tool schema text and prior-action + copies are absent from prompts; agent history is no longer flattened and + retransmitted as a newly rendered user transcript. Selector commands no + longer mutate the action schema as records become visible. Phrase-level + repairs, repeated copy calls, Night rereview, post-close model judgment, and + edits to unqueried articles remain absent. Whole-article prose is the sole + Night Editor close unit, and exactly the query-bearing articles are mandatory. + The roster, query projection, front-page proof, and article assembly reuse the + generic agent harness and existing public-record, agenda, accepted issue, and + audit shapes. No dependency, crate, binary, daemon, persistent schema, + service, cache, event writer, target platform, code-generation path, or + world-simulation pass was added. - **Verification and build budget:** focused newspaper tests retain query admission, exact focus proof, agenda commit, selected-record alignment, and - audit grounding. V8 gates prove one complete copy report, exact unique query - targets, one Night Editor action, complete query disposition, mandatory - rewrites for query-bearing articles, optional polish of any other article, - preservation of assignment-owned structure and citations, no post-close copy - or editorial stage, deterministic source/printed digests, - exact receipt ancestry, close-checkpoint resume without writer or copy replay, - idempotent persisted-composition validation, printing the checkpointed page - after a structurally rejected close, and conversion of one legacy v7 copy tip - into one v8 close without replay. Strategic recomposition v3 binds the copy + audit grounding. V9 gates prove stable selector and Night schemas plus + append-only provider prefixes, assignment-aware reporter context isolation, one complete + copy report, exact unique query targets, one Night Editor action over only the + queried articles and facts, complete query disposition, preservation of + assignment-owned structure and citations, no post-close copy or editorial + stage, deterministic source/printed digests, exact receipt ancestry, + close-checkpoint resume without reporter or copy replay, idempotent persisted + composition validation, printing the checkpointed page after a structurally + rejected close, and conversion of one legacy v7 copy tip into one v9 close + without replay. Strategic recomposition v3 binds the copy report and press witness by digest while v1/v2 remain historical validation inputs. Independent blind editorial and grounding reviews evaluate the resulting artifact for the acceptance experiment only; they are deliberately diff --git a/state/evidence.jsonl b/state/evidence.jsonl index 4c4c5e9..e1e11ca 100644 --- a/state/evidence.jsonl +++ b/state/evidence.jsonl @@ -229,4 +229,6 @@ {"note": "Query-backed Canopy Ledger newsroom cut: canonical NewsIssue plus Event rows now own public facts and stable record IDs; the generic agent harness provides bounded deterministic ledger queries; selector, editor, copy desk, audit, and reconciliation share those stable IDs; numeric citations are late display labels. Removed NewsroomSource packet authority, fact-bundle deduplication, fixed archive windows, citation-count ceilings, cross-story record uniqueness, and the newspaper-local event-kind status switch. Deterministic admission rejects ambiguous ledger IDs and duplicate IDs within persisted agenda or draft groups. Verified 28/28 focused newspaper, 483/483 library, and 15/15 strategic-smoke tests before final formatting pass. Independent Modeling review confirmed the cut and isolated the remaining MemberMemory publicity defect to the kernel; revision 27 and run 54 remain immutable.", "status": "ok", "ts": "2026-08-29T14:06:49+00:00", "type": "implementation"} {"note": "Exact commit c34c4b1f94706661cdc35d5314ea23347bda6fd7 replaces direct newsroom agenda acceptance with query, proposal, deterministic front-page proof, and exact-current-digest commit. The publication writer owns narrative construction over admitted facts; the independent copy desk owns factual judgment. Focused newspaper 28/28, library 483/483, and strategic smoke 15/15 pass, including rejection of unreviewed and stale candidates; no world simulation or persistent schema was added.", "status": "ok", "ts": "2026-08-29T14:58:39+00:00", "type": "implementation"} {"note": "Newsroom contract v8 replaces the v7 copy/Night accept-reject tribunal and iterative rewrite loop with one complete copy-desk query report, one max_steps=1 Night Editor close, and a deterministic press witness. Every queried article must be rewritten; unqueried articles may receive optional deadline polish while selection, order, sections, bylines, citations, and agenda stay frozen. Exact v7 copy-tip import resumes Run 58 without replaying narrative selection, writer, copy desk, world, or waves. External reviews are post-press evidence rather than vetoes; typed later-correction memory remains pending. Verified 20/20 newspaper, 475/475 library, and 16/16 strategic-smoke tests.", "status": "ok", "ts": "2026-08-29T19:12:02+00:00", "type": "implementation"} -{"ts": "2026-08-29T19:50:00+00:00", "type": "verification", "status": "ok", "note": "Exact-source Run 59 at pushed 161ff2f19021249563e192cce9e6f76f00429d48 printed from immutable Run 58/revision 27 without upstream replay. The press witness binds one copy report with five queries, one applied Night close, three changed articles, and unchanged live services; archive SHA-256 ecb4db41e9a40ee4db8b0f83dce42d74e40f940e5930b1d3c1ced227dfec76b3 matches. Newspaper ee2ce08ceb627d433ac2d4ff7e1ebecb1437bf28b1399d23bfb3d5b066c8ab27 and audit b210d50d4ef1a350315d528f6d37e1e7b618926346713f737c4169c49df26b49 are frozen. A finalizer schema expectation was corrected and finalization resumed without a model call. Post-press editorial and two grounding diagnostics are later publication debt and do not reopen or block delivery of this edition."} +{"note": "Exact-source Run 59 at pushed 161ff2f19021249563e192cce9e6f76f00429d48 printed from immutable Run 58/revision 27 without upstream replay. The press witness binds one copy report with five queries, one applied Night close, three changed articles, and unchanged live services; archive SHA-256 ecb4db41e9a40ee4db8b0f83dce42d74e40f940e5930b1d3c1ced227dfec76b3 matches. Newspaper ee2ce08ceb627d433ac2d4ff7e1ebecb1437bf28b1399d23bfb3d5b066c8ab27 and audit b210d50d4ef1a350315d528f6d37e1e7b618926346713f737c4169c49df26b49 are frozen. A finalizer schema expectation was corrected and finalization resumed without a model call. Post-press editorial and two grounding diagnostics are later publication debt and do not reopen or block delivery of this edition.", "status": "ok", "ts": "2026-08-29T19:50:00+00:00", "type": "verification"} +{"note": "Run 59 prompt audit falsifies v8 as the target newsroom/cache architecture: narrative selection made ten provider calls over 1,373,208 input characters and 582,184 prompt tokens with zero cached tokens. The generic agent harness places changing action schema before stable work and replays the accumulated tool transcript; selector, whole-page writer, and Night Editor duplicate editorial doctrine. Operator-authorized owners are story-and-journalist assignment from the frozen ledger, bounded personality-bearing journalist drafting, copy desk as sole semantic factual query owner, one Night Editor checklist repair preserving narrative, and deterministic structural/provenance/query-disposition press admission. Exact schema and cache cuts remain pending bounded source inspection.", "status": "ok", "ts": "2026-08-29T20:29:24+00:00", "type": "research"} +{"ts": "2026-08-29T21:42:15+00:00", "type": "implementation", "status": "ok", "note": "character-newsroom.v9 replaces the monolithic page writer with an embodied assignment editor and one bounded journalist agent per assigned story, preserves exact assignment-owned structure, bounds Night Editor context to copy-queried articles and facts, and makes generic agent requests append-only with stable schemas and cache routing. Verified 475/475 library tests, 16/16 strategic-smoke tests, and ghostlight-dungeon all-target checks; no provider or simulation run was launched."} diff --git a/state/ghostlight-state.cultcache.jsonl b/state/ghostlight-state.cultcache.jsonl index f9f325f..30289c7 100644 --- a/state/ghostlight-state.cultcache.jsonl +++ b/state/ghostlight-state.cultcache.jsonl @@ -231,3 +231,5 @@ {"key": "2026-08-29T145839Z0000-0229-d9ee904c8b40", "payload": "eyJub3RlIjogIkV4YWN0IGNvbW1pdCBjMzRjNGIxZjk0NzA2NjYxY2RjMzVkNTMxNGVhMjMzNDdiZGE2ZmQ3IHJlcGxhY2VzIGRpcmVjdCBuZXdzcm9vbSBhZ2VuZGEgYWNjZXB0YW5jZSB3aXRoIHF1ZXJ5LCBwcm9wb3NhbCwgZGV0ZXJtaW5pc3RpYyBmcm9udC1wYWdlIHByb29mLCBhbmQgZXhhY3QtY3VycmVudC1kaWdlc3QgY29tbWl0LiBUaGUgcHVibGljYXRpb24gd3JpdGVyIG93bnMgbmFycmF0aXZlIGNvbnN0cnVjdGlvbiBvdmVyIGFkbWl0dGVkIGZhY3RzOyB0aGUgaW5kZXBlbmRlbnQgY29weSBkZXNrIG93bnMgZmFjdHVhbCBqdWRnbWVudC4gRm9jdXNlZCBuZXdzcGFwZXIgMjgvMjgsIGxpYnJhcnkgNDgzLzQ4MywgYW5kIHN0cmF0ZWdpYyBzbW9rZSAxNS8xNSBwYXNzLCBpbmNsdWRpbmcgcmVqZWN0aW9uIG9mIHVucmV2aWV3ZWQgYW5kIHN0YWxlIGNhbmRpZGF0ZXM7IG5vIHdvcmxkIHNpbXVsYXRpb24gb3IgcGVyc2lzdGVudCBzY2hlbWEgd2FzIGFkZGVkLiIsICJzdGF0dXMiOiAib2siLCAidHMiOiAiMjAyNi0wOC0yOVQxNDo1ODozOSswMDowMCIsICJ0eXBlIjogImltcGxlbWVudGF0aW9uIn0=", "stored_at": "2026-08-29T14:58:39.567023+00:00", "type": "ghostlight.evidence_record.v0"} {"key": "2026-08-29T191202Z0000-0230-6b340eadd8d4", "payload": "eyJub3RlIjogIk5ld3Nyb29tIGNvbnRyYWN0IHY4IHJlcGxhY2VzIHRoZSB2NyBjb3B5L05pZ2h0IGFjY2VwdC1yZWplY3QgdHJpYnVuYWwgYW5kIGl0ZXJhdGl2ZSByZXdyaXRlIGxvb3Agd2l0aCBvbmUgY29tcGxldGUgY29weS1kZXNrIHF1ZXJ5IHJlcG9ydCwgb25lIG1heF9zdGVwcz0xIE5pZ2h0IEVkaXRvciBjbG9zZSwgYW5kIGEgZGV0ZXJtaW5pc3RpYyBwcmVzcyB3aXRuZXNzLiBFdmVyeSBxdWVyaWVkIGFydGljbGUgbXVzdCBiZSByZXdyaXR0ZW47IHVucXVlcmllZCBhcnRpY2xlcyBtYXkgcmVjZWl2ZSBvcHRpb25hbCBkZWFkbGluZSBwb2xpc2ggd2hpbGUgc2VsZWN0aW9uLCBvcmRlciwgc2VjdGlvbnMsIGJ5bGluZXMsIGNpdGF0aW9ucywgYW5kIGFnZW5kYSBzdGF5IGZyb3plbi4gRXhhY3QgdjcgY29weS10aXAgaW1wb3J0IHJlc3VtZXMgUnVuIDU4IHdpdGhvdXQgcmVwbGF5aW5nIG5hcnJhdGl2ZSBzZWxlY3Rpb24sIHdyaXRlciwgY29weSBkZXNrLCB3b3JsZCwgb3Igd2F2ZXMuIEV4dGVybmFsIHJldmlld3MgYXJlIHBvc3QtcHJlc3MgZXZpZGVuY2UgcmF0aGVyIHRoYW4gdmV0b2VzOyB0eXBlZCBsYXRlci1jb3JyZWN0aW9uIG1lbW9yeSByZW1haW5zIHBlbmRpbmcuIFZlcmlmaWVkIDIwLzIwIG5ld3NwYXBlciwgNDc1LzQ3NSBsaWJyYXJ5LCBhbmQgMTYvMTYgc3RyYXRlZ2ljLXNtb2tlIHRlc3RzLiIsICJzdGF0dXMiOiAib2siLCAidHMiOiAiMjAyNi0wOC0yOVQxOToxMjowMiswMDowMCIsICJ0eXBlIjogImltcGxlbWVudGF0aW9uIn0=", "stored_at": "2026-08-29T19:12:02.108593+00:00", "type": "ghostlight.evidence_record.v0"} {"key": "2026-08-29T195000Z0000-0231-c1dcff35845b", "payload": "eyJub3RlIjogIkV4YWN0LXNvdXJjZSBSdW4gNTkgYXQgcHVzaGVkIDE2MWZmMmYxOTAyMTI0OTU2M2UxOTJjY2U5ZTZmNzZmMDA0MjlkNDggcHJpbnRlZCBmcm9tIGltbXV0YWJsZSBSdW4gNTgvcmV2aXNpb24gMjcgd2l0aG91dCB1cHN0cmVhbSByZXBsYXkuIFRoZSBwcmVzcyB3aXRuZXNzIGJpbmRzIG9uZSBjb3B5IHJlcG9ydCB3aXRoIGZpdmUgcXVlcmllcywgb25lIGFwcGxpZWQgTmlnaHQgY2xvc2UsIHRocmVlIGNoYW5nZWQgYXJ0aWNsZXMsIGFuZCB1bmNoYW5nZWQgbGl2ZSBzZXJ2aWNlczsgYXJjaGl2ZSBTSEEtMjU2IGVjYjRkYjQxZTlhNDBlZTRkYjhiMGY4M2RjZTQyZDc0ZTQwZjk0MGU1OTMwYjFkM2MxY2VkMjI3ZGZlYzc2YjMgbWF0Y2hlcy4gTmV3c3BhcGVyIGVlMmNlMDhjZWI2MjdkNDMzYWMyZDRmZjdlMWViZWNiMTQzN2JmMjhiMTM5OWQyM2JmYjNkNWIwNjZjOGFiMjcgYW5kIGF1ZGl0IGIyMTBkNTBkNGVmMWEzNTAzMTVkNTI4ZjZkMzdlMWU3YjYxODkyNjM0NjcxM2Y3MzdjNDE2OWM0OWRmMjZiNDkgYXJlIGZyb3plbi4gQSBmaW5hbGl6ZXIgc2NoZW1hIGV4cGVjdGF0aW9uIHdhcyBjb3JyZWN0ZWQgYW5kIGZpbmFsaXphdGlvbiByZXN1bWVkIHdpdGhvdXQgYSBtb2RlbCBjYWxsLiBQb3N0LXByZXNzIGVkaXRvcmlhbCBhbmQgdHdvIGdyb3VuZGluZyBkaWFnbm9zdGljcyBhcmUgbGF0ZXIgcHVibGljYXRpb24gZGVidCBhbmQgZG8gbm90IHJlb3BlbiBvciBibG9jayBkZWxpdmVyeSBvZiB0aGlzIGVkaXRpb24uIiwgInN0YXR1cyI6ICJvayIsICJ0cyI6ICIyMDI2LTA4LTI5VDE5OjUwOjAwKzAwOjAwIiwgInR5cGUiOiAidmVyaWZpY2F0aW9uIn0=", "stored_at": "2026-08-29T19:50:00.186912+00:00", "type": "ghostlight.evidence_record.v0"} +{"key": "2026-08-29T202924Z0000-0232-b61cd79c3dfd", "payload": "eyJub3RlIjogIlJ1biA1OSBwcm9tcHQgYXVkaXQgZmFsc2lmaWVzIHY4IGFzIHRoZSB0YXJnZXQgbmV3c3Jvb20vY2FjaGUgYXJjaGl0ZWN0dXJlOiBuYXJyYXRpdmUgc2VsZWN0aW9uIG1hZGUgdGVuIHByb3ZpZGVyIGNhbGxzIG92ZXIgMSwzNzMsMjA4IGlucHV0IGNoYXJhY3RlcnMgYW5kIDU4MiwxODQgcHJvbXB0IHRva2VucyB3aXRoIHplcm8gY2FjaGVkIHRva2Vucy4gVGhlIGdlbmVyaWMgYWdlbnQgaGFybmVzcyBwbGFjZXMgY2hhbmdpbmcgYWN0aW9uIHNjaGVtYSBiZWZvcmUgc3RhYmxlIHdvcmsgYW5kIHJlcGxheXMgdGhlIGFjY3VtdWxhdGVkIHRvb2wgdHJhbnNjcmlwdDsgc2VsZWN0b3IsIHdob2xlLXBhZ2Ugd3JpdGVyLCBhbmQgTmlnaHQgRWRpdG9yIGR1cGxpY2F0ZSBlZGl0b3JpYWwgZG9jdHJpbmUuIE9wZXJhdG9yLWF1dGhvcml6ZWQgb3duZXJzIGFyZSBzdG9yeS1hbmQtam91cm5hbGlzdCBhc3NpZ25tZW50IGZyb20gdGhlIGZyb3plbiBsZWRnZXIsIGJvdW5kZWQgcGVyc29uYWxpdHktYmVhcmluZyBqb3VybmFsaXN0IGRyYWZ0aW5nLCBjb3B5IGRlc2sgYXMgc29sZSBzZW1hbnRpYyBmYWN0dWFsIHF1ZXJ5IG93bmVyLCBvbmUgTmlnaHQgRWRpdG9yIGNoZWNrbGlzdCByZXBhaXIgcHJlc2VydmluZyBuYXJyYXRpdmUsIGFuZCBkZXRlcm1pbmlzdGljIHN0cnVjdHVyYWwvcHJvdmVuYW5jZS9xdWVyeS1kaXNwb3NpdGlvbiBwcmVzcyBhZG1pc3Npb24uIEV4YWN0IHNjaGVtYSBhbmQgY2FjaGUgY3V0cyByZW1haW4gcGVuZGluZyBib3VuZGVkIHNvdXJjZSBpbnNwZWN0aW9uLiIsICJzdGF0dXMiOiAib2siLCAidHMiOiAiMjAyNi0wOC0yOVQyMDoyOToyNCswMDowMCIsICJ0eXBlIjogInJlc2VhcmNoIn0=", "stored_at": "2026-08-29T20:29:24.348689+00:00", "type": "ghostlight.evidence_record.v0"} +{"key": "2026-08-29T214215Z0000-0233-cdea7f28b17e", "payload": "eyJub3RlIjogImNoYXJhY3Rlci1uZXdzcm9vbS52OSByZXBsYWNlcyB0aGUgbW9ub2xpdGhpYyBwYWdlIHdyaXRlciB3aXRoIGFuIGVtYm9kaWVkIGFzc2lnbm1lbnQgZWRpdG9yIGFuZCBvbmUgYm91bmRlZCBqb3VybmFsaXN0IGFnZW50IHBlciBhc3NpZ25lZCBzdG9yeSwgcHJlc2VydmVzIGV4YWN0IGFzc2lnbm1lbnQtb3duZWQgc3RydWN0dXJlLCBib3VuZHMgTmlnaHQgRWRpdG9yIGNvbnRleHQgdG8gY29weS1xdWVyaWVkIGFydGljbGVzIGFuZCBmYWN0cywgYW5kIG1ha2VzIGdlbmVyaWMgYWdlbnQgcmVxdWVzdHMgYXBwZW5kLW9ubHkgd2l0aCBzdGFibGUgc2NoZW1hcyBhbmQgY2FjaGUgcm91dGluZy4gVmVyaWZpZWQgNDc1LzQ3NSBsaWJyYXJ5IHRlc3RzLCAxNi8xNiBzdHJhdGVnaWMtc21va2UgdGVzdHMsIGFuZCBnaG9zdGxpZ2h0LWR1bmdlb24gYWxsLXRhcmdldCBjaGVja3M7IG5vIHByb3ZpZGVyIG9yIHNpbXVsYXRpb24gcnVuIHdhcyBsYXVuY2hlZC4iLCAic3RhdHVzIjogIm9rIiwgInRzIjogIjIwMjYtMDgtMjlUMjE6NDI6MTUrMDA6MDAiLCAidHlwZSI6ICJpbXBsZW1lbnRhdGlvbiJ9", "stored_at": "2026-08-29T21:42:15.490465+00:00", "type": "ghostlight.evidence_record.v0"} diff --git a/state/map.yaml b/state/map.yaml index df44f46..699fab0 100644 --- a/state/map.yaml +++ b/state/map.yaml @@ -195,7 +195,6 @@ live_architecture: active_subgoals: - Deliver and preserve the printed Run 59 Canopy Ledger with its one copy report, one deadline-bound Night close, deterministic press witness, and exact artifact, manifest, provenance, and service-isolation proof. The edition is closed and may not be reopened by post-press review; diagnostic findings become later publication debt. - - After Run 59 delivery, implement the publication-Persona direction admitted in plan commit `4916638`: each publication persists its own audience, remembered beats, narrative memeplex, selective story pickup, recurring throughlines, ideological framing, rivals, and later corrections; multiple Delvehold publications may interpret the same committed event substrate differently. Grounding constrains factual assertions and provenance without neutralizing legible interpretation, selection, framing, or insinuation. Do not fold this future authority cut into the closed Run 59 edition or patch it into copy-desk prompting. - Connect a Delvehold-owned adapter to the admitted generic consumer contract: shared compiler/consumer WorldSeed admission, externally authoritative institution-or-Gestalt snapshots, attributed proposal acknowledgement, and committed-event newspaper composition over typed CultNet, without consumer-specific IDs or schemas in Ghostlight. - Extend strategic/offscreen named-person individuation beyond the proven emerge-then-act path so people plot across sustained political waves, acquire relationships, fold, and return without granting a cell, arena, or model commit authority. - Pressure the proven 200-cell dispatcher with authored consumer hierarchies and measure provider cost; the active budget ceiling is 240 while provider concurrency remains separately capped at 32. @@ -327,6 +326,6 @@ data_flow: current_status: summary: Ghostlight's generic consumer boundary admits sparse WorldSeeds, runs the hundreds-class parallel cell machinery used by Dungeon, and projects grounded newspapers from committed public events. Local acceptance proves 1,000 subjects across 200 cells and two-wave durable named-person emergence. Exact-source provider run 38 at `596e50679244b5d42b4cfdf70d723f815751b8ff` resumed the preserved world at revision 8 without replaying compilation or four committed locality elaborations, recovered 51 of 52 strategic-wave cells on pulse three and the final cell on pulse four, then committed all six waves through revision 14. Pushed commit `3a69306f3004e4af2cd57bba612a6ad8d1b8132f` implements the mapped clock-consequence owner and is verified locally: world clocks own declared consequence text and exact observable scope; a bounded agent may propose legacy scope binding, while `WorldKernel` alone admits it and materializes stable threshold-crossing events and public news through the common mutation path. Binding, emitted-event ancestry, mutation receipts, and the next newspaper boundary persist atomically. Exact-source run 39 resumed run 38 without replaying its six committed waves, bound clock consequences, committed six new waves through revision 21, and emitted Issues 7 through 12 plus a final digest. This completes the mechanical provider acceptance path; all inherited wave 1-6 checkpoint hashes match run 38 exactly and live services were unchanged. Independent blind editorial review returns FAIL: only Issue 7 approaches spicy, while Issues 8-12 are dominated by plans, filings, inspections, warnings, and abstract relationship deltas without betrayal, scandal, public reaction, sustained reversal, or material lived cost. That run-39 diagnosis is superseded for the current phase: run 51 closes the world and reconciliation mechanics, and the active defect belongs to newsroom narrative selection and self-contained semantic provenance over immutable facts. Independent grounding review also returns FAIL: Issue 9 inflates a planned expedited agenda into completion, while Issue 8 assigns unsupported feminine pronouns to Sela Venn and Eryn Tal. Other grounding mechanics pass: 208/208 citations resolve, no future events appear, no replay occurred, the player remained unchanged, all 116/116 manifest entries verify, the archive is exact, and root independently confirmed the live service PIDs and invocation IDs remained unchanged. Editorial and grounding acceptance are terminally failed for run 39. Eight titled elaborators use the generic agent harness under deterministic weighted scheduling, bounded parallelism, and Luna/Terra/Sol routing. Workers remain proposal-only; deterministic admission binds exact frozen schedules, dispatch, receipts, conflicts, and semantic verification, and `WorldKernel` alone owns atomic CultCache persistence. Destination reconciliation is a private transactional workbench, provider schema compatibility is lowered once at the shared model-connector boundary, and durable elaboration and strategic-pulse checkpoints bind to their canonical mutation proofs. The generic ModelAgent harness remains consumer-neutral transport. Provider run 23 remains stopped; earlier failed runs and checkpoint archives remain immutable provenance. The live daemons remain outside the acceptance body. Dungeon play remains regression pressure, the Delvehold game-side adapter remains unimplemented, and unpublished Mars and Kalsa destination previews remain unapproved. provider_acceptance_checkpoint: Exact-source Run 59 at pushed commit `161ff2f19021249563e192cce9e6f76f00429d48` is printed from immutable Run 58 at world revision 27 with no selection, writer, copy-desk, world, or wave replay. Its acceptance witness binds one copy report with five queries, one Night Editor close, `night_editor_action_applied=true`, three changed articles, and unchanged live-service PID/invocation identities. Newspaper SHA-256 is `ee2ce08ceb627d433ac2d4ff7e1ebecb1437bf28b1399d23bfb3d5b066c8ab27`; audit is `b210d50d4ef1a350315d528f6d37e1e7b618926346713f737c4169c49df26b49`; immutable local archive `F:\GameCult\GhostlightDungeon\acceptance\elven-realms-autonomous-161ff2f-59.tar` is `ecb4db41e9a40ee4db8b0f83dce42d74e40f940e5930b1d3c1ced227dfec76b3`. A finalizer verifier incorrectly expected a schema field on the assessment-plus-queries report; finalization resumed without another model call. The edition satisfies the press and delivery boundary. - implementation_checkpoint: Pushed exact commit `161ff2f19021249563e192cce9e6f76f00429d48` owns provider-proven `canopy-ledger-night-editor.v8`. The copy desk emits one assessment plus complete consequential query set. One single-step Night Editor action must disposition every query and rewrite every queried article, may polish unqueried articles, and freezes selection, order, sections, bylines, citations, and agenda. One deterministic `WorldNewspaperPressClose` witness binds the source and printed pages, both model receipts, addressed queries, changed articles, source bounds, and exact lineage; no model rereads or grades the closed page. Verification passes 20/20 newsroom tests, 475/475 library tests, and 16/16 strategic-smoke tests. Durable publication-memory corrections remain unimplemented. - next_action: Deliver the actual immutable Run 59 copy and its press/artifact proof; do not rerun, rewrite, or withhold the edition. Preserve two post-press grounding correction candidates as later publication debt: line 47 over-scopes “maintained without disturbing” from cordon and warnings to barriers and path, and line 19 changes historical stalled lifts into “remains stalled.” Preserve the editorial diagnostic—strong child-seizure reporting and raw scandal, but institutional paste, thin lived/public reaction, repetition, an HTML entity, and lowercase `sella`—as future newsroom memory rather than a delivery gate. After delivery, move to the publication-Persona and correction-memory owner; defer multi-publication expansion, TeX layout, and engraved cuts until separately authorized. + implementation_checkpoint: `character-newsroom.v9` is implemented and locally verified. The assignment editor sees one embodied publication roster and owns story, section, evidence, and journalist assignment. One bounded journalist agent writes each assigned story from only its own profile, pitch, and exact cited records; assignment-owned byline, section, citations, and order are assembled deterministically. Dalen Marr alone emits one complete factual checklist. Meret Sorn receives only queried articles, affected assignments, their cited facts, and affected reporter profiles, then gets one checklist-bound close action over exactly those articles. The generic model harness now preserves typed append-only user/assistant request prefixes, keeps schemas out of prompt text, and routes stable stage/model/schema cache keys; selector query/proposal/commit and Night close each keep one stable action schema while their private deterministic workbenches enforce current legality. The one-shot whole-page writer and generic byline registry are deleted. No dependency, crate, binary, daemon, persistent schema, event authority, or simulation pass was added. Gates pass 475/475 library tests, 16/16 strategic-smoke tests, and all-target checks. + next_action: Preserve Run 59 as immutable evidence. Exercise `character-newsroom.v9` in a separately authorized newsroom-only provider run to measure real cached tokens and judge the embodied reporters' copy; do not replay world simulation. Preserve Run 59 findings as later publication debt and defer multi-publication expansion, TeX layout, and engraved cuts. gestalt_population: Gestalt Personas own shared population baselines; persistent member deltas own named individuality; WorldKernel materialization and approval-gated fission preserve lineage and identity. Materialisation/folding changes only representation and the exact member delta, advances resolution epoch, and retires the old cover; it has no Gestalt-wide effect authority. Selected actionable member exceptions are mandatory Projector perspective owners, so aggregation cannot silently erase their decision. Presence projection is limited to nearby active leaves and exact-location dormant members, includes reciprocal player relationship evidence, and may propose an earned cast without committing it. Simulation cells are derived covers only. diff --git a/state/scratch.md b/state/scratch.md index 37618c0..5db3718 100644 --- a/state/scratch.md +++ b/state/scratch.md @@ -1,4 +1,4 @@ -# Ghostlight Scratch +# Scratch Working Memory ## Current Subgoal