Resume strategic waves from typed cell checkpoints
This commit is contained in:
parent
e14156228c
commit
acd544bae6
4 changed files with 891 additions and 66 deletions
|
|
@ -113,6 +113,25 @@ fn read_checkpoint<T: serde::de::DeserializeOwned>(path: &std::path::Path) -> an
|
|||
.map_err(|error| anyhow::anyhow!("cannot decode checkpoint {}: {error}", path.display()))
|
||||
}
|
||||
|
||||
fn latest_partial_wave_checkpoint<T: serde::de::DeserializeOwned>(
|
||||
root: &std::path::Path,
|
||||
wave_index: usize,
|
||||
maximum_generation: usize,
|
||||
) -> anyhow::Result<(usize, Option<T>)> {
|
||||
let mut latest_generation = 0;
|
||||
let mut latest = None;
|
||||
for generation in 1..=maximum_generation {
|
||||
let path = root.join(format!(
|
||||
"wave-{wave_index:02}-partial-pulse-{generation:02}.json"
|
||||
));
|
||||
if path.is_file() {
|
||||
latest = Some(read_checkpoint(&path)?);
|
||||
latest_generation = generation;
|
||||
}
|
||||
}
|
||||
Ok((latest_generation, latest))
|
||||
}
|
||||
|
||||
fn publish_immutable_checkpoint(
|
||||
path: &std::path::Path,
|
||||
value: &impl serde::Serialize,
|
||||
|
|
@ -432,7 +451,10 @@ async fn main() -> anyhow::Result<()> {
|
|||
kernel::{CommandResult, WorldKernel},
|
||||
model_runtime::ModelRuntimeSelection,
|
||||
persistence::CampaignStore,
|
||||
scheduler::{ResolutionWavePipelineFailure, propose_resolution_wave},
|
||||
scheduler::{
|
||||
ResolutionWaveCheckpoint, ResolutionWavePipelineFailure, propose_resolution_wave,
|
||||
resume_resolution_wave,
|
||||
},
|
||||
turn::SnapshotPermit,
|
||||
};
|
||||
use std::{path::PathBuf, sync::Arc, time::Instant};
|
||||
|
|
@ -1205,26 +1227,64 @@ async fn main() -> anyhow::Result<()> {
|
|||
campaign.news.len()
|
||||
};
|
||||
let mut rejected_pulses = Vec::new();
|
||||
let mut rejected_pulse_count = 0;
|
||||
for pulse in 1..=max_rejected_pulses_per_wave + 1 {
|
||||
let path = root.join(format!(
|
||||
"wave-{wave_index:02}-rejected-pulse-{pulse:02}.json"
|
||||
));
|
||||
if !path.is_file() {
|
||||
continue;
|
||||
}
|
||||
rejected_pulses.push(read_checkpoint::<serde_json::Value>(&path)?);
|
||||
rejected_pulse_count = pulse;
|
||||
}
|
||||
let terminal_cell_checkpoint_path = root.join(format!(
|
||||
"wave-{wave_index:02}-cell-terminal-checkpoint.json"
|
||||
));
|
||||
let mut partial_checkpoint = if terminal_cell_checkpoint_path.is_file() {
|
||||
Some(read_checkpoint::<ResolutionWaveCheckpoint>(
|
||||
&terminal_cell_checkpoint_path,
|
||||
)?)
|
||||
} else {
|
||||
let (latest_generation, latest) =
|
||||
latest_partial_wave_checkpoint::<ResolutionWaveCheckpoint>(
|
||||
&root,
|
||||
wave_index,
|
||||
max_rejected_pulses_per_wave + 1,
|
||||
)?;
|
||||
rejected_pulse_count = rejected_pulse_count.max(latest_generation);
|
||||
latest
|
||||
};
|
||||
let output = loop {
|
||||
match propose_resolution_wave(
|
||||
model.clone(),
|
||||
Arc::new(SnapshotPermit::new_resolution(
|
||||
store.clone(),
|
||||
campaign.id,
|
||||
campaign.revision,
|
||||
campaign.resolution_policy.resolution_epoch,
|
||||
)),
|
||||
&campaign,
|
||||
)
|
||||
.await
|
||||
{
|
||||
let permit = Arc::new(SnapshotPermit::new_resolution(
|
||||
store.clone(),
|
||||
campaign.id,
|
||||
campaign.revision,
|
||||
campaign.resolution_policy.resolution_epoch,
|
||||
));
|
||||
let attempt = match partial_checkpoint.clone() {
|
||||
Some(checkpoint) => {
|
||||
resume_resolution_wave(model.clone(), permit, &campaign, checkpoint).await
|
||||
}
|
||||
None => propose_resolution_wave(model.clone(), permit, &campaign).await,
|
||||
};
|
||||
match attempt {
|
||||
Ok(output) => break output,
|
||||
Err(error) => {
|
||||
let pulse = rejected_pulses.len() + 1;
|
||||
let pulse = rejected_pulse_count + 1;
|
||||
let mut resume_checkpoint_path = None;
|
||||
let rejected_stage_receipt_hashes = error
|
||||
.downcast_ref::<ResolutionWavePipelineFailure>()
|
||||
.map(|failure| {
|
||||
store.persist_model_stage_receipts(&failure.stage_receipts)?;
|
||||
if let Some(checkpoint) = &failure.checkpoint {
|
||||
let path = root.join(format!(
|
||||
"wave-{wave_index:02}-partial-pulse-{pulse:02}.json"
|
||||
));
|
||||
publish_immutable_checkpoint(&path, checkpoint)?;
|
||||
partial_checkpoint = Some(checkpoint.clone());
|
||||
resume_checkpoint_path = Some(path);
|
||||
}
|
||||
Ok::<_, anyhow::Error>(
|
||||
failure
|
||||
.stage_receipts
|
||||
|
|
@ -1247,9 +1307,17 @@ async fn main() -> anyhow::Result<()> {
|
|||
"resolution_epoch":campaign.resolution_policy.resolution_epoch,
|
||||
"error":error.to_string(),
|
||||
"rejected_stage_receipt_hashes":rejected_stage_receipt_hashes,
|
||||
"resume_checkpoint":resume_checkpoint_path,
|
||||
});
|
||||
if rejected_pulses.len() < max_rejected_pulses_per_wave {
|
||||
publish_immutable_checkpoint(
|
||||
&root.join(format!(
|
||||
"wave-{wave_index:02}-rejected-pulse-{pulse:02}.json"
|
||||
)),
|
||||
&rejected_pulse,
|
||||
)?;
|
||||
if rejected_pulse_count < max_rejected_pulses_per_wave {
|
||||
rejected_pulses.push(rejected_pulse);
|
||||
rejected_pulse_count = pulse;
|
||||
} else {
|
||||
std::fs::write(
|
||||
root.join(format!("wave-{wave_index:02}-terminal-failure.json")),
|
||||
|
|
@ -1263,6 +1331,16 @@ async fn main() -> anyhow::Result<()> {
|
|||
}
|
||||
}
|
||||
};
|
||||
if terminal_cell_checkpoint_path.is_file() {
|
||||
let persisted: serde_json::Value = read_checkpoint(&terminal_cell_checkpoint_path)?;
|
||||
if persisted != serde_json::to_value(&output.checkpoint)? {
|
||||
anyhow::bail!(
|
||||
"persisted terminal cell checkpoint disagrees with resumed scheduler output"
|
||||
)
|
||||
}
|
||||
} else {
|
||||
publish_immutable_checkpoint(&terminal_cell_checkpoint_path, &output.checkpoint)?;
|
||||
}
|
||||
std::fs::write(
|
||||
root.join(format!("wave-{wave_index:02}-preflight.json")),
|
||||
serde_json::to_vec_pretty(&serde_json::json!({
|
||||
|
|
@ -1995,9 +2073,9 @@ fn strategic_campaign() -> ghostlight_dungeon::domain::Campaign {
|
|||
mod tests {
|
||||
use super::{
|
||||
admitted_public_channel, civic_manifest_is_committed_candidate,
|
||||
committed_elaboration_mutation_proof, final_wave_field, publish_immutable_checkpoint,
|
||||
strategic_campaign, strategic_locality_request, strategic_titled_locality_request,
|
||||
titled_failure_checkpoint_paths,
|
||||
committed_elaboration_mutation_proof, final_wave_field, latest_partial_wave_checkpoint,
|
||||
publish_immutable_checkpoint, strategic_campaign, strategic_locality_request,
|
||||
strategic_titled_locality_request, titled_failure_checkpoint_paths,
|
||||
};
|
||||
|
||||
fn civic_manifest(
|
||||
|
|
@ -2255,4 +2333,29 @@ mod tests {
|
|||
serde_json::json!({"generation":1})
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn orphan_partial_wave_checkpoint_survives_missing_rejection_summary() {
|
||||
let directory = tempfile::tempdir().unwrap();
|
||||
let partial = directory.path().join("wave-01-partial-pulse-01.json");
|
||||
publish_immutable_checkpoint(
|
||||
&partial,
|
||||
&serde_json::json!({"typed_cell_terminals":["cell-accepted"]}),
|
||||
)
|
||||
.unwrap();
|
||||
assert!(
|
||||
!directory
|
||||
.path()
|
||||
.join("wave-01-rejected-pulse-01.json")
|
||||
.exists()
|
||||
);
|
||||
|
||||
let (generation, checkpoint) =
|
||||
latest_partial_wave_checkpoint::<serde_json::Value>(directory.path(), 1, 3).unwrap();
|
||||
assert_eq!(generation, 1);
|
||||
assert_eq!(
|
||||
checkpoint.unwrap()["typed_cell_terminals"][0],
|
||||
"cell-accepted"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -173,7 +173,7 @@ pub struct PermittedCellSlice {
|
|||
pub source_receipt_ids: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
pub struct CellTerminalBundle {
|
||||
pub lived_stream: LivedNarrativeStream,
|
||||
pub persona_output: String,
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ use crate::{
|
|||
},
|
||||
resolution::{
|
||||
cell_action_digest, cell_action_limit, default_demand, plan_cover, plan_receipt,
|
||||
select_resolution_wave, validate_and_resolve_wave, validate_demand,
|
||||
select_resolution_wave, validate_and_resolve_wave, validate_cover, validate_demand,
|
||||
},
|
||||
session_zero::{AggregatedBoundary, CampaignContract},
|
||||
};
|
||||
|
|
@ -91,6 +91,7 @@ pub struct StrategicResolutionOutput {
|
|||
pub stages: Vec<ModelStageOutput>,
|
||||
pub private_cell_traces: Vec<PrivateCellTrace>,
|
||||
pub aggregate_receipt_hash: String,
|
||||
pub checkpoint: ResolutionWaveCheckpoint,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
|
|
@ -101,10 +102,40 @@ pub struct PrivateCellTrace {
|
|||
pub persona_output: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
struct ResolutionCellTerminalCheckpoint {
|
||||
cell_id: String,
|
||||
terminal: crate::persona::CellTerminalBundle,
|
||||
}
|
||||
|
||||
/// Durable, non-canonical progress for one immutable strategic wave.
|
||||
///
|
||||
/// The scheduler owns this shape because only it can prove that a completed
|
||||
/// cell terminal belongs to the exact cover and campaign snapshot. Consumers
|
||||
/// may persist it, but may not merge, edit, or reinterpret its cell outputs.
|
||||
#[derive(Clone, Debug, Serialize, Deserialize)]
|
||||
#[serde(deny_unknown_fields)]
|
||||
pub struct ResolutionWaveCheckpoint {
|
||||
schema: String,
|
||||
campaign_id: uuid::Uuid,
|
||||
world_revision: u64,
|
||||
resolution_epoch: u64,
|
||||
cover: crate::domain::ResolutionCover,
|
||||
plan_receipt: crate::domain::ResolutionPlanReceipt,
|
||||
planning_stage_receipts: Vec<ModelStageReceipt>,
|
||||
campaign_contract: Option<CampaignContract>,
|
||||
aggregate_boundaries: Vec<AggregatedBoundary>,
|
||||
completed_cells: Vec<ResolutionCellTerminalCheckpoint>,
|
||||
failed_cell_ids: BTreeSet<String>,
|
||||
digest: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ResolutionWavePipelineFailure {
|
||||
pub pipeline_errors: Vec<String>,
|
||||
pub stage_receipts: Vec<ModelStageReceipt>,
|
||||
pub checkpoint: Option<ResolutionWaveCheckpoint>,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for ResolutionWavePipelineFailure {
|
||||
|
|
@ -122,6 +153,7 @@ impl std::error::Error for ResolutionWavePipelineFailure {}
|
|||
|
||||
#[derive(Debug)]
|
||||
struct CellPipelineExhaustion {
|
||||
cell_id: Option<String>,
|
||||
diagnostic: String,
|
||||
stage_receipts: Vec<ModelStageReceipt>,
|
||||
}
|
||||
|
|
@ -141,7 +173,7 @@ pub async fn propose_resolution_wave_with_policy(
|
|||
campaign_contract: Option<&CampaignContract>,
|
||||
aggregate_boundaries: &[AggregatedBoundary],
|
||||
) -> Result<StrategicResolutionOutput> {
|
||||
let (demand, mut stages) = project_resolution_demand(
|
||||
let (demand, stages) = project_resolution_demand(
|
||||
model.as_ref(),
|
||||
campaign,
|
||||
campaign_contract,
|
||||
|
|
@ -149,7 +181,53 @@ pub async fn propose_resolution_wave_with_policy(
|
|||
)
|
||||
.await;
|
||||
let cover = plan_cover(campaign, demand)?;
|
||||
let receipt = plan_receipt(campaign, &cover);
|
||||
let checkpoint = seal_resolution_wave_checkpoint(ResolutionWaveCheckpoint {
|
||||
schema: "ghostlight.resolution_wave_checkpoint.v1".into(),
|
||||
campaign_id: campaign.id,
|
||||
world_revision: campaign.revision,
|
||||
resolution_epoch: campaign.resolution_policy.resolution_epoch,
|
||||
plan_receipt: plan_receipt(campaign, &cover),
|
||||
failed_cell_ids: cover.cells.iter().map(|cell| cell.id.clone()).collect(),
|
||||
cover,
|
||||
planning_stage_receipts: stages.into_iter().map(|stage| stage.receipt).collect(),
|
||||
campaign_contract: campaign_contract.cloned(),
|
||||
aggregate_boundaries: aggregate_boundaries.to_vec(),
|
||||
completed_cells: Vec::new(),
|
||||
digest: String::new(),
|
||||
})?;
|
||||
resume_resolution_wave_from_checkpoint(model, permit, campaign, checkpoint).await
|
||||
}
|
||||
|
||||
/// Resume one exact strategic wave from scheduler-owned typed cell terminals.
|
||||
/// Only cells still named in `failed_cell_ids` may invoke the model again.
|
||||
pub async fn resume_resolution_wave(
|
||||
model: Arc<dyn ModelPort>,
|
||||
permit: Arc<dyn ExecutionPermit>,
|
||||
campaign: &Campaign,
|
||||
checkpoint: ResolutionWaveCheckpoint,
|
||||
) -> Result<StrategicResolutionOutput> {
|
||||
resume_resolution_wave_from_checkpoint(model, permit, campaign, checkpoint).await
|
||||
}
|
||||
|
||||
async fn resume_resolution_wave_from_checkpoint(
|
||||
model: Arc<dyn ModelPort>,
|
||||
permit: Arc<dyn ExecutionPermit>,
|
||||
campaign: &Campaign,
|
||||
mut checkpoint: ResolutionWaveCheckpoint,
|
||||
) -> Result<StrategicResolutionOutput> {
|
||||
validate_resolution_wave_checkpoint(campaign, &checkpoint)?;
|
||||
let cover = checkpoint.cover.clone();
|
||||
let receipt = checkpoint.plan_receipt.clone();
|
||||
let mut stages = checkpoint
|
||||
.planning_stage_receipts
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|receipt| ModelStageOutput {
|
||||
narrative: String::new(),
|
||||
structured: None,
|
||||
receipt,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
let outcome_model = model.clone();
|
||||
let outcome_permit = permit.clone();
|
||||
let engine = CellProjectionEngine {
|
||||
|
|
@ -158,14 +236,19 @@ pub async fn propose_resolution_wave_with_policy(
|
|||
projector_model: MODEL_FAST.into(),
|
||||
persona_model: MODEL_FAST.into(),
|
||||
interpreter_model: MODEL_FAST.into(),
|
||||
campaign_contract: campaign_contract.cloned(),
|
||||
aggregate_boundaries: aggregate_boundaries.to_vec(),
|
||||
campaign_contract: checkpoint.campaign_contract.clone(),
|
||||
aggregate_boundaries: checkpoint.aggregate_boundaries.clone(),
|
||||
};
|
||||
let semaphore = Arc::new(tokio::sync::Semaphore::new(usize::from(
|
||||
campaign.resolution_policy.provider_parallelism.max(1),
|
||||
)));
|
||||
let mut jobs = tokio::task::JoinSet::new();
|
||||
for cell in cover.cells.clone() {
|
||||
for cell in cover
|
||||
.cells
|
||||
.iter()
|
||||
.filter(|cell| checkpoint.failed_cell_ids.contains(&cell.id))
|
||||
.cloned()
|
||||
{
|
||||
let engine = engine.clone();
|
||||
let semaphore = semaphore.clone();
|
||||
let slice = cell_slice(campaign, &cell)?;
|
||||
|
|
@ -174,6 +257,7 @@ pub async fn propose_resolution_wave_with_policy(
|
|||
let subject_ids = cell.subject_ids;
|
||||
let Ok(_provider_slot) = semaphore.acquire_owned().await else {
|
||||
return Err(CellPipelineExhaustion {
|
||||
cell_id: Some(cell_id),
|
||||
diagnostic: "provider concurrency gate closed".into(),
|
||||
stage_receipts: Vec::new(),
|
||||
});
|
||||
|
|
@ -189,19 +273,26 @@ pub async fn propose_resolution_wave_with_policy(
|
|||
serde_json::to_string(&subject_ids)
|
||||
.unwrap_or_else(|_| "[unavailable]".into())
|
||||
),
|
||||
cell_id: Some(cell_id.clone()),
|
||||
stage_receipts,
|
||||
}
|
||||
})?;
|
||||
Ok::<_, CellPipelineExhaustion>((cell_id, terminal))
|
||||
});
|
||||
}
|
||||
let mut terminals = Vec::new();
|
||||
let mut terminals = checkpoint
|
||||
.completed_cells
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|completed| (completed.cell_id, completed.terminal))
|
||||
.collect::<Vec<_>>();
|
||||
let mut cell_failures = Vec::new();
|
||||
while let Some(result) = jobs.join_next().await {
|
||||
match result {
|
||||
Ok(Ok(terminal)) => terminals.push(terminal),
|
||||
Ok(Err(failure)) => cell_failures.push(failure),
|
||||
Err(error) => cell_failures.push(CellPipelineExhaustion {
|
||||
cell_id: None,
|
||||
diagnostic: format!("cell Persona task failed: {error}"),
|
||||
stage_receipts: Vec::new(),
|
||||
}),
|
||||
|
|
@ -222,15 +313,40 @@ pub async fn propose_resolution_wave_with_policy(
|
|||
.iter()
|
||||
.flat_map(|failure| failure.stage_receipts.iter().cloned()),
|
||||
);
|
||||
return Err(anyhow::Error::new(ResolutionWavePipelineFailure {
|
||||
pipeline_errors: cell_failures
|
||||
let pipeline_errors = cell_failures
|
||||
.iter()
|
||||
.map(|failure| failure.diagnostic.clone())
|
||||
.collect();
|
||||
let failed_cell_ids = cell_failures
|
||||
.iter()
|
||||
.filter_map(|failure| failure.cell_id.clone())
|
||||
.collect::<BTreeSet<_>>();
|
||||
terminals.sort_by(|left, right| left.0.cmp(&right.0));
|
||||
let resumable_checkpoint = if failed_cell_ids.len() == cell_failures.len() {
|
||||
checkpoint.completed_cells = terminals
|
||||
.into_iter()
|
||||
.map(|failure| failure.diagnostic)
|
||||
.collect(),
|
||||
.map(|(cell_id, terminal)| ResolutionCellTerminalCheckpoint { cell_id, terminal })
|
||||
.collect();
|
||||
checkpoint.failed_cell_ids = failed_cell_ids;
|
||||
Some(seal_resolution_wave_checkpoint(checkpoint)?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
return Err(anyhow::Error::new(ResolutionWavePipelineFailure {
|
||||
pipeline_errors,
|
||||
stage_receipts,
|
||||
checkpoint: resumable_checkpoint,
|
||||
}));
|
||||
}
|
||||
terminals.sort_by(|left, right| left.0.cmp(&right.0));
|
||||
checkpoint.completed_cells = terminals
|
||||
.iter()
|
||||
.cloned()
|
||||
.map(|(cell_id, terminal)| ResolutionCellTerminalCheckpoint { cell_id, terminal })
|
||||
.collect();
|
||||
checkpoint.failed_cell_ids.clear();
|
||||
checkpoint = seal_resolution_wave_checkpoint(checkpoint)?;
|
||||
validate_resolution_wave_checkpoint(campaign, &checkpoint)?;
|
||||
let appraisals = terminals
|
||||
.iter()
|
||||
.map(|(_, terminal)| terminal.appraisal.clone())
|
||||
|
|
@ -314,6 +430,7 @@ pub async fn propose_resolution_wave_with_policy(
|
|||
return Err(anyhow::Error::new(ResolutionWavePipelineFailure {
|
||||
pipeline_errors: vec![error.to_string()],
|
||||
stage_receipts,
|
||||
checkpoint: Some(checkpoint),
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
|
@ -377,9 +494,276 @@ pub async fn propose_resolution_wave_with_policy(
|
|||
stages,
|
||||
private_cell_traces,
|
||||
aggregate_receipt_hash,
|
||||
checkpoint,
|
||||
})
|
||||
}
|
||||
|
||||
fn validate_resolution_wave_checkpoint(
|
||||
campaign: &Campaign,
|
||||
checkpoint: &ResolutionWaveCheckpoint,
|
||||
) -> Result<()> {
|
||||
if checkpoint.digest != resolution_wave_checkpoint_digest(checkpoint)? {
|
||||
return Err(anyhow!(
|
||||
"strategic wave checkpoint digest does not bind its complete typed state"
|
||||
));
|
||||
}
|
||||
if checkpoint.schema != "ghostlight.resolution_wave_checkpoint.v1"
|
||||
|| checkpoint.campaign_id != campaign.id
|
||||
|| checkpoint.world_revision != campaign.revision
|
||||
|| checkpoint.resolution_epoch != campaign.resolution_policy.resolution_epoch
|
||||
|| checkpoint.cover.campaign_id != campaign.id
|
||||
|| checkpoint.cover.world_revision != campaign.revision
|
||||
|| checkpoint.cover.resolution_epoch != campaign.resolution_policy.resolution_epoch
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"strategic wave checkpoint belongs to a different campaign snapshot"
|
||||
));
|
||||
}
|
||||
validate_demand(campaign, &checkpoint.cover.demand)?;
|
||||
validate_cover(campaign, &checkpoint.cover.demand, &checkpoint.cover.cells)?;
|
||||
let planning_binding = format!(
|
||||
"campaign:{}:revision:{}:resolution:{}",
|
||||
campaign.id, campaign.revision, campaign.resolution_policy.resolution_epoch
|
||||
);
|
||||
let planning_sources = campaign
|
||||
.branch_origin
|
||||
.evidence_receipt_ids
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect::<BTreeSet<_>>();
|
||||
let mut planning_receipt_keys = BTreeSet::new();
|
||||
for receipt in &checkpoint.planning_stage_receipts {
|
||||
if receipt.schema != "ghostlight.persona_stage_receipt.v1"
|
||||
|| receipt.stage != "resolution_demand"
|
||||
|| receipt.snapshot_binding != planning_binding
|
||||
|| !model_receipt_hashes_are_well_formed(receipt)
|
||||
|| !planning_receipt_keys.insert(receipt.storage_key())
|
||||
|| receipt
|
||||
.source_receipt_ids
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect::<BTreeSet<_>>()
|
||||
!= planning_sources
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"strategic wave checkpoint contains invalid planning receipt provenance"
|
||||
));
|
||||
}
|
||||
}
|
||||
let expected_plan = plan_receipt(campaign, &checkpoint.cover);
|
||||
if checkpoint.plan_receipt.campaign_id != expected_plan.campaign_id
|
||||
|| checkpoint.plan_receipt.world_revision != expected_plan.world_revision
|
||||
|| checkpoint.plan_receipt.resolution_epoch != expected_plan.resolution_epoch
|
||||
|| checkpoint.plan_receipt.configured_budget != expected_plan.configured_budget
|
||||
|| checkpoint.plan_receipt.effective_budget != expected_plan.effective_budget
|
||||
|| checkpoint.plan_receipt.cell_ids != expected_plan.cell_ids
|
||||
|| checkpoint.plan_receipt.mandatory_overage != expected_plan.mandatory_overage
|
||||
|| checkpoint.plan_receipt.preserved_cell_ids != expected_plan.preserved_cell_ids
|
||||
|| checkpoint.plan_receipt.collapsed_boundaries != expected_plan.collapsed_boundaries
|
||||
|| checkpoint.plan_receipt.merge_losses != expected_plan.merge_losses
|
||||
|| checkpoint.plan_receipt.rationale != expected_plan.rationale
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"strategic wave checkpoint plan does not bind its exact cover"
|
||||
));
|
||||
}
|
||||
let cells = checkpoint
|
||||
.cover
|
||||
.cells
|
||||
.iter()
|
||||
.map(|cell| (cell.id.as_str(), cell))
|
||||
.collect::<BTreeMap<_, _>>();
|
||||
let mut completed_ids = BTreeSet::new();
|
||||
for completed in &checkpoint.completed_cells {
|
||||
let cell = cells.get(completed.cell_id.as_str()).ok_or_else(|| {
|
||||
anyhow!("strategic wave checkpoint contains a terminal for an unknown cell")
|
||||
})?;
|
||||
if !completed_ids.insert(completed.cell_id.as_str()) {
|
||||
return Err(anyhow!(
|
||||
"strategic wave checkpoint contains duplicate completed cell authority"
|
||||
));
|
||||
}
|
||||
validate_resolution_cell_terminal(campaign, cell, completed)?;
|
||||
}
|
||||
if checkpoint.failed_cell_ids.iter().any(|cell_id| {
|
||||
!cells.contains_key(cell_id.as_str()) || completed_ids.contains(cell_id.as_str())
|
||||
}) {
|
||||
return Err(anyhow!(
|
||||
"strategic wave checkpoint has overlapping or unknown failed cell authority"
|
||||
));
|
||||
}
|
||||
let accounted = completed_ids.len() + checkpoint.failed_cell_ids.len();
|
||||
if accounted != cells.len() {
|
||||
return Err(anyhow!(
|
||||
"strategic wave checkpoint does not account for every exact cover cell"
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn validate_resolution_cell_terminal(
|
||||
campaign: &Campaign,
|
||||
cell: &crate::domain::SimulationCell,
|
||||
completed: &ResolutionCellTerminalCheckpoint,
|
||||
) -> Result<()> {
|
||||
let slice = cell_slice(campaign, cell)?;
|
||||
let terminal = &completed.terminal;
|
||||
if terminal.appraisal.cell_id != completed.cell_id
|
||||
|| terminal.appraisal.world_revision != campaign.revision
|
||||
|| terminal.appraisal.resolution_epoch != campaign.resolution_policy.resolution_epoch
|
||||
|| terminal.lived_stream.snapshot_binding != slice.snapshot_binding
|
||||
|| terminal.lived_stream.projector_receipt.snapshot_binding != slice.snapshot_binding
|
||||
|| terminal.lived_stream.projector_receipt.stage != "cell_projector"
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"strategic wave checkpoint contains a stale or misbound cell terminal"
|
||||
));
|
||||
}
|
||||
let base_sources = slice
|
||||
.source_receipt_ids
|
||||
.iter()
|
||||
.cloned()
|
||||
.collect::<BTreeSet<_>>();
|
||||
let all_receipt_keys = terminal
|
||||
.stage_receipts
|
||||
.iter()
|
||||
.map(|receipt| receipt.storage_key().to_owned())
|
||||
.collect::<BTreeSet<_>>();
|
||||
if all_receipt_keys.len() != terminal.stage_receipts.len() {
|
||||
return Err(anyhow!(
|
||||
"strategic wave checkpoint contains duplicate cell receipt authority"
|
||||
));
|
||||
}
|
||||
let mut seen_receipt_keys = BTreeSet::new();
|
||||
let mut projector_receipt_keys = BTreeSet::new();
|
||||
let mut persona_receipt_keys = BTreeSet::new();
|
||||
let mut interpreter_receipt_keys = BTreeSet::new();
|
||||
let mut verifier_receipts = Vec::new();
|
||||
let mut persona_output_is_bound = false;
|
||||
let persona_output_hash = format!(
|
||||
"sha256:{:x}",
|
||||
Sha256::digest(terminal.persona_output.as_bytes())
|
||||
);
|
||||
for receipt in &terminal.stage_receipts {
|
||||
if receipt.schema != "ghostlight.persona_stage_receipt.v1"
|
||||
|| !model_receipt_hashes_are_well_formed(receipt)
|
||||
|| receipt
|
||||
.source_receipt_ids
|
||||
.iter()
|
||||
.any(|source| !base_sources.contains(source) && !seen_receipt_keys.contains(source))
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"strategic wave checkpoint contains invalid or noncausal cell receipts"
|
||||
));
|
||||
}
|
||||
let source_ids = receipt
|
||||
.source_receipt_ids
|
||||
.iter()
|
||||
.map(String::as_str)
|
||||
.collect::<BTreeSet<_>>();
|
||||
let cites_any =
|
||||
|keys: &BTreeSet<String>| keys.iter().any(|key| source_ids.contains(key.as_str()));
|
||||
match receipt.stage.as_str() {
|
||||
"cell_projector" if receipt.snapshot_binding == slice.snapshot_binding => {
|
||||
projector_receipt_keys.insert(receipt.storage_key().to_owned());
|
||||
}
|
||||
"cell_persona"
|
||||
if receipt.snapshot_binding == slice.snapshot_binding
|
||||
&& cites_any(&projector_receipt_keys) =>
|
||||
{
|
||||
if receipt.validation_result == "valid"
|
||||
&& receipt.output_hash == persona_output_hash
|
||||
{
|
||||
persona_output_is_bound = true;
|
||||
}
|
||||
persona_receipt_keys.insert(receipt.storage_key().to_owned());
|
||||
}
|
||||
"cell_interpreter"
|
||||
if receipt.snapshot_binding == slice.snapshot_binding
|
||||
&& cites_any(&projector_receipt_keys)
|
||||
&& cites_any(&persona_receipt_keys) =>
|
||||
{
|
||||
interpreter_receipt_keys.insert(receipt.storage_key().to_owned());
|
||||
}
|
||||
"cell_effect_verifier"
|
||||
if receipt
|
||||
.snapshot_binding
|
||||
.starts_with(&format!("{}:effects:sha256:", slice.snapshot_binding))
|
||||
&& cites_any(&projector_receipt_keys)
|
||||
&& cites_any(&persona_receipt_keys)
|
||||
&& cites_any(&interpreter_receipt_keys) =>
|
||||
{
|
||||
verifier_receipts.push(receipt);
|
||||
}
|
||||
_ => {
|
||||
return Err(anyhow!(
|
||||
"strategic wave checkpoint contains an invalid cell receipt stage or binding"
|
||||
));
|
||||
}
|
||||
}
|
||||
seen_receipt_keys.insert(receipt.storage_key().to_owned());
|
||||
}
|
||||
if projector_receipt_keys.is_empty()
|
||||
|| persona_receipt_keys.is_empty()
|
||||
|| interpreter_receipt_keys.is_empty()
|
||||
|| !persona_output_is_bound
|
||||
|| !terminal
|
||||
.stage_receipts
|
||||
.iter()
|
||||
.any(|receipt| receipt.same_receipted_content(&terminal.lived_stream.projector_receipt))
|
||||
{
|
||||
return Err(anyhow!(
|
||||
"strategic wave checkpoint cell terminal lacks its exact membrane receipts"
|
||||
));
|
||||
}
|
||||
for action in &terminal.appraisal.actions {
|
||||
let binding = crate::persona::cell_effect_verification_binding(
|
||||
&slice.snapshot_binding,
|
||||
std::slice::from_ref(action),
|
||||
)?;
|
||||
if !verifier_receipts.iter().any(|receipt| {
|
||||
receipt.snapshot_binding == binding
|
||||
&& receipt.validation_result == "valid"
|
||||
&& receipt.local_validation_error.is_none()
|
||||
}) {
|
||||
return Err(anyhow!(
|
||||
"strategic wave checkpoint action lacks an accepted exact verifier receipt"
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn model_receipt_hashes_are_well_formed(receipt: &ModelStageReceipt) -> bool {
|
||||
[
|
||||
receipt.storage_key(),
|
||||
receipt.request_hash.as_str(),
|
||||
receipt.output_hash.as_str(),
|
||||
]
|
||||
.into_iter()
|
||||
.all(|value| {
|
||||
value.strip_prefix("sha256:").is_some_and(|digest| {
|
||||
digest.len() == 64 && digest.bytes().all(|byte| byte.is_ascii_hexdigit())
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn seal_resolution_wave_checkpoint(
|
||||
mut checkpoint: ResolutionWaveCheckpoint,
|
||||
) -> Result<ResolutionWaveCheckpoint> {
|
||||
checkpoint.digest = resolution_wave_checkpoint_digest(&checkpoint)?;
|
||||
Ok(checkpoint)
|
||||
}
|
||||
|
||||
fn resolution_wave_checkpoint_digest(checkpoint: &ResolutionWaveCheckpoint) -> Result<String> {
|
||||
let mut unsigned = checkpoint.clone();
|
||||
unsigned.digest.clear();
|
||||
crate::legacy_transition::digest_serializable(&(
|
||||
"ghostlight.resolution_wave_checkpoint.v1",
|
||||
unsigned,
|
||||
))
|
||||
}
|
||||
|
||||
pub fn strategic_individuation_binding(
|
||||
campaign: &Campaign,
|
||||
action_digests: &[String],
|
||||
|
|
@ -1428,6 +1812,14 @@ mod tests {
|
|||
fixture: CellFixtureModel,
|
||||
target_binding: Mutex<Option<String>>,
|
||||
failures_remaining: AtomicUsize,
|
||||
calls: Mutex<Vec<(String, String)>>,
|
||||
projector_prompts: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
struct TransientOutcomeFixtureModel {
|
||||
fixture: CellFixtureModel,
|
||||
failures_remaining: AtomicUsize,
|
||||
calls: Mutex<Vec<String>>,
|
||||
}
|
||||
|
||||
struct PersonFixtureModel;
|
||||
|
|
@ -1499,6 +1891,70 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
fn fixture_interpreter_subject_ids(request: &ModelStageRequest) -> Result<Vec<String>> {
|
||||
request
|
||||
.output_schema
|
||||
.as_ref()
|
||||
.and_then(|schema| {
|
||||
schema
|
||||
.pointer("/properties/command/oneOf/0/properties/decisions/properties")
|
||||
.and_then(serde_json::Value::as_object)
|
||||
})
|
||||
.map(|properties| properties.keys().cloned().collect::<Vec<_>>())
|
||||
.filter(|subject_ids| !subject_ids.is_empty())
|
||||
.ok_or_else(|| anyhow!("fixture Interpreter lacks a bound subject"))
|
||||
}
|
||||
|
||||
fn fixture_interpreter_response(request: &ModelStageRequest) -> Result<String> {
|
||||
let decisions = fixture_interpreter_subject_ids(request)?
|
||||
.into_iter()
|
||||
.map(|subject_id| {
|
||||
let decision = serde_json::json!({"inaction":{
|
||||
"subject_id":subject_id.clone(),
|
||||
"reason":"No justified move this horizon."
|
||||
}});
|
||||
(subject_id, decision)
|
||||
})
|
||||
.collect::<serde_json::Map<_, _>>();
|
||||
Ok(serde_json::json!({
|
||||
"command":{
|
||||
"kind":"submit",
|
||||
"decisions":decisions
|
||||
}
|
||||
})
|
||||
.to_string())
|
||||
}
|
||||
|
||||
fn fixture_actor_interpreter_response(request: &ModelStageRequest) -> Result<String> {
|
||||
let decisions = fixture_interpreter_subject_ids(request)?
|
||||
.into_iter()
|
||||
.map(|subject_id| {
|
||||
(
|
||||
subject_id.clone(),
|
||||
serde_json::json!({"action":{
|
||||
"subject_id":subject_id,
|
||||
"intent":"build a public ration board at the current location",
|
||||
"intended_effect":"prepare the ration board locally",
|
||||
"priority":50,
|
||||
"state_references":[],
|
||||
"public_channels":[],
|
||||
"effects":{"actor_activities":{"prepare":[{
|
||||
"target_subject_ids":[],
|
||||
"location_ids":["center"]
|
||||
}]}}
|
||||
}}),
|
||||
)
|
||||
})
|
||||
.collect::<serde_json::Map<_, _>>();
|
||||
Ok(serde_json::json!({
|
||||
"command":{
|
||||
"kind":"submit",
|
||||
"decisions":decisions
|
||||
}
|
||||
})
|
||||
.to_string())
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ModelPort for CellFixtureModel {
|
||||
async fn run(&self, request: &ModelStageRequest) -> Result<String> {
|
||||
|
|
@ -1545,40 +2001,7 @@ mod tests {
|
|||
}
|
||||
"cell_persona" => Ok("Each constituent watches and deliberately holds.".into()),
|
||||
"cell_interpreter" if self.malformed_cell => Ok("not-json".into()),
|
||||
"cell_interpreter" => {
|
||||
let subject_ids = request
|
||||
.output_schema
|
||||
.as_ref()
|
||||
.and_then(|schema| {
|
||||
schema
|
||||
.pointer(
|
||||
"/properties/command/oneOf/0/properties/decisions/properties",
|
||||
)
|
||||
.and_then(serde_json::Value::as_object)
|
||||
})
|
||||
.map(|properties| properties.keys().cloned().collect::<Vec<_>>())
|
||||
.filter(|subject_ids| !subject_ids.is_empty())
|
||||
.ok_or_else(|| anyhow!("fixture Interpreter lacks a bound subject"))?;
|
||||
let decisions = subject_ids
|
||||
.into_iter()
|
||||
.map(|subject_id| {
|
||||
(
|
||||
subject_id.clone(),
|
||||
serde_json::json!({"inaction":{
|
||||
"subject_id":subject_id,
|
||||
"reason":"No justified move this horizon."
|
||||
}}),
|
||||
)
|
||||
})
|
||||
.collect::<serde_json::Map<_, _>>();
|
||||
Ok(serde_json::json!({
|
||||
"command":{
|
||||
"kind":"submit",
|
||||
"decisions":decisions
|
||||
}
|
||||
})
|
||||
.to_string())
|
||||
}
|
||||
"cell_interpreter" => fixture_interpreter_response(request),
|
||||
stage => Err(anyhow!("unexpected fixture stage {stage}")),
|
||||
}
|
||||
}
|
||||
|
|
@ -1591,6 +2014,16 @@ mod tests {
|
|||
#[async_trait]
|
||||
impl ModelPort for TransientCellFixtureModel {
|
||||
async fn run(&self, request: &ModelStageRequest) -> Result<String> {
|
||||
self.calls
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push((request.stage.clone(), request.snapshot_binding.clone()));
|
||||
if request.stage == "cell_projector" {
|
||||
self.projector_prompts
|
||||
.lock()
|
||||
.unwrap()
|
||||
.push(request.lived_stream.clone());
|
||||
}
|
||||
if request.stage == "cell_persona" {
|
||||
let target_binding = {
|
||||
let mut target = self.target_binding.lock().unwrap();
|
||||
|
|
@ -1621,6 +2054,62 @@ mod tests {
|
|||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ModelPort for TransientOutcomeFixtureModel {
|
||||
async fn run(&self, request: &ModelStageRequest) -> Result<String> {
|
||||
self.calls.lock().unwrap().push(request.stage.clone());
|
||||
match request.stage.as_str() {
|
||||
"cell_persona" => {
|
||||
Ok("The worker now builds a public ration board at this location.".into())
|
||||
}
|
||||
"cell_interpreter" => fixture_actor_interpreter_response(request),
|
||||
"cell_effect_verifier" => Ok(serde_json::json!({
|
||||
"verdicts":[{
|
||||
"action_index":0,
|
||||
"result":"match",
|
||||
"mismatch_kind":null,
|
||||
"repair_guidance":null
|
||||
}]
|
||||
})
|
||||
.to_string()),
|
||||
"strategic_outcome_resolver" => {
|
||||
if self
|
||||
.failures_remaining
|
||||
.fetch_update(Ordering::SeqCst, Ordering::SeqCst, |remaining| {
|
||||
(remaining > 0).then_some(remaining.saturating_sub(1))
|
||||
})
|
||||
.is_ok()
|
||||
{
|
||||
return Err(anyhow!("transient fixture outcome failure"));
|
||||
}
|
||||
let (_, payload) = request
|
||||
.lived_stream
|
||||
.rsplit_once("OUTCOME_CONTEXT:\n")
|
||||
.ok_or_else(|| anyhow!("fixture request omitted outcome context"))?;
|
||||
let context: serde_json::Value = serde_json::from_str(payload)?;
|
||||
let digest = context["actions"][0]["action_digest"]
|
||||
.as_str()
|
||||
.ok_or_else(|| anyhow!("fixture outcome context omitted action digest"))?;
|
||||
Ok(serde_json::json!({
|
||||
"outcomes":[{
|
||||
"action_digest":digest,
|
||||
"band":"mixed",
|
||||
"effect_kind":"no_material_change",
|
||||
"supporting_state_references":[],
|
||||
"reason":"The publication attempt establishes no further durable state."
|
||||
}]
|
||||
})
|
||||
.to_string())
|
||||
}
|
||||
_ => self.fixture.run(request).await,
|
||||
}
|
||||
}
|
||||
|
||||
fn provider(&self) -> &'static str {
|
||||
"fixture"
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl ModelPort for PersonFixtureModel {
|
||||
async fn run(&self, request: &ModelStageRequest) -> Result<String> {
|
||||
|
|
@ -2215,6 +2704,8 @@ mod tests {
|
|||
},
|
||||
target_binding: Mutex::new(None),
|
||||
failures_remaining: AtomicUsize::new(4),
|
||||
calls: Mutex::new(Vec::new()),
|
||||
projector_prompts: Mutex::new(Vec::new()),
|
||||
});
|
||||
|
||||
let error =
|
||||
|
|
@ -2238,6 +2729,230 @@ mod tests {
|
|||
);
|
||||
assert!(model.fixture.maximum.load(Ordering::SeqCst) <= 2);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn partial_wave_checkpoint_resumes_only_failed_cells() {
|
||||
let mut campaign = crate::resolution::tests::campaign(6, 2);
|
||||
campaign.resolution_policy.provider_parallelism = 2;
|
||||
let model = Arc::new(TransientCellFixtureModel {
|
||||
fixture: CellFixtureModel {
|
||||
active: AtomicUsize::new(0),
|
||||
maximum: AtomicUsize::new(0),
|
||||
malformed_cell: false,
|
||||
},
|
||||
target_binding: Mutex::new(None),
|
||||
failures_remaining: AtomicUsize::new(2),
|
||||
calls: Mutex::new(Vec::new()),
|
||||
projector_prompts: Mutex::new(Vec::new()),
|
||||
});
|
||||
let contract = CampaignContract {
|
||||
campaign_name: "POLICY-SIGNAL-EMBER".into(),
|
||||
..Default::default()
|
||||
};
|
||||
let boundaries = vec![AggregatedBoundary {
|
||||
normalized_topic: "policy-signal-ash".into(),
|
||||
display_topic: "POLICY-SIGNAL-ASH".into(),
|
||||
level: crate::session_zero::BoundaryLevel::Veil,
|
||||
}];
|
||||
|
||||
let error = match propose_resolution_wave_with_policy(
|
||||
model.clone(),
|
||||
Arc::new(AllowAllPermit),
|
||||
&campaign,
|
||||
Some(&contract),
|
||||
&boundaries,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => panic!("one exact cell must exhaust its bounded transport attempts"),
|
||||
Err(error) => error,
|
||||
};
|
||||
let failure = error
|
||||
.downcast_ref::<ResolutionWavePipelineFailure>()
|
||||
.expect("cell failure must publish a resumable wave checkpoint");
|
||||
let checkpoint = failure.checkpoint.clone().expect("checkpoint");
|
||||
assert_eq!(checkpoint.completed_cells.len(), 1);
|
||||
assert_eq!(checkpoint.failed_cell_ids.len(), 1);
|
||||
let mut overlapping = checkpoint.clone();
|
||||
overlapping
|
||||
.failed_cell_ids
|
||||
.insert(overlapping.completed_cells[0].cell_id.clone());
|
||||
let overlapping = seal_resolution_wave_checkpoint(overlapping).unwrap();
|
||||
let calls_before_rejection = model.calls.lock().unwrap().len();
|
||||
let overlap_error = match resume_resolution_wave(
|
||||
model.clone(),
|
||||
Arc::new(AllowAllPermit),
|
||||
&campaign,
|
||||
overlapping,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(_) => panic!("overlapping checkpoint authority must fail closed"),
|
||||
Err(error) => error,
|
||||
};
|
||||
assert!(overlap_error.to_string().contains("overlapping"));
|
||||
assert_eq!(model.calls.lock().unwrap().len(), calls_before_rejection);
|
||||
let preserved_receipts = checkpoint.completed_cells[0]
|
||||
.terminal
|
||||
.stage_receipts
|
||||
.iter()
|
||||
.map(|receipt| receipt.storage_key().to_owned())
|
||||
.collect::<BTreeSet<_>>();
|
||||
let checkpoint: ResolutionWaveCheckpoint =
|
||||
serde_json::from_slice(&serde_json::to_vec(&checkpoint).unwrap()).unwrap();
|
||||
|
||||
let output = resume_resolution_wave(
|
||||
model.clone(),
|
||||
Arc::new(AllowAllPermit),
|
||||
&campaign,
|
||||
checkpoint,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(output.wave.appraisals.len(), 2);
|
||||
assert!(
|
||||
preserved_receipts
|
||||
.is_subset(&output.wave.model_receipt_hashes.iter().cloned().collect())
|
||||
);
|
||||
let calls = model.calls.lock().unwrap();
|
||||
let count = |stage: &str| calls.iter().filter(|(called, _)| called == stage).count();
|
||||
assert_eq!(count("resolution_demand"), 1, "resume replanned the wave");
|
||||
assert_eq!(
|
||||
count("cell_projector"),
|
||||
3,
|
||||
"resume replayed a successful cell"
|
||||
);
|
||||
assert_eq!(
|
||||
count("cell_persona"),
|
||||
4,
|
||||
"resume replayed a successful cell"
|
||||
);
|
||||
assert_eq!(
|
||||
count("cell_interpreter"),
|
||||
2,
|
||||
"resume replayed a successful cell"
|
||||
);
|
||||
assert!(
|
||||
model
|
||||
.projector_prompts
|
||||
.lock()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.all(|prompt| {
|
||||
prompt.contains("POLICY-SIGNAL-EMBER") && prompt.contains("POLICY-SIGNAL-ASH")
|
||||
})
|
||||
);
|
||||
validate_and_resolve_wave(&campaign, &output.wave).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn outcome_failure_resume_reuses_every_completed_cell_terminal() {
|
||||
use crate::domain::{ActorState, AgencySubjectKind};
|
||||
let mut campaign = crate::resolution::tests::campaign(1, 1);
|
||||
campaign.resolution_policy.provider_parallelism = 1;
|
||||
let mut worker_profile = campaign.agency_profiles["faction-0000"].clone();
|
||||
campaign
|
||||
.agency_profiles
|
||||
.get_mut("faction-0000")
|
||||
.unwrap()
|
||||
.simulation_eligible = false;
|
||||
worker_profile.subject_id = "fixture-worker".into();
|
||||
worker_profile.subject_kind = AgencySubjectKind::Actor;
|
||||
worker_profile.location_ids = BTreeSet::from(["center".into()]);
|
||||
worker_profile.active_leaf = true;
|
||||
worker_profile.simulation_eligible = true;
|
||||
campaign
|
||||
.agency_profiles
|
||||
.insert(worker_profile.subject_id.clone(), worker_profile);
|
||||
campaign.actors.insert(
|
||||
"fixture-worker".into(),
|
||||
ActorState {
|
||||
id: "fixture-worker".into(),
|
||||
name: "Fixture Worker".into(),
|
||||
location_id: "center".into(),
|
||||
capabilities: BTreeSet::from(["carpentry".into()]),
|
||||
knowledge: BTreeSet::new(),
|
||||
equipment: BTreeSet::new(),
|
||||
conditions: BTreeSet::new(),
|
||||
obligations: BTreeSet::new(),
|
||||
relationships: BTreeMap::new(),
|
||||
goals: vec!["make rationing legible".into()],
|
||||
memories: Vec::new(),
|
||||
},
|
||||
);
|
||||
let model = Arc::new(TransientOutcomeFixtureModel {
|
||||
fixture: CellFixtureModel {
|
||||
active: AtomicUsize::new(0),
|
||||
maximum: AtomicUsize::new(0),
|
||||
malformed_cell: false,
|
||||
},
|
||||
failures_remaining: AtomicUsize::new(2),
|
||||
calls: Mutex::new(Vec::new()),
|
||||
});
|
||||
|
||||
let error = match propose_resolution_wave(
|
||||
model.clone(),
|
||||
Arc::new(AllowAllPermit),
|
||||
&campaign,
|
||||
)
|
||||
.await
|
||||
{
|
||||
Ok(output) => panic!(
|
||||
"the first downstream outcome request must exhaust transport; calls={:?}; appraisals={:?}",
|
||||
model.calls.lock().unwrap(),
|
||||
output.wave.appraisals
|
||||
),
|
||||
Err(error) => error,
|
||||
};
|
||||
let checkpoint = error
|
||||
.downcast_ref::<ResolutionWavePipelineFailure>()
|
||||
.and_then(|failure| failure.checkpoint.clone())
|
||||
.expect("downstream failure must preserve a complete cell checkpoint");
|
||||
assert!(checkpoint.failed_cell_ids.is_empty());
|
||||
assert_eq!(checkpoint.completed_cells.len(), 1);
|
||||
let terminal_receipts = checkpoint.completed_cells[0]
|
||||
.terminal
|
||||
.stage_receipts
|
||||
.iter()
|
||||
.map(|receipt| receipt.storage_key().to_owned())
|
||||
.collect::<BTreeSet<_>>();
|
||||
let calls_before_resume = model.calls.lock().unwrap().clone();
|
||||
|
||||
let output = resume_resolution_wave(
|
||||
model.clone(),
|
||||
Arc::new(AllowAllPermit),
|
||||
&campaign,
|
||||
checkpoint,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let calls_after_resume = model.calls.lock().unwrap().clone();
|
||||
for stage in [
|
||||
"resolution_demand",
|
||||
"cell_projector",
|
||||
"cell_persona",
|
||||
"cell_interpreter",
|
||||
"cell_effect_verifier",
|
||||
] {
|
||||
assert_eq!(
|
||||
calls_before_resume
|
||||
.iter()
|
||||
.filter(|called| called.as_str() == stage)
|
||||
.count(),
|
||||
calls_after_resume
|
||||
.iter()
|
||||
.filter(|called| called.as_str() == stage)
|
||||
.count(),
|
||||
"downstream resume replayed {stage}"
|
||||
);
|
||||
}
|
||||
assert!(
|
||||
terminal_receipts
|
||||
.is_subset(&output.wave.model_receipt_hashes.iter().cloned().collect())
|
||||
);
|
||||
validate_and_resolve_wave(&campaign, &output.wave).unwrap();
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn two_hundred_cell_wave_dispatches_in_parallel_under_one_provider_gate() {
|
||||
let mut campaign = crate::resolution::tests::campaign(1_000, 200);
|
||||
|
|
|
|||
|
|
@ -211,8 +211,15 @@ repairing one action does not repay inference for unchanged accepted actions.
|
|||
An `undecided` result returns to the Persona owner instead of letting the
|
||||
Interpreter invent a choice.
|
||||
|
||||
The strategic scheduler dispatches every simulation cell membrane once and
|
||||
owns no cell retry. The cell engine projects one exact lived moment. If the
|
||||
The strategic scheduler owns partial-wave recovery. Its typed checkpoint binds
|
||||
the exact campaign revision, resolution epoch, deterministic cover and plan,
|
||||
effective campaign contract and aggregate boundaries, planning receipts,
|
||||
successful cell terminal bundles, and exact failed-cell set. A retry validates
|
||||
that complete partition, policy, and causal receipt chain before dispatching
|
||||
only failed cells. The checkpoint is opaque outside the scheduler. The smoke
|
||||
runner may persist whole checkpoint generations but cannot merge or edit cell
|
||||
output; it durably publishes the complete cell checkpoint before wave commit.
|
||||
Within a cell, the engine projects one exact lived moment. If the
|
||||
Persona supplies no explicit decision, the engine retries only Persona and
|
||||
Interpreter against that same projected stream; Projector does not rerun. The
|
||||
second Persona cites the first Persona and Interpreter attempt as causal
|
||||
|
|
|
|||
Loading…
Reference in a new issue