Give semantic retries exact correction context
This commit is contained in:
parent
6e67ab75a9
commit
6f9c078bce
4 changed files with 283 additions and 29 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue