From 6f9c078bced9d3a8dd0f2803b64efc7fe4e9505e Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Mon, 17 Aug 2026 20:00:04 +0200 Subject: [PATCH] Give semantic retries exact correction context --- .../src/bin/ghostlight-gestalt-scale-smoke.rs | 17 +- crates/ghostlight-dungeon/src/compiler.rs | 106 ++++++++++- crates/ghostlight-dungeon/src/persona.rs | 180 +++++++++++++++--- tools/run_ghostlight_live_fire.ps1 | 9 +- 4 files changed, 283 insertions(+), 29 deletions(-) diff --git a/crates/ghostlight-dungeon/src/bin/ghostlight-gestalt-scale-smoke.rs b/crates/ghostlight-dungeon/src/bin/ghostlight-gestalt-scale-smoke.rs index edb92ea..a8b09cf 100644 --- a/crates/ghostlight-dungeon/src/bin/ghostlight-gestalt-scale-smoke.rs +++ b/crates/ghostlight-dungeon/src/bin/ghostlight-gestalt-scale-smoke.rs @@ -100,9 +100,18 @@ async fn main() -> anyhow::Result<()> { anyhow::bail!("cross-faction budget pressure produced no arena cells") } let plan = validate_and_resolve_wave(&campaign, &output.wave)?; - if plan.institution_actions.is_empty() { - anyhow::bail!("24-subject strategic wave proposed no material institution action") - } + let explicit_inaction_count = output + .wave + .appraisals + .iter() + .filter(|appraisal| { + appraisal.actions.is_empty() + && appraisal + .inaction_reason + .as_deref() + .is_some_and(|reason| !reason.trim().is_empty()) + }) + .count(); for stage in &output.stages { store.insert( "persona_stage_receipt.v1", @@ -143,6 +152,8 @@ async fn main() -> anyhow::Result<()> { "cover":cover, "appraisals":output.wave.appraisals, "plan":plan, + "material_action_count":plan.institution_actions.len(), + "explicit_inaction_count":explicit_inaction_count, "model_stage_receipts":output.stages.iter().map(|stage|&stage.receipt).collect::>(), "commit":committed, "player_location_unchanged":true, diff --git a/crates/ghostlight-dungeon/src/compiler.rs b/crates/ghostlight-dungeon/src/compiler.rs index 46c9d18..7f6bc76 100644 --- a/crates/ghostlight-dungeon/src/compiler.rs +++ b/crates/ghostlight-dungeon/src/compiler.rs @@ -332,8 +332,10 @@ impl WorldCompiler { .expect("receipt was just stored"), &error, ); + let previous_structure = + serde_json::to_string(&compiled_seed_structure(&seed))?; correction = format!( - "\n\nLOCAL VALIDATOR REJECTED THE PREVIOUS CANDIDATE: {error}\nReturn a corrected complete candidate against the same START and EVIDENCE." + "\n\nLOCAL VALIDATOR REJECTED THE PREVIOUS CANDIDATE: {error}\nPREVIOUS_CANDIDATE_STRUCTURE:\n{previous_structure}\nReturn a corrected complete candidate against the same START and EVIDENCE. Preserve valid detail, but make every reference use an ID declared by the corrected candidate." ); } Err(error) => { @@ -1657,6 +1659,38 @@ fn seed_to_campaign(seed: CompiledSeed, receipts: &[VaultEvidenceReceipt]) -> Re Ok(campaign) } +fn compiled_seed_structure(seed: &CompiledSeed) -> serde_json::Value { + serde_json::json!({ + "tick_hours": seed.tick_hours, + "player": {"id": seed.player.id, "location_id": seed.player.location_id}, + "locations": seed.locations.iter().map(|location| serde_json::json!({ + "id": location.id, + "container_id": location.container_id, + "routes": location.routes, + })).collect::>(), + "actors": seed.actors.iter().map(|actor| serde_json::json!({ + "id": actor.id, + "location_id": actor.location_id, + })).collect::>(), + "institution_ids": seed.institutions.iter().map(|institution| institution.id.as_str()).collect::>(), + "gestalts": seed.gestalts.iter().map(|gestalt| serde_json::json!({ + "id": gestalt.id, + "home_location_id": gestalt.home_location_id, + })).collect::>(), + "gestalt_members": seed.gestalt_members.iter().map(|member| serde_json::json!({ + "id": member.id, + "gestalt_id": member.gestalt_id, + "materialized_actor_id": member.materialized_actor_id, + })).collect::>(), + "clocks": seed.clocks.iter().map(|clock| serde_json::json!({ + "id": clock.id, + "progress": clock.progress, + "threshold": clock.threshold, + })).collect::>(), + "fact_ids": seed.facts.iter().map(|fact| fact.id.as_str()).collect::>(), + }) +} + fn require_unique_ids<'a>(label: &str, ids: impl IntoIterator) -> Result<()> { let mut seen = BTreeSet::new(); let mut duplicates = BTreeSet::new(); @@ -1954,12 +1988,55 @@ mod tests { use crate::{domain::SourceWitness, model::ModelPort, vault::FixtureVault}; use async_trait::async_trait; use sha2::Digest; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; struct CompilerModel { invalid_route: bool, } struct OversizedQueryModel; + + struct CorrectionAwareCompilerModel { + world_calls: AtomicUsize, + saw_previous_structure: AtomicBool, + } + + #[async_trait] + impl ModelPort for CorrectionAwareCompilerModel { + async fn run(&self, request: &ModelStageRequest) -> Result { + let output = CompilerModel { + invalid_route: false, + } + .run(request) + .await?; + if request.stage != "world_compile" { + return Ok(output); + } + let call = self.world_calls.fetch_add(1, Ordering::SeqCst); + if call == 0 { + let mut candidate: serde_json::Value = serde_json::from_str(&output)?; + candidate["locations"][0]["routes"]["out"]["destination_id"] = + serde_json::Value::String("missing".into()); + return Ok(candidate.to_string()); + } + self.saw_previous_structure.store( + request + .lived_stream + .contains("PREVIOUS_CANDIDATE_STRUCTURE") + && request + .lived_stream + .contains("\"destination_id\":\"missing\"") + && request.lived_stream.contains("\"id\":\"yard\""), + Ordering::SeqCst, + ); + Ok(output) + } + + fn provider(&self) -> &'static str { + "correction-aware-compiler-fixture" + } + } + #[async_trait] impl ModelPort for OversizedQueryModel { async fn run(&self, _: &ModelStageRequest) -> Result { @@ -2406,4 +2483,31 @@ mod tests { .contains("references missing destination_id missing") ); } + + #[tokio::test] + async fn semantic_retry_receives_the_previous_candidates_structural_ids() { + let model = Arc::new(CorrectionAwareCompilerModel { + world_calls: AtomicUsize::new(0), + saw_previous_structure: AtomicBool::new(false), + }); + let compiler = WorldCompiler::new(vault(), model.clone(), "flash", "pro"); + let (_, receipts) = compiler + .compile_custom(CustomStart { + campaign_name: "Correction context".into(), + who: "worker".into(), + where_: "yard".into(), + when: "fixture".into(), + goal: "learn".into(), + }) + .await + .unwrap(); + assert!(model.saw_previous_structure.load(Ordering::SeqCst)); + let world_receipts = receipts + .iter() + .filter(|receipt| receipt.stage == "world_compile") + .collect::>(); + assert_eq!(world_receipts.len(), 2); + assert_eq!(world_receipts[0].validation_result, "semantic_invalid"); + assert_eq!(world_receipts[1].validation_result, "valid"); + } } diff --git a/crates/ghostlight-dungeon/src/persona.rs b/crates/ghostlight-dungeon/src/persona.rs index ad637b4..03912f1 100644 --- a/crates/ghostlight-dungeon/src/persona.rs +++ b/crates/ghostlight-dungeon/src/persona.rs @@ -444,12 +444,18 @@ impl CellProjectionEngine { }); } Err(error) if attempt == 0 => { + let rejected_appraisal = interpreted + .structured + .as_ref() + .map(serde_json::to_string) + .transpose()? + .unwrap_or_else(|| "null".into()); interpreted.receipt.validation_result = "semantic_invalid".into(); interpreted.receipt.local_validation_error = Some(error.to_string().chars().take(1_000).collect()); stage_receipts.push(interpreted.receipt); request.lived_stream.push_str(&format!( - "\n\nLOCAL VALIDATOR REJECTED THE PREVIOUS APPRAISAL: {error}\nReturn one corrected complete appraisal against the same snapshot, lived stream, and Persona turn." + "\n\nLOCAL VALIDATOR REJECTED THE PREVIOUS APPRAISAL: {error}\nPREVIOUS_REJECTED_APPRAISAL:\n{rejected_appraisal}\nReturn one corrected complete appraisal against the same snapshot, lived stream, Persona turn, and exact permission context. Change or remove every action that exceeds its attributed constituent's permissions; explicit inaction is valid." )); } Err(error) => { @@ -549,7 +555,12 @@ fn validate_cell_appraisal( && subject.reachable_destination_ids.contains(destination_id) => {} _ => { return Err(anyhow!( - "action effect exceeds its exact constituent authority" + "action for subject {} has effect {:?}, which exceeds exact authority for kind {:?}, locations {:?}, and reachable destinations {:?}", + subject.subject_id, + action.effect, + subject.subject_kind, + subject.location_ids, + subject.reachable_destination_ids )); } } @@ -609,31 +620,131 @@ fn constrain_interpreter_schema( Ok(()) } -fn allowed_actor_references(slice: &PermittedActorSlice) -> Vec { - slice - .capabilities - .iter() - .map(|value| format!("capability:{value}")) - .chain( - slice - .knowledge - .iter() - .map(|value| format!("knowledge:{value}")), - ) - .chain( - slice - .affordances - .iter() - .map(|value| format!("equipment:{value}")), - ) - .chain(std::iter::once(format!("location:{}", slice.location_id))) - .collect() -} - #[cfg(test)] mod tests { use super::*; - use crate::model::FixtureModel; + use crate::{ + domain::{AgencySubjectKind, SimulationCellMode, StrategicCellEffect}, + model::{FixtureModel, ModelStageRequest}, + }; + use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering}; + + struct CorrectingCellModel { + interpreter_calls: AtomicUsize, + saw_rejected_appraisal: AtomicBool, + } + + #[async_trait] + impl ModelPort for CorrectingCellModel { + async fn run(&self, request: &ModelStageRequest) -> Result { + match request.stage.as_str() { + "cell_projector" => { + Ok("Faction Six sees the public deadline and reviews its own mandate.".into()) + } + "cell_persona" => Ok( + "Faction Six will publish a bounded position using its bulletin access.".into(), + ), + "cell_interpreter" => { + let call = self.interpreter_calls.fetch_add(1, Ordering::SeqCst); + if call == 0 { + return Ok(serde_json::json!({ + "actions":[{ + "subject_id":"faction-06", + "intent":"publish a position", + "intended_effect":"move a person instead", + "priority":5, + "state_references":["institution:faction-06"], + "public_channels":["public bulletin"], + "effect":{"type":"actor_move","actor_id":"faction-06","destination_id":"forum"} + }], + "inaction_reason":null + }).to_string()); + } + self.saw_rejected_appraisal.store( + request.lived_stream.contains("PREVIOUS_REJECTED_APPRAISAL") + && request.lived_stream.contains("actor_move") + && request.lived_stream.contains("faction-06"), + Ordering::SeqCst, + ); + Ok(serde_json::json!({ + "actions":[{ + "subject_id":"faction-06", + "intent":"publish a position", + "intended_effect":"state its bounded institutional posture", + "priority":5, + "state_references":["institution:faction-06"], + "public_channels":["public bulletin"], + "effect":{"type":"institution","institution_id":"faction-06","posture":"published a bounded position","location_ids":["forum"]} + }], + "inaction_reason":null + }).to_string()) + } + stage => Err(anyhow!("unexpected fixture stage {stage}")), + } + } + + fn provider(&self) -> &'static str { + "correcting-cell-fixture" + } + } + + #[tokio::test] + async fn cell_semantic_retry_receives_the_rejected_appraisal() { + let model = Arc::new(CorrectingCellModel { + interpreter_calls: AtomicUsize::new(0), + saw_rejected_appraisal: AtomicBool::new(false), + }); + let engine = CellProjectionEngine { + model: model.clone(), + permit: Arc::new(AllowAllPermit), + projector_model: "flash".into(), + persona_model: "flash".into(), + interpreter_model: "flash".into(), + }; + let output = engine + .execute(PermittedCellSlice { + cell_id: "cell:test".into(), + mode: SimulationCellMode::Arena, + world_revision: 4, + resolution_epoch: 2, + snapshot_binding: "campaign:4:2".into(), + constituents: vec![CellConstituentSlice { + subject_id: "faction-06".into(), + subject_kind: AgencySubjectKind::Institution, + name: "Faction Six".into(), + collective_authority_id: None, + location_ids: BTreeSet::from(["forum".into()]), + knowledge: BTreeSet::from(["the public deadline".into()]), + capabilities: BTreeSet::new(), + resources: BTreeSet::from(["bulletin access".into()]), + information_channels: BTreeSet::from(["public bulletin".into()]), + permitted_state_references: BTreeSet::from(["institution:faction-06".into()]), + reachable_destination_ids: BTreeSet::new(), + goals: vec!["publish a position".into()], + pressures: vec!["the vote is near".into()], + }], + shared_knowledge: BTreeSet::new(), + shared_capabilities: BTreeSet::new(), + perceived_events: vec!["The final vote is public.".into()], + world_clock_pressure: vec!["vote 5/6".into()], + detail_focus_subject_id: Some("faction-06".into()), + max_actions: 1, + source_receipt_ids: vec![], + }) + .await + .unwrap(); + assert!(model.saw_rejected_appraisal.load(Ordering::SeqCst)); + assert_eq!(output.stage_receipts.len(), 4); + assert_eq!( + output.stage_receipts[2].validation_result, + "semantic_invalid" + ); + assert!(matches!( + output.appraisal.actions[0].effect, + StrategicCellEffect::Institution { .. } + )); + } + #[tokio::test] async fn persona_receives_only_projected_stream() { let engine = PersonaProjectionEngine { @@ -667,3 +778,24 @@ mod tests { assert_eq!(result.proposals.reaction_priority, 0); } } + +fn allowed_actor_references(slice: &PermittedActorSlice) -> Vec { + slice + .capabilities + .iter() + .map(|value| format!("capability:{value}")) + .chain( + slice + .knowledge + .iter() + .map(|value| format!("knowledge:{value}")), + ) + .chain( + slice + .affordances + .iter() + .map(|value| format!("equipment:{value}")), + ) + .chain(std::iter::once(format!("location:{}", slice.location_id))) + .collect() +} diff --git a/tools/run_ghostlight_live_fire.ps1 b/tools/run_ghostlight_live_fire.ps1 index a48c202..d708585 100644 --- a/tools/run_ghostlight_live_fire.ps1 +++ b/tools/run_ghostlight_live_fire.ps1 @@ -341,13 +341,20 @@ function Read-ScenarioSummary($Run) { 'strategic' { if ($result) { "$($result.event_count) events; $($result.news_count) news" } } 'scale' { if ($result) { "budget=$($result.configured_budget); cells=$($result.cell_count); arenas=$($result.arena_count)" } } } + $elapsedSeconds = if ($result -and $result.PSObject.Properties.Name -contains 'elapsed_seconds') { + [double]$result.elapsed_seconds + } elseif ($result -and $result.PSObject.Properties.Name -contains 'total_seconds') { + [double]$result.total_seconds + } else { + ([DateTimeOffset]$Run.Process.ExitTime - $Run.StartedAt).TotalSeconds + } [pscustomobject]@{ schema = 'ghostlight.live_fire_scenario_summary.v1' scenario_id = $Run.Scenario.Id kind = $Run.Scenario.Kind exit_code = $Run.Process.ExitCode succeeded = ($Run.Process.ExitCode -eq 0 -and $null -ne $result) - elapsed_seconds = [math]::Round(([DateTimeOffset]::UtcNow - $Run.StartedAt).TotalSeconds, 3) + elapsed_seconds = [math]::Round($elapsedSeconds, 3) stage_count = $receipts.Count provider_attempt_count = $providerAttempts prompt_tokens = $promptTokens