use crate::goal::{evaluate_goal, GoalCondition, GoalInputs};
use crate::{apply_action_effects, StaticState};
use car_ir::precondition;
use car_ir::{dependency_edges, ActionProposal, ActionType};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use std::collections::{HashMap, HashSet};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(default)]
pub struct MonteCarloConfig {
pub trials: u32,
pub seed: u64,
pub default_success_rate: f64,
pub retry_attempts: u32,
}
impl Default for MonteCarloConfig {
fn default() -> Self {
Self {
trials: 1000,
seed: 0x0CA1_0CA1_0CA1_0CA1,
default_success_rate: 0.5,
retry_attempts: 0,
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
pub struct Distribution {
pub mean: f64,
pub min: u32,
pub p50: u32,
pub p95: u32,
pub max: u32,
}
impl Distribution {
fn from_samples(samples: &mut [u32]) -> Self {
if samples.is_empty() {
return Self::default();
}
samples.sort_unstable();
let n = samples.len();
let sum: u64 = samples.iter().map(|&v| u64::from(v)).sum();
Self {
mean: sum as f64 / n as f64,
min: samples[0],
p50: samples[nearest_rank(n, 0.50)],
p95: samples[nearest_rank(n, 0.95)],
max: samples[n - 1],
}
}
}
fn nearest_rank(n: usize, q: f64) -> usize {
debug_assert!(n > 0);
let rank = (q * n as f64).ceil() as usize;
rank.saturating_sub(1).min(n - 1)
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct KeyOutcome {
pub key: String,
pub p_present: f64,
pub values: Vec<ValueFrequency>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ValueFrequency {
pub value: Value,
pub probability: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct ActionOutcome {
pub action_id: String,
pub p_rejected: f64,
pub p_failed: f64,
pub p_effects_landed: f64,
pub mean_blast_radius: f64,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct MonteCarloResult {
pub trials: u32,
pub seed: u64,
pub p_goal_reached: Option<f64>,
pub goal_underivable_conditions: Vec<String>,
pub p_all_effects_landed: f64,
pub tool_calls: Distribution,
pub actions_executed: Distribution,
pub state_distribution: Vec<KeyOutcome>,
pub action_outcomes: Vec<ActionOutcome>,
}
impl MonteCarloResult {
pub const fn evidence_tier(&self) -> crate::EvidenceTier {
crate::EvidenceTier::Sampled
}
}
pub fn simulate_monte_carlo(
proposal: &ActionProposal,
initial_state: Option<&HashMap<String, Value>>,
tool_success_rates: &HashMap<String, f64>,
goal: Option<&GoalCondition>,
config: &MonteCarloConfig,
) -> MonteCarloResult {
let n_actions = proposal.actions.len();
let goal_underivable_conditions = goal.map(underivable_kinds).unwrap_or_default();
let mut result = MonteCarloResult {
trials: config.trials,
seed: config.seed,
p_goal_reached: goal.map(|_| 0.0),
goal_underivable_conditions,
p_all_effects_landed: 0.0,
tool_calls: Distribution::default(),
actions_executed: Distribution::default(),
state_distribution: Vec::new(),
action_outcomes: Vec::new(),
};
if config.trials == 0 {
result.action_outcomes = proposal
.actions
.iter()
.map(|a| ActionOutcome {
action_id: a.id.clone(),
p_rejected: 0.0,
p_failed: 0.0,
p_effects_landed: 0.0,
mean_blast_radius: 0.0,
})
.collect();
return result;
}
let levels = car_ir::build_dag(&proposal.actions);
let descendants = transitive_dependents(&proposal.actions);
let attempts = config.retry_attempts.saturating_add(1);
let default_rate = config.default_success_rate.clamp(0.0, 1.0);
let trials = config.trials as usize;
let mut rng = SplitMix64::new(config.seed);
let mut tool_call_samples: Vec<u32> = Vec::with_capacity(trials);
let mut executed_samples: Vec<u32> = Vec::with_capacity(trials);
let mut rejected_counts = vec![0u32; n_actions];
let mut failed_counts = vec![0u32; n_actions];
let mut landed_counts = vec![0u32; n_actions];
let mut blast_totals = vec![0u64; n_actions];
let mut clean_runs = 0u32;
let mut goal_met = 0u32;
let mut key_values: HashMap<String, HashMap<String, u32>> = HashMap::new();
for _ in 0..trials {
let mut state = match initial_state {
Some(s) => StaticState::from_map(s.clone()),
None => StaticState::new(),
};
let mut tool_calls = 0u32;
let mut executed = 0u32;
let mut landed = vec![false; n_actions];
let mut rejected = vec![false; n_actions];
let mut failed = vec![false; n_actions];
for level in &levels {
for &idx in level {
let action = &proposal.actions[idx];
let gated = action
.preconditions
.iter()
.any(|pre| precondition::check_precondition(pre, &state).is_some())
|| action
.state_dependencies
.iter()
.any(|dep| !state.exists(dep) && !state.is_unknown(dep));
if gated {
rejected[idx] = true;
continue;
}
if action.action_type == ActionType::ToolCall {
let Some(tool) = action.tool.as_deref() else {
rejected[idx] = true;
continue;
};
let p = tool_success_rates
.get(tool)
.copied()
.unwrap_or(default_rate)
.clamp(0.0, 1.0);
let mut succeeded = false;
for _ in 0..attempts {
tool_calls += 1;
if rng.next_f64() < p {
succeeded = true;
break;
}
}
if !succeeded {
failed[idx] = true;
continue;
}
}
apply_action_effects(action, &mut state);
landed[idx] = true;
executed += 1;
}
}
for idx in 0..n_actions {
if rejected[idx] {
rejected_counts[idx] += 1;
}
if failed[idx] {
failed_counts[idx] += 1;
blast_totals[idx] +=
descendants[idx].iter().filter(|&&d| rejected[d]).count() as u64;
}
if landed[idx] {
landed_counts[idx] += 1;
}
}
if n_actions > 0 && landed.iter().all(|&l| l) {
clean_runs += 1;
} else if n_actions == 0 {
clean_runs += 1;
}
if let Some(condition) = goal {
let inputs = GoalInputs {
state: state.known.clone(),
..Default::default()
};
if evaluate_goal(condition, &inputs).met {
goal_met += 1;
}
}
for (key, value) in &state.known {
*key_values
.entry(key.clone())
.or_default()
.entry(canonical(value))
.or_insert(0) += 1;
}
tool_call_samples.push(tool_calls);
executed_samples.push(executed);
}
let t = trials as f64;
result.p_all_effects_landed = f64::from(clean_runs) / t;
result.p_goal_reached = goal.map(|_| f64::from(goal_met) / t);
result.tool_calls = Distribution::from_samples(&mut tool_call_samples);
result.actions_executed = Distribution::from_samples(&mut executed_samples);
result.action_outcomes = proposal
.actions
.iter()
.enumerate()
.map(|(i, a)| ActionOutcome {
action_id: a.id.clone(),
p_rejected: f64::from(rejected_counts[i]) / t,
p_failed: f64::from(failed_counts[i]) / t,
p_effects_landed: f64::from(landed_counts[i]) / t,
mean_blast_radius: if failed_counts[i] == 0 {
0.0
} else {
blast_totals[i] as f64 / f64::from(failed_counts[i])
},
})
.collect();
let mut state_distribution: Vec<KeyOutcome> = key_values
.into_iter()
.map(|(key, counts)| {
let present: u32 = counts.values().sum();
let mut values: Vec<ValueFrequency> = counts
.into_iter()
.map(|(encoded, count)| ValueFrequency {
value: serde_json::from_str(&encoded).unwrap_or(Value::Null),
probability: f64::from(count) / t,
})
.collect();
values.sort_by(|a, b| {
b.probability
.partial_cmp(&a.probability)
.unwrap_or(std::cmp::Ordering::Equal)
.then_with(|| canonical(&a.value).cmp(&canonical(&b.value)))
});
KeyOutcome {
key,
p_present: f64::from(present) / t,
values,
}
})
.collect();
state_distribution.sort_by(|a, b| a.key.cmp(&b.key));
result.state_distribution = state_distribution;
result
}
fn canonical(value: &Value) -> String {
serde_json::to_string(value).unwrap_or_else(|_| "null".to_string())
}
fn transitive_dependents(actions: &[car_ir::Action]) -> Vec<HashSet<usize>> {
let deps = dependency_edges(actions);
let n = actions.len();
let mut descendants: Vec<HashSet<usize>> = vec![HashSet::new(); n];
for i in (0..n).rev() {
for &parent in &deps[i] {
descendants[parent].insert(i);
let closure: Vec<usize> = descendants[i].iter().copied().collect();
descendants[parent].extend(closure);
}
}
descendants
}
fn underivable_kinds(condition: &GoalCondition) -> Vec<String> {
let mut found = Vec::new();
collect_underivable(condition, &mut found);
found.sort();
found.dedup();
found
}
fn collect_underivable(condition: &GoalCondition, out: &mut Vec<String>) {
match condition {
GoalCondition::AllOf { conditions } | GoalCondition::AnyOf { conditions } => {
for c in conditions {
collect_underivable(c, out);
}
}
GoalCondition::StatePredicate { .. } => {}
GoalCondition::ToolReceiptsGrounded => out.push("tool_receipts_grounded".into()),
GoalCondition::PlanAchieved => out.push("plan_achieved".into()),
GoalCondition::StateConsistent => out.push("state_consistent".into()),
GoalCondition::Command { id, .. } => out.push(format!("command:{id}")),
GoalCondition::ModelJudge { id } => out.push(format!("model_judge:{id}")),
}
}
struct SplitMix64 {
state: u64,
}
impl SplitMix64 {
fn new(seed: u64) -> Self {
Self { state: seed }
}
fn next_u64(&mut self) -> u64 {
self.state = self.state.wrapping_add(0x9E37_79B9_7F4A_7C15);
let mut z = self.state;
z = (z ^ (z >> 30)).wrapping_mul(0xBF58_476D_1CE4_E5B9);
z = (z ^ (z >> 27)).wrapping_mul(0x94D0_49BB_1331_11EB);
z ^ (z >> 31)
}
fn next_f64(&mut self) -> f64 {
(self.next_u64() >> 11) as f64 / (1u64 << 53) as f64
}
}
#[cfg(test)]
mod tests {
use super::*;
use car_ir::{Action, ActionProposal, Precondition};
fn bare(id: &str, action_type: ActionType) -> Action {
{
let mut a = Action::new(action_type);
a.id = id.to_string();
a
}
}
fn tool_action(id: &str, tool: &str, deps: &[&str], effects: &[(&str, Value)]) -> Action {
let mut a = bare(id, ActionType::ToolCall);
a.tool = Some(tool.to_string());
a.state_dependencies = deps.iter().map(|d| d.to_string()).collect();
a.expected_effects = effects
.iter()
.map(|(k, v)| (k.to_string(), v.clone()))
.collect();
a
}
fn proposal(actions: Vec<Action>) -> ActionProposal {
ActionProposal {
id: "mc-test".to_string(),
source: "test".to_string(),
actions,
timestamp: chrono::Utc::now(),
context: HashMap::new(),
}
}
fn rates(pairs: &[(&str, f64)]) -> HashMap<String, f64> {
pairs.iter().map(|(k, v)| (k.to_string(), *v)).collect()
}
#[test]
fn certain_tools_reproduce_deterministic_simulate() {
let p = proposal(vec![
tool_action("a", "fetch", &[], &[("data", Value::from(1))]),
tool_action("b", "write", &["data"], &[("done", Value::from(true))]),
]);
let r = simulate_monte_carlo(
&p,
None,
&rates(&[("fetch", 1.0), ("write", 1.0)]),
None,
&MonteCarloConfig::default(),
);
assert_eq!(r.p_all_effects_landed, 1.0);
let deterministic = crate::simulate(&p, None);
for key in deterministic.keys() {
let outcome = r
.state_distribution
.iter()
.find(|k| &k.key == key)
.expect("key present in monte carlo output");
assert_eq!(outcome.p_present, 1.0);
}
assert_eq!(r.tool_calls.p50, 2);
assert_eq!(r.tool_calls.p95, 2);
}
#[test]
fn impossible_tool_blocks_itself_and_its_dependent() {
let p = proposal(vec![
tool_action("a", "flaky", &[], &[("data", Value::from(1))]),
tool_action("b", "write", &["data"], &[("done", Value::from(true))]),
]);
let r = simulate_monte_carlo(
&p,
None,
&rates(&[("flaky", 0.0), ("write", 1.0)]),
None,
&MonteCarloConfig::default(),
);
assert_eq!(r.action_outcomes[0].p_failed, 1.0);
assert_eq!(r.action_outcomes[0].p_effects_landed, 0.0);
assert_eq!(r.action_outcomes[1].p_rejected, 1.0);
assert_eq!(r.action_outcomes[1].p_failed, 0.0);
assert_eq!(r.action_outcomes[0].mean_blast_radius, 1.0);
assert_eq!(r.tool_calls.max, 1);
assert!(r.state_distribution.is_empty());
}
#[test]
fn probability_lands_near_the_configured_rate() {
let p = proposal(vec![tool_action(
"a",
"coin",
&[],
&[("data", Value::from(1))],
)]);
let r = simulate_monte_carlo(
&p,
None,
&rates(&[("coin", 0.7)]),
None,
&MonteCarloConfig {
trials: 20_000,
..Default::default()
},
);
assert!(
(r.action_outcomes[0].p_effects_landed - 0.7).abs() < 0.02,
"expected ≈0.7, got {}",
r.action_outcomes[0].p_effects_landed
);
assert!((r.p_all_effects_landed - 0.7).abs() < 0.02);
}
#[test]
fn same_seed_reproduces_byte_identical_output() {
let p = proposal(vec![
tool_action("a", "coin", &[], &[("data", Value::from(1))]),
tool_action("b", "coin2", &["data"], &[("done", Value::from(true))]),
]);
let rates = rates(&[("coin", 0.6), ("coin2", 0.4)]);
let cfg = MonteCarloConfig {
trials: 500,
seed: 42,
..Default::default()
};
let a = simulate_monte_carlo(&p, None, &rates, None, &cfg);
let b = simulate_monte_carlo(&p, None, &rates, None, &cfg);
assert_eq!(a, b);
let c = simulate_monte_carlo(
&p,
None,
&rates,
None,
&MonteCarloConfig { seed: 43, ..cfg },
);
assert_ne!(a, c, "a different seed should produce a different sample");
}
#[test]
fn retries_raise_effective_success() {
let p = proposal(vec![tool_action(
"a",
"coin",
&[],
&[("data", Value::from(1))],
)]);
let rates = rates(&[("coin", 0.5)]);
let base = MonteCarloConfig {
trials: 20_000,
..Default::default()
};
let with_retries = simulate_monte_carlo(
&p,
None,
&rates,
None,
&MonteCarloConfig {
retry_attempts: 2,
..base
},
);
assert!(
(with_retries.action_outcomes[0].p_effects_landed - 0.875).abs() < 0.02,
"got {}",
with_retries.action_outcomes[0].p_effects_landed
);
assert!(with_retries.tool_calls.max > 1);
assert!(with_retries.tool_calls.mean > 1.0);
}
#[test]
fn unknown_tool_uses_the_default_rate() {
let p = proposal(vec![tool_action(
"a",
"never-seen",
&[],
&[("data", Value::from(1))],
)]);
let r = simulate_monte_carlo(
&p,
None,
&HashMap::new(),
None,
&MonteCarloConfig {
trials: 20_000,
default_success_rate: 0.25,
..Default::default()
},
);
assert!(
(r.action_outcomes[0].p_effects_landed - 0.25).abs() < 0.02,
"got {}",
r.action_outcomes[0].p_effects_landed
);
}
#[test]
fn state_distribution_reports_a_split() {
let p = proposal(vec![
tool_action("a", "always", &[], &[("data", Value::from(1))]),
tool_action("b", "coin", &["data"], &[("data", Value::from(2))]),
]);
let r = simulate_monte_carlo(
&p,
None,
&rates(&[("always", 1.0), ("coin", 0.5)]),
None,
&MonteCarloConfig {
trials: 20_000,
..Default::default()
},
);
let data = &r.state_distribution[0];
assert_eq!(data.key, "data");
assert_eq!(data.p_present, 1.0);
assert_eq!(data.values.len(), 2);
let total: f64 = data.values.iter().map(|v| v.probability).sum();
assert!((total - 1.0).abs() < 1e-9);
for v in &data.values {
assert!((v.probability - 0.5).abs() < 0.02, "got {}", v.probability);
}
}
#[test]
fn goal_probability_tracks_a_state_predicate() {
let p = proposal(vec![tool_action(
"a",
"coin",
&[],
&[("deployed", Value::from(true))],
)]);
let goal = GoalCondition::StatePredicate {
key: "deployed".into(),
equals: Value::from(true),
};
let r = simulate_monte_carlo(
&p,
None,
&rates(&[("coin", 0.8)]),
Some(&goal),
&MonteCarloConfig {
trials: 20_000,
..Default::default()
},
);
let p_goal = r.p_goal_reached.expect("goal supplied");
assert!((p_goal - 0.8).abs() < 0.02, "got {p_goal}");
assert!(r.goal_underivable_conditions.is_empty());
}
#[test]
fn underivable_goal_conditions_are_surfaced_not_silently_zero() {
let p = proposal(vec![tool_action(
"a",
"always",
&[],
&[("deployed", Value::from(true))],
)]);
let goal = GoalCondition::AllOf {
conditions: vec![
GoalCondition::StatePredicate {
key: "deployed".into(),
equals: Value::from(true),
},
GoalCondition::ToolReceiptsGrounded,
GoalCondition::Command {
id: "tests".into(),
expect_exit: 0,
},
],
};
let r = simulate_monte_carlo(
&p,
None,
&rates(&[("always", 1.0)]),
Some(&goal),
&MonteCarloConfig::default(),
);
assert_eq!(r.p_goal_reached, Some(0.0));
assert_eq!(
r.goal_underivable_conditions,
vec![
"command:tests".to_string(),
"tool_receipts_grounded".to_string()
]
);
}
#[test]
fn blast_radius_counts_transitive_dependents() {
let p = proposal(vec![
tool_action("a", "flaky", &[], &[("x", Value::from(1))]),
tool_action("b", "ok", &["x"], &[("y", Value::from(1))]),
tool_action("c", "ok", &["y"], &[("z", Value::from(1))]),
tool_action("d", "ok", &[], &[("w", Value::from(1))]),
]);
let r = simulate_monte_carlo(
&p,
None,
&rates(&[("flaky", 0.0), ("ok", 1.0)]),
None,
&MonteCarloConfig::default(),
);
assert_eq!(r.action_outcomes[0].mean_blast_radius, 2.0);
assert_eq!(r.action_outcomes[3].p_effects_landed, 1.0);
}
#[test]
fn zero_trials_is_empty_not_a_divide_by_zero() {
let p = proposal(vec![tool_action("a", "t", &[], &[])]);
let r = simulate_monte_carlo(
&p,
None,
&HashMap::new(),
None,
&MonteCarloConfig {
trials: 0,
..Default::default()
},
);
assert_eq!(r.trials, 0);
assert_eq!(r.p_all_effects_landed, 0.0);
assert_eq!(r.action_outcomes.len(), 1);
assert!(r.state_distribution.is_empty());
assert_eq!(r.tool_calls, Distribution::default());
}
#[test]
fn out_of_range_rates_are_clamped() {
let p = proposal(vec![tool_action(
"a",
"weird",
&[],
&[("x", Value::from(1))],
)]);
let r = simulate_monte_carlo(
&p,
None,
&rates(&[("weird", 5.0)]),
None,
&MonteCarloConfig::default(),
);
assert_eq!(r.action_outcomes[0].p_effects_landed, 1.0);
let r = simulate_monte_carlo(
&p,
None,
&rates(&[("weird", -2.0)]),
None,
&MonteCarloConfig::default(),
);
assert_eq!(r.action_outcomes[0].p_effects_landed, 0.0);
}
#[test]
fn precondition_gating_rejects_before_any_coin_flip() {
let mut blocked = tool_action("a", "coin", &[], &[("x", Value::from(1))]);
blocked.preconditions = vec![Precondition {
key: "ready".into(),
operator: "eq".into(),
value: Value::from(true),
description: String::new(),
}];
let p = proposal(vec![blocked]);
let r = simulate_monte_carlo(
&p,
None,
&rates(&[("coin", 1.0)]),
None,
&MonteCarloConfig::default(),
);
assert_eq!(r.action_outcomes[0].p_rejected, 1.0);
assert_eq!(r.action_outcomes[0].p_failed, 0.0);
assert_eq!(r.tool_calls.max, 0);
}
#[test]
fn non_tool_actions_do_not_flip_coins() {
let mut write = bare("w", ActionType::StateWrite);
write.parameters = [
("key".to_string(), Value::from("k")),
("value".to_string(), Value::from(9)),
]
.into();
let p = proposal(vec![write]);
let r = simulate_monte_carlo(
&p,
None,
&HashMap::new(),
None,
&MonteCarloConfig::default(),
);
assert_eq!(r.action_outcomes[0].p_effects_landed, 1.0);
assert_eq!(r.tool_calls.max, 0);
}
}