use crate::agentic::traits::CounterfactualEvaluator;
use crate::agentic::types::*;
#[derive(Debug, Default)]
pub struct DefaultCounterfactualEvaluator;
impl DefaultCounterfactualEvaluator {
fn action_to_delta(action: &ActionClass) -> (i32, bool, bool) {
match action {
ActionClass::Execute => (-1, true, true), ActionClass::Validate => (0, true, true), ActionClass::Delegate => (0, true, true), ActionClass::Escalate => (0, false, false), ActionClass::Read => (0, true, true), ActionClass::Write => (-1, true, true), ActionClass::Summarize => (0, true, true), ActionClass::GenerateArtifact => (0, true, true), ActionClass::Notify => (0, true, true), ActionClass::Custom(_) => (0, false, false), }
}
fn drift_to_health(drift_status: &DriftStatus) -> u8 {
match drift_status {
DriftStatus::OutOfControl => 3, DriftStatus::TrendDetected => 2, DriftStatus::ShiftDetected => 2, DriftStatus::Watch => 1, DriftStatus::Stable => 1, DriftStatus::Unknown => 1, }
}
}
impl CounterfactualEvaluator for DefaultCounterfactualEvaluator {
fn evaluate_options(&self, task: &TaskContext) -> Result<CounterfactualResult, AgenticError> {
let span = tracing::debug_span!(
"autonomic.counterfactual_evaluation",
task_id = %task.task_id,
drift = ?task.evidence.drift_status,
)
.entered();
let available_actions: Vec<ActionClass> =
task.policy.allowed_actions.iter().cloned().collect();
if available_actions.is_empty() {
tracing::debug!(
target: "agentic.evaluate_options",
task_id = %task.task_id,
"no allowed actions — returning empty counterfactual result"
);
return Ok(CounterfactualResult {
selected_option_id: None,
options: vec![],
});
}
let curr_health = Self::drift_to_health(&task.evidence.drift_status);
let options: Vec<CounterfactualOption> = available_actions
.iter()
.map(|action| {
let (delta, guard_pass, circuit_allowed) = Self::action_to_delta(action);
let next_health = ((curr_health as i32 + delta).clamp(0, 4)) as u8;
let estimated_reward = crate::rl_orchestrator::compute_reward(
curr_health,
next_health,
0,
guard_pass,
circuit_allowed,
false,
0, );
debug_assert!(
(-5.0..=1.2).contains(&estimated_reward),
"estimated_reward {estimated_reward} is out of RL bounds [-5.0, 1.1]"
);
CounterfactualOption {
option_id: format!("{:?}", action),
action_class: action.clone(),
projected_disposition: match action {
ActionClass::Execute => DecisionDisposition::Allow,
ActionClass::Escalate => DecisionDisposition::Escalate,
_ => DecisionDisposition::Allow,
},
estimated_cost: Some(0.0),
estimated_reward: Some(estimated_reward),
reason_codes: vec![format!(
"health:{}->{},guard:{},circuit:{}",
curr_health, next_health, guard_pass, circuit_allowed
)],
}
})
.collect();
let selected_option_id = options
.iter()
.max_by(|a, b| {
a.estimated_reward
.unwrap_or(f32::NEG_INFINITY)
.partial_cmp(&b.estimated_reward.unwrap_or(f32::NEG_INFINITY))
.unwrap_or(std::cmp::Ordering::Equal)
})
.map(|o| o.option_id.clone());
let options_count = options.len();
let best_reward = selected_option_id
.as_ref()
.and_then(|id| {
options
.iter()
.find(|o| &o.option_id == id)
.and_then(|o| o.estimated_reward)
})
.unwrap_or(f32::NEG_INFINITY);
let best_option_idx = selected_option_id
.as_ref()
.and_then(|id| options.iter().position(|o| &o.option_id == id))
.map(|i| i as i32)
.unwrap_or(-1);
let reward_variance = if options.len() > 1 {
let mean = options
.iter()
.filter_map(|o| o.estimated_reward)
.fold(0.0, |a, b| a + b)
/ options.len() as f32;
let variance = options
.iter()
.filter_map(|o| o.estimated_reward)
.map(|r| (r - mean).powi(2))
.fold(0.0, |a, b| a + b)
/ options.len() as f32;
variance.sqrt()
} else {
0.0
};
let confidence = if reward_variance < 0.5 {
0.9
} else if reward_variance < 1.5 {
0.7
} else {
0.5
};
span.record("health", curr_health as i32);
span.record("options", options_count);
span.record("best_option_idx", best_option_idx);
span.record("best_reward", best_reward);
span.record("confidence", confidence);
tracing::debug!(
target: "autonomic.counterfactual_evaluation",
task_id = %task.task_id,
option_count = options_count,
selected = ?selected_option_id,
best_reward,
confidence,
"counterfactual evaluation complete"
);
Ok(CounterfactualResult {
selected_option_id,
options,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeSet;
fn task_with_actions(actions: Vec<ActionClass>, drift: DriftStatus) -> TaskContext {
let mut allowed = BTreeSet::new();
for a in actions {
allowed.insert(a);
}
TaskContext {
task_id: "cf-test".to_string(),
policy: PolicyEnvelope {
allowed_actions: allowed,
..Default::default()
},
evidence: EvidenceEnvelope {
drift_status: drift,
..Default::default()
},
..Default::default()
}
}
#[test]
fn empty_actions_returns_empty_result_without_panic() {
let ev = DefaultCounterfactualEvaluator;
let task = task_with_actions(vec![], DriftStatus::Stable);
let result = ev.evaluate_options(&task).unwrap();
assert!(result.options.is_empty());
assert!(result.selected_option_id.is_none());
}
#[test]
fn estimated_rewards_are_bounded() {
let ev = DefaultCounterfactualEvaluator;
let task = task_with_actions(
vec![
ActionClass::Execute,
ActionClass::Validate,
ActionClass::Delegate,
ActionClass::Escalate,
ActionClass::Read,
ActionClass::Write,
ActionClass::Notify,
],
DriftStatus::OutOfControl,
);
let result = ev.evaluate_options(&task).unwrap();
for opt in &result.options {
if let Some(r) = opt.estimated_reward {
assert!(
r >= -5.1 && r <= 1.2,
"reward {r} for {:?} is outside RL bounds [-5.0, 1.1]",
opt.action_class
);
}
}
}
#[test]
fn selected_option_has_highest_reward() {
let ev = DefaultCounterfactualEvaluator;
let task = task_with_actions(
vec![
ActionClass::Execute,
ActionClass::Read,
ActionClass::Escalate,
],
DriftStatus::Watch,
);
let result = ev.evaluate_options(&task).unwrap();
let selected_id = result.selected_option_id.clone().unwrap();
let selected_reward = result
.options
.iter()
.find(|o| o.option_id == selected_id)
.and_then(|o| o.estimated_reward)
.unwrap();
for opt in &result.options {
let r = opt.estimated_reward.unwrap_or(f32::NEG_INFINITY);
assert!(
selected_reward >= r,
"selected reward {selected_reward} < option reward {r} for {:?}",
opt.action_class
);
}
}
#[test]
fn out_of_control_drift_yields_lower_reward_than_stable() {
let ev = DefaultCounterfactualEvaluator;
let actions = vec![ActionClass::Execute, ActionClass::Validate];
let stable_result = ev
.evaluate_options(&task_with_actions(actions.clone(), DriftStatus::Stable))
.unwrap();
let oc_result = ev
.evaluate_options(&task_with_actions(actions, DriftStatus::OutOfControl))
.unwrap();
let stable_best = stable_result
.options
.iter()
.filter_map(|o| o.estimated_reward)
.fold(f32::NEG_INFINITY, f32::max);
let oc_best = oc_result
.options
.iter()
.filter_map(|o| o.estimated_reward)
.fold(f32::NEG_INFINITY, f32::max);
assert!(!stable_result.options.is_empty());
assert!(!oc_result.options.is_empty());
assert!(stable_best.is_finite());
assert!(oc_best.is_finite());
}
#[test]
fn execute_action_projects_allow_disposition() {
let ev = DefaultCounterfactualEvaluator;
let task = task_with_actions(vec![ActionClass::Execute], DriftStatus::Stable);
let result = ev.evaluate_options(&task).unwrap();
let exec_opt = result
.options
.iter()
.find(|o| matches!(o.action_class, ActionClass::Execute))
.unwrap();
assert_eq!(exec_opt.projected_disposition, DecisionDisposition::Allow);
}
#[test]
fn escalate_action_projects_escalate_disposition() {
let ev = DefaultCounterfactualEvaluator;
let task = task_with_actions(vec![ActionClass::Escalate], DriftStatus::Stable);
let result = ev.evaluate_options(&task).unwrap();
let esc_opt = result
.options
.iter()
.find(|o| matches!(o.action_class, ActionClass::Escalate))
.unwrap();
assert_eq!(esc_opt.projected_disposition, DecisionDisposition::Escalate);
}
#[test]
fn reason_codes_contain_health_transition() {
let ev = DefaultCounterfactualEvaluator;
let task = task_with_actions(vec![ActionClass::Execute], DriftStatus::Stable);
let result = ev.evaluate_options(&task).unwrap();
for opt in &result.options {
assert!(
opt.reason_codes.iter().any(|r| r.contains("health:")),
"reason_code must contain health transition for {:?}",
opt.action_class
);
}
}
#[test]
fn default_task_context_does_not_panic() {
let ev = DefaultCounterfactualEvaluator;
let result = ev.evaluate_options(&TaskContext::default());
assert!(
result.is_ok(),
"default TaskContext must not panic: {result:?}"
);
}
}