Give semantic retries exact correction context

This commit is contained in:
Meta Vens 2026-08-17 20:00:04 +02:00
commit 6f9c078bce
4 changed files with 283 additions and 29 deletions

View file

@ -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::<Vec<_>>(),
"commit":committed,
"player_location_unchanged":true,

View file

@ -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::<Vec<_>>(),
"actors": seed.actors.iter().map(|actor| serde_json::json!({
"id": actor.id,
"location_id": actor.location_id,
})).collect::<Vec<_>>(),
"institution_ids": seed.institutions.iter().map(|institution| institution.id.as_str()).collect::<Vec<_>>(),
"gestalts": seed.gestalts.iter().map(|gestalt| serde_json::json!({
"id": gestalt.id,
"home_location_id": gestalt.home_location_id,
})).collect::<Vec<_>>(),
"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::<Vec<_>>(),
"clocks": seed.clocks.iter().map(|clock| serde_json::json!({
"id": clock.id,
"progress": clock.progress,
"threshold": clock.threshold,
})).collect::<Vec<_>>(),
"fact_ids": seed.facts.iter().map(|fact| fact.id.as_str()).collect::<Vec<_>>(),
})
}
fn require_unique_ids<'a>(label: &str, ids: impl IntoIterator<Item = &'a str>) -> 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<String> {
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<String> {
@ -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::<Vec<_>>();
assert_eq!(world_receipts.len(), 2);
assert_eq!(world_receipts[0].validation_result, "semantic_invalid");
assert_eq!(world_receipts[1].validation_result, "valid");
}
}

View file

@ -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<String> {
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<String> {
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<String> {
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()
}

View file

@ -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